C 语言编程经典 100 例（详解版） 本系列面向零基础（Base）或刚开始学习 C 语言的同学，共收录 程序 1 ~ 程序 100 一百道题目，按知识点从易到难编排，讲解循序渐进。 如何使用本文档 每一题包含：题目 → 解题思路 → 完整代码 → 代码讲解 → 知识点 → 数学原理（如适用） → 扩展思考 / 其他解法。 讲解中第一次出现的概念会展开解释；后面的题目会直接引用（ref）前面的概念，不再重复展开。 建议学习方式：先自己读题、动手写，写不出来再看解题思路，最后对照完整代码逐行理解，再合上文档自己重写一遍。 文中代码均采用符合现代 C 标准的写法（补全 #include、使用 int main()），多数代码在 Dev-C++、Visual Studio、Code::Blocks、VS Code + GCC 等环境下可直接编译（Compiler）运行。 特殊环境说明：程序 32~35 涉及终端（Terminal）颜色与光标控制，推荐在 Windows Terminal / VS Code 终端 / Linux / macOS 终端等现代终端中运行；程序 56~65 为图形绘制题，使用轻量的 ACLLib 图形库（GitHub：wengkai/ACLLib）实现，需要先按第六部分的说明配置（Windows + MinGW / Dev-C++ / VS Code + GCC）。 零基础读者必读：预备知识 如果你完全没接触过编程，请先花十几分钟读完这一节。这一节里提到的名词，后面的每一题都会用到。 1. 什么是程序？什么是 C 语言？ 计算机只会"执行指令"。程序就是把你想让计算机做的事，用某种"人看得懂、机器能执行"的语言写下来的一串指令。C 语言是其中最经典的一种，它语法简洁、运行速度快，是很多大学的第一门编程语言，也是操作系统（Operating System）（Linux、Windows 内核（Kernel））、嵌入式系统的基础语言。 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. 函数：把代码"打包" 函数就是一段有名字、可以反复调用的代码。比如把"交换两个数"写成函数后，需要时调用一下即可。写函数就是"先声明参数（Parameter）（输入），计算，返回结果"： 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：行号越大，输出的 # 越多，形成楼梯/三角形。 知识点 循环嵌套中，内层循环次数依赖（Dependencies）外层循环变量（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）开头的"控制字符"，终端收到后就会改变后续文字的样式（Style），而不会把这些字符显示出来。 背景色的核心格式是 \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 是闪烁属性；多个属性可以合并（Merge）写，如 \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 被划掉了"。这是"用下标做索引（Index）"的经典技巧。 外层只到 $\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 并链接系统库，命令行（Command-line）写法： 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 是事件（Event）驱动的：没有 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' 结尾约定 指针遍历字符串 gets vs scanf("%s") vs fgets 扩展思考（其他解法） 用下标 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 类型别名 链表的创建与遍历 数学原理/类比 链表像一个"寻宝游戏"：你只知道自己手中这张纸条的内容和下一张纸条在哪。与数组对比：数组是一排连续的房间（知道门牌号就能直达任意房间），链表是纸条链（必须从第一张开始顺着找）。数组随机（Random）访问快（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 / i vs 1.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(&lt)); // 转成字符串 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(&lt) 需要传指针；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、把代码用函数重构、加注释。 深入数据结构：栈、队列、二叉树、哈希（Hash）表——链表学的"节点 + 指针"模式、程序 99 的栈都是这一切的基础。 学习更多算法：归并排序、广度优先搜索（BFS）、动态规划、贪心——程序 96 的分治、98 的中心扩展、100 的回溯已经为你铺好了路。 接触工程化：多文件编译、makefile、版本控制（Git）、调试器（gdb/VS Code 调试）。 更进一步：如果对 C 感兴趣，可以继续学习指针的高级用法、动态内存管理、文件操作（FILE、fopen/fclose）、结构体对齐等。 学习编程没有捷径，唯一的捷径就是多写、多错、多改。报错不可怕，可怕的是不读报错信息。祝学习顺利！ 本文档共收录 100 道经典 C 语言题目，代码均采用现代 C 标准编写，力求讲解清晰、独立可读。程序 32~35 涉及终端颜色与光标控制，程序 56~65 为图形绘制题，其余题目均不依赖任何图形库。