C语言入门这件事绕不开一本流传很广的教材。那本教材现在出到了第五版很多学校的C语言课都把它当配套读物。书里第八章讲的是“函数”对不少初学者来说这一章是第一个真正的分水岭。前面几章你还能靠背语法混过去到了函数这里光靠背已经不管用了你得理解“模块化”到底是怎么回事。所以每次有人来找第八章课后习题答案我都会多说几句代码抄下来不是目的搞懂每一步为什么这么写才算真会。这篇文章就把教材第八章里几类高频习题拿出来按类型拆开讲。我会给完整代码也会解释函数名为什么这么设计、参数为什么传指针、递归调用到底在内存里发生了什么。这些都是我在实际写代码和带新人时反复遇到的问题属于那种书上不会写太细、但考试和项目里一定会用上的东西。1. 第八章到底考什么先看懂命题思路1.1 函数部分的考点分布教材第八章的习题看着数量不少仔细归类后其实就四大类。第一类是“函数的定义和调用”比如写一个求几个数最大值的函数、写一个求乘积或阶乘的函数这类题考的是函数的基本语法返回类型、参数列表、return语句的位置。第二类是“参数传递机制”最常见的就是交换两个变量的值这类题表面考交换实际考的是值传递与地址传递的区别属于易错题中的常客。第三类是“递归”通常以阶乘、汉诺塔、整数转字符串等形式出现考察的是对递归出口和递归体的理解。第四类是“函数与数组、字符串组合”比如统计字符、排序数组这类题把前面的数组知识和函数知识串在一起题目难度会明显提升。如果把这四类题目的考察能力做成一张表会更直观一些题型分类典型表述核心考察点基础定义题编写一个函数求两个正整数的最大公约数函数定义、return、分支结构参数传递题用函数交换两个变量的值值传递与指针参数的差别递归题用递归方法求解汉诺塔递归出口、递归调用关系数组综合题编写函数对数组排序或统计数组名做实参的退化为指针特点复习的时候先按这张表把题目归类再一类一类突破比从头到尾一题一题顺序做效率高得多。因为同一类题的核心逻辑是相通的做会一道剩下的只是改改业务条件而已。1.2 做课后题的两种常见误区先说第一种误区对着教材答案抄代码抄完就觉得自己会了。这种状态在“函数”这一章尤其危险。前几章抄代码还能混过去因为语法结构比较直观函数这一章如果不亲手把调用关系梳理清楚下一章指针一出来马上就会露馅。我见过太多人考试时能把冒泡排序背出来但题目一改成“按姓氏笔画排序”就无从下手原因就是当初没理解排序函数的参数和循环边界只是被代码而已。第二种误区是只看代码不跑代码。函数涉及的变量作用域、递归栈、指针传递光靠眼睛看是看不出问题的。比如递归阶乘很多人觉得代码就那么几行看懂了但你自己在编译器上单步跟踪一遍会看到每一次递归调用都把自己的参数和返回地址压入栈这种感觉和读代码完全不同。我建议每一道函数题写完都至少做两件事第一在main函数里构造至少三组测试数据包含边界值第二用调试器或printf大法跟踪关键变量。这个习惯养成了后面写大项目会省很多事。2. 三个基础函数题手把手的完整答案2.1 求最大公约数与最小公倍数这道题几乎每个版本的教材都会有。题目要求自己写一个函数求两个正整数的最大公约数然后顺便求最小公倍数。最大公约数最常见也最稳定的算法是辗转相除法也叫欧几里得算法。核心公式只有一句话gcd(a, b) gcd(b, a % b)直到余数为0当时的除数就是最大公约数。为什么要用这个方法因为对于两个较大的整数枚举所有可能的因子既不现实又容易出错。#include stdio.h int gcd(int a, int b) { int t; while (b ! 0) { t a % b; a b; b t; } return a; } int lcm(int a, int b) { return a / gcd(a, b) * b; } int main() { int x, y; printf(请输入两个正整数); scanf(%d%d, x, y); if (x 0 || y 0) { printf(输入错误请输入正整数。\n); return 1; } printf(最大公约数%d\n, gcd(x, y)); printf(最小公倍数%d\n, lcm(x, y)); return 0; }注意我在lcm函数里写成a / gcd(a, b) * b而不是a * b / gcd(a, b)。因为先乘后除在a和b都很大的时候可能中间结果溢出先除后乘就能把数值控制在更安全的范围内。这是实际工程里常用的细节考试时虽然不容易体现但面试和项目里经常被问到。如果你是在做ACM或OJ平台上的题输入可能包含多组数据建议把主函数里改成while (scanf(%d%d, x, y) 2)的方式循环读入这样每次输入一组就输出一组结果。2.2 用函数交换两个变量的正确写法这道题的陷阱性非常强。教材里经常用“写一个函数交换两个整数的值”来引出参数传递机制。很多新手的第一版代码是这样写的void swap(int a, int b) { int tmp a; a b; b tmp; }代码逻辑没错但调用之后main里的变量毫无变化。原因在于C语言默认采用值传递调用swap的时候实参的值被复制一份给形参a和b函数内部交换的是形参那份副本而实参自己根本没被碰过。这就好比你把照片复印件上的人脸涂黑原照片不会跟着变化。想让函数真正修改外部的变量必须把变量的地址传进去函数内部通过指针间接访问原来的变量#include stdio.h void swap(int *pa, int *pb) { int tmp *pa; *pa *pb; *pb tmp; } int main() { int m 3, n 7; printf(交换前m%d, n%d\n, m, n); swap(m, n); printf(交换后m%d, n%d\n, m, n); return 0; }这道题给我的经验是写函数前先问自己一个问题“这个函数要不要改变实参的值”。如果只是用实参的值算结果那就直接传值如果必须修改实参本身那就传地址。后面学结构体、链表的时候这个判断会一遍一遍重复出现。现在花点时间把指针传递想明白后面会非常省事。交换的逻辑本身还有个小细节当两个变量是同一个内存地址时比如调用swap(m, m)函数里的tmp *pa之后立即执行*pa *pb其实没有影响因为两个指针指向同一个地方最终结果还是原来的值。但如果你用异或交换法这一步就会出现把变量清零的bug。所以稳妥起见我一直建议初学者用中间变量tmp来交换不要炫技。2.3 判断质数与完数的函数设计教材里有一类题是“编写函数判断一个数是否为质数”另一类是“找出1000以内的所有完数”。这两种题都能很好地锻炼函数的职责划分一个函数只做一件事返回值只表达一个结论。判断质数的函数要特别注意边界条件。小于2的整数都不是质数2是最小的质数。循环判断时不需要一直除到n-1只需要除到sqrt(n)就够了因为如果n有一个大于sqrt(n)的因子必然存在一个小于sqrt(n)的对应因子。#include stdio.h #include math.h int isPrime(int n) { int i; if (n 2) return 0; for (i 2; i sqrt(n); i) { if (n % i 0) return 0; } return 1; } int main() { int num; printf(请输入一个整数); scanf(%d, num); if (isPrime(num)) { printf(%d 是质数。\n, num); } else { printf(%d 不是质数。\n, num); } return 0; }注意在循环边界那里我写的是i sqrt(n)。如果写成i * i n可以避免反复计算sqrt但要注意i*i可能溢出的问题一般int范围内问题不大。很多教材标准答案是除以n / 2也就是只判断到n的一半那也可以只是效率略低。对于初学者我更推荐用i * i n逻辑简单且不容易出错。完数是指一个数恰好等于它所有真因子之和比如6 1 2 3。写函数时核心是累加因子但要注意1不能算进去自己对本身不能算。通常找完数的函数会配合输出可以直接在函数里把因子打印出来也可以让函数只返回判断结果在main里再打印因子。我更倾向后者函数逻辑更干净符合八章一直强调的模块化思想。3. 递归题型从原理到答案3.1 递归求阶乘的调用过程拆解递归是第八章里最让初学者头疼的内容。其实递归就是一个函数自己调用自己但调用时参数在逐渐变化最终到达一个不再递归的出口。阶乘是最经典的入门题n! n * (n-1)!且0! 1。#include stdio.h long fact(int n) { if (n 0) return -1; if (n 0 || n 1) return 1; return n * fact(n - 1); } int main() { int n; scanf(%d, n); long result fact(n); if (result 0) { printf(输入错误\n); } else { printf(%d! %ld\n, n, result); } return 0; }当n5时函数执行过程是fact(5)调用fact(4)fact(4)调用fact(3)一直调到fact(1)。在fact(1)返回值1之后才开始逐层回溯fact(2)返回2fact(3)返回6fact(4)返回24fact(5)返回120。这个过程很像你先往箱子里塞东西塞到最里面后再一件一件拿出来后进去的先出来。关于递归有两个重要提醒。第一递归必须有一个明确的出口否则会无限调用直到栈溢出。像上面代码里的n0 || n1就是出口。第二阶乘的增长非常快int装不了几个数所以建议返回类型用long。即便如此long也装不下20以上的阶乘到21!就会溢出。如果你在练习里已经算到很大的n要么换long long要么改用循环迭代。递归在8.2这类题里更多是练思维实际大量计算时不一定是最优方案。3.2 汉诺塔问题的核心代码汉诺塔是计算机专业学生躲不开的一道递归题。题目描述很简单有三根柱子A、B、CA上有n个大小不同的圆盘小的在上、大的在下要求全部移到C上每次只能移动一个且大的不能压在小盘上。如果用循环思维去硬解会非常痛苦因为移动路径本身是递归嵌套的。递归的切入点是把问题拆成三步第一步把上面n-1个盘从A通过C移到B第二步把最底下的第n个盘从A移到C第三步把B上的n-1个盘从A移到C。每一步里n-1个盘的问题又不断缩小直到只剩1个盘直接移动。#include stdio.h void hanoi(int n, char from, char tmp, char to) { if (n 1) { printf(Move disk 1 from %c to %c\n, from, to); return; } hanoi(n - 1, from, to, tmp); printf(Move disk %d from %c to %c\n, n, from, to); hanoi(n - 1, tmp, from, to); } int main() { int n; printf(请输入盘的数量); scanf(%d, n); hanoi(n, A, B, C); return 0; }这段代码如果在编译器上跑一次n3就会看到输出序列。值得注意的是函数里参数位置千万别搞错第一次递归调用是hanoi(n-1, from, to, tmp)把from当作起点to是暂存区tmp是目标第二次递归调用是hanoi(n-1, tmp, from, to)把原来的tmp作为起点。这两个调用对应了前面说的“先把上面n-1盘移开最后再把它们移回来”的对称结构。汉诺塔还有一个衍生考点移动总次数是2^n - 1。如果你在试卷填空里遇到“移动n个盘需要多少次”直接用这个公式。n3是7次n4是15次。这个公式也能帮你检验自己的递归函数有没有重复移动某个盘如果次数对不上多半是参数传递写错了。3.3 递归把整数转成字符串的陷阱处理有些版本教材会出“编写一个递归函数将整数转换为字符串并输出”的题。这个题用来考查两件事第一递归处理数字位的方式第二字符和整数的转换。比如整数1234你要得到字符串1234。一种容易卡壳的写法是不小心使用static局部变量记录数组下标。static变量在递归中确实能维持状态但第二次调用同一个函数时初始值不会清零容易造成结果拼接错误。更稳妥的做法是让递归函数返回当前处理到的位置或者用指针逐层推进。下面用指针方案实现#include stdio.h char* itoa_rec(int n, char* buf) { if (n 0) { *buf -; n -n; } if (n / 10) { buf itoa_rec(n / 10, buf); } *buf n % 10 0; *buf \0; return buf; } int main() { char str[32]; int num -1234; itoa_rec(num, str); printf(%s\n, str); return 0; }这里的核心逻辑是每次递归都先处理高位等高位处理完再把当前这一位的数字字符追加到字符串末尾。比如1234先进入递归处理123处理完返回后再把4放到后面。这样递归的深入和回溯正好对应了数字的高位到低位的顺序。这道题容易踩的坑主要有三个一是负数符号处理必须在递归之前把负号写进字符串二是数组越界如果buf容量不够字符串越长越容易越界所以main里的数组要预留足够空间三是递归返回值和尾部位置搞混代码里每次返回的是当前写入位置这样上层调用才知道后续字符该往哪里写。4. 数组和字符串函数题容易失分的综合应用4.1 数组元素逆置数组与函数结合的题核心要理解一个机制数组名作实参时函数里接收到的是数组首元素的地址。所以在函数内部修改数组元素主函数里的数组也会跟着变这和普通变量的值传递完全不一样。逆置数组就是一个典型例子。#include stdio.h void reverseArray(int arr[], int n) { int i; for (i 0; i n / 2; i) { int tmp arr[i]; arr[i] arr[n - 1 - i]; arr[n - 1 - i] tmp; } } int main() { int a[] {1, 2, 3, 4, 5, 6}; int n sizeof(a) / sizeof(a[0]); reverseArray(a, n); for (int i 0; i n; i) { printf(%d , a[i]); } printf(\n); return 0; }这个函数的循环次数是n/2因为一次交换处理两个元素。偶数长度正好交换n/2次奇数长度中间那个元素不动。这个边界条件需要特别注意写错了会导致前后位置错乱。另一个常见问题是主函数里用sizeof(a)计算数组长度这个做法在main里没问题因为a是真正的数组但在函数内部如果把函数参数写成int arr[]其实形参arr是一个指针sizeof(arr)只会得到864位系统上的指针大小而不是数组的字节数。这就是为什么函数设计时通常要把数组长度n一并传进去。这个知识点几乎每届学生都会有人中招而且考试特别喜欢在这里出选择题。4.2 统计字符串中的字母、数字、空格及其他字符统计字符类型的题目从字符串章节一直延续到函数章节。函数版的题目通常会让你把统计结果通过参数返回而不是在函数内部直接打印。这就引出一个很实用的模式用指针参数收集多个结果。#include stdio.h void countChars(const char str[], int *letters, int *digits, int *spaces, int *others) { int i 0; *letters *digits *spaces *others 0; while (str[i] ! \0) { if ((str[i] a str[i] z) || (str[i] A str[i] Z)) { (*letters); } else if (str[i] 0 str[i] 9) { (*digits); } else if (str[i] ) { (*spaces); } else { (*others); } i; } } int main() { char s[] Hello, C 2025!; int letter 0, digit 0, space 0, other 0; countChars(s, letter, digit, space, other); printf(字母:%d 数字:%d 空格:%d 其他:%d\n, letter, digit, space, other); return 0; }这里最容易出错的是一行(*letters)。很多人会写成*letters那就有大问题了。因为运算符优先级里自增运算符的优先级高于指针解引用所以*letters等价于*(letters)意思是“先让指针移动一位再取旧的指针所指位置的值”。这样不仅没累加目标变量还把指针给改了函数后续就乱了。这个错误很隐蔽但只要你用指针参数做统计就一定会碰到。建议记成口诀如果要对“指针指向的变量”做自增必须写(*指针)。4.3 带提前退出优化的冒泡排序排序函数几乎是教材课后习题的常青树。第八章的排序题通常会要求把排序过程封装成函数这就把循环、数组、函数三个知识一起考了。由于排序函数会修改数组内容所以和数组逆置一样函数里直接操作数组元素即可外面能感知到变化。#include stdio.h void bubbleSort(int arr[], int n) { int i, j; for (i 0; i n - 1; i) { int swapped 0; for (j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped 1; } } if (!swapped) { break; } } } int main() { int a[] {3, 1, 4, 1, 5, 9}; int n sizeof(a) / sizeof(a[0]); bubbleSort(a, n); for (int i 0; i n; i) { printf(%d , a[i]); } printf(\n); return 0; }与教材上的基础版不同我加了一个swapped标记。这个标记的作用是如果某一趟内层循环完全没有发生交换说明数组已经有序可以提前跳出外层循环。这个优化不会改变排序的正确性但在处理基本有序的大数组时效率提升非常明显。考试时写基础版不会被扣分但如果你在注释里说明这个优化思路反而能成为加分项。排序代码还有一个值得提到的点内层循环的边界是n - 1 - i。每完成一趟外层循环最大的元素就会被“冒泡”到数组的最后一位所以下一趟就不需要再比较它。很多初学者写排序代码越界就是在内层循环里写了j n - 1而没有减去i。虽然不越界的时候也能排对但多做了很多无意义比较。理解这个边界的来源比死记代码更重要。5. 课后练习里最常见的五个错误函数这一章的错误排错起来普遍有规律可循。我把常见现象和原因整理成一张表方便你对照自己的代码。错误现象可能原因修正思路函数内交换变量外面没变实参和形参是值传递改成传递变量的地址数组传入函数后长度对不上形参中的数组退化为指针同时传数组长度n递归运行很久甚至卡死缺少递归出口或参数不递进检查if边界条件是否成立编译报错找不到函数函数定义在main之后且没有原型声明在main前加函数原型返回局部变量地址结果错乱局部变量生命周期在函数结束后结束返回static变量或传入缓冲区第一个错误就是swap那道题的原理本质是没区分值传递和地址传递。第二个错误是数组形参的“退化”特性int arr[]本质就是int *arr。第四个错误在写多文件项目或者函数定义顺序不当时特别容易出现。第五个错误稍微进阶有些初学者写一个返回数组地址的函数结果函数结束后数组空间已被回收主函数拿到地址再访问就是未定义行为。排查这些问题的时候最有效的方法不是盯着代码看而是插入printf输出中间结果。比如遇到递归问题就在函数开头打印当前参数值和调用层级遇到指针问题就打印指针的地址和指向的值。我见过不少学生一开始不好意思用printf觉得太低端其实printf调试法是最高效的初步定位手段。等以后用上调试器断点、watch变量思路也是一样的先缩小问题范围再观察关键变量在哪里发生了变化。刚开始练习时我会建议你刻意把代码改成会出错的样子跑一跑。比如把swap写成值传递版本亲眼看一次“函数里交换成功但外面没变”把统计字符的代码写成*letters亲眼看一次内存错乱后的输出。踩过这几个坑比背十遍结论都管用。最后分享一个小习惯函数章节的习题写完先自己设计5组测试数据包括边界值、负数、0、超大数、重复数据。把测试结果和预期对比再去看参考答案。这样训练出来的代码能力才是真正能带到项目现场的能力。第八章是一个转折点把这一章的习题真正吃透后面指针和链表的内容你就会发现原来很多套路都是相通的。