很多人在学C语言的时候,一看到"请将以下程序段输入编辑器,记录两个程序段的输出结果,并分析每个程序段结果"这种题,第一反应就是赶紧把代码复制进编译器跑一遍,然后把屏幕上的输出抄下来。这在我看来,等于把一道好题做废了。程序段分析题真正练的不是手速,而是你在没有运行环境的情况下,能不能靠大脑把代码的执行过程一步步推理出来。我今天就拿一道很有代表性的练习题来演示这个过程,编号就叫7-3。这道题包含两个程序段,一个涉及递归调用,一个涉及指针自增运算,都是C语言考试和面试里反复出现的经典考点。
1. 题面原型:两道看似简单却藏了四个考点的程序段
7-3这类题在不少教材和练习平台里很常见,题干一般不复杂,就一句话:将以下程序段输入编辑器,记录输出结果,并分析每个程序段结果。但真正动手写的人会意识到,这两段代码几乎把C语言的函数调用、递归、运算符优先级、指针运算这几个核心知识点全串起来了。
1.1 程序段一:递归求阶乘和
第一段代码长这样:
#include <stdio.h> int fact(int n) { if (n <= 1) { return 1; } return n * fact(n - 1); } int main(void) { int i; int sum = 0; for (i = 1; i <= 5; i++) { sum += fact(i); } printf("sum = %d\n", sum); return 0; }乍一看,这段代码很短,结构也很清晰:先定义了一个fact函数,主函数里用一个for循环从1到5,把fact(1)、fact(2)、fact(3)、fact(4)、fact(5)的值累加进sum,最后打印出来。难点在于fact这个函数本身是递归的,你得搞清楚它在每一轮循环里到底返回了什么。
1.2 程序段二:指针自增运算的"连环陷阱"
第二段代码是这个:
#include <stdio.h> int main(void) { int a[5] = {1, 3, 5, 7, 9}; int *p = a; printf("%d\n", *p++); printf("%d\n", *(p + 1)); printf("%d\n", *++p); printf("%d\n", *p--); return 0; }这段代码没有函数调用,看起来更简单,实际上却更容易翻车。四条printf语句长得几乎一样,无非是星号、括号、加号和减号的排列组合,但每一条对指针p的作用都不一样。尤其是*p++和*++p这两个写法,在C语言里是出了名的坑。
1.3 记录输出的正确姿势:先手算,别急着运行
我的建议是,拿到这样的题目,第一件事不是开编译器,而是找张纸,把两个程序段的输出结果先手算一遍。算完再运行对照。这样做的好处是,运行结果只能告诉你"输出是什么",但手算过程能逼你搞清楚"为什么是这个结果"。如果一开始就依赖编译器,你可能永远分不清*p++和*++p的区别,下次遇到还是错。
为了方便对照,我把最终答案先放在这里。程序段一输出的结果是sum = 153;程序段二输出的结果是四行数字,分别是1、5、5、5。
| 程序段 | 输出结果 | 涉及考点 |
|---|---|---|
| 程序段一 | sum = 153 | 递归调用、函数返回值、循环累加 |
| 程序段二 | 1 \n 5 \n 5 \n 5 | 运算符优先级、指针自增自减、数组访问 |
如果你第一次手算就能完全算对这个答案,说明你对函数递归和指针运算的基础已经相当扎实了。如果哪里对不上,下面两章就是为对不上的人准备的逐行拆解。
2. 程序段一的执行推演:递归不再靠背,而是靠画栈
递归是C语言函数部分的核心,也是很多人从入门到放弃的地方。程序段一里的fact函数是个非常典型的递归函数,它求的是n的阶乘,也就是n!。数学上的定义是:n! = n × (n-1) × ... × 1,并且0! = 1。代码里的实现思路是,当n小于等于1时直接返回1,否则返回n乘以fact(n-1)。
2.1 fact(5)的递归展开:从调用到返回的完整顺序
先单独看fact(5)是怎么执行的。递归最难的地方在于,你得从"调用顺序"和"返回顺序"两个维度同时理解它。调用的时候是一层一层往下钻,返回的时候是一层一层往上爬。我把fact(5)的完整过程展开如下:
fact(5) 被调用 -> 5 > 1,因此执行 5 * fact(4) -> fact(4) 被调用 -> 4 > 1,因此执行 4 * fact(3) -> fact(3) 被调用 -> 3 > 1,因此执行 3 * fact(2) -> fact(2) 被调用 -> 2 > 1,因此执行 2 * fact(1) -> fact(1) 被调用 -> 1 <= 1,return 1 -> fact(1) 返回 1,所以 2 * 1 = 2,return 2 -> fact(2) 返回 2,所以 3 * 2 = 6,return 6 -> fact(3) 返回 6,所以 4 * 6 = 24,return 24 -> fact(4) 返回 24,所以 5 * 24 = 120,return 120 fact(5) 最终返回值是 120这个过程用生活中的场景类比,就像玩叠积木:你先把一块积木放上去,再放一块,层层往上堆,堆到第五层的时候发现最顶上那一块就是终止条件,然后你从最上面开始一块一块抽下来,每抽一块都要乘上一个数,最后回到地面,得到最终结果。
2.2 sum的变化轨迹:每一轮循环后到底加了什么
单独搞懂fact(5)还不够,因为主函数里的for循环是把fact(1)到fact(5)全部累加起来。很多人的误区就在这里:他们算出了fact(5)等于120,就以为答案是120,完全忽略了sum是五个值相加的结果。
我把每一轮循环的执行情况列成一张表:
| i的值 | fact(i)的返回值 | 累加后sum的值 |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 2 | 3 |
| 3 | 6 | 9 |
| 4 | 24 | 33 |
| 5 | 120 | 153 |
从这张表能很清楚地看到,当i等于1的时候,fact(1)直接命中终止条件返回1;i等于2的时候,fact(2)要先调用fact(1),拿到1之后乘以2,返回2;i等于3的时候,fact(3)要先经过fact(2)再到fact(1),一层层返回后得到6。到i等于5的时候,前面已经积累了33,再加上120,最终结果是153。
2.3 为什么sum不等于120,也不等于15
我让不少同学手算过这道题,最常见的两个错误答案分别是120和15。说120的人,是只算了fact(5),丢掉for循环的累加过程;说15的人,是误以为fact(i)返回的是i本身,也就是1+2+3+4+5,完全把fact函数当成恒等函数了。
这两个错误本质上是同一个问题:没有搞清楚函数调用的返回值到底是什么。fact(i)不是返回i,而是返回i的阶乘。只有当i=1或i=0时,fact(i)才恰好等于1;i=2时它等于2,i=3时它等于6,i=4时等于24,i=5时等于120。所以sum是这五个数的和,这个和是153,跟单算5!的120差了一个33,也就是fact(1)到fact(4)之和。
这里有个细节值得多说一句:fact函数在n<=1时返回1,这个写法其实是对数学定义做了简化。严谨的阶乘定义中,0! = 1,但这段代码在n=1时也返回1,所以如果你把循环改成从0开始,fact(0)依然返回1,不会出错。这种写法在题目里常见,不算Bug,但你要能看出它的边界行为。
3. 程序段二的反直觉输出:*p++ 和 *++p 到底谁先算
程序段二比程序段一更容易让人怀疑人生,因为它考的是C语言里最让人头疼的运算符优先级和指针自增自减的组合。四条printf语句,每条看起来都差不多,实际运行结果却是四行独立的数字,而且里面有连续两个5。如果不仔细推理,很容易算出一堆乱七八糟的结果。
3.1 优先级与结合性的逐条拆解
先明确第一条语句*p++。在C语言里,后缀自增运算符++的优先级比解引用运算符*高,而且后缀自增的结合方向是从左到右。所以*p++不是(*p)++,而是*(p++)。关键点在于,后缀自增表达式的值是自增之前的值。也就是说,p++这个表达式的值是p原本指向的地址,副作用是让p指向下一个元素。因此*p++做的事情是:先取出p当前指向的元素,再把p往后挪一格。
第二条语句*(p + 1)相对温和。括号里的p + 1计算的是p指向位置的下一个地址,但这次加减操作不会修改p本身。它只是临时算出一个新地址来读取数据。
第三条语句*++p的规则跟前缀自增有关。前缀自增++p的优先级比*略低还是高?准确地说,前缀自增和*的优先级相同,结合方向从右到左,所以*++p先执行++p,也就是让p先自增,再对p解引用。跟后缀版本最大的区别是,前缀自增表达式的值是自增之后的新地址。
第四条语句*p--的处理方式跟第一条类似。后缀自减优先级高,整个表达式等价于*(p--),先取出p当前指向的元素,然后p再减一。
3.2 p指针在第4条printf后的精确指向
光讲规则不够,我把指针p的变化过程完整跟踪一遍。数组a在内存中的布局是连续的五个int,初始时p指向a[0],也就是元素1。
| 语句 | 表达式值 | 执行后p指向的位置 | 对应元素 |
|---|---|---|---|
| 初始状态 | — | a[0] | 1 |
printf("%d\n", *p++); | 1 | a[1] | 3 |
printf("%d\n", *(p + 1)); | 5 | a[1] | 3 |
printf("%d\n", *++p); | 5 | a[2] | 5 |
printf("%d\n", *p--); | 5 | a[1] | 3 |
逐条解释一下这张表。初始状态下p指向a[0],所以第一条语句输出a[0]的值1,紧接着p自增,指向a[1]。
第二条语句执行时,p指向a[1],所以p + 1指向a[2],解引用后输出a[2]的值5。注意这条语句从头到尾没有修改p,p依然指向a[1]。
第三条语句是*++p,p先自增,从a[1]变成a[2],再解引用a[2],输出5。
第四条语句是*p--,先解引用p当前指向的a[2],输出5,然后p自减,回到a[1]。这里的自减属于副作用,后续代码如果还用到p,会拿到a[1]的地址,但在这段程序里已经没有任何用处了。所以最终屏幕上的输出是:
1 5 5 53.3 常见的误判结果和最容易被忽略的陷阱
我在现实中见到最多的错误版本是把这四条语句的输出理解成1, 3, 5, 5,也就是认为第二条语句*(p + 1)输出了a[1]的值3。这个误判的核心在于,他们没有意识到*(p + 1)中的p在第一条语句执行后已经指向a[1]了,所以p加1指向的是a[2]而不是a[1]。还有一种错误是认为第四行输出3,理由是p--让p先减再取值,这是把后缀自减跟前缀自减搞混了。后缀版本永远先取当前值再移动指针。
另一个容易被忽略的陷阱是,如果你把第二条语句的括号去掉,写成*p + 1,那意思就完全变了:它变成了"取出p指向的值再加1",输出结果会变成6,因为a[1]是5?等等,不对,第二条执行时p指向a[1],a[1]的值是3,3加1等于4。你看,一个括号的差异就能改变整个输出。C语言里像这样"差之毫厘,谬以千里"的地方太多了,这就是为什么程序段分析题要求你不仅看结果,还要分析原因。
4. 动手改造实验:改一个条件,输出就可能天翻地覆
程序段分析题还有一个很有价值的玩法,就是改代码。把条件改一改,看看输出会发生什么变化,再想想变化的原因。这个过程中形成的直觉,比单纯做一百道题都管用。我用几个有代表性的改造来说明。
4.1 把递归终止条件从 n <= 1 改成 n <= 0
第一个改动很隐蔽:把程序段一里fact函数的if (n <= 1)改成if (n <= 0),其他都不动。此时fact(1)不再直接返回1,而是执行1 * fact(0)。由于fact(0)满足n<=0,返回1,所以fact(1)的结果仍然是1。整个for循环从1到5,sum依然等于153,输出不变。
但如果终止条件改成if (n == 1),也就是只对n等于1做处理,那问题就来了。当循环从i=1开始时没问题,fact(1)直接返回1,后面的fact(2)、fact(3)等都能一层层递归到fact(1)结束。可一旦你某天把循环改成从i=0开始,fact(0)就会调用fact(-1),fact(-1)调用fact(-2),无限递归直到栈溢出崩溃。这就是边界条件的威力。
4.2 把循环改成从 i = 0 开始累加
接着说循环起点。如果把程序段一的for循环改成for (i = 0; i <= 5; i++),在原来的n <= 1条件下,fact(0)返回1,所以sum会从1开始累加,最终结果是154。只是起点从1变成0,答案就从153变成了154。这说明,做题时不能只盯着函数体,循环的边界、初值、终值都必须一起看。
4.3 指针程序段换成等价写法:用下标方式重塑逻辑
程序段二也可以改造。把四条printf改成完全等价的数组下标写法,帮助理解背后到底发生了什么:
printf("%d\n", a[0]); p = p + 1; printf("%d\n", a[2]); p = p + 1; printf("%d\n", a[2]); p = p - 1;这段改写跟原代码的输出是一致的:第一条输出a[0],第二条输出a[2],第三条输出a[2],第四条原样也是a[2]。通过这种"指针写法"到"下标写法"的转换,你会发现指针自增运算的本质,就是让指针在数组里游走,配合下标访问能做到同样效果。不同之处在于,指针写法更贴近底层内存地址的概念,也更考验你对运算符的理解。
这个改造实验很有实际价值。在真实项目里,指针运算经常出现在字符串处理、数组遍历、缓冲区操作等场景中,如果你能一眼看出*p++到底干了什么,调试代码的速度会快很多。
5. 这种题的通用解法:把"看答案"变成"验答案"
最后我想总结一套我用了很多年的手算方法。不管是7-3这种练习题,还是考试里的程序阅读题,都可以用这套方法来应对。核心思路就一句话:在代码运行之前,先强迫自己当一回"人肉编译器"。
5.1 手算五步法
第一步,把代码抄到纸上,一行一行看,不要跳。第二步,遇到复杂表达式,先标出运算符优先级,把高优先级的运算先括起来。比如*p++就写成*(p++),*++p就写成*(++p)。第三步,给关键变量画状态表,像上面那张指针变化表一样。第四步,口算出每一步的结果,并把每一次函数调用和返回都记录下来。第五步,运行程序,对照结果。如果手算和运行不一致,说明你对某个知识点的理解有偏差,这时候才是真正进步的机会。
这套方法看着繁琐,但熟练之后,像程序段二那样的题,三十秒内就能手算出结果。因为指针自增自减就那么几个规律,见多了自然就记住。
5.2 验证时别用"dev工具偷懒",要用 -Wall 编译
实际操作中,我建议用GCC验证。如果你的环境是VS Code配的C语言开发环境,在终端里执行这样一条命令就能编译并运行:
gcc -std=c11 -Wall -Wextra 7-3.c -o 7-3 && ./7-3-Wall -Wextra的作用是显示所有警告。对于程序段二这种代码,虽然逻辑正确,但有些编译器在开启警告时可能会提示未使用变量之类的信息。学会看这些警告,对培养代码敏感度很有帮助。很多人初学的时候觉得警告无所谓,只要能跑就行,这个习惯其实很危险。
5.3 函数与指针专题的强化路径
如果你正在学C语言函数章节,我觉得系统强化路径可以是这样:先把课本上的递归练习题做一遍,不限于阶乘和斐波那契,还要做汉诺塔、字符串逆序这类经典题。然后做指针专题,把自增自减、优先级、数组名和指针的关系全部理清楚。最后做综合题,也就是像7-3这样把函数、循环、指针混在一起考的题。
评价标准只有一个:你能不能在不运行代码的情况下,百分之百确定输出结果。如果能,那这部分内容基本就过关了。
我个人在做这类题目的时候,最大的体会是:把每一行代码的执行过程拆开来看,尤其是搞清楚"谁变了、谁没变",比背十个规律都管用。递归里的参数、指针里的地址、循环里的累加变量,本质上都是这个道理。你不需要一次记住一百个结论,只需要掌握一种严谨的推理习惯,剩下的题目都能用这套习惯慢慢推出来。