C语言刷题避坑指南:50道经典题覆盖scanf、指针、递归与动态内存

C语言刷题避坑指南:50道经典题覆盖scanf、指针、递归与动态内存 说个我经常在带新人时看到的场景很多人把《C程序设计》从头翻到尾觉得自己语法都会了一打开PTA或者OJ刷题立刻被各种意想不到的结果打懵。scanf读字符为什么老是多吞一个换行两个浮点数明明打印出来一样用判断却是假*p到底改的是哪个值这类问题课本例题里很少讲但刷题时一定绕不过去。这50题我分成上下两篇发布。上篇25题覆盖输入输出、运算符流程控制、数组字符串、指针动态内存、函数递归这几个最核心的专题下篇再补文件读写、结构体、链表和综合实战。每道题的构成是题目描述、参考代码、考点拆解三部分。题目我刻意安排了梯度从入门到进阶都有部分题目还埋了初学者最容易踩的坑解析里会明确指出来。建议你先自己写一遍再对照参考代码最后重点看考点拆解——这是刷题真正产生复利的地方。1. 输入输出与数据类型五道题把scanf的坑摸透几乎所有C语言新手的第一个bug都出在输入输出上尤其是scanf和缓冲区交互的那点事。这个专题的五道题表面看都是简单题实际上每一道都在考察你对数据在内存中如何存储、类型如何转换的理解深度。1.1 题1 scanf与缓冲区整数后读字符为什么失灵【题目】从键盘输入一个整数n和一个字符c输出n的值和c的ASCII码。为什么下面这段代码在输入5回车A回车之后输出的并不是65#include stdio.h int main() { int n; char c; scanf(%d, n); scanf(%c, c); printf(n%d, c%d\n, n, c); return 0; }【考点拆解】这段代码的输出结果是n5, c10。10是换行符\n的ASCII码。原因在于scanf的工作机制%d读取时会先跳过空白字符然后读走连续的数字5但此时缓冲区里还留着用户敲下的那个换行符。紧接着的%c是一个来者不拒的格式符它不会跳过任何字符直接读走了缓冲区里残留的\n。解决办法有两个方向。第一个是在%c前面加空格写成scanf( %c, c)空格的作用是告诉scanf先跳过所有空白字符再读第二个是在第一个scanf后面手动清理缓冲区比如getchar()或者循环读走\n。实际工程里我更喜欢第一种写法因为getchar()在缓冲区为空时会阻塞等待输入用不好反而引入新问题。这个坑在PTA、OJ混合输入数字和字符的题目里出现频率极高原理就是一句话%d会自动跳过空白%c不会。记住这一点这个专题就通了八成。1.2 题2 getchar返回值char装得下EOF吗【题目】编写程序从标准输入读取字符统计字符个数直到遇到文件结束符EOF停止。下面的写法有什么问题#include stdio.h int main() { char ch; int count 0; while ((ch getchar()) ! EOF) { count; } printf(%d\n, count); return 0; }【考点拆解】这段代码在大多数平台上看好像能运行但它有两个隐患。第一getchar()的返回值类型是int不是char。EOF在标准库中定义的值是-1如果char在编译环境里是无符号类型某些嵌入式平台如此那么-1会被转换成255永远不可能等于EOF循环变成死循环。第二即使char是有符号类型也无法区分读到了一个ASCII码为255的合法字符和遇到了文件结束符这两种情况。正确写法是把接收变量声明为intint ch; while ((ch getchar()) ! EOF) { count; }这里值得多说一句main函数中getchar()从终端读入时在Linux下按CtrlD、Windows下按CtrlZ回车触发EOF。这个考点在文件操作里同样重要因为fgetc的返回值也是同样的设计思路——用一个更宽的int类型来同时容纳数据和结束标志。1.3 题3 整数溢出int臀位不够时发生了什么【题目】阅读下面的程序写出输出结果#include stdio.h int main() { int a 2147483647; a a 1; printf(%d\n, a); return 0; }【考点拆解】输出结果是-2147483648。这看起来像数学上不可能但在C语言里这个行为在标准层面属于有符号整数溢出是未定义行为在几乎所有的现代台式机平台上它的实际表现就是补码回绕。2147483647是int能表示的最大正数二进制是0111...111加1后变成1000...000在补码表示中恰好是-2147483648。这个考点经常和阶乘累加类题目结合。比如计算1! 2! ... 20!如果全部用int存结果早就炸了。遇到这类题第一反应应该是考虑数据类型够不够宽。long long至少64位能表示到9.2×10^18在入门题范围内基本够用。还有个相关的小知识点unsigned int和int混用时的隐式类型转换遵循无符号优先规则比如-1 1u在C里竟然为真真因为-1会先被转换成无符号的4294967295再比较。刷题时见过不少人在循环条件里栽这个跟头。1.4 题4 浮点数相等比较0.1加0.2为什么不等于0.3【题目】下面代码的运行结果是什么为什么#include stdio.h int main() { double a 0.1, b 0.2, c 0.3; if (a b c) { printf(equal\n); } else { printf(not equal\n); } return 0; }【考点拆解】输出not equal。原因是0.1、0.2、0.3在IEEE 754双精度浮点数里都不能被二进制精确表示它们存储的是一串近似值。a b的近似结果和c的近似结果之间存在极小的误差大约2.78×10^-17所以返回假。浮点数的比较在工程里是一个老生常谈的问题。正确做法是比较差值绝对值是否小于某个容忍误差#include math.h if (fabs(a b - c) 1e-9) { printf(equal\n); }这个1e-9被称为epsilon容差具体取值要看你的应用场景计算几何通常用1e-8到1e-10金融计算则建议完全避开浮点数改用整数表示分。刷题时凡是涉及浮点结果的判题OJ用的一般也是类似原理的相对误差或绝对误差判定这也侧面说明比较浮点数在真实场景中确实不可靠。1.5 题5 整数除法与类型转换7除以2到底等于几【题目】写出以下代码的输出#include stdio.h int main() { int a 7, b 2; printf(%d\n, a / b); printf(%.1f\n, (double)a / b); printf(%d\n, (int)(a / (double)b)); printf(%f\n, (double)(a / b)); return 0; }【考点拆解】输出依次为3 3.5 3 3.000000第一行两个int相除结果直接截断小数部分7/23这是C语言整数除法向零截断的规则。第二行(double)a把a转换为double整个运算升级为浮点除法结果是3.5。第三行先做浮点除法得到3.5再强转为int截断为3。第四行最容易错a / b先算整数除法得到3然后强转成double变成3.0所以打印出来是3.000000。这里的核心考点是强制类型转换的优先级——(double)(a / b)是先运算再转换(double)a / b是先转换再运算两者意义完全不同。另外还需要注意的是(int)3.9的结果是3截断而非四舍五入如果题目要求四舍五入要自己写(int)(x 0.5)。2. 运算符与流程控制那些想当然的执行顺序这个专题的题目有个共同特点代码看起来特别简单但结果总出人意料。原因在于C语言表达式的执行规则很多反直觉的地方——短路求值不执行后面的操作了switch没有break就一路穿下去do-while至少先执行一次。弄懂这些你才算真正控制了程序的流向。2.1 题6 短路求值if条件里的副作用真的发生了吗【题目】写出以下程序的输出#include stdio.h int main() { int a 0, b 2, c 3; if (a b) { // do nothing } printf(b%d\n, b); if (c || b) { // do nothing } printf(b%d\n, b); return 0; }【考点拆解】输出是b2 b2和||都有短路特性左边为假时右边的表达式根本不会执行||左边为真时右边的表达式也不会执行。第一段代码a0已经决定了a b为假b被跳过b保持2。第二段代码c3非零||左侧为真右侧b同样被跳过b还是2。这个知识点在刷题中最常见的应用场景是精简代码比如判断一个数是否在某个范围内if (i n a[i] 0)一旦i越界a[i]根本不会被访问从而安全地避免了数组越界。很多算法题的标准写法都依赖这个性质。2.2 题7 switch没有break的穿透故意不写break行不行【题目】当x分别等于1、2、3、4时下面程序的输出各是什么#include stdio.h int main() { int x; scanf(%d, x); switch (x) { case 1: printf(A); case 2: printf(B); break; case 3: printf(C); default: printf(D); } return 0; }【考点拆解】x1时输出ABx2时输出Bx3时输出CDx4时输出D。switch的匹配规则是从匹配的case处开始依次向下执行所有语句直到遇到break或者整个switch结束。C语言要求每个case末尾必须显式break这与其他语言完全不同。忘了写break被称为case穿透fall-through是新手查半天都找不到原因的经典bug。但case穿透也不是一无是处。工程上有一个合法用法当多个值需要执行同一段逻辑时可以故意让它们穿到同一个代码块switch (grade) { case A: case B: printf(pass\n); break; case C: default: printf(fail\n); }此外还要注意case后面的值必须是整型常量表达式不能是变量。这个题里default的位置也很灵活可以在switch的任意位置但通常放在最后。2.3 题8 位运算三板斧判断2的幂、交换变量、数1的个数【题目】三个经典位运算问题请分别用C语言实现判断一个正整数n是否是2的幂不使用临时变量交换两个整数a和b统计一个整数n的二进制表示中1的个数。【考点拆解】这三个问题在所有位运算入门的教材里都会出现因为它们恰好展示了位运算最典型的三种思维模式。判断2的幂核心观察是2的幂的二进制只有一个1比如8是10007是01118 7 0。因此if (n 0 (n (n - 1)) 0) { // n 是 2 的幂 }注意n 0必须写上否则0也满足后面那个条件。不使用临时变量交换a ^ b; b ^ a; a ^ b;原理是异或的自反性a ^ b ^ b a。第一步之后a存的是a^b第二步用这个结果异或原来的b得到原来的a赋给b第三步再异或得到原来的b赋给a。实际工程里更推荐用临时变量因为可读性强编译器优化后两者机器码效率差不多但笔试面试里这道题的位运算版本是常规操作。统计1的个数经典技巧是反复执行n (n - 1)每执行一次就消掉最右边的一个1int count 0; while (n) { n (n - 1); count; }循环次数等于1的个数而不是固定的32次。这个trick在布隆过滤器海量数据去重等场景里也经常用到。2.4 题9 while和do-while密码校验的两种写法【题目】要求用户输入密码直到输入的值等于123456为止。分别用while和do-while实现指出两个版本的差异。【考点拆解】do-while版本int pw 123456, input; do { printf(请输入密码: ); scanf(%d, input); } while (input ! pw);while版本int pw 123456, input 0; while (input ! pw) { printf(请输入密码: ); scanf(%d, input); }两者都能完成任务但语义有本质区别do-while保证循环体至少执行一次适用于无论如何都要先做一次的场景while则可能一次都不执行。上面while版本必须把input初始化为一个不等于pw的值否则密码正确时会直接跳过循环。这个初始化很容易漏漏了就是未定义行为。刷题遇到先运行再判断需求时比如菜单显示、用户输入校验用do-while通常更自然。除此之外while的使用频率远高于do-while但考试就是喜欢考这个至少执行一次的边界差异。2.5 题10 嵌套三目运算符求三个数的最大值【题目】已知三个整数a、b、c要求只用三目运算符?:求出其中的最大值写出一行表达式。【考点拆解】int max a b ? (a c ? a : c) : (b c ? b : c);三目运算符是右结合的也就是说a ? b : c ? d : e会被解析成a ? b : (c ? d : e)。上面的嵌套写法相当于先比较a和ba大时再从a、c里取大者否则从b、c里取大者。不过在实际项目里嵌套三目的可读性非常差我见过有人写出三层嵌套的表达式调试的时候自己都看不懂。工程化的建议是一行嵌套超过两层就改用if-else或者写成普通函数。3. 数组与字符串地址、下标与终止符的三角关系数组这块的知识难点不在于数组是什么而在于数组名在表达式中到底代表什么、多维数组在内存里怎么排、字符串和字符数组之间那根看不见的终止符。这个专题的题目设计思路是把隐含的内存模型一个一个挖出来。3.1 题11 二维数组实战成绩统计表【题目】有3名学生每名学生考4门课程。输入12个成绩输出每名学生的总分以及每门课程的平均分。要求用int二维数组存储成绩。【参考代码】#include stdio.h #define STUDENTS 3 #define COURSES 4 int main() { int scores[STUDENTS][COURSES]; int i, j; for (i 0; i STUDENTS; i) { for (j 0; j COURSES; j) { scanf(%d, scores[i][j]); } } for (i 0; i STUDENTS; i) { int sum 0; for (j 0; j COURSES; j) { sum scores[i][j]; } printf(学生%d总分: %d\n, i 1, sum); } for (j 0; j COURSES; j) { int sum 0; for (i 0; i STUDENTS; i) { sum scores[i][j]; } printf(课程%d平均分: %.2f\n, j 1, sum / (double)STUDENTS); } return 0; }【考点拆解】二维数组int scores[3][4]在内存中是连续存放的按行优先排列先是第0行的4个元素再是第1行的4个元素最后是第2行的4个元素。所以求每名学生总分时内层循环遍历的是j求每门课程平均分时外层循环遍历j、内层循环遍历i实际上是竖着遍历数组。有一个容易忽略的坑计算平均分时sum是int直接用sum / STUDENTS会做整数除法结果被截断。要得到浮点结果必须至少把其中一个操作数转成double比如sum / (double)STUDENTS或者把sum声明成double。这类整数除法吃掉小数的错误在统计类题目里非常高频我每次看到学生写sum / n都会特别提醒一句。3.2 题12 字符串逆序PTA原题与fgets的收尾问题【题目】输入一个可能包含空格的字符串长度不超过80输出它的逆序。要求不能用库函数strrev。【参考代码】#include stdio.h #include string.h int main() { char s[81]; int len, i, j; char tmp; fgets(s, sizeof(s), stdin); len strlen(s); if (s[len - 1] \n) { s[len - 1] \0; len--; } i 0; j len - 1; while (i j) { tmp s[i]; s[i] s[j]; s[j] tmp; i; j--; } printf(%s\n, s); return 0; }【考点拆解】这道题是PTA上的经典原题主要考察三个点。第一读入一行含空格的字符串不能用scanf(%s)因为%s遇到空格就停了。gets()在很多平台已经被移除安全的做法是fgets(s, sizeof(s), stdin)。但fgets有个副作用如果输入行末尾有换行符它会把这个换行符也存进数组导致strlen多算1所以必须手动去掉尾部的\n。这里的判断顺序很重要先算len再判断s[len-1]。第二逆序使用双指针从两端向中间交换循环条件是i j。如果数组长度是奇数最中间的元素不需要交换是偶数时双指针会在中间擦肩而过前停下。第三有人会问为什么不能直接用strrev——因为strrev不是C标准库函数只有某些编译器自带PTA和OJ通常不支持。3.3 题13 字符统计字母、数字、空格和其他【题目】输入一行字符分别统计其中英文字母、数字、空格和其他字符的个数。要求不用ctype.h直接用ASCII码范围判断。【参考代码】#include stdio.h int main() { char ch; int letters 0, digits 0, spaces 0, others 0; while ((ch getchar()) ! \n) { if ((ch a ch z) || (ch A ch Z)) { letters; } else if (ch 0 ch 9) { digits; } else if (ch ) { spaces; } else { others; } } printf(字母%d 数字%d 空格%d 其他%d\n, letters, digits, spaces, others); return 0; }【考点拆解】字符判断的本质是ASCII码比较。a到z的ASCII码是连续的97到122A到Z是65到900到9是48到57。所以完全可以用范围判断代替库函数编译器内部对ch a ch z这种写法的优化也很成熟不需要担心效率。这里有个容易踩的坑如果题目说输入一行字符循环用while ((ch getchar()) ! \n)会漏掉最后一行的文件结束情况但如果题目明确输入只有一行并以回车结束这样写是对的。有些平台会在输入里混入\rWindows换行风格导致else分支多统计一个\r。处理方法是把\r也当空白跳过或者用ch ! \n ch ! \r作为循环条件。3.4 题14 strlen和sizeof数组名与指针的分水岭【题目】在64位Linux系统下写出以下代码的输出#include stdio.h #include string.h int main() { char s[] hello; char *p hello; printf(%lu\n, sizeof(s)); printf(%lu\n, sizeof(p)); printf(%lu\n, strlen(s)); printf(%lu\n, strlen(p)); return 0; }【考点拆解】输出是6 8 5 5第一个sizeof(s)是6因为s是包含6个元素的char数组——5个字符加上字符串末尾的\0。第二个sizeof(p)是8因为p是一个指针64位平台上指针大小固定为8字节它只保存地址和指向的内容有多长没有任何关系。第三个和第四个strlen都是5因为strlen在遇到\0时停下不计入终止符。这个题是每次面试必考的老题考察的是数组名和指针不是一回事这个C语言核心认知。比sizeof更隐蔽的是在函数参数里当一个数组作为函数参数传递时它会退化成指针所以函数内部的sizeof(arr)拿到的永远是指针大小而不是数组大小。工程上一旦需要在函数里知道数组长度必须额外传一个长度参数或者用宏定义长度。3.5 题15 冒泡排序完整实现加个flag能快多少【题目】输入n1≤n≤100个整数用冒泡排序从小到大输出。要求写出完整可运行的代码并在内层循环中优化整轮无交换即提前结束。【参考代码】#include stdio.h int main() { int n, i, j, tmp; int a[100]; int swapped; scanf(%d, n); for (i 0; i n; i) { scanf(%d, a[i]); } for (i 0; i n - 1; i) { swapped 0; for (j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { tmp a[j]; a[j] a[j 1]; a[j 1] tmp; swapped 1; } } if (!swapped) { break; } } for (i 0; i n; i) { printf(%d , a[i]); } printf(\n); return 0; }【考点拆解】冒泡排序的代码背下来不难刷题时真正值钱的是理解两个边界外层循环为什么是i n - 1而不是i nn个数最多需要n-1轮排序内层循环为什么是j n - 1 - i每完成一轮最大的数就已经沉到末尾下一轮不需要再比较它。优化部分用了一个布尔变量swapped如果某轮内层循环从头到尾一次交换都没发生说明数组已经有序直接break。这个优化在数组接近有序的场景下能把时间复杂度从O(n²)降到O(n)。我在实际工程里见过有人用一个很暴力的冒泡处理长度上万的数组加了early break之后运行时间从几秒降到几十毫秒——虽然更好的选择是排序库函数但也说明这个flag不是摆设。4. 指针与动态内存先学会画内存图再写代码指针的难点不在于语法而在于心里没有内存模型。很多人写指针程序出错是因为根本没想过指针指向哪里、那块内存到底能不能写。我建议做这个专题的题之前先在纸上把每个变量的内存布局画出来。题目本身不难但画图的过程能帮你建立肌肉记忆。4.1 题16 *p的表达式的真实顺序改的是数组还是指针【题目】写出以下程序的输出#include stdio.h int main() { int a[3] {1, 2, 3}; int *p a; *p 10; printf(a[0]%d a[1]%d a[2]%d\n, a[0], a[1], a[2]); printf(p-a%ld\n, p - a); (*p); printf(a[1]%d\n, a[1]); return 0; }【考点拆解】第一行输出a[0]10 a[1]2 a[2]3第二行输出p-a1第三行输出a[1]3。*p这个表达式由于后缀的优先级高于*从语法上被解析为*(p)。但关键在于p的值是p自增之前指向的地址。所以整个表达式的作用是先把10赋给p当前指向的位置也就是a[0]然后p自增指向a[1]。它其实等价于*p 10; p;这两条语句的组合。(*p)就完全不同了。括号强制先把*p解引用出来再对解引用结果做自增也就是让a[1]从2变成3。这两个表达式之差是C语言指针题里最经典的一类。笔试里经常让你填*p、(*p)、*p、*p四种写法的结果本质上就是考察作用于指针还是作用于指针指向的值。4.2 题17 行指针遍历二维数组p跳了多远【题目】用行指针数组指针实现遍历一个3行4列的二维数组计算所有元素之和。【参考代码】#include stdio.h int main() { int a[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*p)[4]; int i, sum 0; for (p a; p a 3; p) { for (i 0; i 4; i) { sum (*p)[i]; } } printf(%d\n, sum); return 0; }【考点拆解】int (*p)[4]声明了一个指向长度为4的int数组的指针这就是行指针。p a让p指向第0行p在C语言里不是简单地址加1而是跳到下一个完整行——指针自增的步长由它指向的类型的sizeof决定这里是sizeof(int[4])也就是16字节。访问元素时(*p)[i]先把p解引用得到那一行的数组名再用下标取第i个元素。在不写(*p)[i]而直接写p[i]时其实是等价于*(*(p i))由于p已经是数组指针类型这里反而没那么直观。我个人的经验是用行指针遍历二维数组重点是理解类型决定步长这在处理图像数据像素矩阵时非常实用。4.3 题18 malloc动态数组从堆上拿一块内存的正确姿势【题目】从键盘读入n个学生的成绩n由用户输入用malloc动态分配一个int数组存储成绩计算平均分和最高分最后释放内存。【参考代码】#include stdio.h #include stdlib.h int main() { int n, i, sum 0, max; int *score; printf(输入人数: ); scanf(%d, n); score (int *)malloc(n * sizeof(int)); if (score NULL) { printf(内存分配失败\n); return 1; } for (i 0; i n; i) { scanf(%d, score[i]); if (i 0 || score[i] max) { max score[i]; } sum score[i]; } printf(平均分: %.2f\n, sum / (double)n); printf(最高分: %d\n, max); free(score); score NULL; return 0; }【考点拆解】malloc的正确使用有四个铁律。第一参数是字节数不是元素个数所以必须写n * sizeof(int)而不是n。有人写malloc(n)在int为4字节的平台上只分配了n个字节存4个int就已经越界了这是典型的隐藏bug。第二malloc的返回值是void *在C语言中可以隐式转换为任意指针类型写不写强制转换(int *)都可以。但在C里必须显式转换建议在纯C项目里也写上代码意图更清晰。第三必须检查malloc的返回值是否为NULL。内存分配失败时返回NULL不检查就继续使用是未定义行为几乎必然段错误。虽然OJ上的小数据量题目几乎不会触发内存不足但这是工程级代码的基本素养。第四free之后把指针置为NULL。free只释放内存不会修改指针变量指针仍然保存着已释放内存的地址这就是悬空指针。继续使用它、或者再次free它都会出问题。置NULL相当于打标记让后续代码知道这个指针已失效。4.4 题19 重复free与野指针崩溃现场还原【题目】分析下面的程序为什么会崩溃并说明修复方式#include stdio.h #include stdlib.h int main() { int *p (int *)malloc(sizeof(int)); *p 42; free(p); free(p); printf(%d\n, *p); return 0; }【考点拆解】这段代码包含两个致命操作。第一个是double free对同一个指针调用两次free在glibc的堆管理机制里第一次free后这块内存的元信息已经被修改第二次free会触发堆一致性检查失败程序通常会报错double free or corruption然后崩溃。这个行为在不同平台的表现不完全一致但都属于未定义行为绝不能依赖碰巧能跑。第二个是use-after-freefree(p)之后*p访问的是一块已经归还给堆管理器的内存内容随时可能被改写。printf可能打印出42也可能打印垃圾值但这种看起来正常正是最危险的因为它让你以为代码没问题。正确写法是free之后马上p NULL后续对p的解引用操作会直接段错误——段错误虽然粗暴但至少能在开发阶段暴露问题而不是在生产环境里以随机bug的形式出现。4.5 题20 字符串字面量能修改吗段错误是怎么来的【题目】代码A和代码B哪个能正常运行哪个会崩溃// 代码A char s[] hello; s[0] H; printf(%s\n, s); // 代码B char *p hello; p[0] H; printf(%s\n, p);【考点拆解】代码A正常运行输出Hello代码B在运行时崩溃原因是段错误。A中的s[]是字符数组在栈上分配了6字节空间把字符串字面量hello复制到这块可读写内存中此后修改s[0]是合法的。B中的p是指向字符串字面量的指针而字符串字面量在大多数平台被放在只读数据段.rodata试图修改它会触发操作系统级别的写保护进程直接收到SIGSEGV。用一句话概括char s[] hello是拿到了hello的副本char *p hello是指向hello字面量本体。这个知识点在很多字符串题目里是关键分水岭只要题目里出现了char *p xxx后续所有对p指向内容的修改都要格外警惕。更隐蔽的版本是作为函数参数传入时数组退化成指针你无法在函数内部判断传入的到底是可改写的栈数组还是只读字面量。5. 函数与递归边界意识从小函数练出来函数和递归是上篇的最后一道坎。很多初学者觉得递归难是因为一直在试图跟踪每一步调用而不是把握住递归的两个核心终止条件和问题规模递减。这个专题的五道题前两道帮大家理清函数传参的本质后三道用递归把分而治之的思维模型建立起来。5.1 题21 swap现场为什么传值版本交换了个寂寞【题目】下面的swap函数为什么不能交换main函数中a和b的值如何修改void swap(int a, int b) { int tmp a; a b; b tmp; } int main() { int a 3, b 5; swap(a, b); printf(a%d b%d\n, a, b); return 0; }【考点拆解】输出a3 b5。C语言只有值传递调用swap(a, b)时形参a和b是实参的拷贝函数内部怎么交换都只影响这两个局部变量对main里的a、b毫无影响。函数返回时这两个拷贝随之销毁。正确版本是传地址void swap(int *pa, int *pb) { int tmp *pa; *pa *pb; *pb tmp; }调用时写swap(a, b)把a和b的地址传进去函数通过地址间接修改了实参。注意这里仍然是值传递——传给函数的是指针变量本身的值也就是地址函数修改的是地址指向的内容。每写一个函数前先问自己这个函数需要修改调用者的变量吗如果需要传指针如果只是读取传值。还有一个容易混淆的点如果传入的是指针变量想在函数内改变指针本身的指向那必须传指针的指针int **pp这个在链表操作里特别常见。5.2 题22 斐波那契数列递归的优雅与代价【题目】用递归实现求Fibonacci数列的第n项n从1开始f(1)1, f(2)1并说明n50时会发生什么。【参考代码】#include stdio.h long long fib(int n) { if (n 1 || n 2) { return 1; } return fib(n - 1) fib(n - 2); } int main() { int n; scanf(%d, n); printf(%lld\n, fib(n)); return 0; }【考点拆解】递归边界是n1和n2都返回1。这是终止条件。但n50时这段代码几乎跑不动。原因是fib(50)会调用fib(49)和fib(48)这两个又分别继续往下拆最终产生的调用总数是2^50量级这个指数爆炸在现代计算机上根本算不完。更麻烦的是大量重复计算fib(48)在左子树里算了一次在右子树里又被算了一次整个递归树充满这种重复。一个经典优化是记忆化搜索用数组缓存已算过的值或者直接用迭代long long fib_iter(int n) { if (n 1 || n 2) return 1; long long a 1, b 1, c; for (int i 3; i n; i) { c a b; a b; b c; } return b; }这个题给我们的工程启示是递归是解决问题的思维工具但不一定是最优的执行方案。刷题时写了递归后养成追问能不能改迭代会不会重复计算边界n很大时会不会爆栈或溢出的习惯。另外fib(50)已经超过了int的范围函数返回类型要选long long这也是题目考察的一部分——计算斐波那契时用int从第47项开始就开始溢出了。5.3 题23 汉诺塔三根柱子上的递归思维模型【题目】实现汉诺塔问题的递归解法有n个盘子从A柱借助B柱移动到C柱输出每一步的移动过程。【参考代码】#include stdio.h void hanoi(int n, char A, char B, char C) { if (n 1) { printf(%c - %c\n, A, C); return; } hanoi(n - 1, A, C, B); printf(%c - %c\n, A, C); hanoi(n - 1, B, A, C); } int main() { int n; scanf(%d, n); hanoi(n, A, B, C); return 0; }【考点拆解】汉诺塔的递归逻辑非常干净但很多人第一次看时绕不出来。把n个盘子从A移到C可以拆成三步先把上面n-1个从A经C移到B此时A上只剩最大的盘子再把最大的盘子直接从A移到C最后把B上的n-1个从B经A移到C。代码里三行递归调用和printf正好对应这三步。这里最容易搞混的是参数在递归过程中不停交换这件事。可以用这个思路辅助理解递归调用中的A和B、C只是角色不是固定的柱子名。第一次递归调用hanoi(n-1, A, C, B)里的B是目标柱第二次递归调用hanoi(n-1, B, A, C)里的B变成了起始柱。画的递归展开图上三根柱子的排列方式一直在变但把n-1个移到中间柱子上这个操作模式始终不变。n3时程序会输出7步移动n10时是1023步。汉诺塔是少有的代码短但递归深度清晰可见的问题非常适合用来建立对递归栈的直觉。5.4 题24 最大公约数辗转相除法递归版【题目】用递归实现求两个正整数的最大公约数GCD要求使用辗转相除法。【参考代码】#include stdio.h int gcd(int a, int b) { if (b 0) { return a; } return gcd(b, a % b); } int main() { int a, b; scanf(%d %d, a, b); printf(%d\n, gcd(a, b)); return 0; }【考点拆解】辗转相除法的数学原理是gcd(a, b) gcd(b, a % b)当余数为0时除数就是最大公约数。用a12, b18举例gcd(18, 12)→gcd(12, 6)→gcd(6, 0)返回6。递归的妙处在于终止条件b 0相当自然任何数和0的公约数就是这个数本身。每一步中a % b一定比b小所以问题规模必然递减递归必然收敛不会无限递归。这个是评判一个递归函数是否合格的关键指标边界条件规模递减缺一不可。实现细节上如果调用gcd时传入了负数%的结果可正可负所以工程版通常先取绝对值。有的教材还要求两数交换后保证a大于b但其实辗转相除法本身的逻辑并不依赖这个约束因为第一次递归时自动就交换了。5.5 题25 回文判断递归从两端往中间夹逼【题目】用递归判断一个字符串是否为回文正读反读一样不能使用循环。【参考代码】#include stdio.h #include string.h int isPalindrome(char s[], int left, int right) { if (left right) { return 1; } if (s[left] ! s[right]) { return 0; } return isPalindrome(s, left 1, right - 1); } int main() { char s[100]; scanf(%s, s); if (isPalindrome(s, 0, strlen(s) - 1)) { printf(是回文\n); } else { printf(不是回文\n); } return 0; }【考点拆解】终止条件有两层如果left和right相遇或者交错left right说明所有对应位置的字符都比对过了返回1表示是回文如果在某一层发现s[left] ! s[right]直接返回0不需要继续递归。每次递归做的事情是比较当前两端字符然后调用自身处理去掉两端之后的子串。这就是规模递减——每次递归处理的范围缩小两个字符。边界上是空串或单字符一定是回文这个直观事实。这个题目正确的循环版本就是前面字符串逆序里用过的双指针递归版本和循环版本逻辑一一对应。刷题时看到递归版本之后应该有能力把它改写成迭代版本反之亦然。这是算法题的核心基本功。刷题这件事最怕的是题海战术——做一道忘一道最后只留下我好像都见过的错觉。这套练习的每一题都建议按三步走先独立写出能跑的代码再对照参考代码找差异最后合上答案把考点用自己的话复述一遍。前面21题帮大家把函数参数传递、指针操作、动态内存的坑都过了一遍后面几道递归题的共同套路是终止条件规模递减这个思维模型在二叉树的题目里还会反复用到。剩下的文件读写、结构体、链表和综合实战留到下篇等我整理好再发出来。