说个我经常在带新人时看到的场景:很多人把《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; }【考点拆解】
这段代码的输出结果是n=5, c=10。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的合法字符"和"遇到了文件结束符"这两种情况。正确写法是把接收变量声明为int:
int ch; while ((ch = getchar()) != EOF) { count++; }这里值得多说一句:main函数中getchar()从终端读入时,在Linux下按Ctrl+D、Windows下按Ctrl+Z回车触发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/2=3,这是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; }【考点拆解】
输出是:
b=2 b=2&&和||都有短路特性:&&左边为假时,右边的表达式根本不会执行;||左边为真时,右边的表达式也不会执行。第一段代码,a=0已经决定了a && b++为假,b++被跳过,b保持2。第二段代码,c=3非零,||左侧为真,右侧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; }【考点拆解】
x=1时输出AB,x=2时输出B,x=3时输出CD,x=4时输出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是1000,7是0111,8 & 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),每执行一次就消掉最右边的一个1:
int 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和b,a大时再从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到122,'A'到'Z'是65到90,'0'到'9'是48到57。所以完全可以用范围判断代替库函数,编译器内部对ch >= 'a' && ch <= 'z'这种写法的优化也很成熟,不需要担心效率。
这里有个容易踩的坑:如果题目说"输入一行字符",循环用while ((ch = getchar()) != '\n')会漏掉最后一行的文件结束情况;但如果题目明确输入只有一行并以回车结束,这样写是对的。有些平台会在输入里混入\r(Windows换行风格),导致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能快多少
【题目】输入n(1≤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 < n(n个数最多需要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-a=1,第三行输出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-free:free(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; }【考点拆解】
输出a=3 b=5。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),并说明n=50时会发生什么。
【参考代码】
#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; }【考点拆解】
递归边界是n=1和n=2都返回1。这是"终止条件"。
但n=50时,这段代码几乎跑不动。原因是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个移到中间柱子上"这个操作模式始终不变。
n=3时程序会输出7步移动,n=10时是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时,除数就是最大公约数。用a=12, b=18举例: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题帮大家把函数参数传递、指针操作、动态内存的坑都过了一遍,后面几道递归题的共同套路是"终止条件+规模递减",这个思维模型在二叉树的题目里还会反复用到。剩下的文件读写、结构体、链表和综合实战留到下篇,等我整理好再发出来。