简介:这份PPT课件聚焦C语言中函数的递归调用,属于面向高校计算机专业学生、C语言初学者及程序设计课程教师的专业课件,适合在讲授函数章节或复习递归算法时作为课堂演示与自学参考。课件围绕递归的定义、调用链、参数与返回值传递等核心概念展开,并配备多个循序渐进的教学实例:从打印调用层级的recur函数,到阶乘求解、斐波那契数列、年龄推算等经典问题,还安排了课堂习题供读者检验理解,能够帮助读者弄清递归调用前后语句的执行顺序、每级函数拥有独立变量的机制,以及递归与循环之间的转换关系。资源包内共1个文件,为pptx演示文稿,整体约125KB,章节与例题组织紧凑,可直接投影讲解或按页拆解练习。目前已有1245人学习下载,适合希望系统掌握递归思想、并为后续数据结构与算法学习打基础的读者使用。
1. 递归调用不是循环的替代品:先看栈帧怎么长出来
很多人第一次写 C 语言递归,是在教材里算阶乘。factorial(5)能跑,factorial(100000)直接 Segmentation fault。这个反差不是巧合:递归调用每深入一层,都要在调用栈上压入一个新的栈帧,保存参数、返回地址和局部变量。函数在栈上"叠罗汉",叠到超过操作系统给的栈上限就崩。标题里的"函数递归调用",讲的正是这套压在栈上的执行机制,而不只是"自己调自己"这句口头定义。
会对着 PPT 讲这节课的人、准备计算机二级 C 语言或者期末考的人、以及要在解析嵌套结构、遍历目录树、处理表达式求值这类工程场景里真正用递归的人,都值得把这套机制从栈帧层面过一遍。下面从执行模型开始,把递归调用的内存布局、终止条件、典型题型、栈溢出排查和尾递归优化边界依次落地,代码都能直接编译跑起来。
2. 函数递归调用的执行模型:栈帧、返回地址与递归深度
理解递归的第一步,是别把它想成"循环的另一种写法"。循环在同一个栈帧里来回跳,递归是在栈上不断叠加新的栈帧,每一层都有自己的参数副本和局部变量。这个差别决定了递归天然受栈空间约束,也决定了返回值必须一层层往回传。先看一个最小的可运行例子,把调用链和内存布局对上。
2.1 一次 factorial(4) 到底压了几层栈帧
#include <stdio.h> long factorial(int n) { if (n <= 1) return 1; /* 终止条件:n 降到 1 时不再递归 */ return n * factorial(n - 1); /* 递推:当前 n 乘上 n-1 层的结果 */ } int main(void) { printf("%ld\n", factorial(4)); return 0; }执行顺序是factorial(4) → factorial(3) → factorial(2) → factorial(1),四层栈帧依次压入。factorial(1)命中终止条件返回 1,然后factorial(2)拿到 1 算出 2,factorial(3)拿到 2 算出 6,factorial(4)拿到 6 算出 24。所以递归有两个阶段:递推阶段一路压栈往下走,回归阶段一路弹栈往上算。每个栈帧里至少放着返回地址(上一层执行到哪了)、参数n的副本、以及保存的寄存器现场。x86-64 下-O0编译时,一个这样的函数栈帧通常在几十字节量级,加上对齐开销,实际占用会比肉眼估算的多。
提示:把
printf("%d\n", n);放在递归调用之前和之后各打一次,能直观看到"下去"和"上来"两个阶段的顺序差异,这比看 PPT 上的箭头图有效得多。
2.2 递归深度和栈上限的关系
递归能走多深,不取决于 C 语言本身,取决于进程能用的栈空间上限和单层栈帧的大小。两者相除,就是大致的最大递归深度。
| 平台 | 默认栈上限(常见值) | 查看方式 |
|---|---|---|
| Linux / macOS | 8 MB | ulimit -s |
| Windows(MSVC 默认) | 1 MB | 链接器/STACK选项 |
| 嵌入式 RTOS 任务栈 | 几百字节到几 KB | 由创建任务时指定 |
Linux 下可以直接查看和临时调整当前 shell 会话的栈上限:
ulimit -s # 查看当前栈上限,单位 KB,常见输出 8192 ulimit -s 65536 # 临时放宽到 64 MB,仅对当前 shell 会话生效第一行确认当前值,第二行把软限制抬到 64 MB,退出这个 shell 就恢复。ulimit改的是软限制,硬限制不允许超过时要用管理员权限调整。注意,把栈调大只是让你晚一点撞墙,不是解法。一个每层栈帧上百字节、递归深度几十万的算法,真正该做的是转成迭代或者改算法,而不是靠ulimit续命。
2.3 用 gcc -S 看递归调用到底长什么样
把源码编成汇编,递归的骨架就露出来了:
gcc -S -O0 factorial.c -o factorial.s # -O0 关闭优化,保留最原始的调用结构 grep -n "call.*factorial" factorial.s # 看函数内部对自身的 call 指令 objdump -d factorial | less # 已编译文件也能反汇编对照第一条命令生成 AT&T 语法的汇编文件,-O0是为了不被优化干扰。第二条命令定位函数体内部对factorial自身的call,那一条指令就是递归在机器层面的全部含义——没有魔法,就是普通的函数调用,只不过被调用的函数名和当前函数一样。第三条命令用来在只有目标文件时反汇编查看。理解到这一层,后面所有递归题型的调试都能用同一套思路去分析。
3. 写对递归的三要素与必调参数
递归写错,绝大多数不是思路问题,是三个点没对齐:终止条件、递推关系、返回值传递。这三个点任何一个出问题,表现要么是死循环式递归直到栈溢出,要么是结果算错但程序不报错。这一章把三要素拆开讲,并给出参数表设计的取舍。
3.1 终止条件写错的两种典型形态
第一种是条件永远碰不到。比如把if (n <= 1)写成if (n == 1),调用方传进来 0 或者负数,递归就往负无穷方向一路走下去。这类错误在factorial(-1)这种边界输入下立刻暴露,但平时用 5、6 测试根本看不出来。
第二种是条件写得对,但递推的步长跨过了终止点。比如递推用n - 2,终止条件盯着n == 1,n为偶数时永远命中不了,只能一路减到负数。写递归时先把"合法输入的取值范围"写清楚,再让终止条件和步长互相配合:递推用n - 1就配n <= 1,递推用n / 2就配n <= 0,保证有限步内一定收敛到终止点。
3.2 规模收敛方向的选择
递归成立的前提是每次调用都在解决一个"更小规模"的同类问题。规模递减、规模减半、规模按分割点缩小,都是常见方向,但选哪个直接影响深度。
| 递推方向 | 典型场景 | 递归深度量级 |
|---|---|---|
n → n - 1 | 阶乘、单链表反转 | O(n) |
n → n / 2 | 二分查找、快速幂 | O(log n) |
| 分割成两半 | 归并排序、快排 | O(log n) |
| 树形分支 | 二叉树遍历、汉诺塔 | O(n) 或 O(log n) |
n → n - 1这一类,深度跟着输入线性涨,输入 10 万就可能触发栈溢出;n → n / 2深度只有约 17 层,几乎不用担心。所以面对线性收敛的递归,要提前估一下最大输入规模。
3.3 返回值如何穿过每一层栈帧回传
递归的返回值不是一步到位的,它经历"回归阶段"逐层向上传递。factorial(4)里,factorial(1)返回的 1 交给factorial(2),乘 2 得到 2 再交给factorial(3),依此类推。每一层只关心"我这一层要拿子问题的结果做什么运算",不需要关心更下层发生了什么。写代码时把这个局部视角守住,递归就不会乱。
如果想在递归过程中顺带收集结果,有两种做法:一是让函数返回一个值,上层负责拼接或累加;二是传入一个指针或者结构体,把结果写进调用方提供的内存。第二种在字符串逆序、路径收集这类场景里更自然,因为它避免了每一层都返回新的临时对象。
3.4 参数表设计:哪些该传、哪些放进累加器
参数表设计的一个实用技巧,是把"随递归变化的量"和"全程不变的量"分开。以字符串逆序为例,需要进出的只是左右下标,字符串首地址在整个递归过程中都不变,它作为参数传进去只是提供访问入口。
#include <stdio.h> #include <string.h> /* s 不变;left、right 是随递归收缩的一对下标 */ void reverse(char *s, int left, int right) { if (left >= right) return; /* 终止条件:两指针相遇或交叉 */ char tmp = s[left]; s[left] = s[right]; s[right] = tmp; reverse(s, left + 1, right - 1); /* 规模收敛:向中间各收一格 */ } int main(void) { char buf[] = "hello"; reverse(buf, 0, (int)strlen(buf) - 1); printf("%s\n", buf); /* 输出 olleh */ return 0; }s是"不变的上下文",left和right是"变化的规模描述"。有些实现会把left隐去,改用s + left作为指针传入,两种写法等价,但显式传下标更利于调试时打印。当你发现某个参数在递归里从头到尾没变过,就可以考虑把它移到一个外层包装函数里,让递归函数本身更干净。
注意:递归函数不要用全局变量做"隐式累加器"来绕开参数传递,这会让函数不可重入,同一进程里多个调用互相污染,在多线程场景下直接出问题。
4. 高频递归题型落地:从字符串逆序到汉诺塔
把模型和三要素立住之后,剩下的就是具体题型。这一章挑四类在 PPT 和习题里出现频率最高的递归题,给出可直接编译的完整代码和对应的复杂度分析,重点说明每道题的"递推视角"是怎么找出来的。
4.1 字符串逆序递归的另一种写法
上一节的reverse用左右下标对撞,也可以用"先递归后半段再处理当前字符"的思路:
#include <stdio.h> #include <string.h> /* 把 s 指向的字符串原地逆序 */ void reverse2(char *s, int len) { if (len <= 1) return; /* 长度 0 或 1 不用动 */ char tmp = s[0]; /* 拿走首字符 */ s[0] = s[len - 1]; /* 末字符挪到最前 */ s[len - 1] = tmp; /* 首字符挪到最后 */ reverse2(s + 1, len - 2); /* 中间那段继续逆序 */ } int main(void) { char buf[] = "abcdef"; reverse2(buf, (int)strlen(buf)); printf("%s\n", buf); /* 输出 fedcba */ return 0; }递归参数从(s, left, right)换成了(s, len),指针s + 1向内收缩的同时长度减 2,收敛方向更直观。这种写法在习题里很常见,注意每次递推要同时更新指针和长度,只改其中一个就会越界。时间复杂度 O(n),递归深度约 n/2,输入特别长的字符串时同样要考虑栈上限。
4.2 汉诺塔:双分支递归的样板
汉诺塔是递归里最典型的"树形"结构,每一层都展开成两个子调用:
#include <stdio.h> /* 把 n 个盘子从 a 借助 b 移到 c */ 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); /* 先把上面 n-1 个移到中转柱 */ printf("%c -> %c\n", a, c); /* 最大那个盘子搬到目标柱 */ hanoi(n - 1, b, a, c); /* 再把 n-1 个从中转柱搬到目标柱 */ } int main(void) { hanoi(3, 'A', 'B', 'C'); return 0; }关键在于参数的"角色交换":第一次递归调用里,原来的中转柱b变成了目标柱,目标柱c变成了中转柱。这个交换不是随便写的,它对应真实搬盘子的顺序。汉诺塔的移动次数是 2^n - 1,n 取 32 时计数就到四十多亿,递归调用次数更是无法承受,所以汉诺塔只能用来理解递归结构,不适合当性能测试。
4.3 二叉树遍历与递归的天然契合
树结构本身就是递归定义的,用递归遍历最自然:
#include <stdio.h> #include <stdlib.h> typedef struct Node { int val; struct Node *left, *right; } Node; Node *new_node(int v) { Node *p = (Node *)malloc(sizeof(Node)); p->val = v; p->left = p->right = NULL; return p; } /* 中序遍历:左 - 根 - 右 */ void inorder(Node *root) { if (root == NULL) return; /* 空树是递归的终止条件 */ inorder(root->left); printf("%d ", root->val); inorder(root->right); }空指针就是终止条件,左右子树就是规模收缩的方向。前序、中序、后序的区别只在于printf的位置,递归骨架完全一致。这里也顺带暴露了递归的内存代价:链式存储的二叉树如果退化成链表,递归深度等于节点数,几万个节点就足以把默认 8 MB 的栈吃穿。工程里遍历深树时,迭代配显式栈或 Morris 遍历更稳妥。
4.4 记忆化递归:斐波那契的两种写法对比
朴素递归版斐波那契是复杂度反面教材:
#include <stdio.h> long long memo[100] = {0}; /* 0 表示未计算,下标上限按实际需求调整 */ long long fib(int n) { if (n <= 1) return n; if (memo[n] != 0) return memo[n]; /* 命中缓存,避免重复展开 */ memo[n] = fib(n - 1) + fib(n - 2); /* 算完写回缓存 */ return memo[n]; } int main(void) { printf("%lld\n", fib(50)); return 0; }不加memo的版本每次调用都分裂成两个子调用,时间复杂度 O(2^n),fib(50)基本跑不完;加上缓存后每个n只算一次,复杂度降到 O(n)。memo用long long是因为fib(50)已经超出int范围;数组下标上限要和实际调用范围对齐,越界一样会栈溢出或者写坏内存。
| 写法 | 时间复杂度 | 递归深度 | 适用 n 范围 |
|---|---|---|---|
| 朴素递归 | O(2^n) | n | 约 30 以内 |
| 记忆化递归 | O(n) | n | 受 memo 数组和栈上限约束 |
| 迭代递推 | O(n) | O(1) | 几乎不受限 |
提示:记忆化递归深度仍然是 n,输入到几万时依然可能栈溢出。追求稳妥就直接写迭代版,递归版只用来表达思路。
5. 栈溢出排查、递归转迭代与尾递归优化边界
递归代码跑挂时,最常见的就是 Segmentation fault,而且堆栈信息里看不出具体是哪里出的错。这时候需要的不是盯着代码发呆,而是用工具确认调用深度和栈帧分布。最后一章给三个可直接上手的技巧:GDB 看递归栈、把递归手工改成迭代、以及验证尾递归在-O2下到底有没有被优化。
5.1 用 GDB 定位递归深度和溢出的那一层
编译时带上调试信息,再开 GDB:
gcc -g -O0 factorial.c -o factorial # -g 生成调试符号,-O0 保留完整栈帧 gdb ./factorial # (gdb) break factorial # 在递归函数入口下断点 # (gdb) run # (gdb) bt # 打印调用栈,能看到 factorial 重复的帧 # (gdb) info frame # 查看当前帧的返回地址和寄存器 # (gdb) print n # 打印当前层的参数值bt的输出里,如果同一个函数名连续出现几十上百次,说明递归深度就是这么堆上去的。print n可以确认当前层参数走到了哪个值,从而判断是终止条件没命中还是输入规模本身就超了。栈溢出时bt有时会显示???或者截断,这时候可以先用ulimit -s把栈临时调大,等 GDB 能打出完整栈之后再定位问题。
5.2 把递归改成迭代的通用套路
绝大多数递归都能改写成"自己维护一个栈",把系统调用栈改成一个显式的数组或者malloc出来的缓冲区:
#include <stdio.h> #include <stdlib.h> typedef struct Node { int val; struct Node *left, *right; } Node; /* 前序遍历的迭代版:显式栈代替递归调用栈 */ void preorder_iter(Node *root) { if (root == NULL) return; Node *stack[1024]; /* 显式栈,容量按树高上限设定 */ int top = 0; stack[top++] = root; while (top > 0) { Node *cur = stack[--top]; printf("%d ", cur->val); if (cur->right) stack[top++] = cur->right; /* 先压右,后弹左 */ if (cur->left) stack[top++] = cur->left; } }显式栈的容量由你自己控制,可以放在堆上按需扩容,不再受进程栈上限约束。压栈顺序要反过来:想让左子树先出栈,就得先压右子树。这套改写对前序、中序、后序都适用,只是中序和后序的压栈逻辑更绕,通常需要额外的状态标记。
5.3 尾递归在 -O2 下的真实表现
尾递归指的是递归调用是整个函数的最后一个动作,返回值直接来自递归调用,不再参与本层的运算:
/* 非尾递归:本层还要拿返回值做一次乘法 */ long factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); } /* 尾递归:结果通过累加器参数向下传递,本层不再做后续运算 */ long factorial_tail(int n, long acc) { if (n <= 1) return acc; return factorial_tail(n - 1, acc * n); }GCC 的-O2默认开启-foptimize-sibling-calls,能把满足条件的尾递归改写成一个向后跳转,不再新增栈帧:
gcc -O2 -S factorial.c -o fast.s # 优化版汇编 gcc -O0 -S factorial.c -o slow.s # 未优化版汇编 grep -c "call.*factorial_tail" fast.s # 优化后这个计数通常为 0 grep -c "call.*factorial_tail" slow.s # 未优化时能看到自调用指令比对两个汇编文件里的自调用指令数量,就能确认优化是否真的生效。要注意的是,C 标准并不要求编译器必须做尾调用优化,换一个编译器或者换一组优化选项,结果可能就变了;依赖它来规避栈溢出,等于把程序正确性寄托在编译器的实现细节上。真正要处理超深递归,还是显式迭代更可靠。
注意:递归和函数指针、回调函数经常一起出现在工程代码里,比如表达式求值器把节点处理函数存成函数指针数组再递归下降。这种结构下更要控制递归深度,因为回调本身也会占用栈空间,单层栈帧比纯递归函数更厚,实际能承受的深度会更浅。
本文还有配套的精品资源,点击获取