C 语言编程经典 100 例(详解版)
C 语言编程经典 100 例(详解版)
本系列面向零基础或刚开始学习 C 语言的同学,共收录 程序 1 ~ 程序 100 一百道题目,按知识点从易到难编排,讲解循序渐进。
如何使用本文档
- 每一题包含:题目 → 解题思路 → 完整代码 → 代码讲解 → 知识点 → 数学原理(如适用) → 扩展思考 / 其他解法。
- 讲解中第一次出现的概念会展开解释;后面的题目会直接引用前面的概念,不再重复展开。
- 建议学习方式:先自己读题、动手写,写不出来再看解题思路,最后对照完整代码逐行理解,再合上文档自己重写一遍。
- 文中代码均采用符合现代 C 标准的写法(补全
#include、使用int main()),多数代码在 Dev-C++、Visual Studio、Code::Blocks、VS Code + GCC 等环境下可直接编译运行。 - 特殊环境说明:程序 32~35 涉及终端颜色与光标控制,推荐在 Windows Terminal / VS Code 终端 / Linux / macOS 终端等现代终端中运行;程序 56~65 为图形绘制题,使用轻量的 ACLLib 图形库(GitHub:wengkai/ACLLib)实现,需要先按第六部分的说明配置(Windows + MinGW / Dev-C++ / VS Code + GCC)。
零基础读者必读:预备知识
如果你完全没接触过编程,请先花十几分钟读完这一节。这一节里提到的名词,后面的每一题都会用到。
1. 什么是程序?什么是 C 语言?
计算机只会"执行指令"。程序就是把你想让计算机做的事,用某种"人看得懂、机器能执行"的语言写下来的一串指令。C 语言是其中最经典的一种,它语法简洁、运行速度快,是很多大学的第一门编程语言,也是操作系统(Linux、Windows 内核)、嵌入式系统的基础语言。
2. 从"源代码"到"可运行的程序"
用 C 语言写的文件叫源代码(后缀通常是 .c),它本身计算机还不能直接执行,需要经过两个步骤:
- 编译:编译器(如 GCC)把源代码翻译成机器能懂的二进制代码。
- 链接:把我们用到的库函数(比如
printf)和主程序"拼"在一起,生成可执行文件(Windows 下是.exe)。
所以,编程界有一句话:"代码写错一处,编译报错一片"。不要怕报错,报错信息是编译器在帮你。
3. 程序的基本框架(几乎每个程序都一样)
#include <stdio.h> // 头文件:引入 printf、scanf 等函数
int main() // 主函数:程序从这里开始执行
{
// 在这里写你的代码
return 0; // 返回 0 表示程序正常结束
}
#include <stdio.h>:stdio是"标准输入输出"(Standard Input Output)。printf(输出)、scanf(输入)都在这个头文件里声明。用了就要#include。main:主函数,程序唯一的人口。程序一运行,编译器就会从main的第一行开始执行。- 花括号
{ }:把一组语句"圈"在一起,叫一个代码块。 - 每一条语句末尾要有分号
;,就像中文句子末尾的句号。
4. 变量与数据类型
变量就是"存放数据的盒子"。用之前要先"声明"(告诉计算机盒子的名字和它能装什么类型的数):
| 类型 | 含义 | 示例 |
|---|---|---|
int |
整数(不含小数) | int age = 18; |
long / long long |
更大的整数 | long long x; |
float |
小数(单精度) | float pi = 3.14; |
double |
小数(双精度,更精确) | double pi = 3.1415926; |
char |
单个字符 | char c = 'A'; |
- 赋值:
age = 18;表示把 18 放进age这个盒子。 - 变量名:只能由字母、数字、下划线组成,不能以数字开头,不能用关键字(如
int、if)。
为什么有
int和float之分?因为计算机内存里整数和小数的存储方式完全不同。用int装小数会丢失小数部分,用float装大整数可能产生误差。后面很多题(如程序 3、20、24)会体现这一点。
5. 运算符
- 算术运算符:
+加、-减、*乘、/除、%取余(求余数,如7 % 3 = 1)。注意:整数除以整数结果还是整数!例如
5 / 2在 C 里结果是2而不是2.5(小数部分被丢掉)。想得到小数,要让其中一个数是小数,如5 / 2.0或(float)5 / 2。 - 关系运算符:
>大于、<小于、>=大于等于、<=小于等于、==等于(注意是两个等号!)、!=不等于。结果只有两种:成立(真,记为 1)或不成立(假,记为 0)。 - 逻辑运算符:
&&与(两边都成立才成立)、||或(一边成立就成立)、!非(取反)。 - 自增自减:
i++表示i = i + 1,i--表示i = i - 1。 - 复合赋值:
a += 3等价于a = a + 3;a *= 2等价于a = a * 2。
6. 流程控制:让程序"会思考"
-
if语句:如果条件成立就做某事。if (score >= 60) printf("及格了\n"); else printf("不及格\n"); switch语句:多选一(根据某个整数的值跳到对应分支)。-
for循环:重复执行某段代码固定次数。for (i = 1; i <= 10; i++) // 从 1 数到 10 printf("%d ", i); // 循环体:重复做的事for括号里分三部分:初始;条件;每轮结束后的动作。先执行初始,然后判断条件,成立就执行循环体,再执行"每轮动作",再判断条件……直到条件不成立为止。 while循环:条件成立就一直循环;do...while:先做一次再判断条件(至少执行一次)。
7. 数组:一排变量
数组是一串同类型变量的集合。int a[10]; 表示一次声明了 10 个整数盒子,用下标访问:a[0]、a[1]……a[9]。
重要:C 语言下标从 0 开始!
a[10]的第一个元素是a[0],最后一个元素是a[9]。
8. 函数:把代码"打包"
函数就是一段有名字、可以反复调用的代码。比如把"交换两个数"写成函数后,需要时调用一下即可。写函数就是"先声明参数(输入),计算,返回结果":
int add(int a, int b) // 两个参数 a、b,返回 int 类型结果
{
return a + b; // return 把结果交回去
}
9. 指针:盒子的"门牌号"
每个变量在内存里都有一个地址(门牌号)。指针就是用来存放地址的变量。int *p = &x; 表示:p 这个指针变量存放了变量 x 的地址;*p 表示"通过这个地址找到那个盒子里的值"。
初学者会觉得指针很难,其实只要记住两句话:
&变量名→ 取地址(找到门牌号)*指针名→ 取内容(按门牌号进门拿东西)
10. 结构体:自己发明的"复合盒子"
结构体(struct) 允许你把不同类型的数据打包成一个整体。例如一个"学生"可以同时包含学号、姓名、成绩:
struct student {
int num; // 学号
char name[20]; // 姓名
float score; // 成绩
};
11. 字符串与 \0
C 语言没有专门的"字符串类型",字符串就是一个字符数组,并且末尾自动带一个 \0(空字符)作为结束标志。printf("%s", s) 遇到 \0 才知道字符串到哪结束。这正是后面很多题(程序 70、82、86 等)的解题关键。
12. 一个实用的调试建议
写代码遇到问题,先看两处:① 每条语句末尾有没有分号;② 括号是否配对。编译报错不可怕,把第一条报错信息(往往在文件最上面)看懂并改掉,通常后面的错误也会一起消失。
第一部分:入门基础题(程序 1 ~ 10)
【程序 1】用 1、2、3、4 能组成多少个互不相同且无重复数字的三位数?
题目:有 1、2、3、4 四个数字,能组成多少个互不相同且无重复数字的三位数?都是多少?
解题思路
这就是"枚举 + 筛选":
- 百位、十位、个位三个位置,每个位置都可以放 1、2、3、4 中的任意一个,所以一共有 $4 \times 4 \times 4 = 64$ 种放法(这叫穷举)。
- 题目要求三个数字互不相同,所以从 64 种里把"有重复数字"的组合筛选掉。
用三重循环把三个位置的所有组合都走一遍,再用一个 if 判断三个数是否两两不同,满足就输出。这体现了一种非常经典的编程思想:先暴力枚举所有可能,再过滤掉不合条件的。
完整代码
#include <stdio.h>
int main()
{
int i, j, k;
for (i = 1; i <= 4; i++) // 百位
for (j = 1; j <= 4; j++) // 十位
for (k = 1; k <= 4; k++)// 个位
{
if (i != j && i != k && j != k) // 三个数字互不相同
printf("%d%d%d\n", i, j, k);
}
return 0;
}
代码讲解
- 三重
for循环嵌套:最外层i(百位)每取一个值,内层的j(十位)就要完整跑一遍 1~4,同理k(个位)又要完整跑一遍。总执行次数 $4 \times 4 \times 4 = 64$ 次,这就是"枚举所有可能"。 i != j && i != k && j != k:!=是"不等于",&&是"并且"。三个条件同时成立才说明三个数字互不相同。- 题目问"多少个":可以加一个计数器
count++,最后打印count,这就是简单的统计思想(第 12 题也会用到)。
知识点
for循环嵌套- 关系运算符
!=、逻辑运算符&& - 穷举(枚举)思想:把所有可能列出来再筛选
扩展思考(其他解法)
题目要的是"个数",还可以用排列组合的数学方法直接算:百位有 4 种选择,十位不能和百位重复剩 3 种,个位剩 2 种,所以总数是 $4 \times 3 \times 2 = 24$ 个。这个题的目的不是要"算得快",而是要你学会用循环枚举让计算机替你做重复劳动——这也是编程和手算的本质区别。
【程序 2】奖金提成问题
题目:企业发放的奖金根据利润提成:利润 I ≤ 10 万时,提 10%;10 万 < I ≤ 20 万时,超出 10 万的部分提 7.5%;20 万 < I ≤ 40 万时,超出 20 万的部分提 5%;40 万 < I ≤ 60 万时,超出 40 万的部分提 3%;60 万 < I ≤ 100 万时,超出 60 万的部分提 1.5%;I > 100 万时,超出 100 万的部分提 1%。从键盘输入利润 I,求应发奖金总额。
解题思路
这是典型的分段函数问题。关键思想:先在数轴上把每个"分界点"的累计奖金算出来,再根据输入落在哪一段,只计算"超出的那一小段"。
以 10 万为例:利润低于 10 万时,奖金 = I × 10%;利润在 10~20 万之间时,前 10 万固定拿 1 万($100000 \times 0.1$),多出的部分按 7.5% 计算。所以先算出 bonus1(正好 10 万时的奖金)、bonus2(正好 20 万时的奖金)……存起来,后面判断落在哪段就直接用。
完整代码
#include <stdio.h>
int main()
{
long profit; // 利润(元)
double bonus; // 奖金
int bonus1, bonus2, bonus4, bonus6, bonus10;
printf("请输入当月利润(元):");
scanf("%ld", &profit);
/* 先算出各个分界点对应的累计奖金 */
bonus1 = 100000 * 0.1; // 利润正好10万时的奖金
bonus2 = bonus1 + 100000 * 0.075; // 利润正好20万时
bonus4 = bonus2 + 200000 * 0.05; // 利润正好40万时
bonus6 = bonus4 + 200000 * 0.03; // 利润正好60万时
bonus10 = bonus6 + 400000 * 0.015; // 利润正好100万时
if (profit <= 100000)
bonus = profit * 0.1;
else if (profit <= 200000)
bonus = bonus1 + (profit - 100000) * 0.075;
else if (profit <= 400000)
bonus = bonus2 + (profit - 200000) * 0.05;
else if (profit <= 600000)
bonus = bonus4 + (profit - 400000) * 0.03;
else if (profit <= 1000000)
bonus = bonus6 + (profit - 600000) * 0.015;
else
bonus = bonus10 + (profit - 1000000) * 0.01;
printf("应发奖金为 %.2f 元\n", bonus);
return 0;
}
代码讲解
- 先算
bonus1等"分界点的累计奖金",再写if...else if...else判断利润落在哪一段。每一段都是:"前面已算好的累计奖金 + 超出部分 × 对应提成比例"。 scanf("%ld", &profit):%ld对应long类型,&profit表示把输入的值存到 profit 这个盒子里(&是取地址,见预备知识第 9 节)。- 注意不要把
bonus定义为int:那会让奖金的小数部分被丢弃,这里使用了double。
知识点
if...else if...else多分支判断- 整数
long与浮点数double混用的类型问题 scanf从键盘读入数据
数学原理
分段函数(piecewise function)的思想:奖金总额是利润的分段线性函数,每段的"斜率"(提成比例)不同。分段点的累计值是预先算好的"基准",避免每段都从头累加。这也是现实世界很多计费系统(如阶梯电价、个税)的模型。
扩展思考(其他解法)
- 用数组 + 循环改写:把分界点和比例分别存进数组,用循环从高段往低段找,代码更简洁、也更容易扩展到更多档位。可以自己尝试。
- 本题的"多个方案"不是重点,重点是理解分段处理的思想。
【程序 3】求一个数:它加上 100 后是完全平方数,再加 168 又是完全平方数
题目:一个整数,它加上 100 后是一个完全平方数,再加上 168 又是一个完全平方数,请问该数是多少?
解题思路
完全平方数就是能写成"某个整数的平方"的数,比如 1、4、9、16、25……
设这个数为 $x$,条件就是:$x+100 = a^2$ 且 $x+100+168 = x+268 = b^2$,其中 $a$、$b$ 都是整数。
思路:在 1~100000 的范围内逐个检查 $x$,对每个 $x$ 算出 $\sqrt{x+100}$ 和 $\sqrt{x+268}$,如果这两个平方根"开方开得尽"(即平方根再平方回去等于原数),就说明是完全平方数。
判断"开方开得尽"的技巧:
(int)sqrt(n) * (int)sqrt(n) == n。因为sqrt返回的是double小数,强转成int会把小数部分丢掉,若开方开得尽,丢不掉东西,两者相等。
完整代码
#include <stdio.h>
#include <math.h>
int main()
{
long int x;
int a, b;
for (x = 1; x < 100000; x++)
{
a = (int)sqrt(x + 100); // 平方根取整
b = (int)sqrt(x + 268); // x+100 再加 168,即 x+268
if (a * a == x + 100 && b * b == x + 268)
printf("%ld\n", x);
}
return 0;
}
代码讲解
sqrt是"开平方"函数,声明在<math.h>里,所以要多一个#include <math.h>。a = (int)sqrt(x + 100);:(int)叫强制类型转换,把sqrt返回的小数转成整数(小数部分直接丢掉)。a * a == x + 100:如果"整数平方根再平方"还能等于原数,说明原数恰好能开方开尽 → 是完全平方数。- 一个
&&同时验证两个条件都成立。
知识点
<math.h>头文件与数学函数sqrt- 强制类型转换
(int) - 完全平方数的判断技巧
数学原理
这里值得展开讲讲:题目其实有更妙的数学解法。设 $b^2 - a^2 = 168$,即 $(b-a)(b+a) = 168$。因为 168 是偶数,$b-a$ 与 $b+a$ 同奇偶,且 $168 = 2 \times 84 = 4 \times 42 = 6 \times 28 = 12 \times 14$,可以解出有限组整数解,从而得到 $x$。用程序枚举则更"暴力"但更通用——计算机擅长的就是这种笨办法,把数学推导交给代码去试。
小知识:直接运行可得到答案 21(对应 $21+100=121=11^2$,$21+268=289=17^2$)等结果。
【程序 4】判断某年某月某日是这一年的第几天
题目:输入某年某月某日,判断这一天是这一年的第几天。
解题思路
以 3 月 5 日为例:先把 1 月和 2 月的总天数加起来(31 + 28 = 59 天),再加上本月的 5 天,就是第 64 天。唯一要注意的是闰年:闰年 2 月有 29 天,所以如果输入的是闰年且月份在 3 月及以后,要在结果上加 1 天。
闰年的判断规则(格里高利历):能被 400 整除,或者能被 4 整除但不能被 100 整除。写成表达式是 year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)。
完整代码
#include <stdio.h>
int main()
{
int year, month, day, sum, leap;
printf("请输入年,月,日:");
scanf("%d,%d,%d", &year, &month, &day);
/* 先累加"这个月之前"所有月份的天数 */
switch (month)
{
case 1: sum = 0; break;
case 2: sum = 31; break;
case 3: sum = 59; break;
case 4: sum = 90; break;
case 5: sum = 120; break;
case 6: sum = 151; break;
case 7: sum = 181; break;
case 8: sum = 212; break;
case 9: sum = 243; break;
case 10: sum = 273; break;
case 11: sum = 304; break;
case 12: sum = 334; break;
default: printf("月份输入错误\n"); return 1;
}
sum = sum + day; // 再加上本月的天数
/* 判断闰年 */
if (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))
leap = 1;
else
leap = 0;
if (leap == 1 && month > 2) // 闰年且月份在 2 月之后,多算一天
sum++;
printf("这一天是这一年的第 %d 天\n", sum);
return 0;
}
代码讲解
switch (month)按月份取"该月之前的总天数",每个分支末尾的break很关键——没有break,程序会"掉下去"继续执行下一个 case(这叫"贯穿",有时是特性,这里必须避免)。- 各个月的累计天数:1 月前 0 天、2 月前 31 天、3 月前 31+28=59 天……注意这里先按平年算(2 月 28 天)。
- 最后用闰年判断修正:
leap == 1 && month > 2。
知识点
switch...case...break多分支结构- 闰年的判断逻辑(
||和&&的组合) scanf一次读入多个值(用逗号分隔的输入格式要和代码里的一致,即输入2024,3,5)
扩展思考(其他解法)
可以用数组替代 switch,把每个月之前的天数放进数组:
int days[13] = {0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
sum = days[month] + day;
(下标 0 不用,days[m] 直接存"m 月之前的总天数"。)这样代码短很多,也体现了用数据代替分支的思想——能用查表解决的就不用一堆 if。另外还可以把"每月的天数"用数组存起来再循环累加,作为第三种方法,这里不再展开。
【程序 5】把三个整数从小到大输出
题目:输入三个整数 x, y, z,把它们按从小到大输出。
解题思路
"冒泡式比较 + 交换":先把最小的数送到 x,再把剩下的最小的送到 y,剩下的 z 自然就是最大的。
具体做法:依次比较 x 与 y,若 x > y 就交换,使 x <= y;再比较 x 与 z,若 x > z 就交换,这样 x 一定是三个数中最小的;最后比较 y 与 z,使 y <= z。
交换三个变量的值需要借助一个临时变量 t:t = a; a = b; b = t;。直接写 a = b; b = a; 是不行的——a 被覆盖后,原来的值就丢了。
完整代码
#include <stdio.h>
int main()
{
int x, y, z, t;
scanf("%d%d%d", &x, &y, &z);
if (x > y) { t = x; x = y; y = t; } // 交换 x、y,使 x <= y
if (x > z) { t = x; x = z; z = t; } // 交换 x、z,使 x <= z(现在 x 最小)
if (y > z) { t = y; y = z; z = t; } // 交换 y、z,使 y <= z
printf("从小到大:%d %d %d\n", x, y, z);
return 0;
}
代码讲解
- 三步比较的顺序不能乱:必须先让
x成为最小值,再处理y、z。 - 花括号内是三条赋值语句,借助
t完成交换。
知识点
- 交换两个变量的经典写法(临时变量法)
- 分支结构的组合应用
数学原理
这种"比较-交换"其实就是选择排序的雏形:每轮找出当前最小(大)的元素放到最前(后)。程序 37 的"对 10 个数排序"就是对本题思想的推广。可以想象成打扑克时把牌一张张理整齐的过程。
扩展思考(其他解法)
- if 嵌套穷举:把所有 6 种大小关系都写出来,代码长但直观。
- 数组 + 排序:把三个数放进数组,用程序 37 的排序方法处理,适用于更多个数。
【程序 6】用 * 号输出字母 C 的图案
题目:用 * 号在屏幕上输出字母 C 的图案。
解题思路
先把字母 C 想象成由 * 组成的"像素画",然后在纸上(或脑子里)数好每一行要打几个 *、前面要空几格,再用 printf 把每一行原样打印出来。这题没有算法,纯粹是练习 printf 的排版能力。
完整代码
#include <stdio.h>
int main()
{
printf(" ****\n");
printf(" *\n");
printf("*\n");
printf(" *\n");
printf(" ****\n");
return 0;
}
代码讲解
\n是换行符,表示"光标移到下一行开头"。字符串里的空格原样输出,用来控制*的缩进。- 连续几个
printf依次输出每一行,拼起来就是字母 C。
知识点
printf的基本用法与转义字符\n、\t(制表符)等- 字符画的基本思路:把图形按行拆解
扩展思考
- 这类"画图题"在后面的程序 8(九九乘法表)、9(棋盘)、10(楼梯)、23(菱形)、61(杨辉三角)里会升级成用循环控制,那才是重点。本题只是热身。
- 想画别的字母?把 C 的轮廓数据换掉即可,这就是"点阵字"的雏形。
【程序 7】输出特殊图案(ASCII 字符画)
题目:输出一个由特殊字符组成的图案,运行后很漂亮。
解题思路
计算机里的每个字符都有一个编号(ASCII 码),范围 0~255。某些编号对应的是"图形字符"(如块状、条状符号),在文本模式下可以拼出图案。本题把字符码 176 和 219 交替排列,组成对称的图案。
完整代码
#include <stdio.h>
int main()
{
char a = 176, b = 219;
printf("%c%c%c%c%c\n", b, a, a, a, b);
printf("%c%c%c%c%c\n", a, b, a, b, a);
printf("%c%c%c%c%c\n", a, a, b, a, a);
printf("%c%c%c%c%c\n", a, b, a, b, a);
printf("%c%c%c%c%c\n", b, a, a, a, b);
return 0;
}
代码讲解
char a = 176;:把 176 这个整数当作字符码存进a。%c是按"字符"打印,printf("%c", a)就会输出编号为 176 的字符。- 每个
printf打一行 5 个字符,5 行组成菱形图案。
知识点
- ASCII 码与
%c输出 - 用整数给字符变量赋值
注意:字符 176、219 属于"扩展 ASCII"图形字符,在老的 DOS 文本模式下显示为花纹方块;现代 Windows 终端可能显示为问号或其他符号,属正常现象。若想在现代环境重现效果,可以改用普通字符(如
#、*)排版。本题的主要目的是让你理解"字符的本质是数字"。
【程序 8】输出 9×9 乘法口诀表
题目:输出 9×9 乘法口诀表。
解题思路
口诀表是一个 9 行 9 列的表格:第 i 行第 j 列的内容是 i × j。用双重循环:外层 i 控制行(1~9),内层 j 控制列(1~9),输出 i*j 的结果。关键是控制格式,让表格对齐。
完整代码
#include <stdio.h>
int main()
{
int i, j, result;
for (i = 1; i <= 9; i++)
{
for (j = 1; j <= i; j++) // 只打印到 j <= i,得到"下三角"表格
{
result = i * j;
printf("%d*%d=%-3d ", i, j, result); // %-3d 左对齐,占3位
}
printf("\n"); // 一行结束换行
}
return 0;
}
代码讲解
- 内层
j <= i:只输出下三角(第 1 行 1 个、第 2 行 2 个……),这是口诀表的常见排法。若写成j <= 9则是完整矩形。 %-3d:-表示左对齐,3表示这个数字至少占 3 个字符宽。这样 1、12、81 等不同位数也能对齐。格式控制符在排版题里非常常用。
知识点
- 双重循环(外层控制行、内层控制列,经典套路)
- 输出格式控制
%d、%-3d、%5d等
扩展思考(其他解法)
可以只用一重循环加字符串拼接实现,但可读性差,不推荐。本题的标准解法就是双重循环,已经足够好,不需要硬凑第二种方案。重点是把"行列"对应关系想清楚。
【程序 9】输出国际象棋棋盘
题目:输出 8×8 的国际象棋棋盘(黑白相间)。
解题思路
棋盘是 8×8 的格子,相邻格子颜色相反。把行号 i、列号 j 加起来看奇偶:(i + j) % 2 == 0 时是黑格,否则是白格(或者反过来)。这是经典的奇偶性判断技巧。
完整代码
#include <stdio.h>
int main()
{
int i, j;
for (i = 0; i < 8; i++)
{
for (j = 0; j < 8; j++)
{
if ((i + j) % 2 == 0)
printf(" "); // 白格(两个空格)
else
printf("##"); // 黑格(两个#,模拟方块)
}
printf("\n");
}
return 0;
}
代码讲解
- 字符码 219 是"实心方块"图形字符,在部分终端中不显示;这里改用
##更通用(任何终端都显示)。 - 每格打两个字符是为了让格子近似正方形(终端字符高 > 宽,打两个字符视觉上更接近方块)。
(i + j) % 2:相邻格子i+j奇偶性必然相反,所以能保证黑白相间。
知识点
%取余判断奇偶- 二维图形与双重循环的对应关系
数学原理
棋盘染色问题(棋盘格定理):$(i+j) \bmod 2$ 相同的位置同色,这背后是"平面二染色"的数学思想,在国际象棋走法、染色问题(如马踏棋盘)里都会用到。
扩展思考
想验证黑白是否相间正确:可以用字符 □、■(Unicode 方块字符)在现代终端输出,效果更接近真棋盘。
【程序 10】打印楼梯,同时在楼梯上方打印两个笑脸
题目:打印一个楼梯图案,并在楼梯上方打印两个"笑脸"。
解题思路
楼梯就是每行方块数递增的三角形:第 i 行输出 i 个方块。用外层循环控制行,内层循环按行号控制每行输出的个数——这是所有"三角形图案"题的通用套路。
完整代码
#include <stdio.h>
int main()
{
int i, j;
printf("^^\n"); // 上方两个"笑脸"(ASCII 码 1 的转义字符在现代终端不显示,改用 ^^)
for (i = 1; i <= 10; i++)
{
for (j = 1; j <= i; j++)
printf("#"); // 每行 i 个方块
printf("\n");
}
return 0;
}
代码讲解
printf("\1\1\n")输出 ASCII 码 1 的两个字符当作"笑脸",这在现代终端不显示,这里换成^^。- 内层循环
j <= i:行号越大,输出的#越多,形成楼梯/三角形。
知识点
- 循环嵌套中,内层循环次数依赖外层循环变量(
j <= i) - 三角形/阶梯图形的通用画法
扩展思考
把 j <= i 改成 j <= 2*i-1 就是"每行奇数个"的三角形,改造成本极小——图形题的本质就是找出每行个数与行号的数学关系。程序 23 的菱形就用了这个思路,可以对照学习。
第二部分:数学算法题(程序 11 ~ 20)
【程序 11】古典问题:兔子生小兔(斐波那契数列)
题目:有一对兔子,从出生后第 3 个月起每个月都生一对兔子,小兔子长到第 3 个月后又每个月生一对兔子。假如兔子都不死,问每个月的兔子总数为多少?
解题思路
先算前几个月:第 1 个月 1 对,第 2 个月 1 对,第 3 个月变成 2 对,第 4 个月 3 对,第 5 个月 5 对……规律是 1, 1, 2, 3, 5, 8, 13, 21, ...,也就是著名的斐波那契数列(Fibonacci):每一项等于前两项之和。
递推公式:
$$
F(n) = F(n-1) + F(n-2),\quad F(1) = F(2) = 1
$$
关键点:只需要保留最近两个数,用两个变量 f1、f2 滚动更新即可,不需要开数组存下所有月份。
完整代码
#include <stdio.h>
int main()
{
long f1 = 1, f2 = 1; // 前两个月
int i;
for (i = 1; i <= 20; i++)
{
printf("%12ld %12ld", f1, f2);
if (i % 2 == 0)
printf("\n"); // 每行输出 4 个数(两个一组)
f1 = f1 + f2; // 下一组的前一个数
f2 = f1 + f2; // 下一组的后一个数
}
return 0;
}
代码讲解
- 输出
f1 f2后,立即算出下一组:f1 = f1 + f2(这是下一对的第一个),再f2 = f1 + f2(下一对的第二个)。注意第二个语句里的f1已经是更新后的值了。 - 为什么用
long?斐波那契数列增长极快(大约每 5 个月翻 10 倍),第 40 项就超过 10 亿,超出int范围。所以必须用long。
知识点
- 斐波那契数列的递推
long类型与数值范围(int约 21 亿封顶)
数学原理
斐波那契数列在自然界随处可见(向日葵种子排列、菠萝的鳞片、兔子的繁殖模型),它与黄金分割 $\varphi = \frac{1+\sqrt5}{2} \approx 1.618$ 紧密相关:$\frac{F(n+1)}{F(n)} \to \varphi$。这是递归与递推思想的经典入口。
扩展思考(其他解法)
- 递归写法(程序 26 会正式讲递归,这里先看思路):
long fib(int n) { if (n <= 2) return 1; return fib(n - 1) + fib(n - 2); }代码极短但效率很低(会重复计算大量子问题),n 稍微大一点就跑不动了。所以实际计算时用循环递推更优。
- 本解法(滚动两个变量)已经足够好,不必再强行追求别的方案。
【程序 12】判断 101~200 之间有多少个素数,并输出所有素数
题目:判断 101 到 200 之间有多少个素数,并输出所有素数。
解题思路
素数(质数):大于 1 且只能被 1 和它本身整除的自然数。比如 2、3、5、7 是素数,4、6、8 不是。
判断 $m$ 是否为素数的经典方法:用 2 到 $\sqrt{m}$ 之间的每个整数去试除 $m$,只要有一个能整除,$m$ 就不是素数;全不能整除,$m$ 就是素数。
为什么只试到 $\sqrt{m}$ 就够了? 如果 $m$ 有一个大于 $\sqrt{m}$ 的因子 $d$,那么 $m/d$ 必然小于 $\sqrt{m}$ 且也是 $m$ 的因子——所以因子总是成对出现,检查到 $\sqrt{m}$ 就够了。这能把计算量从 $m$ 次降到 $\sqrt{m}$ 次。
完整代码
#include <stdio.h>
#include <math.h>
int main()
{
int m, i, k, count = 0;
for (m = 101; m <= 200; m++)
{
int isPrime = 1; // 先假设 m 是素数
k = (int)sqrt(m);
for (i = 2; i <= k; i++)
{
if (m % i == 0) // 能被整除,不是素数
{
isPrime = 0;
break; // 提前结束内层循环
}
}
if (isPrime)
{
printf("%-4d", m);
count++;
if (count % 10 == 0)
printf("\n"); // 每行 10 个
}
}
printf("\n101~200 之间共有 %d 个素数\n", count);
return 0;
}
代码讲解
isPrime是"标志变量":先置 1(假设是素数),一旦发现能被整除就置 0。break的作用是提前跳出循环:已经确定不是素数了,就没必要继续试除了,这是很常见的优化。- 用
count统计个数,每输出 10 个换一行(count % 10 == 0)。
知识点
- 素数的判断方法(试除法,试除到 $\sqrt{m}$)
break提前终止循环- 标志变量(flag)的用法
数学原理
"因子成对出现"是数论的基础结论。$m = a \times b$,若 $a \le b$,则必有 $a \le \sqrt{m}$,所以只需检查小于等于 $\sqrt{m}$ 的因子。这个结论后面很多题(程序 14、36、84)都会反复用到。
扩展思考(其他解法)
- 筛法(埃拉托斯特尼筛法):程序 36 会展示。其思想是"把合数全部筛掉",一次性生成一大段范围内的素数表,批量计算时比逐个试除快得多。
- 本题试除法已经足够清晰,不必硬凑其他方案;重点理解"为什么试到 $\sqrt{m}$"。
【程序 13】打印所有的"水仙花数"
题目:打印出所有"水仙花数"。所谓水仙花数是一个三位数,其各位数字的立方和等于该数本身。例如 153 是一个水仙花数,因为 $153 = 1^3 + 5^3 + 3^3$。
解题思路
遍历 100~999 的所有三位数,把每个数分解成百位、十位、个位,然后检验"各位立方和是否等于它自己"。核心技巧是用整除和取余分解数位:
- 百位:
n / 100(除以 100 丢掉十位个位) - 十位:
n / 10 % 10(先去掉个位,再取个位) - 个位:
n % 10(直接取余)
完整代码
#include <stdio.h>
int main()
{
int n, a, b, c;
printf("水仙花数有:");
for (n = 100; n < 1000; n++)
{
a = n / 100; // 百位
b = n / 10 % 10; // 十位
c = n % 10; // 个位
if (a * a * a + b * b * b + c * c * c == n)
printf("%d ", n);
}
printf("\n");
return 0;
}
代码讲解
n / 100:整数除法直接丢掉小数,比如 153/100 = 1。n / 10 % 10:153/10 = 15,再 15%10 = 5。n % 10:153%10 = 3。- 注意判断条件里是
a*a*a + b*b*b + c*c*c == n,别漏掉立方运算。
知识点
- 数位分解(
/和%的配合)——后面程序 29、30、89 都会用到 - 枚举 + 筛选
数学原理
水仙花数属于"自幂数"(Armstrong number):$n$ 位数等于其各位数字的 $n$ 次方之和。三位的是 153、370、371、407 四个;还有四位的"玫瑰花数"等。可以试着把代码改成判断四位数,看看能得到什么。
扩展思考
- 用循环套数位分解可以泛化到任意位数(n 位自幂数),但需要用循环求"每位数字的 n 次方",这里不展开。
- 本题原解法已经很简洁,无需多方案。
【程序 14】将一个正整数分解质因数
题目:将一个正整数分解质因数。例如输入 90,输出 90 = 2 * 3 * 3 * 5。
解题思路
质因数分解:把一个合数拆成若干个质数相乘。
经典算法(试除分解法):
- 从最小的质数
i = 2开始,如果n能被i整除,就输出i,并把n更新为n / i(商),继续用i试除(因为可能有多个相同的质因子,如 90 = 2 × 3 × 3 × 5 里 3 出现了两次)。 - 如果
n不能被i整除,i加 1 继续试。 - 直到
n变成 1(或最后剩下的 n 本身是质数,直接输出)。
妙处:虽然我们没判断
i是不是质数,但任何合数因子在遇到它之前,它的质因子一定已经被除掉了。比如遇到 4 之前,2 已经把 4 分解完了,所以i取到 4 时n不可能再被 4 整除。这就是"不会输出合数"的原因。
完整代码
#include <stdio.h>
int main()
{
int n, i;
printf("请输入一个正整数:");
scanf("%d", &n);
printf("%d = ", n);
for (i = 2; i <= n; i++)
{
while (n % i == 0) // 能整除就反复除(处理重复质因子)
{
printf("%d", i);
n = n / i;
if (n != 1)
printf(" * "); // 后面还有因子就输出乘号
}
}
printf("\n");
return 0;
}
代码讲解
- 外层
for负责"换下一个试除数",内层while负责"把同一个质因子除干净"。 - 例如 n=90:i=2 整除一次(输出 2,n=45);i=3 时 45%3==0(输出 3,n=15),再 15%3==0(输出 3,n=5);i=4 不整除;i=5 时输出 5,n=1。最终得到 2 3 3 * 5。
if (n != 1)判断是否输出乘号,避免结尾多一个*。
知识点
while与for的嵌套- 质因数分解算法
- 除法求商与取余配合使用
数学原理
算术基本定理(又称唯一分解定理):任何大于 1 的自然数都可以唯一地分解成质因数的乘积。这是数论的基础。分解质因数在密码学(大整数分解难题)中扮演着核心角色——RSA 加密算法的安全性就建立在"大数分解很难"上。
扩展思考
- 可以优化:
i只需试到 $\sqrt{原数}$,剩下的如果大于 1 一定是质数,直接输出。不过对本题规模没必要。 - 本题算法经典且高效,无需多方案。
【程序 15】用条件运算符输出成绩等级
题目:学习成绩 ≥ 90 分用 A 表示,60~89 分用 B 表示,60 分以下用 C 表示。
解题思路
条件运算符(三目运算符) 条件 ? 值1 : 值2 是 if...else 的简洁写法:条件成立取"值1",否则取"值2"。可以嵌套使用,一层层判断。
完整代码
#include <stdio.h>
int main()
{
int score;
char grade;
printf("请输入成绩:");
scanf("%d", &score);
grade = score >= 90 ? 'A' : (score >= 60 ? 'B' : 'C');
printf("%d 分的等级是 %c\n", score, grade);
return 0;
}
代码讲解
score >= 90 ? 'A' : (score >= 60 ? 'B' : 'C'):先判断是否 ≥90,是则 A;否则进入内层括号再判断是否 ≥60,是则 B,否则 C。嵌套的三目运算符就是"一层层筛选"。- 结果是一个字符,所以存到
char类型变量里。
知识点
- 三目运算符
?: char类型与字符常量'A'
扩展思考(其他解法)
用 if...else if...else 写更直观,适合逻辑复杂时使用:
if (score >= 90) grade = 'A';
else if (score >= 60) grade = 'B';
else grade = 'C';
两种写法等价,三目适合简单判断,if 适合多分支。都掌握,看场景选用。
【程序 16】求最大公约数和最小公倍数(辗转相除法)
题目:输入两个正整数 m 和 n,求它们的最大公约数和最小公倍数。
解题思路
最大公约数(GCD) 用辗转相除法(欧几里得算法):不断用"大数除小数取余数",再用"除数"和"余数"重复,直到余数为 0,此时的除数就是最大公约数。
例如 18 和 12:18 % 12 = 6 → 12 % 6 = 0,最大公约数是 6。
最小公倍数(LCM) 有一个漂亮的性质:
$$
\text{lcm}(a, b) = \frac{a \times b}{\gcd(a, b)}
$$
先算出最大公约数,最小公倍数就顺手得出了。
完整代码
#include <stdio.h>
int main()
{
int a, b, num1, num2, temp;
printf("请输入两个正整数:");
scanf("%d,%d", &num1, &num2);
a = num1;
b = num2;
while (b != 0) // 辗转相除,直到余数为 0
{
temp = a % b;
a = b;
b = temp;
}
printf("最大公约数:%d\n", a);
printf("最小公倍数:%d\n", num1 * num2 / a);
return 0;
}
代码讲解
- 循环体三行是辗转相除的核心:
temp = a % b; a = b; b = temp;。第一次循环里 a、b 分别是输入的两个数;之后 a、b 变成"除数"和"余数"。 - 循环结束(b == 0)时,a 就是最大公约数。
- 注意在循环前把原值存进
num1、num2,因为循环会改掉a、b,而算最小公倍数还需要原数。
知识点
- 辗转相除法(欧几里得算法)
- 变量备份(循环会改变变量时先保存原值)
数学原理
辗转相除法的正确性依据:$\gcd(a, b) = \gcd(b, a \bmod b)$。因为 $a$ 和 $b$ 的公约数集合与 $b$ 和 $a \bmod b$ 的公约数集合完全相同。欧几里得算法是"人类已知最古老的算法之一",距今约 2300 年。而"两数乘积 = 最大公约数 × 最小公倍数"是数论的常用性质,可用于快速求最小公倍数。
扩展思考(其他解法)
- 更相减损术(中国古代《九章算术》):
while (a != b) { if (a > b) a -= b; else b -= a; },思想是"两数相减,差的公约数不变"。它和辗转相除法本质相同,但减法在数很大时慢得多。 - 本题辗转相除法最优,不必强求别的方案。
【程序 17】统计一行字符中各类字符的个数
题目:输入一行字符,分别统计其中英文字母、空格、数字和其他字符的个数。
解题思路
用 getchar() 一个字符一个字符地读,读到 '\n'(换行符)为止,对每个字符分类计数。分类依据是字符的 ASCII 码区间:
- 英文字母:
'A'~'Z'或'a'~'z' - 数字:
'0'~'9' - 空格:
' ' - 其他:剩下的
完整代码
#include <stdio.h>
int main()
{
char c;
int letters = 0, space = 0, digit = 0, others = 0;
printf("请输入一行字符:");
while ((c = getchar()) != '\n')
{
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
letters++;
else if (c == ' ')
space++;
else if (c >= '0' && c <= '9')
digit++;
else
others++;
}
printf("字母 %d 个,空格 %d 个,数字 %d 个,其他 %d 个\n",
letters, space, digit, others);
return 0;
}
代码讲解
(c = getchar()) != '\n':先把读到的字符赋给c,再判断是不是换行符。这是 C 里非常经典的写法——把"读取"和"判断"写在一个表达式里。- 字符可以直接比较大小:因为字符本质上是整数(ASCII 码),
c >= 'a'就是在比较 ASCII 码。字符按 ASCII 码顺序排列,所以c >= 'a' && c <= 'z'就能判断 c 是否小写字母。 - 注意逻辑优先级:
&&优先级高于||,所以c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'等价于(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')。
知识点
getchar()逐个读字符- ASCII 码区间判断字符类别
- 循环中的"边读边判断"
数学原理
这题用到的是 ASCII 编码的有序性:英文字母、数字在 ASCII 表中都是连续排列的('a'=97,'A'=65,'0'=48),所以可以用区间判断。计算机里所有"字符分类"最终都是数值比较。
扩展思考(其他解法)
- 标准库里有
isalpha()、isspace()、isdigit()等现成的字符分类函数(在<ctype.h>里),直接调用更简洁且不会出错:if (isalpha(c)) letters++; else if (c == ' ') space++; else if (isdigit(c)) digit++;工程实践中推荐用库函数,学习阶段自己写能加深理解。
【程序 18】求 a + aa + aaa + ... 的值
题目:求 $s = a + aa + aaa + aaaa + \dots$ 的值,其中 a 是一个数字,项数由键盘输入。例如 2 + 22 + 222 + 2222 + 22222(共 5 项)。
解题思路
找规律:后一项是前一项"后面再添一个 a"。
- 第 1 项:
a - 第 2 项:
a*10 + a - 第 3 项:前一项
*10 + a
所以每项可以用递推得到:tn = tn * 10 + a,再累加进总和 sn。用一个变量存"当前项",另一个存"累计和"。
完整代码
#include <stdio.h>
int main()
{
int a, n, count = 1;
long sn = 0, tn = 0; // tn: 当前项; sn: 总和
printf("请输入数字 a 和项数 n:");
scanf("%d,%d", &a, &n);
while (count <= n)
{
tn = tn * 10 + a; // 生成下一项:aa、aaa...
sn = sn + tn; // 累加
count++;
}
printf("a+aa+aaa+... = %ld\n", sn);
return 0;
}
代码讲解
- 循环第 1 轮:
tn = 0 * 10 + 2 = 2;第 2 轮:tn = 2 * 10 + 2 = 22;第 3 轮:tn = 22 * 10 + 2 = 222……每一项都从前一项递推出来。 sn用long:n 稍大时和会很大,防止溢出。
知识点
- 递推生成数列项
- 累加器的模式(
sn = sn + tn)
数学原理
这实际上是等比数列的变形。通项 $t_k = a \times \frac{10^k - 1}{9}$,总和可以用等比数列求和公式直接算。但程序里的递推法更通用,也不需要记公式。可以把这两种方法对照理解:公式适合人算,递推适合计算机算。
扩展思考
- 若 a、n 都比较大,
long也可能溢出,可改用long long或高精度(大数运算)思想,超出本题范围。
【程序 19】找出 1000 以内的所有"完数"
题目:一个数如果恰好等于它的因子之和(不含它本身),这个数就称为"完数"。例如 $6 = 1 + 2 + 3$。编程找出 1000 以内的所有完数。
解题思路
对每个数 $j$(从 2 到 999),找出它所有真因子(能整除 $j$ 且小于 $j$ 的数),把因子累加,如果和等于 $j$ 本身,就是完数。判断因子:j % i == 0 说明 i 是 j 的因子。
完整代码
#include <stdio.h>
int main()
{
int i, j, s;
printf("1000 以内的完数:\n");
for (j = 2; j < 1000; j++)
{
s = 0; // 因子和清零
for (i = 1; i < j; i++) // 找所有真因子
{
if (j % i == 0)
s = s + i;
}
if (s == j) // 因子和等于自身 → 完数
{
printf("%d 的因子为:", j);
for (i = 1; i < j; i++)
if (j % i == 0)
printf("%d ", i);
printf("\n");
}
}
return 0;
}
代码讲解
- 第一轮循环求因子和,若等于本身再开第二轮循环打印因子(也可以用数组先存因子,这里直接再算一遍更直观)。
- 因子从 1 试到
j-1(不含j本身)。
知识点
- 双重循环
- 因子的判断(取余)
数学原理
完数(perfect number)与数学史上著名的完全数猜想有关。已知的前几个完数是 6、28、496、8128。欧几里得证明了:若 $2^p - 1$ 是素数(梅森素数),则 $2^{p-1}(2^p - 1)$ 是完数;欧拉后来证明了其逆定理。至今人们仍未找到"奇完数",这是数学未解之谜之一。本题的暴力枚举思路能直接算出 6、28、496 三个答案。
扩展思考(其他解法)
- 优化:因子只需试到 $\sqrt{j}$(因子成对出现),每找到一个因子
i就把j/i也加上。可以自己改写,体会优化带来的速度提升。 - 也可以用一个数组先把因子存起来再输出,但直接再遍历一遍更直观,代码更短。
【程序 20】自由落体反弹问题
题目:一球从 100 米高度自由落下,每次落地后反跳回原高度的一半,再落下。求它在第 10 次落地时共经过多少米?第 10 次反弹多高?
解题思路
模拟过程:设总路程 sn = 100(第一次落地的路程),反弹高度 hn = 50(第一次反弹高度)。之后每次落地前都要经过"反弹上去再落下来"两段,每段都是上一次反弹高度。即第 n 次落地时,总路程增加 2 * hn,然后 hn 减半。
需要区分"反弹高度"和"落地时的总路程"这两个量:第 10 次反弹高度是在第 10 次落地后再弹起的高度,所以循环结束后 hn 已经是第 11 次反弹……要仔细看清题目问的是"第 10 次反弹多高"。按题意"第 10 次落地时共经过多少米,第 10 次反弹多高",下面是标准写法。
完整代码
#include <stdio.h>
int main()
{
double sn = 100.0, hn = 50.0; // 总路程、首次反弹高度
int n;
for (n = 2; n <= 10; n++)
{
sn = sn + 2 * hn; // 第 n 次落地时,多走了"上去再下来"两段
hn = hn / 2; // 下一次反弹高度减半
}
printf("第 10 次落地时共经过 %.4f 米\n", sn);
printf("第 10 次反弹 %.4f 米\n", hn);
return 0;
}
代码讲解
hn初始为 50(第一次反弹高度)。循环从第 2 次落地开始累计:每次多走2 * hn。- 循环结束后
hn已经被减半 9 次(即 $50 \times (1/2)^9$),对应第 10 次反弹高度。 - 用
double:100/2 有小数,且不断除以 2,用浮点数才准确。
知识点
- 等比数列(公比 1/2)
- 浮点数
double的应用
数学原理
这是等比数列求和问题。总路程
$$
S = 100 + 2 \times 50 + 2 \times 25 + \dots = 100 + 2 \times 50 \times (1 + \frac12 + \frac14 + \dots)
$$
等比数列求和公式 $S_n = \frac{a_1(1-q^n)}{1-q}$ 可以手算验证。注意"落地路程"和"反弹高度"是两组不同的量,编程时最容易搞混的就是这里。
扩展思考
可以改造成"问第 n 次落地":把 10 换成用户输入即可。原解法清晰简洁,无需多方案。
第三部分:递推与递归(程序 21 ~ 30)
【程序 21】猴子吃桃问题(逆向思维)
题目:猴子第一天摘下若干个桃子,当即吃了一半,还不过瘾,又多吃了一个。以后每天早上都吃了前一天剩下的一半零一个。到第 10 天早上想再吃时,只剩一个桃子了。求第一天共摘了多少个。
解题思路
正向想很麻烦,但倒着推极其简单。第 10 天早上剩 1 个,那么第 9 天早上吃之前有多少?设第 n 天早上有 $xn$ 个,则
$$
x{n+1} = \frac{x_n}{2} - 1 \quad\Longrightarrow\quad xn = (x{n+1} + 1) \times 2
$$
即"第 n 天的桃子数 = (第 n+1 天的桃子数 + 1) × 2"。从第 10 天的 1 个倒推 9 次,就得到第 1 天的总数。这就是逆向思维 / 递推。
完整代码
#include <stdio.h>
int main()
{
int day = 9; // 需要倒推 9 次(从第10天推到第1天)
int x1, x2 = 1; // x2 是"后一天"的桃子数,从第10天的 1 开始
while (day > 0)
{
x1 = (x2 + 1) * 2; // 前一天 = (后一天 + 1) * 2
x2 = x1; // 前一天变成新的"后一天",继续往前推
day--;
}
printf("第一天共摘了 %d 个桃子\n", x1);
return 0;
}
代码讲解
- 循环 9 次(day 从 9 减到 0),每次把"后一天"的桃子数换算成"前一天"的。
- 顺序:
x2=1(第 10 天)→ 第一次循环算出第 9 天 → 第二次算出第 8 天……第九次算出第 1 天。 - 最终
x1就是答案 1534。
知识点
- 逆向递推(从已知的最后状态倒推初始状态)
while循环
数学原理
这其实是递推关系 $xn = 2x{n+1} + 2$,是简单的一阶线性递推,可以用通项公式解:$x_1 = 2^{10} \times 1 + 2^{10} - 2 = 1534$。程序用迭代代替公式,思路更直观。
扩展思考(其他解法)
- 递归写法:
f(n) = n==10 ? 1 : 2*(f(n+1)+1),与程序 28 的递归思想一致,学到递归后再回来看这题会很有感触。 - 本题递推已经最优,重点体会"逆向思维"。
【程序 22】乒乓球比赛对阵名单(穷举 + 筛选)
题目:甲队有 a、b、c 三人,乙队有 x、y、z 三人。已抽签决定比赛名单。a 说他与 x 不比,c 说他与 x、z 不比。请找出对阵名单。
解题思路
变量 i、j、k 分别表示 a、b、c 的对手(取值 x、y、z)。用三层循环穷举所有对阵组合,然后按条件筛选:
- a、b、c 的对手必须互不相同(
i != j && i != k && j != k)。 - a 不与 x 比(
i != 'x')。 - c 不与 x、z 比(
k != 'x' && k != 'z')。
这是"排列 + 约束条件"问题,也就是小型搜索问题:枚举所有可能,剔除不满足约束的。约束越多,剩下的越少。
完整代码
#include <stdio.h>
int main()
{
char i, j, k; // i: a的对手, j: b的对手, k: c的对手
for (i = 'x'; i <= 'z'; i++) // a 的对手候选
for (j = 'x'; j <= 'z'; j++) // b 的对手候选
{
if (i != j) // a、b 对手不同
for (k = 'x'; k <= 'z'; k++)
{
if (i != k && j != k && // c 与 a、b 对手不同
i != 'x' && // a 不与 x 比
k != 'x' && k != 'z') // c 不与 x、z 比
printf("a--%c b--%c c--%c\n", i, j, k);
}
}
return 0;
}
代码讲解
- 用字符
'x'、'y'、'z'循环,i <= 'z'是因为字符在 ASCII 表里连续排列('x'=120,'y'=121,'z'=122)。 - 先判断
i != j再进入第三层循环,减少无谓的组合(这叫"剪枝"思想,虽然这里组合数很少)。 - 所有条件同时满足才输出。
知识点
- 三重循环穷举
- 约束条件筛选(搜索问题雏形)
数学原理
这是简单的"受限排列"问题:总排列数 $3! = 6$ 种,加上约束条件后只剩下唯一解。可以手算验证:由 c 不与 x、z 比,c 只能对 y;a 不与 x 比,a 只能对 z(y 已被 c 占);剩下 b 对 x。
扩展思考
- 手动推一下更快得到答案:c--y,a--z,b--x。程序的意义在于展示"让计算机替你穷举"的方法。
- 本题解法最优,无需多方案。
【程序 23】打印菱形图案
题目:打印如下菱形(7 行):
*
***
*****
*******
*****
***
*
解题思路
把菱形分成上下两半:上半部分 4 行(星号数 1、3、5、7 递增),下半部分 3 行(星号数 5、3、1 递减)。每行结构都是"空格 + 星号":上半部分第 i 行(i 从 0 开始)有 2-i 个空格、2*i+1 个星号。
关键是找出每行空格数、星号数与行号的数学关系。
完整代码
#include <stdio.h>
int main()
{
int i, j, k;
/* 上半部分:4 行 */
for (i = 0; i <= 3; i++)
{
for (j = 0; j <= 2 - i; j++) // 空格数递减:3,2,1,0
printf(" ");
for (k = 0; k <= 2 * i; k++) // 星号数递增:1,3,5,7
printf("*");
printf("\n");
}
/* 下半部分:3 行 */
for (i = 0; i <= 2; i++)
{
for (j = 0; j <= i; j++) // 空格数递增:1,2,3
printf(" ");
for (k = 0; k <= 4 - 2 * i; k++) // 星号数递减:5,3,1
printf("*");
printf("\n");
}
return 0;
}
代码讲解
- 上半部分第 i 行(i=0,1,2,3):空格
2-i个(3,2,1,0),星号2i+1个(1,3,5,7)。因为中间行是第 3 行(i=3),星号2*3+1=7。 - 下半部分第 i 行(i=0,1,2):空格
i+1个(1,2,3),星号5-2i个(5,3,1)。 - 把行号与数量写成公式,是图形类题目的通用解法。
知识点
- 图形题的核心:行号 → 空格数、星号数的函数关系
- 双重/三重循环
数学原理
菱形的每行可以看作一个等差数列。星号数是等差数列 $1,3,5,7,\dots$(公差 2),即奇数序列 $2i+1$。在数学建模中,"把图形抽象成行列公式"是非常基础的一步。
扩展思考(其他解法)
- 更统一的写法:把菱形看成坐标平面,星号满足 $|x| + |y| \le r$(曼哈顿距离),可以用一个双重循环加绝对值判断直接输出整个菱形,适合"任意大小"的菱形。作为进阶练习可以自己实现。
- 原解法已足够好,重点是理解"行列公式"。
【程序 24】分数序列求和
题目:有一分数序列 $\frac21, \frac32, \frac53, \frac85, \frac{13}8, \frac{21}{13} \dots$,求前 20 项之和。
解题思路
观察规律:每一项的分子是上一项的分子加分母;每一项的分母是上一项的分子。也就是说,分子分母本身构成斐波那契数列的相邻两项!
用变量 a(分子)、b(分母)递推:算出 $\frac{a}{b}$ 后,下一项的分子是 a+b,分母是 a。注意更新顺序:t = a; a = a + b; b = t; 需要借助临时变量,否则 a 先被修改后 b 就拿不到原值了。
完整代码
#include <stdio.h>
int main()
{
int n;
float a = 2, b = 1, s = 0; // 分子、分母、和
for (n = 1; n <= 20; n++)
{
s = s + a / b; // 累加当前项
int t = a;
a = a + b; // 下一项分子 = 当前分子 + 分母
b = t; // 下一项分母 = 当前分子
}
printf("前 20 项之和为 %.6f\n", s);
return 0;
}
代码讲解
- 为什么用
float:a/b是除法,结果有小数。 - 临时变量
t的作用:a = a + b会覆盖a的旧值,但下一项的分母要的就是这个旧值,所以先存到t里。这就是"先备份再修改"的经典场景。
知识点
- 斐波那契递推的变体
- 借助临时变量的正确更新顺序
数学原理
这个数列有个有趣的性质:相邻项之比 $\frac{F_{n+1}}{F_n}$ 越来越接近黄金分割 $\varphi \approx 1.618$,而 $\frac{a}{b}$ 本身趋向 $\varphi$,所以这个级数并不收敛(每项趋于 1.618 > 0),和会越来越大。前 20 项之和约为 32.66。这题让你体会:递推公式的每一步必须保持变量的"新鲜度"。
扩展思考
- 分子分母在 20 项时已经很大,用
int可能溢出,实际可用long。本题规模小,float也能算,但理解"数值范围"很重要。
【程序 25】求 1! + 2! + 3! + ... + 20! 的和
题目:求 $1! + 2! + 3! + \dots + 20!$ 的和。($n!$ 表示 $1 \times 2 \times \dots \times n$,读作"n 的阶乘")
解题思路
$n! = n \times (n-1)!$,所以可以边乘边加:用一个变量 t 保存"当前阶乘",每轮 t *= n 得到 $n!$,再累加。这样不需要每次重新从 1 乘到 n,时间复杂度是 O(n) 而不是 O(n²)。
完整代码
#include <stdio.h>
int main()
{
int n;
double s = 0, t = 1; // t 存 n!,s 存和
for (n = 1; n <= 20; n++)
{
t = t * n; // 得到 n!
s = s + t; // 累加
}
printf("1!+2!+...+20! = %e\n", s); // %e 科学计数法输出
return 0;
}
代码讲解
- 循环体只有两行:先乘后加。第 1 轮 t=1!,第 2 轮 t=2!,……第 20 轮 t=20!。
- 为什么用
double?$20! \approx 2.4 \times 10^{18}$,远超int(约 21 亿)甚至long(约 9.2 × 10¹⁸,差一点点)的范围。double能表示约 $10^{308}$ 的数。 %e表示按科学计数法输出(如2.561327e+18)。
知识点
- 阶乘的递推计算
- 数据溢出与选择合适的类型
数学原理
阶乘增长极快(比指数还快),是"组合数学"的基础运算。$n!$ 出现在排列数、组合数公式里:$P(n,k) = \frac{n!}{(n-k)!}$,$C(n,k) = \frac{n!}{k!(n-k)!}$。20! 已经有 19 位,这就是为什么不能随手用 int。
扩展思考
- 如果想得到精确整数,可用
long long(约 9.2×10¹⁸),20! = 2432902008176640000 仍在范围内。用double会有精度损失(浮点数的通病),可以对比一下两种输出。
【程序 26】利用递归求 5!
题目:利用递归方法求 5!。
解题思路
递归就是"函数调用自己"。求 $n!$ 可以这样看:
$$
n! = n \times (n-1)!, \qquad 0! = 1
$$
要求 fact(n),就先调用 fact(n-1) 算出 $(n-1)!$,再乘以 n。递归必须有终止条件(这里是 n == 0 返回 1),否则会无限调用自己直到栈溢出。
完整代码
#include <stdio.h>
int fact(int n)
{
if (n == 0)
return 1; // 终止条件
else
return n * fact(n - 1); // 递归调用
}
int main()
{
int i;
for (i = 0; i <= 5; i++)
printf("%d! = %d\n", i, fact(i));
return 0;
}
代码讲解
fact(5)执行时:发现 n=5 ≠ 0,于是调用fact(4);fact(4)调用fact(3)……直到fact(0)直接返回 1。然后结果一层层"弹回来":fact(1) = 1*1 = 1,fact(2) = 2*1 = 2……最终fact(5) = 120。- 递归分两个阶段:递推(层层深入,直到触底)和回归(逐层返回结果)。程序 28 会再讲这个概念。
知识点
- 递归的定义:函数调用自身
- 递归必须有终止条件
- 调用栈的概念(每次递归调用都会占一份内存,递归太深会栈溢出)
数学原理
阶乘的递归定义 $n! = n(n-1)!$ 是数学归纳法思想的直接体现:只要基础情形($0! = 1$)成立,并且递推步骤正确,结论对所有 n 成立。递归与数学归纳法一一对应,这也是为什么学 C 语言一定会学递归。
扩展思考(其他解法)
- 循环迭代版本见程序 25,比递归更省内存。递归的优势是代码与数学定义一致、可读性好,但深度很大时(如
fact(100000))会栈溢出,此时必须用迭代。 - 本题递归是考点,两种写法都要会。
【程序 27】递归实现字符串逆序打印
题目:输入 5 个字符,用递归方法以相反顺序打印出来。
解题思路
核心技巧:先递归处理"后面的字符",再打印"当前字符"。这样最后打印的是第一个字符,实现了逆序。
对 palin(n):读一个字符存起来 → 调用 palin(n-1) 处理剩余的 → 打印存起来的字符。当 n 减到 1 时直接读、直接打印(触底)。这就是"后进先出"——最先读的字符最后打印,完全符合"递归栈"的特性。
完整代码
#include <stdio.h>
void palin(int n)
{
char c;
if (n <= 1)
{
c = getchar();
putchar(c); // 最后一个字符,直接打印
}
else
{
c = getchar(); // 读当前字符
palin(n - 1); // 先处理后面的字符
putchar(c); // 再打印当前字符 → 实现逆序
}
}
int main()
{
printf("请输入 5 个字符:");
palin(5);
printf("\n");
return 0;
}
代码讲解
getchar()每次读一个字符(包括回车也算字符!输入时注意别多敲)。- 关键在
palin(n-1)和putchar(c)的顺序:先递归,后打印。递归栈是"后进先出",所以越早读的字符越晚打印,自然就逆序了。
知识点
- 递归与"栈"(后进先出)的关系
getchar/putchar
数学原理
这体现了递归的本质:把大问题分解成"一个相同结构的小问题 + 一点额外工作"。逆序打印 5 个字符 = 打印第 1 个字符 + 逆序打印后 4 个字符,而逆序打印后 4 个 = 打印第 2 个 + 逆序打印后 3 个……直到只剩 1 个字符直接打印。递归就是这种"自我相似"的分解。
扩展思考(其他解法)
- 用数组 + 循环从后往前打印更简单,但本题的考点就是递归思想。非递归版本:
for (i = 4; i >= 0; i--) printf("%c", str[i]);
【程序 28】递归求年龄
题目:5 个人坐在一起。第 5 个人说他比第 4 个人大 2 岁,第 4 个说比第 3 个大 2 岁……第 1 个人说他 10 岁。问第 5 个人多大?
解题思路
递归公式非常清晰:
$$
age(n) = \begin{cases} 10 & n = 1 \ age(n-1) + 2 & n > 1 \end{cases}
$$
即第 n 个人的年龄比第 n-1 个人大 2 岁。这题是程序 26 的"应用题",理解递归的回推(递推深入)与回归(逐层返回)两个阶段即可。
完整代码
#include <stdio.h>
int age(int n)
{
if (n == 1)
return 10; // 第 1 个人 10 岁(终止条件)
else
return age(n - 1) + 2; // 第 n 个人比第 n-1 个大 2 岁
}
int main()
{
printf("第 5 个人的年龄是 %d 岁\n", age(5));
return 0;
}
代码讲解
age(5)→age(4)+2→age(3)+4→age(2)+6→age(1)+8 = 10+8 = 18。- 每一步"挂起"等待子调用返回,最后逐层加回 2。
知识点
- 递归的两个阶段:回推(递推)与回归
- 递归终止条件的重要性
扩展思考
用循环从第 1 个人往后推 4 次也一样:age = 10; for(i=1; i<5; i++) age += 2;。两种方法对比着看,能加深对递归本质的理解。
【程序 29】不多于 5 位的正整数的位数与逆序输出
题目:给一个不多于 5 位的正整数,求它是几位数,并逆序打印各位数字。
解题思路
用整除和取余把每一位分解出来:除以 10000 得万位、除以 1000 再取余得千位……然后用 if 判断最高位在哪个位置,从而确定位数,同时逆序输出(先输出个位再输出万位)。
固定分解 5 个数位再判断的写法代码冗长;更通用的写法是循环:不断 n % 10 取个位、n /= 10 去掉个位,直到 n 为 0,同时计数。
完整代码
#include <stdio.h>
int main()
{
long n;
int digits = 0;
printf("请输入一个不多于 5 位的正整数:");
scanf("%ld", &n);
printf("逆序为:");
while (n > 0)
{
printf("%ld", n % 10); // 输出当前个位
n = n / 10; // 去掉个位
digits++; // 位数 +1
}
printf("\n这是一个 %d 位数\n", digits);
return 0;
}
代码讲解
n % 10取个位,n / 10砍掉个位,循环直到 n 变成 0。这个"逐位剥离"写法是通用技巧,比固定分解 5 个数位的方法好得多(任意位数都能用)。- 循环次数就是位数。
知识点
- 逐位剥离数字(
%10+/10循环) - 通用算法优于针对特定长度的写法
扩展思考(其他解法)
- "固定分解 5 位"的方法(
a=x/10000等)在"不多于 5 位"的限制下也可行,但代码长且不通用。本题循环版是最佳方案。 - 若想保留原数(循环会破坏 n),可以先用一个变量备份。
【程序 30】判断回文数
题目:一个 5 位数,判断它是不是回文数。例如 12321 是回文数(个位与万位相同、十位与千位相同)。
解题思路
回文数就是"正着读和倒着读一样"的数。对 5 位数,只需比较"个位 == 万位"且"十位 == 千位"。分解出这四个数位后比较即可。
完整代码
#include <stdio.h>
int main()
{
long x, wan, qian, shi, ge;
printf("请输入一个 5 位数:");
scanf("%ld", &x);
wan = x / 10000; // 万位
qian = x % 10000 / 1000; // 千位
shi = x % 100 / 10; // 十位
ge = x % 10; // 个位
if (ge == wan && shi == qian)
printf("%ld 是回文数\n", x);
else
printf("%ld 不是回文数\n", x);
return 0;
}
代码讲解
- 数位分解方式同程序 29。
- 5 位数中间那位(百位)不参与比较——回文对称轴。
- 更通用的回文判断(任意位数)可以用程序 29 的"逐位剥离"法:把数逆序重建,若逆序后与原数相等就是回文。下面给出这种通用解法。
知识点
- 数位分解
- 回文判断
数学原理
回文(palindrome)概念不只在数字里:字符串("level"、"上海自来水来自海上")也是回文。数字回文的判断本质是"对称性验证"。通用思路:倒过来重建这个数,比较是否相等。
扩展思考(其他解法)
通用版本(任意位数):
long n, rev = 0, m;
scanf("%ld", &n);
m = n;
while (m > 0) {
rev = rev * 10 + m % 10; // 倒着重建
m /= 10;
}
if (rev == n) printf("是回文数\n");
这个"数字反转"技巧非常常用(比如程序 89 的加密题也会涉及类似思想)。
第四部分:控制流与数组(程序 31 ~ 40)
【程序 31】按字母判断星期几
题目:输入星期几的第一个字母来判断是星期几,如果第一个字母相同,则继续判断第二个字母。
解题思路
星期一到星期日的英文是 Monday、Tuesday、Wednesday、Thursday、Friday、Saturday、Sunday。看首字母:M(Monday)、W(Wednesday)、F(Friday)是唯一的,直接判断;S 有 Saturday/Sunday 两种可能,T 有 Tuesday/Thursday 两种可能,需要再看第二个字母。
用 switch 处理首字母,需要时再用 getchar() 读第二个字母区分。注意要用标准的 getchar() 而不是某些编译器特有的 getch()(不回车直接读按键),这里给出标准写法。
完整代码
#include <stdio.h>
int main()
{
char letter;
printf("请输入星期几的首字母:");
letter = getchar();
switch (letter)
{
case 'M': printf("Monday(星期一)\n"); break;
case 'W': printf("Wednesday(星期三)\n"); break;
case 'F': printf("Friday(星期五)\n"); break;
case 'S':
printf("请输入第二个字母:");
getchar(); // 吃掉输入缓冲里的换行符
letter = getchar();
if (letter == 'a') printf("Saturday(星期六)\n");
else if (letter == 'u') printf("Sunday(星期日)\n");
else printf("数据错误\n");
break;
case 'T':
printf("请输入第二个字母:");
getchar(); // 吃掉换行符
letter = getchar();
if (letter == 'u') printf("Tuesday(星期二)\n");
else if (letter == 'h') printf("Thursday(星期四)\n");
else printf("数据错误\n");
break;
default: printf("数据错误\n");
}
return 0;
}
代码讲解
- 注意
getchar()会把"回车"也当作一个字符读走,所以第二次读字符前要先用一个getchar()把残留的换行符"吃掉"。这是输入缓冲的经典坑(预备知识第 11 节也提到)。 switch里S、T两个 case 用嵌套if区分第二字母。
知识点
switch多分支getchar与输入缓冲(换行符问题)
扩展思考
- 更工程化的做法是读入整个单词再比较字符串(程序 79 会涉及字符串比较),但本题限定"按字母判断",switch 法就是正解。
【程序 32】按键切换颜色
题目:按任意键改变屏幕颜色。
解题思路
要让终端显示彩色文字/背景,需要向终端输出 ANSI 转义序列:一串以 ESC 字符(\033)开头的"控制字符",终端收到后就会改变后续文字的样式,而不会把这些字符显示出来。
背景色的核心格式是 \033[40m~\033[47m(黑红绿黄蓝品红青白 8 种),配合 getch() 实现"按任意键切换"。
完整代码
#include <stdio.h>
#include <conio.h> // getch():读一个按键(不回车、不回显),Windows/MinGW 自带
int main()
{
int color;
for (color = 0; color < 8; color++)
{
printf("\033[%dm", 40 + color); // 设置背景色:40~47 对应 8 种颜色
printf("This is color %d\r\n", color);
printf("Press any key to continue\r\n");
getch(); // 按任意键继续
}
printf("\033[0m"); // 恢复默认颜色
return 0;
}
代码讲解
\033[40m让背景变黑,\033[41m变红……\033[47m变白,所以40 + color正好循环 8 种背景色。\r\n是"回车 + 换行"(DOS/Windows 风格),终端里与\n效果相近。getch()来自<conio.h>,读键但不需要按回车,天然适合"按任意键继续"的交互。- 最后输出
\033[0m重置样式,否则后续输出都会带着最后一种背景色。
知识点
- ANSI 转义序列(CSI + SGR 参数)控制终端样式
- 颜色代码表:前景 30~37 / 明亮 90~97,背景 40~47 / 明亮 100~107
conio.h的getch()(现代 Windows 编译器仍可用)
说明
- ANSI 转义序列在 Linux/macOS 终端、Windows Terminal、VS Code 终端、PowerShell 7 中开箱即用;旧版
cmd.exe需先启用"虚拟终端处理"(可先执行system("");或改用新版终端)。 - 程序 32(背景色)、33(清屏/定位)、35(前景色)三题合起来就是"终端控制三件套",覆盖了终端样式控制最常见的需求,跨平台通用。
【程序 33】gotoxy 与 clrscr 函数
题目:学习 gotoxy()(光标定位)与 clrscr()(清屏)函数。
解题思路
clrscr()(清屏)与 gotoxy(x, y)(光标定位)在终端中有对应的 ANSI 转义序列:
- 清屏:
\033[2J(清空整个屏幕);常配合\033[H把光标移回左上角。 - 光标定位:
\033[行;列H,把光标移动到指定行列(行、列都从 1 开始数)。
完整代码
#include <stdio.h>
int main()
{
printf("\033[2J"); // 清屏(相当于 clrscr)
printf("\033[5;1H"); // 光标定位到第 5 行第 1 列(相当于 gotoxy(1,5))
printf("Output at row 5 column 1\n");
printf("\033[10;20H"); // 光标定位到第 10 行第 20 列
printf("Output at row 10 column 20\n");
printf("\033[0m"); // 恢复默认样式
return 0;
}
代码讲解
\033[5;1H中5是行号、1是列号,用分号分隔,末尾字母H表示"移动光标"。注意行、列从 1 开始。- 清屏后光标停在原位置,通常紧接着输出
\033[H回到左上角:\033[2J\033[H是标准的"清屏并回到开头"组合。 - 这段代码不需要
conio.h,ANSI 序列只是普通字符串,跨平台通用。
知识点
- ANSI 光标控制:
\033[H(回原点)、\033[2J(清屏)、\033[行;列H(定位) - 坐标约定从 1 开始(与 C 数组从 0 开始对比,注意区分)
扩展思考(其他解法)
Windows 下也可以 system("cls"); 清屏,但那是调用外部命令,速度慢、不可移植,教学上 ANSI 序列更优。
【程序 34】练习函数调用
题目:练习函数调用(调用自定义函数)。
解题思路
定义一个打印"Hello, world!"的函数,再定义一个调用它 3 次的函数,main 调用后者。练习的是函数定义、声明、调用的基本流程。
完整代码
#include <stdio.h>
void hello_world(void) // 无返回值、无参数
{
printf("Hello, world!\n");
}
void three_hellos(void)
{
int counter;
for (counter = 1; counter <= 3; counter++)
hello_world(); // 调用上面的函数
}
int main(void)
{
three_hellos(); // main 调用函数
return 0;
}
代码讲解
void表示"没有返回值"(不需要return值)或"没有参数"。- 被调用的函数必须先定义(或先声明),
main才能调用它,编译器按从上到下的顺序找定义。
知识点
- 函数的定义、调用
void类型- 函数声明的顺序问题
扩展思考
函数的意义在于复用:把重复代码抽成函数,一处修改处处生效。后续程序 66、67、70、71 都是函数化编程的练习。
【程序 35】文本颜色设置
题目:文本颜色设置练习。
解题思路
终端可以用 ANSI 转义序列设置前景色、显示闪烁文字:
- 前景色:
\033[30m~\033[37m(8 种基础色)、\033[90m~\033[97m(8 种明亮色),或 256 色写法\033[38;5;Nm。 - 闪烁:
\033[5m;重置:\033[0m。
完整代码
#include <stdio.h>
int main()
{
int color;
/* 256 色表中 0~15 号正好对应经典 16 色 */
for (color = 0; color < 16; color++)
{
printf("\033[38;5;%dm", color); // 设置前景色为 color 号颜色
printf("This is color %2d\r\n", color);
}
printf("\033[5m"); // 开启闪烁
printf("This is blinking\r\n");
printf("\033[0m"); // 重置所有样式
return 0;
}
代码讲解
\033[38;5;Nm:38表示"设置前景色",5表示"使用 256 色模式",N是颜色编号(0~255)。0~15 号就是经典 16 色(黑红绿黄蓝品红青白及其明亮版)。- 背景色把
38换成48:\033[48;5;Nm。 \033[5m是闪烁属性;多个属性可以合并写,如\033[1;31m表示"加粗 + 红色"。- 结束时务必
\033[0m重置,否则样式会"污染"后续输出。
知识点
- 256 色 ANSI 颜色(前景
38;5;N/ 背景48;5;N) - SGR 属性可以组合(加粗
1、下划线4、闪烁5、反显7等)
说明
程序 32(背景色)、33(清屏/定位)、35(前景色)三题合起来就是"终端控制三件套",已覆盖终端样式控制最常见的需求,跨平台通用。
【程序 36】求 100 之内的素数(筛法)
题目:求 100 以内的所有素数。
解题思路
前面程序 12 用"逐个试除"判断素数。本题介绍更高效的埃拉托斯特尼筛法(Sieve of Eratosthenes):
- 准备一张 1~100 的"名单"(数组)。
- 从 2 开始:2 是素数,把名单上所有 2 的倍数划掉(置 0);找下一个没被划掉的数 3,把 3 的倍数划掉;再下一个是 5(4 已被划掉)……
- 划到 $\sqrt{100} = 10$ 为止,剩下没被划掉的全是素数。
筛法的精髓:每个合数只通过"它的最小质因子"被划掉一次,避免了试除法的大量重复计算。
完整代码
#include <stdio.h>
#include <math.h>
#define N 101 // 数组下标 0~100
int main()
{
int a[N];
int i, j;
for (i = 2; i < N; i++)
a[i] = i; // 初始化名单:a[i] 存放数字 i,未被划掉
for (i = 2; i <= sqrt(N); i++) // 只需筛到 sqrt(N)
{
if (a[i] != 0) // 当前数字还没被划掉,说明是素数
for (j = i + i; j < N; j += i)
a[j] = 0; // 划掉它的所有倍数
}
printf("100 以内的素数:\n");
for (i = 2; i < N; i++)
if (a[i] != 0)
printf("%5d", a[i]);
printf("\n");
return 0;
}
代码讲解
- 数组下标就是数字本身,
a[i] = 0表示"数字 i 被划掉了"。这是"用下标做索引"的经典技巧。 - 外层只到 $\sqrt{N}$:因为 $100 = 10 \times 10$,任何合数必然有一个不超过 10 的因子,超过 10 的倍数都已经被更小的素数划掉了。
- 内层
j += i步长是 i,正好扫过 i 的所有倍数。
知识点
- 埃拉托斯特尼筛法
- 数组作"标记表"的用法(用下标表示数据本身)
- 时间复杂度:试除法 O(n√n),筛法 O(n log log n),n 越大优势越明显
数学原理
筛法的数学依据:任何合数 $m$ 都有小于 $\sqrt m$ 的质因子。所以把所有素数的倍数都划掉后,剩下的必然都是素数。这是"数论 + 数据结构"结合的第一个经典例子,也是"质数表"的标准生成方法(密码学中生成大素数也要用到类似思路)。
扩展思考
- 程序 12 是"逐个判断",本题是"批量生成",两种思路各有适用场景:判断单个大数用试除法;需要大量素数表用筛法。
- 可以优化:只筛奇数、只标记"奇数的倍数"等,节省一半内存,作为进阶练习。
【程序 37】对 10 个数排序(选择排序)
题目:对 10 个数进行排序(从小到大)。
解题思路
用选择排序(Selection Sort),思想与程序 5 一脉相承:
- 第 1 轮:在全部 10 个数里找最小的,和
a[0]交换 →a[0]就是全局最小。 - 第 2 轮:在
a[1]~a[9]里找最小的,和a[1]交换。 - ……直到最后两个数比较完。
每轮"选择"一个最小元素放到正确位置,共需 n-1 轮。用 min 记录"当前轮最小元素的下标",找到后再交换。
完整代码
#include <stdio.h>
#define N 10
int main()
{
int a[N];
int i, j, min, temp;
printf("请输入 10 个数:\n");
for (i = 0; i < N; i++)
scanf("%d", &a[i]);
/* 选择排序 */
for (i = 0; i < N - 1; i++)
{
min = i; // 假设 a[i] 最小
for (j = i + 1; j < N; j++) // 在后面的元素中找更小的
if (a[min] > a[j])
min = j; // 记录更小元素的下标
/* 交换 a[i] 与 a[min] */
temp = a[i];
a[i] = a[min];
a[min] = temp;
}
printf("排序后:\n");
for (i = 0; i < N; i++)
printf("%5d", a[i]);
printf("\n");
return 0;
}
代码讲解
- 找最小的是"记录下标"而不是"记录值":因为交换时需要下标。
- 外层循环 n-1 轮即可:前 n-1 个位置放好了,最后一个自然就位。
- 与程序 5 对比:那里"比较 3 个数"就是选择排序处理 3 个元素的特例。
知识点
- 选择排序算法
- 数组下标作为"指针/索引"使用
数学原理
选择排序的时间复杂度是 O(n²)——外层 n-1 轮,内层平均 n/2 次比较。对 10 个数约 45 次比较。虽然它不是最快的排序,但思路最直观,是学习排序的第一课。更快的算法(快速排序、归并排序)在工程中更常用,但都是本思想的深化。
扩展思考(其他解法)
- 冒泡排序:相邻元素两两比较,大的往后"冒泡",每轮把最大值送到最后。可以自己实现对比:
for (i = 0; i < N - 1; i++) for (j = 0; j < N - 1 - i; j++) if (a[j] > a[j + 1]) { temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; }注意每轮能少比较 i 次(末尾已就位)。
- 标准库还有
qsort(快速排序),工程中直接调用。学习阶段务必亲手实现选择排序和冒泡排序。
【程序 38】求 3×3 矩阵对角线元素之和
题目:求一个 3×3 矩阵对角线元素之和。
解题思路
矩阵用二维数组 a[3][3] 存储。主对角线(从左上到右下)的元素是 a[0][0]、a[1][1]、a[2][2]——它们的共同点是行号等于列号(a[i][i])。所以累加 a[i][i] 即可。
题目只说"对角线元素之和",经典含义是主对角线;若要包含副对角线(右上到左下),还要加上
a[i][2-i],注意正中间元素别重复加。
完整代码
#include <stdio.h>
int main()
{
float a[3][3], sum = 0;
int i, j;
printf("请输入 3×3 矩阵的 9 个元素:\n");
for (i = 0; i < 3; i++)
for (j = 0; j < 3; j++)
scanf("%f", &a[i][j]);
for (i = 0; i < 3; i++)
sum = sum + a[i][i]; // 主对角线:行号 == 列号
printf("主对角线元素之和为 %.2f\n", sum);
return 0;
}
代码讲解
- 二维数组的遍历:外层循环管行,内层循环管列。
- 主对角线规律:
i == j;副对角线规律:i + j == 2(n 阶矩阵是i + j == n - 1)。
知识点
- 二维数组的定义、输入、遍历
- 对角线元素的坐标规律
扩展思考(其他解法)
副对角线也求和(不重复计算中心元素):
for (i = 0; i < 3; i++)
sum += a[i][i]; // 主对角线
for (i = 0; i < 3; i++)
if (i != 3 - 1 - i) // 跳过中心元素(3x3 时 i==1)
sum += a[i][2 - i]; // 副对角线
【程序 39】向有序数组中插入一个数
题目:有一个已经排好序的数组。现输入一个数,要求按原来的规律将它插入数组中。
解题思路
数组是升序的(10 个元素),要插入第 11 个位置:
- 先判断新数是否比最后一个元素大,是就直接放最后。
- 否则找到第一个比它大的位置
i,把从i开始的所有元素从后往前依次后移一位,腾出位置后把新数放进去。
必须从后往前移:如果从前往后移,前面的元素会覆盖后面的,数据就丢了。
完整代码
#include <stdio.h>
int main()
{
int a[11] = {1, 4, 6, 9, 13, 16, 19, 28, 40, 100}; // 预留一个位置
int i, j, number;
printf("原数组:\n");
for (i = 0; i < 10; i++)
printf("%5d", a[i]);
printf("\n请输入要插入的数:");
scanf("%d", &number);
if (number > a[9]) // 比最后一个还大,直接放末尾
a[10] = number;
else
{
for (i = 0; i < 10; i++)
{
if (a[i] > number) // 找到插入位置 i
{
for (j = 9; j >= i; j--) // 从后往前依次后移
a[j + 1] = a[j];
a[i] = number; // 插入
break;
}
}
}
printf("插入后:\n");
for (i = 0; i < 11; i++)
printf("%5d", a[i]);
printf("\n");
return 0;
}
代码讲解
- 数组声明为 11 个元素,最后留一个空位给新数。
- 后移的循环从
j = 9递减到i:a[10] = a[9],a[9] = a[8]……a[i+1] = a[i]。从后往前才不会覆盖未移动的数据。 break跳出查找循环。
知识点
- 有序插入算法("从后往前后移"的细节)
- 数组扩容的模拟(静态数组预留空间)
数学原理
插入排序(Insertion Sort)的第一步就是"把一个数插入有序序列"。完整的插入排序算法:从第 2 个元素开始,把每个元素插入前面已排序的部分。可以自己实现完整版,与选择排序、冒泡排序对照。
【程序 40】将数组逆序输出
题目:将一个数组逆序输出。
解题思路
用"首尾交换"法:a[0] 和 a[n-1] 交换,a[1] 和 a[n-2] 交换……只需要交换 n/2 次。注意循环上界是 n/2:交换超过 n/2 次会把数组又换回去。
完整代码
#include <stdio.h>
#define N 5
int main()
{
int a[N] = {9, 6, 5, 4, 1};
int i, temp;
printf("原数组:\n");
for (i = 0; i < N; i++)
printf("%4d", a[i]);
for (i = 0; i < N / 2; i++) // 只需交换 n/2 次
{
temp = a[i];
a[i] = a[N - 1 - i]; // 对称位置的元素
a[N - 1 - i] = temp;
}
printf("\n逆序后:\n");
for (i = 0; i < N; i++)
printf("%4d", a[i]);
printf("\n");
return 0;
}
代码讲解
a[N-1-i]是与a[i]对称的位置。i=0 对应 N-1,i=1 对应 N-2……- N 为奇数时,最中间的元素不动(i 到不了中间)。
知识点
- 首尾交换法
- 对称下标
N-1-i
扩展思考
- 只想"逆序打印"不改数组的话,从
N-1循环到0打印即可。 - 字符串反转(程序 70 的进阶题)用的也是同样的首尾交换法。
第五部分:变量的存储类型与宏(程序 41 ~ 50)
【程序 41】学习 static 定义静态变量
题目:学习 static(静态变量)的用法。
解题思路
函数内普通局部变量每次调用时"重新出生"(重新分配、重新初始化);而 static 局部变量只初始化一次,函数调用结束后它的值依然保留,下次调用接着用。
用一个对比实验:普通变量 var 每次从 0 开始,静态变量 static_var 的值会一直累加。
完整代码
#include <stdio.h>
void varfunc()
{
int var = 0; // 普通局部变量:每次调用都重新初始化为 0
static int static_var = 0;// 静态局部变量:只初始化一次,值会保留
printf("var = %d, static_var = %d\n", var, static_var);
var++;
static_var++;
}
int main()
{
int i;
for (i = 0; i < 3; i++)
varfunc();
return 0;
}
代码讲解
- 输出结果:
var = 0, static_var = 0 var = 0, static_var = 1 var = 0, static_var = 2 - 为什么
var始终是 0?每次调用varfunc,var都重新分配内存并赋初值 0。而static_var存放在"静态存储区",整个程序运行期间只初始化一次。
知识点
- 变量的存储类型:
auto(默认,局部)、static(静态)、register(寄存器)、extern(外部) - 静态变量的生命周期:从程序开始到程序结束
- 作用域与生命周期的区别:静态变量作用域仍是所在函数,但生命周期是整个程序
扩展思考
static 在工程中还有两个重要用途:
- 修饰全局变量/函数:限制为"本文件可见",防止多个 .c 文件之间命名冲突。
- 做"计数器":比如统计函数被调用了多少次。程序 43 会再看一个例子。
【程序 42】学习 auto 定义变量
题目:学习使用 auto 定义变量。
解题思路
auto 是 C 语言"默认存储类型"关键字:普通局部变量默认就是 auto(自动分配内存,离开作用域自动释放)。写不写 auto 效果完全一样,所以实际代码很少写它。本题还演示了内层代码块(花括号包起来的局部区域)里可以重新定义同名变量,与外部变量互不影响。
完整代码
#include <stdio.h>
int main()
{
int i, num;
num = 2;
for (i = 0; i < 3; i++)
{
printf("外层 num = %d\n", num);
num++;
{
auto int num = 1; // 内层块中新的 num(写法上 auto 可省略)
printf("内层 num = %d\n", num);
num++;
} // 离开内层块,内层 num 销毁
// 外层 num 不受影响
}
return 0;
}
代码讲解
- 内层
{}块内的num与外层num是两个不同的变量(虽然同名)。内层块结束,内层 num 就"消失"。 auto关键字只是强调"这是自动变量",写不写都一样。
知识点
auto存储类型(默认)- 变量的作用域(块作用域)与同名遮蔽(shadowing)
- 内存分配时机:进入块时分配、离开块时释放
扩展思考
现代 C 代码基本不写 auto(C++11 中 auto 含义完全不同,是"自动推断类型",注意别混淆)。本题的价值在于理解"作用域与生命周期"。
【程序 43】static 的另一用法(块内静态变量)
题目:学习使用 static 的另一用法(块内静态变量)。
解题思路
与程序 42 对比:内层块里的 static int num = 1 也是"只初始化一次",虽然作用域只在块内,但生命周期是整个程序,所以每次进入块时不会重新初始化。
完整代码
#include <stdio.h>
int main()
{
int i, num;
num = 2;
for (i = 0; i < 3; i++)
{
printf("外层 num = %d\n", num);
num++;
{
static int num = 1; // 静态变量:只初始化一次
printf("内层 num = %d\n", num);
num++; // 值被保留
}
}
return 0;
}
代码讲解
- 输出中内层 num 依次是 1、2、3(值被保留),而程序 42 中是 1、1、1(每次重新初始化)。
- 与程序 42 对比,是理解
static的最佳实验。
知识点
static与auto的区别:初始化时机与生命周期
【程序 44】学习 external 的用法
题目:学习使用 extern(外部变量)。
解题思路
extern 用来声明"这个变量定义在其他地方"(另一个 .c 文件或本文件后面)。全局变量默认可以在整个程序里共享。本题中 a、b、c 是全局变量,add() 函数和 main 都能访问它们。
注意一个常见的坑:add() 里若重新定义了局部变量 int a;,会遮蔽全局 a,此时 c = a + b 用的是局部 a(未初始化!),结果不可预期。这正好是个"反面教材"——局部变量会遮蔽全局变量。下面给出修正后的代码。
完整代码
#include <stdio.h>
int a, b, c; // 全局变量(默认值为 0)
void add()
{
c = a + b; // 使用全局变量 a、b
}
int main()
{
a = b = 4;
add();
printf("c = %d\n", c); // 输出 8
return 0;
}
代码讲解
- 全局变量定义在函数外,程序中任何地方都能访问。
- 修正后
add()不再定义局部a,c = a + b = 4 + 4 = 8。 - 函数内定义同名局部变量会"遮蔽"全局变量,初学者极易踩中,务必注意。
知识点
- 全局变量与
extern - 同名变量的遮蔽(shadowing)问题
扩展思考
- 多文件工程中,在一个 .c 文件里定义全局变量,另一个 .c 文件用
extern int a;声明后即可访问。 - 工程实践中应少用全局变量:它们让程序各部分隐式耦合,难以维护。传参是更清晰的方式。程序 87 会讲"结构体变量传递"。
【程序 45】学习 register 定义变量
题目:学习使用 register 定义变量。
解题思路
register 建议编译器把变量放在 CPU 寄存器里而不是内存中,读写更快。适合循环计数器这类"高频访问"的变量。注意它只是"建议",现代编译器优化能力很强,register 已基本多余;且寄存器变量不能取地址(&)。
完整代码
#include <stdio.h>
int main()
{
register int i; // 建议放入寄存器
int sum = 0;
for (i = 1; i <= 100; i++)
sum += i;
printf("1+2+...+100 = %d\n", sum);
return 0;
}
代码讲解
sum += i是sum = sum + i的简写。- 求 1~100 的和:答案 5050。这也复习了累加器模式。
知识点
register存储类型(了解即可)- 累加求和
数学原理
1 到 n 的和有公式 $S = \frac{n(n+1)}{2}$,n=100 时是 5050。传说高斯 10 岁就用配对法(1+100、2+99…共 50 对,每对 101)瞬间算出。程序用循环"笨算",但通用性更强(任意规则的和都能算)。
【程序 46】宏 #define 命令练习(1)
题目:宏 #define 命令练习——定义常量与函数式宏。
解题思路
#define 是预处理指令:在编译前,把代码中所有出现"宏名"的地方原样替换成"宏体"。有两种宏:
- 对象宏(常量):
#define TRUE 1——用名字代替数字,代码可读性好。 - 函数式宏(带参数):
#define SQ(x) (x)*(x)——像函数一样用,但本质是文本替换。
⚠️ 函数式宏必须给参数加括号:
SQ(x)定义为(x)*(x)而不是x*x。否则SQ(1+2)会被替换成1+2*1+2 = 5(错!),加括号后是(1+2)*(1+2) = 9(对)。
完整代码
#include <stdio.h>
#define TRUE 1
#define FALSE 0
#define SQ(x) ((x) * (x)) // 宏体里多加一层括号更安全
int main()
{
int num, again = TRUE;
printf("输入小于 50 的数时程序结束。\n");
while (again)
{
printf("请输入一个数:");
scanf("%d", &num);
printf("它的平方是 %d\n", SQ(num));
if (num >= 50)
again = TRUE;
else
again = FALSE;
}
return 0;
}
代码讲解
while (again):again为 TRUE(1) 就继续循环,FALSE(0) 结束。SQ(num)在编译前被替换成((num) * (num)),所以即使传num+1也不会出错。
知识点
- 宏定义(对象宏、函数式宏)
- 预处理阶段(编译前的文本替换)
- 宏的括号陷阱
扩展思考(其他解法)
- 函数式宏与真函数的区别:宏是编译前文本替换,无类型检查、无调用开销;函数有类型检查、有调用开销。现代代码中函数式宏多被
inline函数或static inline取代,但嵌入式/底层代码仍常用宏。 - 例:
SQ(3.5)用宏没问题,若写成函数int sq(int x)则会丢失小数。
【程序 47】宏 #define 命令练习(2)——宏交换
题目:宏 #define 命令练习——用宏交换两个变量的值。
解题思路
宏体可以包含多条语句,用花括号包起来;跨行的宏要用续行符 \(每行末尾加反斜杠,表示"下一行仍是宏的一部分")。
完整代码
#include <stdio.h>
#define EXCHANGE(a, b) \
do { \
int t = (a); \
(a) = (b); \
(b) = t; \
} while (0)
int main()
{
int x = 10, y = 20;
printf("交换前:x=%d, y=%d\n", x, y);
EXCHANGE(x, y);
printf("交换后:x=%d, y=%d\n", x, y);
return 0;
}
代码讲解
- 每行末尾的
\是续行符(\后面不能有任何字符,包括空格)。 - 宏体用
do { ... } while(0)包裹是经典技巧:让宏像一个"语句"一样使用,且在if里使用时不会破坏分支结构(如果直接写{...},在if (x) EXCHANGE(a,b); else ...场景会出问题)。 - 注意宏内临时变量
t用了独立名字:若参数恰好叫t会冲突,这是宏的固有缺点。
知识点
- 多语句宏与续行符
do { } while(0)宏封装技巧
扩展思考(其他解法)
交换两个数还有不借助临时变量的经典写法(异或法,见程序 53):
a = a ^ b;
b = a ^ b;
a = a ^ b;
这是位运算的应用,理解异或的性质后很巧妙,但可读性差,工程中一般不用。
【程序 48】宏 #define 命令练习(3)
题目:宏 #define 命令练习——用宏定义运算符。
解题思路
宏甚至可以把运算符"改名":#define LAG >。这只是一种趣味练习,实际工程不推荐(影响可读性),但能帮你理解"宏就是文本替换"的本质。
完整代码
#include <stdio.h>
#define LAG >
#define SMA <
#define EQ ==
int main()
{
int i = 10, j = 20;
if (i LAG j)
printf("%d 大于 %d\n", i, j);
else if (i EQ j)
printf("%d 等于 %d\n", i, j);
else if (i SMA j)
printf("%d 小于 %d\n", i, j);
return 0;
}
代码讲解
- 预处理后
i LAG j变成i > j,程序输出"10 小于 20"。 - 这个练习让你直观看到宏替换发生在编译之前。
知识点
- 宏替换的本质(文本替换)
- 预处理指令在编译流程中的位置
【程序 49】#if #ifdef 和 #ifndef 的综合应用
题目:#if、#ifdef 和 #ifndef 的综合应用。
解题思路
条件编译:让编译器"有选择地编译"某些代码。常用场景:同一份代码在不同平台编译不同版本、调试版与发布版。
#ifdef MAX:如果宏 MAX 已定义,编译下面代码,否则编译#else分支。#ifndef MIN:如果宏 MIN 未定义……#undef MAX:取消宏定义。
完整代码
#include <stdio.h>
#define MAX
#define MAXIMUM(x, y) ((x) > (y) ? (x) : (y))
#define MINIMUM(x, y) ((x) > (y) ? (y) : (x))
int main()
{
int a = 10, b = 20;
#ifdef MAX // MAX 已定义 → 编译此分支
printf("较大的数是 %d\n", MAXIMUM(a, b));
#else
printf("较小的数是 %d\n", MINIMUM(a, b));
#endif
#ifndef MIN // MIN 未定义 → 编译此分支
printf("较小的数是 %d\n", MINIMUM(a, b));
#else
printf("较大的数是 %d\n", MAXIMUM(a, b));
#endif
#undef MAX // 取消 MAX 定义
#ifdef MAX // MAX 现在未定义 → 走 else
printf("较大的数是 %d\n", MAXIMUM(a, b));
#else
printf("较小的数是 %d\n", MINIMUM(a, b));
#endif
return 0;
}
代码讲解
#ifdef/#ifndef判断的是"宏是否已定义",与宏的值无关。#undef之后宏就不存在了,后面#ifdef的判断结果会变化。MAXIMUM(a, b)展开为((a) > (b) ? (a) : (b)),是三目运算符的应用(复习程序 15)。
知识点
- 条件编译:
#ifdef、#ifndef、#else、#endif、#undef - 用途:头文件防重复包含(
#ifndef XXX_H包裹整个头文件,见程序 50 扩展)、平台兼容代码
数学原理
MAXIMUM 的展开用到了三目运算符,一次比较 O(1) 完成取最大值,不依赖比较次数。
扩展思考
真正工程中最常见的条件编译是"头文件守卫":
#ifndef MY_HEADER_H
#define MY_HEADER_H
/* 头文件内容 */
#endif
防止同一个头文件被多次包含导致重复定义错误。程序 50 的 #include 练习后可以回来看这个技巧。
【程序 50】#include 的应用练习
题目:#include 的应用——自己写一个头文件并引用。
解题思路
#include 的作用是把另一个文件的内容原样"粘贴"到本文件。我们可以自己创建一个头文件 test.h,里面定义宏,然后在 .c 文件里 #include "test.h" 使用。
完整代码
test.h(自己创建):
/* test.h —— 自定义头文件 */
#define LAG >
#define SMA <
#define EQ ==
程序 50 主文件:
#include "test.h" // 引用自己写的头文件(用双引号)
#include <stdio.h> // 引用标准库头文件(用尖括号)
int main()
{
int i = 10, j = 20;
if (i LAG j)
printf("%d 大于 %d\n", i, j);
else if (i EQ j)
printf("%d 等于 %d\n", i, j);
else
printf("%d 小于 %d\n", i, j);
return 0;
}
代码讲解
#include "文件名"先找当前目录,再找系统目录;#include <文件名>只找系统目录。自定义头文件用双引号。- 预处理时
test.h的内容被"粘贴"进来,所以能直接用LAG等宏。
知识点
#include的两种写法与查找规则- 头文件的作用:声明共享的函数、宏、类型
扩展思考
- 结合程序 49 学到的"头文件守卫",自己写头文件时养成加守卫的习惯。
- 大型项目里,头文件负责"声明"(如函数原型、结构体定义),.c 文件负责"实现",这是 C 语言工程组织的基础。
第六部分:位运算、图形绘制与杨辉三角(程序 51 ~ 65)
【程序 51】学习按位与 &
题目:学习使用按位与 &。
解题思路
按位与对两个整数的二进制位逐位运算:只有两个位都是 1,结果位才是 1,否则为 0。
0 1 1 1 1 1 1 (63, 即八进制 077)
& 0 0 0 0 0 1 1 (3)
= 0 0 0 0 0 1 1 (3)
按位与最常用的用途是取某些位(掩码):x & 0xFF 取 x 的最低 8 位;x & 1 判断 x 的奇偶(最低位为 1 是奇数)。程序 54 会用到。
注意区分:
&是按位与,&&是逻辑与(结果是 0 或 1)。这两个运算符完全不同!
完整代码
#include <stdio.h>
int main()
{
int a = 077; // 八进制 077 = 十进制 63 = 二进制 111111
int b = a & 3; // 只保留最低两位
printf("a & 3 = %d\n", b); // 3
b &= 7; // b = b & 7,保留最低三位
printf("b & 7 = %d\n", b); // 3
return 0;
}
代码讲解
- C 语言里以 0 开头的整数是八进制:
077= $7 \times 8 + 7 = 63$。 b &= 7是b = b & 7的复合赋值写法。
知识点
- 按位与
& - 八进制、十六进制表示法(
077八进制,0xFF十六进制) - 掩码(mask)思想
数学原理
按位与是布尔代数里"逻辑乘"在计算机上的实现:$1 \& 1 = 1$,其余为 0。用 $2^k-1$ 这样的数(二进制全 1)做掩码,可以一次取出一段连续的二进制位,这是硬件编程(寄存器操作)的日常操作。
【程序 52】学习按位或 |
题目:学习使用按位或 |。
解题思路
按位或:只要两个位中有一个是 1,结果位就是 1。
0 1 1 1 1 1 1 (63)
| 0 0 0 0 0 1 1 (3)
= 0 1 1 1 1 1 1 (63)
按位或常用于把某些位置为 1:x | 0x80 把第 7 位(从 0 数)置 1,其余位不变。
完整代码
#include <stdio.h>
int main()
{
int a = 077; // 63
int b = a | 3;
printf("a | 3 = %d\n", b); // 63
b |= 7; // b = b | 7
printf("b | 7 = %d\n", b); // 63
return 0;
}
知识点
- 按位或
| - 复合赋值
|=
数学原理
按位或是布尔代数"逻辑加":$1 | 0 = 1$。在计算机里,"把某位置 1"用或、"把某位取反"用异或(下一个题)、"取某几位"用与(上一个题)——这三个操作构成了位操作工具箱。
【程序 53】学习按位异或 ^
题目:学习使用按位异或 ^。
解题思路
按位异或:两个位不同则为 1,相同则为 0。
0 1 1 1 1 1 1 (63)
^ 0 0 0 0 0 1 1 (3)
= 0 1 1 1 1 0 0 (60)
异或有三个重要性质(数学上叫"自反性"):
- $a \oplus a = 0$(自己异或自己得 0)
- $a \oplus 0 = a$(异或 0 不变)
- $a \oplus b \oplus b = a$(异或两次回到原值)→ 可用于加密解密!
完整代码
#include <stdio.h>
int main()
{
int a = 077;
int b = a ^ 3;
printf("a ^ 3 = %d\n", b); // 60
b ^= 7;
printf("b ^ 7 = %d\n", b); // 59
return 0;
}
代码讲解
- 63 ^ 3:63 的二进制 111111,3 的二进制 000011,逐位异或得 111100 = 60。
- 因为 63 的低两位都是 1,与 3(低两位 11)异或后变 0。
知识点
- 按位异或
^ - 异或的三大性质
数学原理
异或的"可逆性"($a \oplus b \oplus b = a$)是它最神奇的地方:把 $b$ 当作"密钥",$a \oplus b$ 就是"密文",再异或一次 $b$ 就还原。这是对称加密最朴素的模型(如简单的流密码)。程序 47 扩展里提到的"无临时变量交换"也是这个性质的应用。
扩展思考(应用)
交换两个数(不借助第三个变量):
a = a ^ b;
b = a ^ b; // b = (a^b)^b = a
a = a ^ b; // a = (a^b)^a = b
面试常考题,理解原理比背代码更重要。
【程序 54】取整数 a 从右端开始的 4~7 位
题目:取一个整数 a 从右端开始的第 4~7 位。
解题思路
三步走:
- 把 a 右移 4 位(
a >> 4),这样原来的第 4~7 位变成了新的第 0~3 位。 - 构造一个掩码:低 4 位全 1、其余全 0,即
~(~0 << 4)。~0是全 1(所有位都是 1)~0 << 4是"全 1 左移 4 位",低 4 位变成 0- 再取反,低 4 位变 1,其余变 0 → 正是掩码
0000...1111
- 用按位与取出来:
(a >> 4) & mask。
完整代码
#include <stdio.h>
int main()
{
unsigned a, b, c, d;
printf("请输入一个八进制数:");
scanf("%o", &a); // %o 按八进制读入
b = a >> 4; // 右移 4 位:4~7 位来到低 4 位
c = ~(~0 << 4); // 掩码:低 4 位全 1
d = b & c; // 取出低 4 位,即原数的 4~7 位
printf("原数(八进制):%o\n", a);
printf("第4~7位(八进制):%o\n", d);
return 0;
}
代码讲解
- 右移
>>:每右移一位相当于除以 2(二进制下)。 - 左移
<<:每左移一位相当于乘以 2。 - 掩码技巧:
~(~0 << n)生成"低 n 位全 1"的数,这是位操作经典写法。
知识点
- 左移
<<、右移>>、取反~ - 移位 + 掩码提取指定位
数学原理
二进制下,右移 k 位 = 除以 $2^k$,左移 k 位 = 乘以 $2^k$。程序里用移位代替乘除法,在硬件层更快。掩码运算本质上是在做"向量投影":只保留感兴趣的维度,其余清零。
【程序 55】学习按位取反 ~
题目:学习使用按位取反 ~。
解题思路
~ 把一个数的每一位取反(0 变 1,1 变 0)。注意它在计算机里是针对"补码"(包括符号位)取反,所以 ~a 通常得到负数。这涉及"补码表示法",是理解 C 语言整数的基础知识。
完整代码
#include <stdio.h>
int main()
{
int a = 234;
int b = ~a;
printf("~a 的十进制是 %d\n", b); // -235
printf("~a 的十六进制是 %x\n", ~a); // 按十六进制看更直观
return 0;
}
代码讲解
- 为什么
~234 = -235?因为234 + (~234) = -1(每位都是 0+1 或 1+0,结果全 1 表示 -1 的补码)。于是~234 = -1 - 234 = -235。一般地,~a = -a - 1。 - 用十六进制输出能直观看到每一位取反的效果。
知识点
- 按位取反
~ - 补码表示法(负数在计算机中的存储方式)
%x十六进制输出
数学原理
补码规定:$-x$ 的补码是 $x$ 各位取反再加 1(~x + 1)。这是硬件实现减法的统一方案(减法 = 加补码)。所以 ~x = -x - 1。理解补码是理解 C 语言整数溢出、位运算的钥匙。
扩展思考
~0得到全 1,即 -1(int 类型)。程序 54 用~(~0<<4)生成掩码,正是取反的实际用途。
【程序 56~60、62~65】图形绘制题
题目:学习用图形库画圆、画线、画矩形、画点、画椭圆、综合绘图等。
图形库使用说明
本系列图形题使用 ACLLib(Advanced C Lab Library) 实现——一个非常轻量的 C 图形库(GitHub:wengkai/ACLLib),由上海交通大学翁恺老师的 C 语言 MOOC 课程维护,封装了 Win32 API,只有一个头文件 acllib.h 加一个源文件 acllib.c,特别适合初学者快速上手 Windows 图形编程。
ACLLib 的安装与使用(Windows + MinGW / Dev-C++ / VS Code + GCC):
- 从 GitHub(wengkai/ACLLib)下载源码,把
src目录下的acllib.h和acllib.c复制到你的项目目录。 - 写代码时
#include "acllib.h"。 - 编译时需要同时编译
acllib.c并链接系统库,命令行写法:gcc 程序.c acllib.c -I. -lgdi32 -lole32 -loleaut32 -luuid -lwinmm -lmsimg32 -DWINVER=0x0501 -o 程序(Dev-C++ 则在“项目属性 → 参数 → 链接器”里加入这些库。)
ACLLib 程序的基本结构(注意!入口不是 main):
#include "acllib.h"
int Setup() // ACLLib 自动调用 Setup 作为程序入口
{
initWindow("标题", DEFAULT, DEFAULT, 640, 480); // 创建 640×480 窗口
beginPaint(); // 开始绘制
// ……在这里画图……
endPaint(); // 结束绘制(把内容显示到屏幕)
return 0;
}
常用绘图函数一览:
| 功能 | 函数 |
|---|---|
| 创建窗口 | initWindow("标题", DEFAULT, DEFAULT, 宽, 高) |
| 清屏 | clearDevice() |
| 画笔颜色 / 粗细 | setPenColor(颜色) / setPenWidth(像素) |
| 画刷颜色 | setBrushColor(颜色) |
| 画线 | line(x1, y1, x2, y2) |
| 画圆 / 椭圆 | ellipse(左, 上, 右, 下)(外接矩形) |
| 画矩形 | rectangle(左, 上, 右, 下) |
| 画点 | putPixel(x, y, 颜色) |
| 输出文字 | setTextSize(号) + paintText(x, y, "字符串") |
| 定时器(动画用) | registerTimerEvent(回调) + startTimer(编号, 毫秒) |
| 同时用控制台 | initConsole()(之后可用 printf) |
颜色:预定义常量 BLACK、RED、GREEN、BLUE、CYAN、MAGENTA、YELLOW、WHITE,也可以自己用 RGB(红, 绿, 蓝) 定义任意颜色。
坐标系统:原点在窗口左上角,x 向右、y 向下,单位是像素。画圆要写外接矩形:圆心 (cx, cy)、半径 r 的圆写作 ellipse(cx-r, cy-r, cx+r, cy+r)。下面每题都给出完整、可直接运行的 ACLLib 代码。
【程序 56】画圆
题目:画一系列同心圆。
解题思路:圆心固定,半径按固定步长递增,用循环画出一组同心圆。ACLLib 中画圆要写成外接矩形:圆心 (cx, cy)、半径 r 的圆 = ellipse(cx-r, cy-r, cx+r, cy+r)。
完整代码:
#include "acllib.h"
int Setup()
{
int i, r;
initWindow("同心圆", DEFAULT, DEFAULT, 640, 480);
beginPaint();
setPenColor(BLUE);
setPenWidth(2);
for (i = 0; i <= 25; i++)
{
r = 20 + i * 10; // 半径 20,30,40...270
ellipse(320 - r, 240 - r, 320 + r, 240 + r); // 外接矩形画圆
}
endPaint();
return 0;
}
代码讲解:
- 入口是
Setup()而不是main:ACLLib 会自动调用它,这是它和普通 C 程序最大的区别。 initWindow("同心圆", DEFAULT, DEFAULT, 640, 480):标题、窗口位置(DEFAULT 表示由系统决定)、宽、高。- 所有绘图代码都夹在
beginPaint()与endPaint()之间;endPaint()会把画的内容真正显示到窗口。 - 每次循环以半径 r 画圆:外接矩形的左上角 (320-r, 240-r)、右下角 (320+r, 240+r)。
知识点:ACLLib 的 Setup 入口与 beginPaint/endPaint 流程、用外接矩形画圆、循环控制几何参数。
【程序 57】画直线
题目:学习用 line 画直线(两组放射线)。
完整代码:
#include "acllib.h"
int Setup()
{
int i;
float x0 = 263, y0 = 263, y1 = 275, x1 = 275;
initWindow("画直线", DEFAULT, DEFAULT, 640, 480);
beginPaint();
setPenColor(MAGENTA);
/* 第一组:从左上角区域向外"生长"的线段 */
for (i = 0; i <= 18; i++)
{
line(x0, y0, x0, y1); // 画一条竖线段
x0 = x0 - 5; y0 = y0 - 5; // 起点向左上移
x1 = x1 + 5; y1 = y1 + 5; // 终点向右下移
}
/* 第二组:从左上向右下延伸的线段 */
x0 = 263; y0 = 263; y1 = 275;
setPenColor(YELLOW);
for (i = 0; i <= 20; i++)
{
line(x0, y0, x0, y1);
x0 = x0 + 5; y0 = y0 + 5; // 起点向右下移
y1 = y1 - 5; // 终点向上移
}
endPaint();
return 0;
}
代码讲解:每次循环里"线段的两个端点按相反方向移动",线段就被不断拉长并平移,两组线段叠加出放射状图案。line(x1, y1, x2, y2) 从点 (x1,y1) 画到 (x2,y2)。注意 line 的坐标参数是 int 类型,这里用 float 存坐标是为了避免中间计算产生小数误差(传给函数时自动取整)。
知识点:line 画线;用"参数每步变化"生成图案的递推思想。
【程序 58】画矩形
题目:学习用 rectangle 画方形,并配上文字。
完整代码:
#include "acllib.h"
int Setup()
{
int x0 = 263, y0 = 263, x1 = 275, y1 = 275;
int i;
initWindow("画矩形", DEFAULT, DEFAULT, 640, 480);
beginPaint();
/* 一组嵌套矩形:两个对角同时向外扩展 */
setPenColor(BLUE);
for (i = 0; i <= 18; i++)
{
rectangle(x0, y0, x1, y1);
x0 -= 5; y0 -= 5; // 左上角向外
x1 += 5; y1 += 5; // 右下角向外
}
/* 点缀:圆 + 文字 + 直线 */
setPenColor(RED);
ellipse(320 - 150, 240 - 150, 320 + 150, 240 + 150); // 画圆(外接矩形)
setTextSize(24);
paintText(150, 40, "How beautiful it is!");
line(130, 60, 480, 60); // 文字下方画一条线
endPaint();
return 0;
}
代码讲解:
rectangle(左, 上, 右, 下)按两个对角点画矩形;循环里两角同时外扩,画出一层层嵌套的方框。- 文字用
setTextSize(24)设置字号(数字越大字越大),paintText(x, y, "字符串")在指定位置输出。 - 画圆仍用外接矩形形式:
ellipse(320-150, 240-150, 320+150, 240+150)是圆心 (320,240)、半径 150 的圆。
知识点:rectangle 画矩形、paintText 输出文字、用外接矩形画圆。
【程序 59】综合例子:参数方程画放射线
题目:综合画图——圆 + 用三角函数参数方程画放射线(椭圆变形)。
数学要点:
- 圆上点的参数方程:$x = x_0 + r\cos\theta$,$y = y_0 + r\sin\theta$,$\theta$ 从 0 到 $2\pi$(弧度制,$360° = 2\pi$)。
- 把 $\sin$ 项乘以系数 $B$(如 0.809),圆就被"压扁"成椭圆——这是屏幕像素纵横比不同时的经典做法。
cos/sin来自<math.h>;(int)把浮点坐标转成像素整数。
完整代码:
#include "acllib.h"
#include <math.h>
#define PI 3.1415926
#define B 0.809 // 纵轴压缩系数
int Setup()
{
int i, x0 = 320, y0 = 240, x, y;
double a;
initWindow("参数方程", DEFAULT, DEFAULT, 640, 480);
beginPaint();
/* 三个基准圆(外接矩形画圆) */
setPenColor(WHITE);
ellipse(x0 - 10, y0 - 10, x0 + 10, y0 + 10);
ellipse(x0 - 20, y0 - 20, x0 + 20, y0 + 20);
ellipse(x0 - 50, y0 - 50, x0 + 50, y0 + 50);
/* 把圆周分成 16 份,从圆心向每个分点画放射线 */
setPenColor(YELLOW);
for (i = 0; i < 16; i++)
{
a = (2 * PI / 16) * i; // 16 等分圆周的角度
x = x0 + (int)(48 * cos(a));
y = y0 + (int)(48 * sin(a) * B); // y 乘以 B 压成椭圆
line(x0, y0, x, y);
}
setPenColor(RED);
ellipse(x0 - 60, y0 - 60, x0 + 60, y0 + 60);
setTextSize(16);
paintText(10, 440, "Press a key to exit...");
endPaint();
return 0;
}
代码讲解:a = (2*PI/16)*i 把 $2\pi$ 均分成 16 份,cos(a)、sin(a) 算出第 i 个分点在单位圆上的坐标,乘以半径 48 再平移到圆心 (x0, y0),从圆心连线即得放射线。三个基准圆和放射线、外圈圆用不同颜色区分层次。
扩展思考:还可以加上旋转动画(每帧把角度整体偏移再重画)——用 ACLLib 的定时器:registerTimerEvent(回调函数) 注册,startTimer(0, 30) 让回调函数每 30 毫秒被调用一次,在回调里重画即可,试试"转动的花"——这就是动画的基本框架。
【程序 60】"小球反弹"动画
题目:两个点按固定速度移动,撞到窗口边界就反向,形成弹跳动画。
解题思路:这是最简单的"游戏物理":位置随速度更新(x += dx),撞墙检测(越界就把该方向速度取反,实现反弹)。ACLLib 是事件驱动的:没有 while 死循环,而是用定时器让系统每隔一小段时间自动调用一次回调函数,在回调里更新位置并重画——这是图形/游戏编程的标准模式。
完整代码:
#include "acllib.h"
#define LEFT 0
#define TOP 0
#define RIGHT 639
#define BOTTOM 479
int x1 = 10, y1 = 10, x2 = 10, y2 = 10; // 两个点的坐标(全局变量,供回调函数访问)
int dx1 = 2, dy1 = 2, dx2 = 3, dy2 = 3; // 各自的速度
ACL_Color colors[] = {RED, GREEN, BLUE, CYAN, MAGENTA, YELLOW};
int colorIdx = 0;
/* 定时器回调:每隔一段时间自动执行一次 */
void timerEvent(int tid)
{
beginPaint();
clearDevice(); // 清屏:只保留当前一根线
setPenColor(colors[colorIdx]);
colorIdx = (colorIdx + 1) % 6; // 颜色循环变化
line(x1, y1, x2, y2); // 画两个点的连线
endPaint();
/* 位置随速度更新 */
x1 += dx1; y1 += dy1;
x2 += dx2; y2 += dy2;
/* 撞墙反弹:越界就把该方向速度取反 */
if (x1 <= LEFT || x1 >= RIGHT) dx1 = -dx1;
if (y1 <= TOP || y1 >= BOTTOM) dy1 = -dy1;
if (x2 <= LEFT || x2 >= RIGHT) dx2 = -dx2;
if (y2 <= TOP || y2 >= BOTTOM) dy2 = -dy2;
}
int Setup()
{
initWindow("弹跳动画", DEFAULT, DEFAULT, 640, 480);
registerTimerEvent(timerEvent); // 注册定时器回调
startTimer(0, 20); // 启动定时器 0:每 20 毫秒触发一次
return 0;
}
代码讲解:
registerTimerEvent(timerEvent)把函数timerEvent注册为定时器回调;startTimer(0, 20)启动编号 0 的定时器,每 20 毫秒调用一次回调——这就是"帧"(每秒钟约 50 帧)。- 坐标用全局变量保存:
Setup注册完就返回,之后的动画全靠回调函数驱动,回调里要访问这些数据。 - 每帧先
clearDevice()清屏再画线,就只显示"当前一根线";如果去掉清屏,线条轨迹会留在屏幕上,视觉效果更有趣。 ACL_Color是颜色类型,用一个颜色数组 + 下标循环实现变色。
知识点:事件驱动编程、定时器与动画帧、碰撞检测与速度取反、全局变量在回调间的数据共享。
【程序 62】putPixel 画点
题目:用 putPixel 画点,形成网格线。
完整代码:
#include "acllib.h"
int Setup()
{
int i, j;
initWindow("网格", DEFAULT, DEFAULT, 640, 480);
beginPaint();
/* 横向网格线:固定 y,x 从左到右逐点画 */
for (j = 50; j <= 230; j += 20)
for (i = 50; i <= 230; i++)
putPixel(i, j, BLUE);
/* 纵向网格线:固定 x,y 从上到下逐点画 */
for (i = 50; i <= 230; i += 20)
for (j = 50; j <= 230; j++)
putPixel(i, j, BLUE);
endPaint();
return 0;
}
代码讲解:putPixel(x, y, 颜色) 在 (x, y) 画一个像素点(注意第三个参数是颜色)。两组循环分别固定 y(画横线)和固定 x(画竖线),把点连成网格。这是理解"光栅图形"(一切图像都由像素组成)的入门。
知识点:putPixel 画点、双重循环遍历坐标、像素与网格。
【程序 63】画椭圆
题目:学习用 ellipse 画椭圆(一系列从"圆"逐渐变"扁长"的椭圆)。
完整代码:
#include "acllib.h"
int Setup()
{
int i;
int xr = 130, yr = 130; // 横向半径、纵向半径
initWindow("椭圆", DEFAULT, DEFAULT, 640, 480);
beginPaint();
setPenColor(BLUE);
for (i = 0; i < 20; i++)
{
/* 外接矩形:左上角 (320-xr, 240-yr),右下角 (320+xr, 240+yr) */
ellipse(320 - xr, 240 - yr, 320 + xr, 240 + yr);
xr -= 5; // 横向半径缩小
yr += 5; // 纵向半径增大
}
endPaint();
return 0;
}
代码讲解:ACLLib 的 ellipse(左, 上, 右, 下) 用外接矩形确定椭圆,这与以"圆心 + 半径"为参数的画法不同。这里 xr、yr 分别是横、纵方向"半径"(外接矩形的一半宽、高):xr 递减、yr 递增,画出一组从圆逐渐变扁长的椭圆;当 xr == yr 时画出的就是正圆。
知识点:ellipse 的外接矩形参数形式、椭圆与圆的半径关系。
【程序 64】ellipse 和 rectangle 综合画图
题目:综合使用 ellipse 与 rectangle 画图。
完整代码:
#include "acllib.h"
int Setup()
{
int i;
int num = 15, top = 50, left = 20, right = 50;
initWindow("综合画图", DEFAULT, DEFAULT, 640, 480);
beginPaint();
for (i = 0; i < num; i++)
{
/* 横向椭圆:左右半径 right 变大 */
setPenColor(BLUE);
ellipse(320 - right, 240 - left, 320 + right, 240 + left);
/* 纵向椭圆:上下半径 top 变大 */
setPenColor(RED);
ellipse(320 - 20, 240 - top, 320 + 20, 240 + top);
/* 嵌套矩形 */
setPenColor(GREEN);
rectangle(20 - 2 * i, 20 - 2 * i, 10 * (i + 2), 10 * (i + 2));
right += 5;
left += 5;
top += 10;
}
endPaint();
return 0;
}
代码讲解:三组图形(横向椭圆、纵向椭圆、嵌套矩形)的尺寸参数随循环变化,叠加在一起形成图案。rectangle 的参数可以越界(如负坐标),ACLLib 会自动裁剪,不影响程序运行。
知识点:多种绘图函数组合、参数随循环变化。
【程序 65】一个最优美的图案
题目:圆内接多边形的所有顶点两两连线(弦),形成对称的"星芒"图案。
数学原理:把圆等分成 n 个点(这里 15 个),每两个点之间连一条线段(弦),得到的图案正是数学上的"完全图" $K_{15}$。用参数方程算出各点坐标:
$$
x_i = x_c + r\cos\theta_i, \quad y_i = y_c - r\sin\theta_i, \quad \theta_i = i \cdot \frac{2\pi}{n}
$$
完整代码:
#include "acllib.h"
#include <math.h>
#define MAXPTS 15
#define PI 3.1415926
int Setup()
{
int points[MAXPTS][2]; // 15 个点的坐标
int i, j, xc = 320, yc = 240;
int radius = 180, angle, step;
double rads, aspect = 0.85;
initWindow("弦图", DEFAULT, DEFAULT, 640, 480);
/* 用参数方程计算圆上 15 个等分点(画图前先算好坐标) */
step = 360 / MAXPTS; // 每个点间隔的角度
angle = 0;
for (i = 0; i < MAXPTS; i++)
{
rads = angle * PI / 180.0; // 角度转弧度
points[i][0] = xc + (int)(cos(rads) * radius);
points[i][1] = yc - (int)(sin(rads) * radius * aspect);
angle += step;
}
beginPaint();
/* 画外接圆(外接矩形) */
setPenColor(WHITE);
ellipse(xc - radius, yc - radius, xc + radius, yc + radius);
/* 任意两点连线:弦图 */
setPenColor(YELLOW);
for (i = 0; i < MAXPTS; i++)
for (j = i; j < MAXPTS; j++)
line(points[i][0], points[i][1], points[j][0], points[j][1]);
endPaint();
return 0;
}
代码讲解:
- 角度转弧度:
rads = angle * PI / 180.0($360° = 2\pi$ 弧度)。 points[i][0]、points[i][1]分别存第 i 个点的 x、y 坐标(用二维数组存点集,复习二维数组)。坐标在beginPaint之前就算好,画图阶段只负责连线。- 双重循环
for i+for j = i让每对点恰好连一次线,避免重复:15 个点两两连线共 $\frac{15 \times 14}{2} = 105$ 条,这正是组合数 $\binom{15}{2}$。 aspect = 0.85对 y 坐标压缩,抵消屏幕像素纵横比,让外接圆视觉上接近正圆。
知识点:三角函数参数方程、角度/弧度换算、二维数组存点集、嵌套循环连线(组合数的应用)。
小结:56~65 的图形题把"循环 + 坐标公式"结合,是计算机图形学的最初级体验——把几何图形翻译成"对每根线条/每个像素做一次计算"。ACLLib 只是一个轻量入门库,如果以后想开发更复杂的图形程序,还可以学习 XEGE、SDL2 或 raylib(跨平台)。
【程序 61】打印杨辉三角形
题目:打印 10 行杨辉三角形。
解题思路
杨辉三角(帕斯卡三角)的每一行,除了两端的 1 之外,每个数等于它"左上方 + 正上方"两个数之和:
$$
a[i][j] = a[i-1][j-1] + a[i-1][j]
$$
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1
用二维数组 a[10][10] 存储:先把每行两端的元素(a[i][0] 和 a[i][i])设为 1,再按递推公式填中间部分,最后打印。
完整代码
#include <stdio.h>
int main()
{
int a[10][10];
int i, j;
for (i = 0; i < 10; i++)
{
a[i][0] = 1; // 每行第一个元素是 1
a[i][i] = 1; // 每行最后一个元素是 1(对角线)
}
for (i = 2; i < 10; i++)
for (j = 1; j < i; j++)
a[i][j] = a[i-1][j-1] + a[i-1][j]; // 递推公式
for (i = 0; i < 10; i++)
{
for (j = 0; j <= i; j++)
printf("%5d", a[i][j]);
printf("\n");
}
return 0;
}
代码讲解
- 注意外层从
i = 2开始:第 0、1 行只有"1"或"1 1",两端都已是 1,中间没有需要算的格子。 j < i保证不覆盖对角线元素a[i][i]。- 打印时
j <= i:第 i 行只有 i+1 个元素(三角形)。
知识点
- 二维数组的初始化与递推填充
- 递推公式建模
数学原理
杨辉三角是数学的"宝库",藏着大量规律:
- 第 n 行第 k 个数 = 组合数 $C(n, k) = \frac{n!}{k!(n-k)!}$(第 0 行起)。
- 每行之和 = $2^n$。
- 斜对角线和构成斐波那契数列(程序 11 的联系!)。
- 与二项式展开 $(a+b)^n$ 的系数完全一致。
这个三角形还出现在概率论(二项分布)、组合数学(路径计数)中。比如"从方格左上角走到右下角有多少种走法"就是组合数问题,正是杨辉三角的每个格子。
扩展思考(其他解法)
- 不用二维数组、只用一维数组滚动更新的版本:从右往左更新
a[j] += a[j-1],可以省一半内存,作为进阶练习。 - 若想输出"等腰"形状,可以在每行前打印空格(程序 23 学过的技巧)。
第七部分:指针入门与应用(程序 66 ~ 70)
说明:程序 62~65 的图形绘制题已在第六部分讲解,本部分从程序 66 开始讲解指针。
【程序 66】用指针方法把三个数按大小顺序输出
题目:输入 3 个数 a、b、c,按大小顺序输出(用指针实现)。
解题思路
排序逻辑和程序 5 完全一样(三次比较-交换)。区别在于:这次把"交换"写成一个函数 swap(p1, p2),参数是指针。
为什么必须传指针? 因为 C 函数参数是"值传递"——如果直接传两个数,函数里交换的是参数的副本,函数外的原变量不会变。传指针(变量的地址)后,函数通过 *p1、*p2 直接操作原变量的内存,才能真的交换。
完整代码
#include <stdio.h>
void swap(int *p1, int *p2) // 参数是"指向 int 的指针"
{
int t;
t = *p1; // *p1 取出指针所指变量的值
*p1 = *p2;
*p2 = t;
}
int main()
{
int n1, n2, n3;
int *p1, *p2, *p3;
printf("请输入 3 个数:");
scanf("%d,%d,%d", &n1, &n2, &n3);
p1 = &n1; // & 取地址:p1 指向 n1
p2 = &n2;
p3 = &n3;
if (n1 > n2) swap(p1, p2);
if (n1 > n3) swap(p1, p3);
if (n2 > n3) swap(p2, p3);
printf("从小到大:%d, %d, %d\n", n1, n2, n3);
return 0;
}
代码讲解
p1 = &n1;:把n1的地址存进指针p1。swap(p1, p2):传进去的是地址。函数里*p1就是n1本身,*p2就是n2本身,交换成功。- 回忆预备知识第 9 节:
&取地址、*取内容。这就是指针的两个基本操作。
知识点
- 指针的声明、取地址、间接引用
- 值传递 vs 传地址:想通过函数修改外部变量,必须传地址
- 指针作为函数参数
数学原理
排序逻辑同程序 5(选择排序雏形)。本题重点是理解为什么传指针:C 参数传递是复制值,指针让我们复制"地址"从而能间接修改原变量。这是 C 语言最核心也最难的概念,值得反复琢磨。
扩展思考(其他解法)
- 不借助指针,直接用全局变量也能让函数"修改外部变量",但全局变量坏处多(程序 44 讨论过)。传指针才是正道。
- 传指针与传值对比如下:传值——函数内改的是副本;传指针——函数通过地址改原变量。
【程序 67】最大的与第一个交换、最小的与最后一个交换
题目:输入数组,最大的元素与第一个元素交换,最小的与最后一个元素交换,输出数组。
解题思路
用指针遍历数组找最大、最小元素的位置(记住是指针/下标,不是值),然后交换。
有一种常见的错误写法:交换时先动 array[0] 再动 array[9],导致最小值位置被覆盖。下面给出清晰正确的版本。
完整代码
#include <stdio.h>
void input(int a[], int n)
{
int i;
for (i = 0; i < n; i++)
scanf("%d", &a[i]);
}
void max_min(int a[], int n)
{
int *max = a, *min = a; // 先都指向第一个元素
int *p, temp;
for (p = a + 1; p < a + n; p++) // 指针遍历数组
{
if (*p > *max) max = p; // 记录更大元素的位置
if (*p < *min) min = p; // 记录更小元素的位置
}
/* 最大的与第一个交换 */
temp = *max; *max = a[0]; a[0] = temp;
/* 最小的与最后一个交换 */
temp = *min; *min = a[n-1]; a[n-1] = temp;
}
void output(int a[], int n)
{
int i;
for (i = 0; i < n; i++)
printf("%d ", a[i]);
printf("\n");
}
int main()
{
int number[10];
printf("请输入 10 个数:\n");
input(number, 10);
max_min(number, 10);
output(number, 10);
return 0;
}
代码讲解
int *max = a;:数组名本身就是"指向首元素的指针",a等价于&a[0]。指针p从a+1走到a+n-1,正好遍历所有元素(p < a + n是结束条件)。*p > *max时更新max = p:记录的是位置(指针),交换时才能定位。- 注意:若最大值恰好是最后一个元素、最小值恰好是第一个元素,先交换最大值再交换最小值时要小心错位。更稳妥的做法是用下标实现,这里给出下标版作为对照(见扩展思考)。
知识点
- 指针遍历数组(
p = a; p < a + n; p++模式) - 数组名即首地址
- 记住"位置"而非"值"的算法思想
扩展思考(其他解法)
下标版更易读,也避免指针交换的错位问题:
int maxi = 0, mini = 0, i;
for (i = 1; i < n; i++) {
if (a[i] > a[maxi]) maxi = i;
if (a[i] < a[mini]) mini = i;
}
/* 先交换,再判断是否冲突 */
若 mini == 0 且先交换了 a[0] 与 a[maxi],最小值位置可能被破坏——一个经典细节题,建议自己推演:最大值在最后、最小值在最前的特殊情况。
【程序 68】数组元素循环右移 m 位
题目:有 n 个整数,使其前面各数顺序向后移 m 个位置,最后 m 个数变成最前面的 m 个数。
解题思路
"循环右移 m 位":每个元素向后移 m 个位置,末尾的 m 个元素绕回开头。例如 1 2 3 4 5 右移 2 位变成 4 5 1 2 3。
一种经典做法:每次把数组右移 1 位(最后元素移到最前),重复 m 次;也可以用递归实现。更高效的做法是"三次反转法"(见扩展思考)。
完整代码(单步右移 m 次)
#include <stdio.h>
void move(int a[], int n, int m)
{
int i, last;
while (m-- > 0) // 右移 m 次
{
last = a[n - 1]; // 备份最后一个元素
for (i = n - 1; i > 0; i--)
a[i] = a[i - 1]; // 每个元素后移一位(从后往前!)
a[0] = last; // 最后一个元素移到开头
}
}
int main()
{
int number[20], n, m, i;
printf("元素个数:");
scanf("%d", &n);
printf("右移位数:");
scanf("%d", &m);
printf("请输入 %d 个数:", n);
for (i = 0; i < n; i++)
scanf("%d", &number[i]);
move(number, n, m % n); // 右移 n 位等于没移,取余优化
for (i = 0; i < n; i++)
printf("%d ", number[i]);
printf("\n");
return 0;
}
代码讲解
- 单步右移必须从后往前赋值(
a[i] = a[i-1]):从前往后会覆盖还没移动的元素(和程序 39 的插入同理)。 m % n:右移 n 位数组回到原样,所以取余后只需移m % n次,这是个重要优化。- 若
m > n不取余,程序也能正确工作但多做了无用的整轮移动。
知识点
- 数组整体移位(从后往前的赋值顺序)
- 取余优化循环位移
扩展思考(其他解法)
三次反转法(O(n) 时间,面试常考):先整体反转,再反转前 m 个,再反转后 n-m 个。
1 2 3 4 5 → 反转整体 5 4 3 2 1 → 反转前2个 4 5 3 2 1 → 反转后3个 4 5 1 2 3 ✓
可以自己实现一个 reverse(a, from, to) 函数验证。这就是"旋转数组"问题的经典解。
【程序 69】约瑟夫环问题(报数出圈)
题目:有 n 个人围成一圈,顺序排号。从第 1 个人开始报数(从 1 数到 3),凡报到 3 的人退出圈子,问最后留下的是原来第几号的那位。
解题思路
这是经典的约瑟夫环(Josephus problem)。用数组模拟:num[i] 存第 i+1 个人的编号,被淘汰的人编号置 0。用三个变量维护状态:
i:当前报到第几个人(下标,循环递增,到末尾回到 0,模拟"围成一圈")k:当前报数到几(1、2、3 循环)m:已淘汰的人数
每轮:i 依次扫过,跳过已淘汰的人(编号 0),没淘汰的人报数 k++;k 数到 3 就淘汰这个人(置 0),k 清零,m++。直到 m == n-1,剩下的就是最后一人。
完整代码
#include <stdio.h>
int main()
{
int num[50];
int n, i, k, m;
printf("请输入总人数:");
scanf("%d", &n);
for (i = 0; i < n; i++)
num[i] = i + 1; // 编号 1~n
i = 0; k = 0; m = 0; // i: 当前下标; k: 报数 1~3; m: 已淘汰人数
while (m < n - 1) // 淘汰到只剩 1 人
{
if (num[i] != 0) // 这个人还在圈里
k++;
if (k == 3) // 报到 3
{
num[i] = 0; // 淘汰
k = 0; // 重新从 1 报数
m++; // 淘汰人数 +1
}
i++; // 下一个人
if (i == n)
i = 0; // 围成一圈:回到开头
}
/* 找到唯一非 0 的编号 */
for (i = 0; i < n; i++)
if (num[i] != 0)
printf("最后留下的是 %d 号\n", num[i]);
return 0;
}
代码讲解
- "围成一圈"用下标取模模拟:
if (i == n) i = 0;(也可以写i = (i + 1) % n;,更简洁)。 - 已淘汰的人用 0 标记,报数时跳过。
- 循环结束时数组里只有一个非 0 元素,就是幸存者。
知识点
- 数组模拟数据结构(环形逻辑)
- 取模模拟环形(
% n) - 状态机思想(i、k、m 三个状态变量)
数学原理
约瑟夫环有数学递推解(不用模拟,O(n) 直接算幸存者编号):
$$
J(1) = 0, \qquad J(n) = (J(n-1) + k) \bmod n
$$
(k 为报数上限,编号从 0 开始)。当 n=41、k=3 时,这就是著名的"约瑟夫问题"历史原型——传说约瑟夫靠计算幸存位置在罗马人包围中活了下来。程序模拟虽然慢(O(nk)),但直观、易写、易扩展(比如报数到 7、每次淘汰间隔变化),是学习阶段首选。
扩展思考
- 用链表(程序 72 之后的内容)实现更接近真实"出圈"操作。
- 数学递推解性能最好,适合 n 特别大的场景,可作为挑战题。
【程序 70】求字符串的长度
题目:写一个函数,求一个字符串的长度(不借助 strlen),在 main 中输入字符串并输出长度。
解题思路
字符串是字符数组,以 '\0' 结尾。用指针从开头往后数,数到 '\0' 为止——这就是求长度的原理。strlen 内部干的就是这件事。
完整代码
#include <stdio.h>
int length(char *p)
{
int n = 0;
while (*p != '\0') // 不是结束符就继续
{
n++;
p++; // 指针后移一位
}
return n;
}
int main()
{
char str[100];
printf("请输入一个字符串:");
gets(str); // 读一整行(含空格)
printf("该字符串的长度是 %d\n", length(str));
return 0;
}
代码讲解
while (*p != '\0'):\0是字符串结束标志(ASCII 码 0)。判断、计数、移动指针三件事配合,直到遇到\0。gets(str)读入整行(包括空格);scanf("%s")遇空格会停,不适合含空格的字符串。注意gets有缓冲区溢出风险,安全写法是fgets(str, 100, stdin),正式代码建议用fgets。- 数组名
str传给函数时"退化"成指向首字符的指针,所以参数写char *p即可。
知识点
- 字符串的
'\0'结尾约定 - 指针遍历字符串
getsvsscanf("%s")vsfgets
扩展思考(其他解法)
- 用下标
for (n = 0; str[n] != '\0'; n++);也一样,本质相同。 - 现代代码直接调用库函数
strlen(str)(<string.h>),自己实现是为了理解原理。程序 86 会用到strcat等字符串库函数。
第八部分:结构体与链表(程序 71 ~ 80)
【程序 71】输入输出 5 个学生的数据记录(结构体数组)
题目:编写 input() 和 output() 函数,输入、输出 5 个学生的数据记录。
解题思路
用结构体把每个学生的学号、姓名、3 门成绩打包成一个整体,再用结构体数组存 5 个学生。input() 负责读入,output() 负责打印。这是结构体的入门练习,也复习函数化编程。
完整代码
#include <stdio.h>
#define N 5
struct student
{
char num[10]; // 学号
char name[20]; // 姓名
int score[4]; // 3 门成绩(预留 4 个位置)
};
void input(struct student stu[], int n)
{
int i, j;
for (i = 0; i < n; i++)
{
printf("请输入第 %d 个学生的学号、姓名:", i + 1);
scanf("%s %s", stu[i].num, stu[i].name);
for (j = 0; j < 3; j++)
{
printf("第 %d 门成绩:", j + 1);
scanf("%d", &stu[i].score[j]);
}
}
}
void output(struct student stu[], int n)
{
int i, j;
printf("\n学号 姓名 成绩1 成绩2 成绩3\n");
for (i = 0; i < n; i++)
{
printf("%-10s%-8s", stu[i].num, stu[i].name);
for (j = 0; j < 3; j++)
printf("%-6d", stu[i].score[j]);
printf("\n");
}
}
int main()
{
struct student stu[N];
input(stu, N);
output(stu, N);
return 0;
}
代码讲解
stu[i].num:用.访问结构体成员("stu 的第 i 个元素的 num 字段")。- 结构体数组作为函数参数:
struct student stu[]会退化成指针,函数内修改会反映到原数组。 - 注意函数要传参:
main里调用input(stu, N)而不是input();,否则无法访问到结构体数组。
知识点
- 结构体定义与结构体数组
- 成员访问运算符
. - 结构体数组作为函数参数
扩展思考(其他解法)
用结构体指针 + 箭头运算符访问成员是工程常用写法:p->num 等价于 (*p).num。可以在 input 里改用 struct student *p; p = &stu[i]; scanf("%s", p->num); 体会一下(程序 72 起会大量使用箭头)。
【程序 72】创建一个链表
题目:创建一个链表并输出各节点数据。
解题思路
链表是动态数据结构:由一个个节点(node)用指针串联而成。每个节点包含"数据 + 指向下一个节点的指针"。最后一个节点的 next 指向 NULL(表示结束)。
创建链表三步:malloc 申请内存 → 填入数据 → 把新节点接到链上。本题先用一个"头节点"作占位,然后循环读入数据、追加节点。
完整代码
#include <stdio.h>
#include <stdlib.h> // malloc 需要
struct list
{
int data;
struct list *next;
};
typedef struct list node; // 给类型起别名 node
typedef node *link; // node* 别名 link(指向节点的指针)
int main()
{
link ptr, head; // head: 头指针; ptr: 当前节点
int num, i;
head = (link)malloc(sizeof(node)); // 申请第一个节点
ptr = head;
printf("请输入 5 个数:\n");
for (i = 0; i < 5; i++)
{
scanf("%d", &num);
ptr->data = num; // 存数据
ptr->next = (link)malloc(sizeof(node)); // 申请下一个节点
if (i == 4)
ptr->next = NULL; // 最后一个节点指向 NULL
else
ptr = ptr->next; // 移到下一个节点
}
/* 遍历输出 */
ptr = head;
while (ptr != NULL)
{
printf("%d ", ptr->data);
ptr = ptr->next;
}
printf("\n");
return 0;
}
代码讲解
malloc(sizeof(node)):向系统申请一块能放下一个 node 的内存,返回它的地址。(link)是类型转换。ptr->data:->是"通过指针访问成员",等价于(*ptr).data。- 遍历链表的通用模式:
while (ptr != NULL) { 处理 ptr; ptr = ptr->next; }——顺着 next 一个个走,走到 NULL 结束。 typedef给类型起别名:typedef struct list node;之后写node就代表struct list,代码更简洁。
知识点
- 链表结构:节点(数据 + next 指针)
malloc动态内存分配、free释放(本程序略,实际用完应 free)typedef类型别名- 链表的创建与遍历
数学原理/类比
链表像一个"寻宝游戏":你只知道自己手中这张纸条的内容和下一张纸条在哪。与数组对比:数组是一排连续的房间(知道门牌号就能直达任意房间),链表是纸条链(必须从第一张开始顺着找)。数组随机访问快(O(1)),链表插入删除快(O(1),不需要搬动其他元素),这是数据结构最基础的选择依据。
扩展思考
- 用完链表要释放内存:
free逐个释放节点(先保存 next 再 free 当前节点),避免内存泄漏。 - 工程中链表插入节点、删除节点的操作很常用,程序 74 会练习删除。
【程序 73】反向输出一个链表
题目:反向输出一个链表。
解题思路
一个思路是"头插法"建链:新节点总是插在最前面,这样建完后链表自然就是逆序的,遍历即得逆序结果。更通用的是用程序 27 的递归思想:先递归遍历到链尾,回归时打印。
完整代码(递归逆序打印——更通用)
#include <stdio.h>
#include <stdlib.h>
struct list
{
int data;
struct list *next;
};
typedef struct list node;
typedef node *link;
/* 递归逆序打印:先打印后面的,再打印自己 */
void print_reverse(link p)
{
if (p == NULL)
return;
print_reverse(p->next); // 先处理下一个
printf("%d ", p->data); // 再打印自己
}
int main()
{
link head = NULL, ptr, temp;
int num, i;
printf("请输入 5 个数:\n");
for (i = 0; i < 5; i++)
{
scanf("%d", &num);
/* 头插法:新节点插到最前面 */
ptr = (link)malloc(sizeof(node));
ptr->data = num;
ptr->next = head;
head = ptr;
}
printf("正序:");
for (ptr = head; ptr != NULL; ptr = ptr->next)
printf("%d ", ptr->data);
printf("\n逆序:");
print_reverse(head);
printf("\n");
return 0;
}
代码讲解
- 头插法核心:
ptr->next = head; head = ptr;——新节点指向旧头,新节点成为新头。这样最先输入的节点被"挤"到链尾。 - 递归逆序打印与程序 27 完全同构:先递归,后打印。
- 遍历链表的三种经典方式都可以对照:正序 for 循环、逆序递归。
知识点
- 头插法建链
- 递归处理链表
扩展思考(其他解法)
- 也可以"反转链表"(就地反转指针方向)后再正序打印,这是面试经典题:
link prev = NULL, cur = head, nxt; while (cur != NULL) { nxt = cur->next; cur->next = prev; prev = cur; cur = nxt; } head = prev;建议自己跑一遍理解指针如何"转身"。
【程序 74】连接两个链表
题目:连接两个链表。
解题思路
本题的核心操作是连接(concatenate)两个链表——找到第一个链表的末尾,把它的 next 指向第二个链表的头即可。这是链表最基础的操作之一。
完整代码
#include <stdio.h>
#include <stdlib.h>
struct list
{
int data;
struct list *next;
};
typedef struct list node;
typedef node *link;
link create_list(int a[], int n) // 用数组创建链表
{
link head, ptr, temp;
int i;
head = (link)malloc(sizeof(node));
head->data = a[0];
head->next = NULL;
ptr = head;
for (i = 1; i < n; i++)
{
temp = (link)malloc(sizeof(node));
temp->data = a[i];
temp->next = NULL;
ptr->next = temp; // 接到链尾
ptr = temp;
}
return head;
}
link concatenate(link p1, link p2) // 连接两个链表
{
link ptr = p1;
if (p1 == NULL) return p2; // 第一个为空直接返回第二个
while (ptr->next != NULL) // 走到第一个链表的末尾
ptr = ptr->next;
ptr->next = p2; // 末尾接上第二个链表
return p1;
}
void print_list(link head)
{
link p;
for (p = head; p != NULL; p = p->next)
printf("%d ", p->data);
printf("\n");
}
int main()
{
int a[] = {1, 3, 5, 7};
int b[] = {2, 4, 6};
link la = create_list(a, 4);
link lb = create_list(b, 3);
printf("链表1:"); print_list(la);
printf("链表2:"); print_list(lb);
la = concatenate(la, lb);
printf("连接后:"); print_list(la);
return 0;
}
代码讲解
concatenate的核心就是"找尾 + 接上":while (ptr->next != NULL) ptr = ptr->next;走到链尾,然后ptr->next = p2。- 链尾的特征是
next == NULL,找链尾是链表基本功。 - 有人会把"连接两个链表"和"选择排序、删除节点"混在一起写,使逻辑复杂化;本题只聚焦"连接",用上面的方法已经最优,不再硬凑多方案。
知识点
- 找链表尾节点
- 链表拼接
扩展思考
- 合并两个有序链表(面试常考):每次取两链头中较小者接到结果链上。可以自己实现,体会"归并"思想(归并排序的雏形)。
【程序 75】放松一下,算一道简单的题目
题目:算一道简单题——综合练习 if 判断。
解题思路
这道题其实是考察"读懂代码会得到什么输出"。对 i = 1..4,依次执行几个 if,统计条件成立次数 n,当 n == 3 时输出 64 + i 对应的字符。%c 输出 65、66、67、68 对应的字符是 'A'、'B'、'C'、'D'。
完整代码(带注释帮助理解)
#include <stdio.h>
int main()
{
int i, n;
for (i = 1; i < 5; i++)
{
n = 0;
if (i != 1) n = n + 1; // i=2,3,4 时成立
if (i == 3) n = n + 1; // 仅 i=3 时成立
if (i == 4) n = n + 1; // 仅 i=4 时成立
if (i != 4) n = n + 1; // i=1,2,3 时成立
if (n == 3)
printf("答案是:%c\n", 64 + i);
}
return 0;
}
代码讲解
- 逐个分析:i=1 时成立条件是第 4 条 → n=1;i=2 时第 1、4 条 → n=2;i=3 时第 1、2、4 条 → n=3 ✓,输出
64+3=67即 'C';i=4 时第 1、3 条 → n=2。 - 所以输出 'C'。这类"读代码写结果"的题是考试常见题型,训练的是逐步跟踪(trace)能力。
知识点
- 程序跟踪/调试能力(人肉执行代码)
- 字符与整数的转换(
64 + i用%c输出)
扩展思考
练习"读代码"的正确姿势:用一张纸,把每个变量的变化一步步写下来(变量表),这是最可靠的调试方法,比瞎猜强得多。
【程序 76】指针函数:偶数求和、奇数求和的调度
题目:输入 n 为偶数时,求 1/2+1/4+...+1/n;为奇数时求 1/1+1/3+...+1/n(利用指针函数)。
解题思路
函数指针:指向函数的指针。函数在内存里也有地址,可以存在指针变量里,通过指针间接调用函数——这叫"回调"。
本题定义一个通用"调度函数" dcall(fp, n),参数 fp 是函数指针,dcall 调用 fp(n)。main 里根据 n 的奇偶选择把 peven 或 podd 传给 dcall,实现"动态选择要执行的函数"。
完整代码
#include <stdio.h>
float peven(int n) // 偶数项求和:1/2 + 1/4 + ... + 1/n
{
float s = 0;
int i;
for (i = 2; i <= n; i += 2)
s += 1.0 / i; // 注意 1.0,避免整数除法
return s;
}
float podd(int n) // 奇数项求和:1/1 + 1/3 + ... + 1/n
{
float s = 0;
int i;
for (i = 1; i <= n; i += 2)
s += 1.0 / i;
return s;
}
float dcall(float (*fp)(int), int n) // fp 是指向函数的指针
{
return (*fp)(n); // 通过指针调用函数
}
int main()
{
float sum;
int n;
printf("请输入 n(>1):");
scanf("%d", &n);
if (n % 2 == 0)
sum = dcall(peven, n);
else
sum = dcall(podd, n);
printf("结果为 %f\n", sum);
return 0;
}
代码讲解
- 函数指针声明:
float (*fp)(int)——fp是一个指针,指向"参数为 int、返回 float"的函数。注意(*fp)的括号不能省,否则float *fp(int)是"返回 float* 的函数"。 dcall(peven, n):函数名peven本身可以当作函数指针传入。1.0 / i:因为i是 int,1 / i会是整数除法(结果 0!),必须让分子是小数。
知识点
- 函数指针与回调
- 整数除法的陷阱(
1 / ivs1.0 / i)
数学原理
调和级数(harmonic series)$1 + \frac12 + \frac13 + \dots$ 是发散的:虽然每项越来越小,但累加和会无限增长(增长速度和 $\ln n$ 相当,所以增长极慢)。本题的偶数项之和 $\frac12 + \frac14 + \dots$ 与奇数项之和 $1 + \frac13 + \frac15 + \dots$ 都是"调和级数的一半"(前者等于 $\frac12 \times$ 调和级数),因此也都发散,n 越大结果越大。这也提醒我们:浮点数累加级数时,n 特别大会有精度问题(浮点误差累积),这是数值计算里要注意的点。
扩展思考(其他解法)
- 不用函数指针,main 里直接
if (n%2==0) sum = peven(n); else sum = podd(n);也一样。函数指针的价值在于:把"选哪个函数"变成数据/参数,代码更灵活,是实现"策略模式"的基础。这在 C 语言里是高级技巧,理解即可。
【程序 77】填空练习(指向指针的指针)
题目:填空练习——char **q 指向指针的指针。
解题思路
char *s[] 是"字符串数组"(每个元素是 char*,指向一个字符串)。char **q 是"指向指针的指针":q 指向 s 的某个元素(一个 char*)。要打印第 k 个字符串,可以让 q = &s[k],然后 *q 就是 s[k](一个 char),`printf("%s", q)` 打印它。
完整代码(填空处补全)
#include <stdio.h>
int main()
{
char *s[] = {"man", "woman", "girl", "boy", "sister"};
char **q;
int k;
for (k = 0; k < 5; k++)
{
q = &s[k]; // ← 填空:让 q 指向 s[k]
printf("%s\n", *q); // *q 即 s[k],输出该字符串
}
return 0;
}
代码讲解
- 这张图帮你理清三层关系:
q(地址)→s[k](char* 指针,存的是字符串首字符的地址)→ 字符('m', 'a', 'n'...)。 &s[k]取 s[k] 的地址(类型是char**),正好赋给q。- 为什么需要两级指针?因为
s数组本身存的是"指针",要指向数组元素就得用"指向指针的指针"。
知识点
- 指向指针的指针(二级指针)
- 字符串数组(指针数组)
- 多级间接引用的概念
数学原理/类比
指针就是"地址",二级指针是"地址的地址"。类比:变量是"房子",一级指针是"记着房子地址的纸条",二级指针是"记着纸条放在哪个抽屉的备忘录"。多级指针在二维数组、命令行参数(int main(int argc, char *argv[]))、动态分配二维结构时很常用。
扩展思考
argv 就是典型的 char **:argv[0] 是程序名,argv[1] 起是命令行参数。可以写个程序 printf("%s\n", argv[1]); 试试传参运行。
【程序 78】找年龄最大的人(找出程序的问题)
题目:找到年龄最大的人并输出。请找出程序中有什么问题。
解题思路
本题是"找 bug"练习。问题代码在于:
if (m < p->age)
q = p++; // 错误:p++ 让 p 后移,q 指向的是"后移前"的 p
m = q->age; // 错误:m 始终取 q 指向的人(而不是当前人)的年龄
q = p++ 中 p++ 是后置自增:q 先被赋值为当前的 p,然后 p 才后移。这导致循环里 p 每次多跳一位(跳过了第 2 个人),而且 m 的更新逻辑也乱了。下面给出修正版。
完整代码(修正版)
#include <stdio.h>
#define N 4
struct man
{
char name[20];
int age;
};
int main()
{
struct man person[N] = {{"li", 18}, {"wang", 19}, {"zhang", 20}, {"sun", 22}};
struct man *q, *p;
int i;
int max = 0; // 记录最大年龄
p = person;
for (i = 0; i < N; i++)
{
if (max < p->age)
{
max = p->age; // 更新最大年龄
q = p; // 记住这个人
}
p++; // 移到下一个人(与 if 无关!)
}
printf("年龄最大的是 %s,%d 岁\n", q->name, q->age);
return 0;
}
代码讲解
- 修正要点:
p++必须放在if外面且独立执行;先更新max再让q指向当前人。 - 用
->访问结构体成员,(*q).name与q->name等价。
知识点
- 结构体指针遍历数组
- 找最大值的"扫描"模式(max 记录值、q 记录位置)
- 后缀自增
p++在表达式中的副作用(q = p++是经典陷阱)
扩展思考
找最大(最小)值的通用模板:一个变量记"当前最大"(或下标),遍历比较更新。程序 67 找最大最小也是这个模式。务必注意自增运算符在表达式里的求值顺序——q = p++ 先赋值后自增,这是很多 bug 的来源。
【程序 79】字符串排序
题目:输入三个字符串,按字典序从小到大输出。
解题思路
用 strcmp 比较字符串,用 strcpy 交换字符串(不能直接 = 赋值字符串!)。排序逻辑与程序 5、66 相同(三次比较-交换)。
为什么字符串不能直接比较或赋值? 字符串是字符数组,str1 == str2 比较的是两个数组的首地址而不是内容;str1 = str2 更是非法。必须用库函数 strcmp(逐字符比较)和 strcpy(逐字符复制)。
完整代码
#include <stdio.h>
#include <string.h> // strcmp, strcpy
void swap(char *p1, char *p2)
{
char temp[50];
strcpy(temp, p1);
strcpy(p1, p2);
strcpy(p2, temp);
}
int main()
{
char str1[50], str2[50], str3[50];
printf("请输入三个字符串:\n");
scanf("%s %s %s", str1, str2, str3);
if (strcmp(str1, str2) > 0) swap(str1, str2);
if (strcmp(str1, str3) > 0) swap(str1, str3);
if (strcmp(str2, str3) > 0) swap(str2, str3);
printf("排序后:\n%s\n%s\n%s\n", str1, str2, str3);
return 0;
}
代码讲解
strcmp(a, b)返回:a < b 时负数、a == b 时 0、a > b 时正数。按 ASCII 码逐字符比较("字典序")。strcpy(目标, 源)把源字符串(含\0)复制到目标。交换字符串 = 三次 strcpy。- 注意
scanf("%s")不能读含空格的字符串,若有空格需求改用fgets。
知识点
- 字符串比较
strcmp、复制strcpy - 字符串数组与指针的关系
数学原理
字典序(lexicographic order)就是把字符串当作"字母序列"按位比较,是字符编码(ASCII)有序性的直接应用。它和整数比较的唯一区别是"长度不同时"的处理(较短的是前缀时,短的更小,如 "abc" < "abcd")。
扩展思考
- 字符串数组
char *strs[3]+ 交换指针(而不是复制内容)是更高效的字符串排序方式——只换"纸条"不搬"内容"。进阶可以尝试。
【程序 80】海滩上分桃子问题
题目:海滩上有一堆桃子,五只猴子来分。第一只猴子把桃子分成五份,多了一个,扔进海里后拿走一份;第二只、第三只、第四只、第五只猴子都这么做。问海滩上原来最少有多少个桃子?
解题思路
从最后一只猴子倒推:设最后一只猴子分完拿走一份后剩下 x 个,那么它分之前有 $\frac{5}{4}x + 1$ 个(因为五份中它拿走一份,剩四份,即"分之前的数 = 剩的 ÷ 4 × 5 + 1")。
倒推思路:枚举"第五只猴子分后剩下的桃子数"(必须是 4 的倍数),逐只往前推,如果每一步都能整出(每次剩下的都是 4 的倍数,才能被下一只猴子平分成 5 份),就说明成立。
枚举可以从 4 开始、步长 4(最后剩下的数必须是 4 的倍数),逐只猴子往前推。下面给出清晰的倒推版本。
完整代码(倒推法)
#include <stdio.h>
int main()
{
int i; // 枚举:最后剩下的桃子数(每次都被分前必须是 4 的倍数)
int t; // 当前"分之前"的桃子数
int k; // 猴子编号(5 只)
int ok;
for (i = 4; i < 100000; i += 4) // 最后剩下的数必须是 4 的倍数
{
t = i;
ok = 1;
for (k = 0; k < 5; k++) // 倒推 5 只猴子
{
t = t / 4 * 5 + 1; // 分之前的数 = 剩下的 ÷4×5 + 1
if (t % 4 != 0) // 分之前不是 4 的倍数:前面那只猴子分不开
{
ok = 0;
break;
}
}
if (ok)
{
printf("海滩上原来最少有 %d 个桃子\n", t);
break;
}
}
return 0;
}
代码讲解
- 倒推公式推导:分之前有 $N$ 个,猴子扔掉 1 个得 $N-1$,分成 5 份拿走 1 份剩 $\frac45 (N-1)$。设剩下 $M$ 个,则 $M = \frac45 (N-1)$,解得 $N = \frac54 M + 1$,即代码里的
t / 4 * 5 + 1。 - 条件
t % 4 != 0:前一只猴子分之前,数量必须能"先减 1 再被 4 整除"(等价于(t-1) % 5 == 0分后剩 4 份是整数)。用整除检查来筛选。 - 枚举从 4 开始、每次加 4:最后剩下的数必须是 4 的倍数,缩小搜索范围。
知识点
- 逆向思维(倒推)
- 枚举 + 整除条件筛选
数学原理
这类"多轮取 1 再均分"的问题在数学上可以化成不定方程,用数论方法求通解;但枚举 + 验证是计算机的强项。顺带一提:这个经典问题的答案是 3121。可以自己用公式验证:$N = 5^5 - 4 = 3121$(有巧妙的数学构造,不展开)。
扩展思考
- 把 5 只猴子改成 n 只、或每只猴子留 2 个,都是同一模型,改参数即可——这就是"模型化"思维。
- 注意别让同一个变量既当外层循环变量又被内层逻辑修改(容易出错);本版本用
i、t分离,更清晰。
第九部分:综合练习(程序 81 ~ 94)
【程序 81】809×??=800×??+9×??+1
题目:$809 \times ?!? = 800 \times ?!? + 9 \times ?!? + 1$,其中 ?? 是两位数,且 $8 \times ??$ 是两位数、$9 \times ??$ 是三位数。求 ?? 及 $809 \times ??$ 的结果。
解题思路
这道题本质是"找满足条件的两位数"。注意:$809 = 800 + 9$,所以 $809 \times ?? = 800\times ?? + 9 \times ??$ 恒成立,题目里的 "+1" 实际上是多余的(笔误)。真正有价值的约束是:$8 \times ??$ 是两位数、$9 \times ??$ 是三位数。枚举 10~99 的两位数,检查这些条件。
完整代码
#include <stdio.h>
int main()
{
long a = 809;
long i, b;
for (i = 10; i < 100; i++)
{
b = a * i;
if (8 * i < 100 && 9 * i >= 100) // 8*i 是两位数、9*i 是三位数
printf("?? = %ld, 809 * %ld = %ld\n", i, i, b);
}
return 0;
}
代码讲解
8 * i < 100:两位数(10~99)的条件;9 * i >= 100:三位数的条件。- 解出 ?? 是 12(8×12=96 两位数,9×12=108 三位数),809×12 = 9708。
知识点
- 枚举 + 条件筛选
- 数位范围的判断
说明
这道题曾经有一种绕弯的写法(用"b/i = 809*i + b%i"这类恒等式故弄玄虚)。本题的核心价值是练习枚举筛选,直接采用简洁版。
【程序 82】八进制转换为十进制
题目:八进制转换为十进制。
解题思路
八进制数每位代表 $8^k$ 的权重。例如八进制 176 = $1 \times 8^2 + 7 \times 8^1 + 6 \times 8^0 = 64 + 56 + 6 = 126$。
程序用秦九韶式累乘:n = n * 8 + (当前位数字),从左到右扫一遍。原理:先读入的位权重大,每读一位就把之前的和乘以 8 再累加。这比"先算 8 的幂"更高效,是进制转换的通用写法。
完整代码
#include <stdio.h>
int main()
{
char s[10];
int n = 0, i;
printf("请输入一个八进制数:");
scanf("%s", s); // 按字符串读入
for (i = 0; s[i] != '\0'; i++)
n = n * 8 + (s[i] - '0'); // 关键:累乘 8 + 数字
printf("十进制是 %d\n", n);
return 0;
}
代码讲解
s[i] - '0':把字符 '0'~'7' 转成数字 0~7。因为字符 '0' 的 ASCII 码是 48,减去 '0' 就是数值。- 累乘过程:读 '1' → n=1;读 '7' → n=18+7=15;读 '6' → n=158+6=126。✓
- 这个模式对任何进制通用:二进制就
*2,十六进制就*16。
知识点
- 进制转换(按权展开)
- 字符数字转数值:
s[i] - '0' - 累乘累加(Horner 算法)
数学原理
按权展开:$N = dk 8^k + d{k-1} 8^{k-1} + \dots + d_0 8^0$。Horner(秦九韶)算法把它改写为 $N = (((dk \times 8 + d{k-1}) \times 8 + d_{k-2}) \times 8 + \dots)$,乘法次数从 $O(k^2)$ 降到 $O(k)$,这是"多项式求值"的经典优化,在数值计算中广泛使用。
扩展思考
反向转换(十进制转八进制):不断 %8 取余、/8 除,余数倒序拼接,用程序 29 的逐位剥离思路即可实现。
【程序 83】求 0~7 能组成的奇数个数
题目:求 0~7 这 8 个数字能组成多少个奇数(数字可重复使用,且一个数最高位不能是 0,但题目按 1~8 位分别统计再求和)。
解题思路
按位数分类统计(组合计数):
- 1 位数:个位必须是奇数(1、3、5、7 四个选择)→ 4 个。
- 2 位数:个位 4 种(奇数),十位不能为 0 且可重复 → 7 种(1~7)→ 4×7 = 28。
- 3 位及以上:个位 4 种,最高位 7 种(不能是 0),中间位 8 种(可为 0)→ 4×7×8^(位数-2)。
递推思路:s 是当前位数的个数,从 1 位数开始,每多一位乘 7(第二位)或 8(第三位起),sum 累加。
完整代码(按组合计数递推)
#include <stdio.h>
int main()
{
long sum = 4, s = 4; // 1 位数:4 个(个位取奇数)
int j;
for (j = 2; j <= 8; j++) // 统计 2~8 位数
{
if (j == 2)
s = s * 7; // 第二位(最高位)7 种:不能为 0
else
s = s * 8; // 后面的位 8 种:可以为 0
sum += s;
printf("%d 位数有 %ld 个\n", j, s);
}
printf("总共 %ld 个\n", sum);
return 0;
}
代码讲解
- 递推关系:
s(当前位数个数)=s(上一位个数)× 新位选择数。 - 第一位(个位)固定 4 种;第二位 7 种;第三位起 8 种。所以 2 位数 28 个、3 位数 4×7×8=224……总个数约 4 + 28 + 224 + ... 累加。
知识点
- 计数原理(乘法原理)
- 递推统计
数学原理
这是排列组合中的乘法原理:如果一件事分几步完成,且各步相互独立,那么总方法数 = 各步方法数相乘。注意"最高位不能为 0"这个约束,是计数题最常见的陷阱。
扩展思考
若题目改成"不能重复使用数字",则要用排列公式 $P(n,k) = \frac{n!}{(n-k)!}$ 分类讨论,更复杂。可以在学完组合数学后回头挑战。
【程序 84】验证哥德巴赫猜想(偶数 = 两素数之和)
题目:一个偶数总能表示为两个素数之和。(验证一个给定偶数)
解题思路
哥德巴赫猜想:任何一个大于 2 的偶数都可以写成两个素数之和。这是著名的数学未解之谜(至今未被完全证明,但已被计算机验证到极大的数)。
程序做法:对偶数 a,枚举可能的素数 b(从 3 开始),检查 b 和 a-b 是否都是素数(用程序 12 的试除法),是则输出一组分解。
完整代码
#include <stdio.h>
#include <math.h>
int is_prime(int x) // 判断素数(封装成函数)
{
int i;
if (x < 2) return 0;
for (i = 2; i <= sqrt(x); i++)
if (x % i == 0)
return 0;
return 1;
}
int main()
{
int a, b;
printf("请输入一个大于 2 的偶数:");
scanf("%d", &a);
for (b = 3; b <= a / 2; b += 2) // 只试奇数(素数除2外都是奇数)
{
if (is_prime(b) && is_prime(a - b))
printf("%d = %d + %d\n", a, b, a - b);
}
return 0;
}
代码讲解
is_prime是函数封装(复习函数定义),试除法判断素数(复习程序 12 的 $\sqrt{x}$ 优化)。b += 2:从 3 开始只检查奇数,因为大于 2 的素数全是奇数,减半搜索量。b <= a/2:只输出"前一半"的解,避免 (b, a-b) 与 (a-b, b) 重复。
知识点
- 素数的判定(函数化)
- 枚举分解
数学原理
哥德巴赫猜想(1742 年提出)是数论中最著名的开放问题之一:"任何大于 2 的偶数都能写成两个素数之和"。数学界已用计算机验证到 $4 \times 10^{18}$ 以内的所有偶数都成立(1978 年 Olivier Ramaré 证明了"每个充分大的偶数可表示为至多 6 个素数之和"等弱化结果),但完整证明至今未出现。程序只能"验证具体例子",无法"证明"猜想——这也是计算机与数学的边界。
扩展思考
- 稍加改造(循环所有偶数)可以验证一大段范围内的偶数都满足猜想,这也是"计算机辅助数学实验"的入门体验。
【程序 85】判断一个素数能被几个 9 整除
题目:判断一个素数能被几个 9 整除(即求最小的 999...9 形式且能被该素数整除的数,由几个 9 组成)。
解题思路
构造 9、99、999、9999……依次检查能否被输入的素数整除。构造方法:m9 = m9 * 10 + 9(复习程序 18 的"添尾"技巧)。每构造一个,计数加 1,直到找到能被整除的。
完整代码
#include <stdio.h>
int main()
{
long sum = 9; // 当前构造的数:9, 99, 999...
int zi, count = 1;
printf("请输入一个素数:");
scanf("%d", &zi);
while (sum % zi != 0)
{
sum = sum * 10 + 9; // 末尾再加一个 9
count++;
}
printf("%ld 能被 %d 整除,由 %d 个 9 组成\n", sum, zi, count);
return 0;
}
代码讲解
sum = sum * 10 + 9:9 → 99 → 999 → ……(与程序 18 生成 aa、aaa 完全同款技巧)。- 循环直到整除成立;用
long防止数过大溢出。
知识点
- 数字拼接(*10 + 数字)
- while 循环与取余判断整除
数学原理
这类"全 9 数"(repunit 9 的变形,数学上叫 repunit,如 111...1)与数论中的"循环小数"问题相关:$\frac1p$ 的小数循环节长度往往与 $10^k \bmod p$ 有关。输入 3 得 999(3 个 9);输入 7 得 999999(6 个 9,因为 $10^6 \equiv 1 \pmod 7$)。
扩展思考
- 大数溢出问题:若素数很大,
long不够,需要"高精度除法"(只用余数参与计算:sum = (sum * 10 + 9) % zi,判断余数是否为 0 即可,根本不用存大数!)。这是一个重要的优化技巧,值得思考为什么可行。
【程序 86】两个字符串连接程序
题目:两个字符串连接程序。
解题思路
本题做的是"按字典序归并两个字符串"(类似归并排序的合并步):比较两字符串当前字符,把较小的放入结果,直到其中一个用完,再把剩余的接上去。这比单纯拼接(strcat)更有算法价值。
完整代码
#include <stdio.h>
#include <string.h>
int main()
{
char a[] = "acegikm";
char b[] = "bdfhjlnpq";
char c[80];
int i = 0, j = 0, k = 0;
/* 归并:每次取较小的字符 */
while (a[i] != '\0' && b[j] != '\0')
{
if (a[i] < b[j])
c[k++] = a[i++];
else
c[k++] = b[j++];
}
/* 把剩余的部分接上去 */
while (a[i] != '\0') c[k++] = a[i++];
while (b[j] != '\0') c[k++] = b[j++];
c[k] = '\0'; // 手动补结束符
printf("%s\n", c);
return 0;
}
代码讲解
c[k++] = a[i++]:先赋值再自增,等价于c[k] = a[i]; k++; i++;——这是 C 语言非常常用的紧凑写法。- 归并结束后,a、b 可能各自有剩余(比如一个长一个短),用两个 while 把剩余字符接上。
- 最后必须手动写
c[k] = '\0',否则 printf("%s") 不知道在哪结束(可能输出乱码)。
知识点
- 归并(merge)思想——归并排序的核心步骤
- 字符串的
\0处理 c[k++] = a[i++]的惯用法
数学原理
归并:两个已排序序列合成一个排序序列,只需线性扫描(O(n+m))。这是"分治算法"(divide and conquer)的关键一步——归并排序把数组分成两半分别排序,再归并起来。可以对照程序 37 的选择排序感受"两种排序哲学"。
扩展思考(其他解法)
- 若只是"拼接"两个字符串,用
strcat(a, b)一行搞定(注意目标数组要够大)。本题原意是归并,比拼接更值得掌握。 - 进阶:输入任意两个字符串而不是固定数组,把循环边界改成手动输入。
【程序 87】回答结果(结构体变量传递)
题目:读程序写结果——结构体变量作为函数参数传递。
解题思路
结构体作为函数参数是值传递:传给函数的是一份副本,函数内修改不影响函数外的原结构体。这与数组(退化为指针,函数内能改原数组)完全不同,是初学者最容易混淆的点。
完整代码
#include <stdio.h>
struct student
{
int x;
char c;
};
void f(struct student b) // 值传递:b 是 a 的副本
{
b.x = 20;
b.c = 'y';
}
int main()
{
struct student a;
a.x = 3;
a.c = 'a';
f(a);
printf("%d, %c\n", a.x, a.c); // 输出 3, a(原结构体没变!)
return 0;
}
代码讲解
f(a)把 a 的内容复制一份给参数 b,函数里改的是 b,a 不受影响。- 所以输出是
3, a而不是20, y。 - 若要函数修改原结构体,必须传指针:
void f(struct student *p) { p->x = 20; },调用f(&a)。
知识点
- 结构体的值传递语义
- 传值 vs 传指针(对比程序 66)
数学原理/类比
值传递 = "把文件复印一份给同事改,原件不动";传指针 = "告诉同事原件在哪,让他直接改原件"。C 语言里数组名是"地址"(特殊),结构体是"内容"(默认值传递),一定要分清。
扩展思考
- 大数据量结构体传值时,复制开销大,工程上常用传指针提高效率:
void f(const struct student *p)(const 表示只读不修改)。
【程序 88】读取 7 个数(1~50),打印对应个数的 *
题目:读取 7 个数(1~50 的整数),每读取一个值,程序打印出该值个数的 *。
解题思路
do...while 验证输入范围(1~50),再用 for 打印对应数量的 *。do...while 保证至少执行一次再判断,适合"先输入、后校验"的场景。
完整代码
#include <stdio.h>
int main()
{
int i, a, n = 1;
while (n <= 7)
{
do
{
printf("请输入第 %d 个数(1~50):", n);
scanf("%d", &a);
} while (a < 1 || a > 50); // 输入不合法就重新输入
for (i = 1; i <= a; i++)
printf("*");
printf("\n");
n++;
}
return 0;
}
代码讲解
do { 输入 } while (输入不合法):输入校验的经典模式——"先做,不对再来"。- 内层
for打印 a 个*,正好复习"用循环画符号"。
知识点
do...while循环与输入校验- 输入合法性检查(防呆设计)
扩展思考
- 校验可以更友好:提示"输入错误,请重新输入"。
- 这种"先校验再处理"的模式在真实程序中无处不在(比如银行取款金额不能为负、密码长度检查)。
【程序 89】数据加密
题目:四位整数加密:每位数字加 5,用和除以 10 的余数代替该数字,然后第一位与第四位交换、第二位与第三位交换。
解题思路
分解出四位数字存入数组 → 每位加 5 取余 → 首尾交换(程序 40 的技巧)→ 输出。整个过程就是"模运算 + 数组翻转",没有真正的高深加密,但体现了"变换"的流程化处理思想。
完整代码
#include <stdio.h>
int main()
{
int a, i, t;
int aa[4];
printf("请输入一个四位整数:");
scanf("%d", &a);
aa[0] = a % 10; // 个位
aa[1] = a / 10 % 10; // 十位
aa[2] = a / 100 % 10; // 百位
aa[3] = a / 1000; // 千位
for (i = 0; i < 4; i++)
{
aa[i] = aa[i] + 5;
aa[i] = aa[i] % 10; // 加 5 后取个位
}
for (i = 0; i < 2; i++) // 首尾交换(0↔3,1↔2)
{
t = aa[i];
aa[i] = aa[3 - i];
aa[3 - i] = t;
}
printf("加密后:");
for (i = 3; i >= 0; i--) // 按原数位顺序输出
printf("%d", aa[i]);
printf("\n");
return 0;
}
代码讲解
- 数位分解:复习程序 13、29 的
/和%技巧。 % 10取个位:加 5 后可能超过 9(如 8+5=13 → 3)。- 交换 4 个元素只需 2 次对称交换(程序 40 的思想)。
知识点
- 模运算取个位
- 数组翻转
- 简单加密变换流程
数学原理
加密本质是可逆变换。这个加密算法是可逆的吗?解密规则:交换回来 → 每个数字减 5 模 10((x + 5) % 10 的逆运算是 (x + 5) % 10 再做一次?不对——注意 (x+5)%10 的逆是 (x+5)%10 本身吗?)。可以验证:((x+5)%10 + 5)%10 = x?例如 x=3 → 8 → 3 ✓;x=7 → 2 → 7 ✓。因为"加 5 模 10"在 0~9 上是一个"对合"(两次应用回到原值,相当于每位旋转 5 格)。这个性质值得自己推敲,是理解"加密-解密配对"的好例子。
扩展思考
- 对称加密的朴素模型:加密和解密用同一套操作(加 5 模 10 是自逆的),真实加密算法(如 AES)要复杂得多,但"变换可逆"这一核心思想是一样的。
【程序 90】读结果:数组逆置
题目:读程序写结果——用指针逆置数组。
解题思路
用两个下标/指针从两端向中间走,交换对应元素(程序 40 的升级版:用指针而不是下标)。i 从头走,j 从尾走,while (i < j) 时交换并各自向中间移动。
完整代码
#include <stdio.h>
#define M 5
int main()
{
int a[M] = {1, 2, 3, 4, 5};
int i = 0, j = M - 1, t;
while (i < j)
{
t = a[i];
a[i] = a[j];
a[j] = t;
i++;
j--;
}
for (i = 0; i < M; i++)
printf("%d ", a[i]); // 输出 5 4 3 2 1
printf("\n");
return 0;
}
代码讲解
- 指针版:
int *p = a, *q = a + M - 1; while (p < q) { 交换 *p、*q; p++; q--; },与下标版完全等价。 - 循环条件
i < j:i、j 相遇(奇数个元素时中间元素不用动)就停。
知识点
- 双指针(从两端向中间)的经典模式
- 数组逆置
扩展思考
"双指针相遇"模式非常常用:字符串反转(程序 70 扩展)、判断回文、二分查找(两个边界指针)都用它。这是算法基本功,务必熟练掌握。
【程序 91~93】时间函数举例
题目:时间函数举例(time、ctime、asctime、localtime、gmtime、clock、difftime)。
解题思路
三个题都在演示 <time.h> 的时间函数:
time(NULL):返回当前时间(从 1970 年 1 月 1 日 0 时起的秒数,即 Unix 时间戳)。ctime():把时间戳转成易读的字符串。localtime()/gmtime():转成 tm 结构(本地时间 / 格林尼治时间)。clock():返回程序运行以来消耗的 CPU 时钟周期数,clock() / CLOCKS_PER_SEC得到秒数,用于测量程序耗时。difftime(end, start):两个时间戳的差值(秒)。
完整代码(程序 91:显示当前时间)
#include <stdio.h>
#include <time.h>
int main()
{
time_t lt; // 时间戳类型
lt = time(NULL); // 取当前时间戳
printf("当前时间:%s", ctime(<)); // 转成字符串
return 0;
}
程序 93:测量代码耗时(推荐写法)
#include <stdio.h>
#include <time.h>
int main()
{
clock_t start, end;
int i;
double seconds;
start = clock(); // 开始计时
for (i = 0; i < 1000000; i++)
; // 空循环占时间
end = clock(); // 结束计时
seconds = (double)(end - start) / CLOCKS_PER_SEC; // 换算成秒
printf("耗时 %.3f 秒\n", seconds);
return 0;
}
代码讲解
time_t是时间戳类型(通常是 long)。ctime(<)需要传指针;clock()返回clock_t,除以CLOCKS_PER_SEC(每秒的时钟数)才是秒。- 测量代码耗时用
clock()更准确(只算 CPU 时间);time()的粒度是秒,太粗糙。
知识点
<time.h>时间库函数- Unix 时间戳的概念
- 用 clock 测量程序运行时间(性能分析入门)
数学原理
Unix 时间戳是"自 1970-01-01 00:00:00 UTC 起的秒数",全世界统一,是计算机系统的"标准时钟"。2038 年问题:32 位时间戳将在 2038 年 1 月 19 日溢出(最大值 2³¹-1 秒),现代系统已用 64 位时间戳解决——这是数据溢出问题的真实案例。
扩展思考
程序 94 的猜数游戏就用 clock() 计时测"反应速度",可以把两题连着学。
【程序 94】猜数游戏(综合应用)
题目:一个猜数游戏,判断一个人反应快慢:程序生成一个 0~99 的随机数,让你猜,提示"大了/小了",猜中后报告用了多少时间,并评价反应快慢。
解题思路
综合运用:
- 随机数:
srand(time(NULL))用当前时间做随机种子,rand() % 100生成 0~99。 - 循环猜数:
while (guess != i),大了提示小一点、小了提示大一点。 - 计时:
clock()前后各取一次,差除以CLOCKS_PER_SEC得秒数。 - 评价:if 判断耗时区间。
完整代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
int guess, i;
clock_t start, end;
double seconds;
char again = 'y';
srand(time(NULL)); // 随机种子
while (again == 'y')
{
i = rand() % 100; // 0~99 的随机数
printf("请猜一个 0~99 的数:");
start = clock(); // 开始计时
scanf("%d", &guess);
while (guess != i)
{
if (guess > i)
printf("太大了,再猜:");
else
printf("太小了,再猜:");
scanf("%d", &guess);
}
end = clock(); // 猜中,停止计时
seconds = (double)(end - start) / CLOCKS_PER_SEC;
printf("猜对了!答案是 %d,用时 %.2f 秒\n", i, seconds);
if (seconds < 5)
printf("反应真快!\n");
else if (seconds < 15)
printf("还不错。\n");
else
printf("反应有点慢哦。\n");
printf("再玩一次?(y/n):");
getchar(); // 吃掉残留的换行符
scanf("%c", &again);
}
return 0;
}
代码讲解
rand()生成的数其实"有规律",所以先用srand(time(NULL))按当前时间打乱起点,每次运行得到的序列不同。% 100把随机数限定在 0~99(取余限制范围)。getchar()吃掉上次scanf留在缓冲区的换行符(复习程序 31 的坑)。- 外层
while (again == 'y')支持反复玩。
知识点
- 随机数生成(
rand、srand、取余限范围) - 综合运用:循环、分支、计时、输入校验
- 输入缓冲问题
数学原理
rand() % 100 的均匀性:如果 rand() 返回 0~RAND_MAX 均匀分布,取余后 0~99 近似均匀(RAND_MAX 通常 32767,不是 100 的倍数,会有轻微偏差,但对游戏无影响)。真正的加密级随机数(如 /dev/urandom)要复杂得多,这就是"伪随机 vs 真随机"的区别。
扩展思考
- 二分查找策略:每次猜中间数(50 → 25/75 → ...),最多 7 次必然猜中(因为 $2^7 = 128 > 100$)。这个"折半"思想就是二分查找算法(在有序数据中查找的 O(log n) 方法),值得去了解。
- 想提高难度:限定猜测次数、或把范围扩大。
第十部分:进阶挑战(程序 95 ~ 100)
前面的题目帮你打牢了基础。这一部分难度有所提升,涉及分治、回溯、栈、双指针等更经典的算法思想,是通往算法世界的敲门砖。建议每道题都先自己思考 10~20 分钟再参考答案。
【程序 95】二分查找
题目:在一个已经按升序排好的整数数组中,用二分查找判断某个数是否存在,若存在返回它的下标,否则返回 -1。
解题思路
二分查找是"折半"思想的正式化:既然数组有序,每次取中间元素和目标比较——
- 相等:找到了,返回下标;
- 目标比中间元素大:答案只可能在后半段,把左边界移到中间元素右边;
- 目标比中间元素小:答案只可能在前半段,把右边界移到中间元素左边。
每比较一次搜索范围缩小一半,所以 n 个元素最多比较 $\log_2 n$ 次(这就是 $O(\log n)$ 时间复杂度的由来)。对比程序 37 的线性查找(挨个找,最坏 n 次),二分查找快得多——前提是数组必须有序。
完整代码
#include <stdio.h>
/* 二分查找:返回 target 的下标,找不到返回 -1 */
int binary_search(int a[], int n, int target)
{
int left = 0, right = n - 1;
while (left <= right) // 区间非空就一直找
{
int mid = left + (right - left) / 2; // 防溢出的中间值写法
if (a[mid] == target)
return mid;
else if (a[mid] < target)
left = mid + 1; // 目标在右半段
else
right = mid - 1; // 目标在左半段
}
return -1;
}
int main()
{
int a[10] = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19};
int x;
printf("请输入要查找的数:");
scanf("%d", &x);
int pos = binary_search(a, 10, x);
if (pos >= 0)
printf("找到了,下标是 %d\n", pos);
else
printf("不存在\n");
return 0;
}
代码讲解
- 三个变量维护"当前搜索区间":
left、right是区间的左右边界(闭区间),循环条件是left <= right(区间非空)。 mid = left + (right - left) / 2:求中间下标。写(left + right) / 2在理论上可能溢出(left+right 超过 int 上限),这种写法更安全,是工程上的标准姿势。- 每次更新边界时排除掉 mid 本身(
mid + 1/mid - 1),因为 mid 已经比较过了。
知识点
- 二分查找算法(O(log n))
- 闭区间三变量(left / right / mid)的边界维护
- 有序性是二分查找的前提
数学原理
为什么是 $\log_2 n$?每轮把规模为 n 的问题变成规模为 n/2 的子问题,k 轮后规模是 $n / 2^k$,当 $n / 2^k < 1$ 即 $k > \log_2 n$ 时结束。所以最多约 $\log_2 n$ 次比较。对 10 亿个元素,线性查找最坏 10 亿次,二分查找只需 30 次——这就是算法的力量。
扩展思考(其他解法)
- 用递归实现:
binary_search(a, left, right, target),终止条件是 left > right 或命中。代码更简洁,但每次递归有函数调用开销。 - 二分查找的变体很多:找"第一个 ≥ target 的位置"、找"最后一个 ≤ target 的位置"(C 标准库的
lower_bound/upper_bound就是干这个的)。可以试着实现。
【程序 96】快速排序
题目:用快速排序算法对一个整数数组进行排序。
解题思路
快速排序(Quick Sort) 是"分治"思想的代表:
- 选基准(pivot):从数组中选一个元素(通常取第一个或中间一个)。
- 分区(partition):把数组重排成"左半部分都 ≤ pivot,右半部分都 > pivot",pivot 落到最终位置。
- 递归:对左右两半分别重复上述过程。
每轮分区后 pivot 就位,左右两部分互不相干,递归处理即可。关键是分区这一步:用一个指针记录"小于 pivot 的区域的边界",遍历时遇到比 pivot 小的就换到前面去。
完整代码
#include <stdio.h>
/* 分区:把 a[left..right] 以 a[right] 为基准分成两半,返回基准的最终下标 */
int partition(int a[], int left, int right)
{
int pivot = a[right]; // 取最右元素为基准
int i = left; // i 指向"小于基准区域"的下一个位置
int j, t;
for (j = left; j < right; j++)
{
if (a[j] < pivot) // 比基准小的,换到左边区域
{
t = a[i]; a[i] = a[j]; a[j] = t;
i++;
}
}
/* 把基准放到 i 位置(此时 i 左边全小于 pivot) */
t = a[i]; a[i] = a[right]; a[right] = t;
return i;
}
void quick_sort(int a[], int left, int right)
{
if (left >= right) // 区间为空或只有一个元素,不用排
return;
int p = partition(a, left, right);
quick_sort(a, left, p - 1); // 排序左半
quick_sort(a, p + 1, right); // 排序右半
}
int main()
{
int a[10] = {34, 7, 23, 32, 5, 62, 31, 1, 9, 12};
int i;
quick_sort(a, 0, 9);
printf("排序后:");
for (i = 0; i < 10; i++)
printf("%d ", a[i]);
printf("\n");
return 0;
}
代码讲解
partition用两个指针扫描:j负责遍历,i标记"比 pivot 小的区域"的末尾。发现a[j] < pivot就把a[j]换到i位置,i后移。- 遍历结束后,把基准
a[right]换到a[i],基准就位——它的左边都比它小,右边都比它大。 - 递归边界:
left >= right(空区间或单元素)直接返回,这是递归终止条件。
知识点
- 分治算法(divide and conquer)
- 快速排序与分区操作
- 递归排序
数学原理
快速排序的平均时间复杂度是 $O(n \log n)$(每层分区 O(n),递归深度约 $\log n$ 层),是实践中最常用的排序算法之一。最坏情况(每次都选到最大/最小元素做基准)退化为 $O(n^2)$——可以通过随机选基准来规避。这与程序 37 的选择排序(固定 $O(n^2)$)、程序 86 铺垫的归并排序(稳定 $O(n \log n)$)形成对比:没有完美的排序,只有适合场景的排序。
扩展思考(其他解法)
- 程序 37 的选择排序、扩展里提到的冒泡排序都是 $O(n^2)$;程序 86 的"归并"思想可以扩展成归并排序(稳定、$O(n \log n)$,但需要额外空间)。建议把三种排序都写一遍对比。
- 面试常考:快速排序的"第 k 大元素"变体——每次分区后根据 pivot 的下标判断目标在哪半部分,只递归一边,平均 $O(n)$。
【程序 97】汉诺塔
题目:有三根柱子 A、B、C,A 柱上从上到下叠着 n 个大小递增的圆盘(小的在上)。每次只能移动一个圆盘,且任何时候大盘都不能压在小盘上。请把 n 个圆盘从 A 柱全部移到 C 柱,并输出每一步的移动方案。
解题思路
这是递归的"封神"例题。把问题分解:
- 先把 n-1 个圆盘从 A 借助 C 移到 B(此时 A 上只剩最大的盘);
- 把最大的盘从 A 直接移到 C;
- 再把 n-1 个圆盘从 B 借助 A 移到 C。
"借助"的柱子就是中转站。整个过程规模从 n 变成 n-1,触底条件是 n == 1(直接移动)。递归的美妙之处在于:我们不需要关心"怎么具体移动 n-1 个盘",只假设这个子问题已经能解决——这正是数学归纳法的思路。
完整代码
#include <stdio.h>
void hanoi(int n, char from, char helper, char to)
{
if (n == 1) // 只剩一个盘,直接移动
{
printf("把第 1 个盘从 %c 移到 %c\n", from, to);
return;
}
hanoi(n - 1, from, to, helper); // 1. n-1 个盘:A → B(借助 C)
printf("把第 %d 个盘从 %c 移到 %c\n", n, from, to); // 2. 最大盘:A → C
hanoi(n - 1, helper, from, to); // 3. n-1 个盘:B → C(借助 A)
}
int main()
{
int n;
printf("请输入圆盘数:");
scanf("%d", &n);
hanoi(n, 'A', 'B', 'C');
return 0;
}
代码讲解
- 三个柱子角色动态变化:
from(来源)、to(目标)、helper(辅助)。第 1 步里 C 变成"辅助",第 3 步里 A 变成"辅助"。 - 移动次数满足递推 $T(n) = 2T(n-1) + 1$,解得 $T(n) = 2^n - 1$。n=3 时需要 7 步,n=10 需要 1023 步,n=64 需要约 1844 亿步——这就是"指数爆炸"。
- 每一步的移动顺序:先移小盘堆,再移大盘,再移小盘堆——保证"大盘不压小盘"由递归结构天然保证。
知识点
- 递归的经典应用(规模分解 + 终止条件)
- 递归与数学归纳法的对应
- 指数增长
数学原理
汉诺塔与很多数学分支相关:移动次数 $2^n - 1$ 是指数函数;它还可以用二进制解释(第 k 步移动的盘子编号恰好是 k 的二进制中最低位 1 的位置)。传说中"世界末日"与 64 层汉诺塔相关,按每秒移一次需 5845 亿年——比宇宙年龄还长,这体现了"指数爆炸"的可怕。
扩展思考
- 试着画出 n=3 时的递归调用树,理解"递推进入、回归返回"的全过程。
- 非递归实现:可以借助"奇偶性规律"(奇数个盘时第一步移 A→C,偶数个盘时第一步移 A→B),有兴趣可以研究。
【程序 98】最长回文子串
题目:给定一个字符串,找出其中最长的回文子串(回文即"正读倒读一样")。例如输入 babad,最长回文子串是 bab(或 aba)。
解题思路
中心扩展法:回文串有一个特性——它关于中心对称。所以可以以每个位置为中心,向两边扩展,记录能扩展出的最长回文长度。
但回文中心有两种情况:
- 奇数长度:中心是一个字符,如
aba的中心是b; - 偶数长度:中心在两个字符之间,如
abba的中心是bb之间。
所以要对每个位置 i 分别以"i 为中心"和"(i, i+1) 之间为中心"扩展两次,取较长者。总复杂度 $O(n^2)$。
完整代码
#include <stdio.h>
#include <string.h>
/* 从 (l, r) 向两边扩展,返回能形成的最大回文长度 */
int expand(char s[], int l, int r)
{
while (l >= 0 && s[r] != '\0' && s[l] == s[r])
{
l--;
r++;
}
return r - l - 1; // 回文的长度
}
int main()
{
char s[100];
int i, len, maxLen = 0, start = 0;
printf("请输入字符串:");
scanf("%s", s);
len = strlen(s);
for (i = 0; i < len; i++)
{
int odd = expand(s, i, i); // 奇数长度回文:中心是 s[i]
int even = expand(s, i, i + 1); // 偶数长度回文:中心在 s[i] 与 s[i+1] 之间
int cur = odd > even ? odd : even;
if (cur > maxLen) // 更新最长的
{
maxLen = cur;
start = i - (cur - 1) / 2; // 由长度反推回文起点
}
}
printf("最长回文子串:");
for (i = start; i < start + maxLen; i++)
printf("%c", s[i]);
printf("(长度 %d)\n", maxLen);
return 0;
}
代码讲解
expand(s, l, r):从 (l, r) 这对中心向外扩展,s[l] == s[r]就继续;循环结束时r - l - 1就是回文长度(可以自己代入aba验证:扩展后 l=-1、r=3,长度 3)。- 每个位置尝试两种中心;
strlen来自<string.h>。 - 记录最长回文的起点下标
start和长度maxLen,最后切片输出。 - 边界处理:
l >= 0防止数组越界到负数;s[r] != '\0'防止越过字符串结尾。
知识点
- 回文判断与对称性
- 中心扩展法(O(n²))
- 字符串边界处理
数学原理
回文串是"镜像对称"的字符串。中心扩展法把"判断任意子串是否回文"优化为"从每个中心向外看能看多远"。更快的算法是 Manacher 算法(O(n)),它利用回文的对称性避免重复比较,是算法竞赛的经典内容,学有余力可以研究。
扩展思考(其他解法)
- 暴力法:枚举所有子串(O(n²) 个子串)再逐个判断是否回文,总复杂度 O(n³),正确但慢——正好用来体会中心扩展的优越性。
- 程序 30 判断"一个数是否回文"是本题的退化版(只有一个中心)。
【程序 99】逆波兰表达式求值(栈的应用)
题目:输入一个后缀表达式(逆波兰表达式),例如 3 4 + 5 *(即 (3+4)×5),计算它的值。只考虑 + - * / 四种运算。
解题思路
栈是一种"后进先出(LIFO)"的数据结构,是逆波兰求值天然的工具:
- 从左到右扫描每个元素(数字或运算符);
- 遇到数字就压入栈;
- 遇到运算符就弹出栈顶两个数,做运算后把结果压回栈;
- 扫描完,栈顶就是最终结果。
为什么这样是对的?后缀表达式中运算符跟在操作数之后,所以"最近的两个数"就是它的操作数——正好是栈顶的两个,完美匹配"后进先出"。
完整代码
#include <stdio.h>
#define MAX 100
int main()
{
int stack[MAX]; // 用数组模拟栈
int top = 0; // 栈顶指针:下一个入栈位置
char token;
int a, b;
printf("请输入后缀表达式(数字与运算符之间用空格隔开,以 # 结束):\n");
do
{
scanf("%c", &token); // 读一个字符
if (token >= '0' && token <= '9')
{
stack[top++] = token - '0'; // 数字(个位)压栈
}
else if (token == '+')
{
b = stack[--top]; // 先弹出的数是右操作数
a = stack[--top];
stack[top++] = a + b;
}
else if (token == '-')
{
b = stack[--top];
a = stack[--top];
stack[top++] = a - b; // 注意顺序:a - b
}
else if (token == '*')
{
b = stack[--top];
a = stack[--top];
stack[top++] = a * b;
}
else if (token == '/')
{
b = stack[--top];
a = stack[--top];
stack[top++] = a / b; // 注意顺序:a / b
}
} while (token != '#');
printf("结果 = %d\n", stack[top - 1]);
return 0;
}
代码讲解
- 用数组
stack+ 栈顶指针top模拟栈:stack[top++] = x是压栈,x = stack[--top]是弹栈。 - 减法和除法的操作数顺序很重要:先弹出的是右操作数 b,后弹出的是左操作数 a,所以是
a - b、a / b,写反了结果就错了。 - 示例:
3 4 + 5 *→ 压 3 → 压 4 → 弹 4、3 得 7 压回 → 压 5 → 弹 5、7 得 35。 - 本示例只支持个位数字;要支持多位数,可以按"读入空格分隔的字符串再逐个转换",思路相同。
知识点
- 栈(后进先出)的概念与数组模拟
- 逆波兰表达式(后缀表达式)求值
- 运算符优先级的中缀转后缀(进阶)
数学原理
逆波兰表达式(由波兰逻辑学家 Jan Łukasiewicz 提出)的最大优点:不需要括号和优先级规则——运算符顺序天然决定了计算次序,非常适合计算机处理。编译器把人类书写的中缀表达式(如 a + b * c)先转成后缀(a b c * +),再求值。你常用的计算器、公式编辑器背后都是这套机制。
扩展思考
- 自己实现"中缀转后缀"(需要处理运算符优先级和括号,也用栈),就能写一个完整的表达式计算器。
- 栈还能用来做:括号匹配检查、函数调用栈模拟、迷宫寻路(回溯)。栈是数据结构的第一课,务必熟练掌握。
【程序 100】八皇后问题(回溯算法)
题目:在国际象棋棋盘(8×8)上放置 8 个皇后,要求任意两个皇后不能在同一行、同一列或同一对角线上。输出所有可行的摆放方案。
解题思路
八皇后是回溯算法(backtracking) 的经典例题。核心思路:
- 逐行放置:每一行放一个皇后,用一维数组
col[i]记录第 i 行皇后所在的列号。 - 约束检查:放第 i 行的皇后到第 j 列时,检查它与前面 i 行的皇后是否冲突:
- 同列:
col[k] == j; - 主对角线:行差 == 列差,即
i - k == j - col[k]; - 副对角线:行差 == -(列差),即
i - k == col[k] - j。
- 同列:
- 回溯:如果第 i 行所有列都试过都冲突,就回退到上一行,把上一行的皇后换一列再试——这就是"回溯"(走不通就回头)。
完整代码
#include <stdio.h>
int col[8]; // col[i]:第 i 行皇后的列号
int total = 0; // 方案总数
/* 检查第 row 行放在第 c 列是否与前面的皇后冲突 */
int is_safe(int row, int c)
{
int k;
for (k = 0; k < row; k++)
{
if (col[k] == c) // 同一列
return 0;
if (row - k == c - col[k]) // 主对角线(\)
return 0;
if (row - k == col[k] - c) // 副对角线(/)
return 0;
}
return 1;
}
void queen(int row)
{
int c;
if (row == 8) // 8 行都放好了,得到一种方案
{
int i;
for (i = 0; i < 8; i++)
printf("(%d,%d) ", i, col[i]); // 输出每行皇后位置
printf("\n");
total++;
return;
}
for (c = 0; c < 8; c++) // 尝试第 row 行的每一列
{
if (is_safe(row, c))
{
col[row] = c; // 放置
queen(row + 1); // 递归放下一行
/* 回溯:col[row] 会在下一轮循环被覆盖,无需显式撤销 */
}
}
}
int main()
{
queen(0); // 从第 0 行开始
printf("共有 %d 种方案\n", total);
return 0;
}
代码讲解
- 递归函数
queen(row)的语义:把第 row 行到最后一行的皇后安排好。row == 8表示全部完成,输出方案。 for (c = 0; c < 8; c++)尝试当前行的每一列:安全就放,然后递归下一行。如果递归返回(说明后面走不通),自动尝试下一列——这正是回溯:尝试 → 深入 → 走不通就换一种尝试。- 三个冲突判断(同列、两条对角线)合起来等价于"这 8 个皇后两两不在一条直线或斜线上"。
- 8 皇后共有 92 种方案(不考虑旋转/镜像对称的 12 种本质解)。
知识点
- 回溯算法(试探 + 回退)
- 递归搜索 + 约束剪枝
- 对角线的数学特征(行差与列差的关系)
数学原理
为什么对角线检查用 row - k == c - col[k]?设两个皇后在 (row, c) 和 (k, col[k])。它们在同一主对角线(\)上当且仅当"行差 == 列差"(即 $|row-k| = |c-col[k]|$ 且符号相同);副对角线(/)上当且仅当"行差 == -(列差)"。这正是斜率等于 ±1 的直线方程——棋盘上的对角线就是斜率 ±1 的直线,数学和编程在这里完美相遇。
扩展思考
- N 皇后推广:把 8 改成任意 n(改数组大小和循环上界),n 皇后问题随之而来。n 增大时方案数爆炸式增长(n=8 有 92 种,n=12 有 14200 种),这正是"组合爆炸"。
- 回溯还可以解:数独、迷宫寻路、全排列生成、图的着色问题。回溯是算法竞赛的入门必修,学完八皇后,你就跨进了"搜索算法"的大门。
结语:学习建议与进阶路线
恭喜你刷完了这 100 道题!到这里,你应该已经掌握了:
- 基础语法:变量、数据类型、运算符、输入输出(程序 1~10)
- 控制流:if/switch、for/while/do-while(贯穿全程)
- 数学算法:素数、水仙花数、质因数分解、最大公约数、进制转换、斐波那契、杨辉三角(程序 11~20、30、36、61 等)
- 数组:一维、二维数组,排序(选择/冒泡)、插入(程序 37~40)
- 递归:阶乘、逆序、年龄问题、汉诺塔(程序 26~28、97)
- 预处理与位运算:宏、条件编译、&、|、^、~、移位(程序 41~55)
- 指针:指针传参、指针遍历数组、函数指针、二级指针(程序 66~70、76~77)
- 结构体与链表:结构体数组、链表创建/遍历/拼接/反转(程序 71~74、78)
- 字符串处理:strcmp、strcpy、字符串长度、归并、最长回文(程序 70、79、82、86、98)
- 综合应用:约瑟夫环、哥德巴赫猜想、加密、猜数游戏(程序 69、84、89、94)
- 进阶算法:二分查找、快速排序、逆波兰求值(栈)、八皇后(回溯)(程序 95~96、99~100)
接下来可以做什么?
- 自己动手重写:合上文档,把每道题独立写一遍,直到不需要看答案。这是最重要的"内化"步骤。
- 挑战改进:给题目增加输入校验、改成任意 n、把代码用函数重构、加注释。
- 深入数据结构:栈、队列、二叉树、哈希表——链表学的"节点 + 指针"模式、程序 99 的栈都是这一切的基础。
- 学习更多算法:归并排序、广度优先搜索(BFS)、动态规划、贪心——程序 96 的分治、98 的中心扩展、100 的回溯已经为你铺好了路。
- 接触工程化:多文件编译、makefile、版本控制(Git)、调试器(gdb/VS Code 调试)。
- 更进一步:如果对 C 感兴趣,可以继续学习指针的高级用法、动态内存管理、文件操作(FILE、fopen/fclose)、结构体对齐等。
学习编程没有捷径,唯一的捷径就是多写、多错、多改。报错不可怕,可怕的是不读报错信息。祝学习顺利!
本文档共收录 100 道经典 C 语言题目,代码均采用现代 C 标准编写,力求讲解清晰、独立可读。程序 32~35 涉及终端颜色与光标控制,程序 56~65 为图形绘制题,其余题目均不依赖任何图形库。