递归是什么 (两个要素、信任递归)
递归执行过程 (层层递推与回归)
经典案例: Fibonacci (弹性)、汉诺塔
常见错误与课堂编码 (基础 3 题 + 案例 5 题)
分治与快速排序 (弹性)
课堂小测
递归: 函数直接或间接地自己调用自己——有些问题的定义本身就是递归的: 阶乘、汉诺塔、目录遍历……
/* 阶乘: n! = n × (n-1)! */
long long f(long long n)
{
if (n == 0) { /* 基准: 0! = 1, 唯一的奠基 */
return 1;
}
return n * f(n - 1); /* 递归: 1! 由 0! 归纳出来 */
}
/* 求幂: x^n = x × x^(n-1), 可以精简成一行 */
int power(int x, int n) {
return n == 0 ? 1 : x * power(x, n - 1);
}
两个要素: 基准情形 (最简单, 直接给答案) + 递归情形 (缩小后再调用自己)
信任递归: 假设 n-1 层的答案是对的, 用它构造 n 的答案——不要在脑中展开所有层
递归三问: 最小问题? 怎么缩小? 怎么构造当前答案?
为什么能自己调用自己? 函数名只是代码的入口——每次调用都开一张新便签、配一套新参数, 互不干扰
递归 = 归纳法的计算版本: 基准 = 奠基, 递归 = 归纳, 信任 = 归纳假设
fact(3) 的跟踪——递推下去, 再回归上来:
每一层调用压一张便签 (昨天的栈), 回归时逐层弹栈结算——递归就是便签一层层叠高、再一层层揭掉
每层调用有自己的一套局部变量和参数, 各层互不干扰 (作用域与生命期见上一讲)
递归深度 = 压栈层数; 递归过深 → 栈溢出
🎬 动画: 打开调用栈动画, 看阶乘递归的层层压栈与返回
🌐 Python Tutor: 把课程中的阶乘代码粘进去, 逐行看栈帧——每调用一次 fact, 栈上多一张便签; 返回时逐层揭掉
#include <stdio.h>
long long fib(long long n)
{
if (n == 0) { /* 奠基之一: F(0) = 0 */
return 0;
}
if (n == 1) { /* 奠基之二: F(1) = 1 */
return 1;
}
return fib(n - 1) + fib(n - 2);
}
int main(void)
{
printf("fib(10) = %lld\n", fib(10));
return 0;
}
样例输出: fib(10) = 55
两个奠基: 递推式需要前两项, 所以基准情形有两个、返回不同值 (对比阶乘: 只靠 0! 一个奠基就够)
F(n) = F(n-1) + F(n-2): 一次调用分成两个分支——递归展开成一棵二叉树, 深度 n、最多 2ⁿ 个结点
重复计算: fib(5) 会把 fib(3) 算 2 遍、fib(2) 算 3 遍——朴素递归的代价; 优化办法 (记忆化/动态规划) 课下可以了解
传说: 印度教寺庙里有 3 根柱子、64 个金盘, 僧侣昼夜不停地移动; 传说 64 个盘移完之日就是世界末日——放心, 一共要 2⁶⁴ − 1 步, 约 5849 亿年
盘子编号: 最小的叫 盘 1 (叠在最上面), 往下依次是 盘 2、盘 3……, 最大的叫 盘 n (最下面; 后面代码输出的就是这些编号)
目标: 把 n 个盘子从 A 柱全部移到 C 柱, 结束时仍然上小下大 (和初始状态一样)
规则:
每次只能移动一个盘子 (某根柱最上面的)
大盘不能压在小盘上
可以借 B 柱暂存
任务: 给定任意 n, 设计算法输出全部移动步骤 (每一步: 哪个盘、从哪柱到哪柱)
→ 下一页: 先看 3 个盘实际怎么挪
按 "下一步" 逐盘观察: 大盘永远不压小盘 (规则见上一页), 3 个盘恰好要 7 步
第 4 步恰好是最大盘就位——后面讲递归分解时对照着看
/* hanoi.c: A = 起始柱, B = 辅助柱, C = 目标柱;
n = 本次要移的盘数 (最大盘编号) */
#include <stdio.h>
void Hanoi(int n, char A, char B, char C)
{
if (n == 1) {
/* 基准: 只剩一个盘 */
printf("盘 %d: %c -> %c\n", n, A, C);
} else {
/* 上面 n-1 个: A -> B (借 C) */
Hanoi(n - 1, A, C, B);
/* 最大盘: A -> C */
printf("盘 %d: %c -> %c\n", n, A, C);
/* 上面 n-1 个: B -> C (借 A) */
Hanoi(n - 1, B, A, C);
}
}
int main(void)
{
/* 3 个盘的完整移法 */
Hanoi(3, 'A', 'B', 'C');
return 0;
}
刚才的动画 (3 个盘、7 步) 正是来自下面的分解 (A 起始、B 辅助、C 目标):
① 先把上面 n-1 个 A → B (借 C)——同类问题小 1
② 把最大盘 n 直接 A → C——一步完成
③ 再把那 n-1 个 B → C (借 A)
①③ 交给递归; n = 1 直接 A → C (基准情形)
⚡ 打开 hanoi.c动画里的 7 步, 正是下面这棵调用树逐层产生的:
Hanoi(3, A, B, C)
├─ Hanoi(2, A, C, B) ← 先把上面 2 个 A→B
│ ├─ Hanoi(1, A, B, C) → 盘1: A→C
│ ├─ 盘2: A→B
│ └─ Hanoi(1, C, A, B) → 盘1: C→B
├─ 盘3: A→C ← 最大盘就位
└─ Hanoi(2, B, A, C) ← 再把上面 2 个 B→C
├─ Hanoi(1, B, C, A) → 盘1: B→A
├─ 盘2: B→C
└─ Hanoi(1, A, B, C) → 盘1: A→C
运行输出 (共 7 步): 盘1: A→C 盘2: A→B 盘1: C→B 盘3: A→C 盘1: B→A 盘2: B→C 盘1: A→C
🌐 在 Python Tutor 中演示 (粘代码, 逐层看调用栈)缺少基准情形: 递归永不终止 (如忘了 n==0 的判断)
问题没有缩小: 每次调用与上次规模相同 (如 f(n) 里调用 f(n))
写递归的两步检查: ① 基准情形对吗? ② 每次调用规模在变小吗?
→ 下一页: 现场演示"没有基准情形"的下场——调用栈爆掉
/* 不规范: 少了基准情形 */
long long fact(int n)
{
return n * fact(n - 1);
}
int main(void)
{
printf("%lld\n", fact(5));
return 0;
}
运行结果: 一阵呼啸之后——
Segmentation fault (core dumped)
每一层调用压一张便签; 没有基准情形, 便签就无限叠高——谁也不返回, 谁也腾不出空间
栈的空间是有限的 (默认几 MB): 用尽即 栈溢出 (stack overflow), 程序直接崩溃
即使有基准情形, 极大的递归也会爆: 递归深度以"层"计, 大输入要留意 (如对 10 万个元素的数组递归求最大值)
应对: 递归三问缺一不可; 有些递归能改写成循环——循环不占栈, 深了也不怕 (这是"尾递归优化"的朴素版理解)
任务: 实现 int power(int x, int n)——计算 x 的 n 次方 (开篇"递归是什么"一页提过的例子, 这里把它写完整)
样例: power(5, 3) → 125; power(2, 10) → 1024
先想参数: 哪些量随递归变化、哪些全程不变?
⚡ 在 VS Code 中打开 power-rec.c (只写函数体)任务: 实现 int sum(int n)——计算 1 + 2 + … + n
样例: sum(10) → 55; sum(100) → 5050
提示: 先想清楚递推关系和递归出口, 再动手写
⚡ 在 VS Code 中打开 sum-rec.c (只写函数体)任务: 实现 void print(int n)——输入 1234, 按位逐个打印
样例: 1234 → 1 2 3 4
提示: 先递归打印前面几位, 回来再打印末位——顺序是本题关键
⚡ 在 VS Code 中打开 print-rec.c (只写函数体)int power(int x, int n)
{
/* 基准 x^0=1; 递归 x × x^(n-1) */
return n == 0 ? 1 : x * power(x, n - 1);
}
参数即状态: 底数 x 全程不变, 指数 n 逐层减 1——两个量都要进参数, 缺一不可
基准情形: n == 0 → 1; 递归情形把问题缩小成 x^(n-1)
int sum(int n)
{
/* 基准 0; 递归 n + sum(n-1) */
return n == 0 ? 0 : n + sum(n - 1);
}
参数即状态: 只带 n——"加到几为止"就是子问题的全部状态
基准情形: n == 0 → 0; 递归情形: sum(n) = n + sum(n-1)
void print(int n)
{
if (n / 10 == 0) {
/* 基准: 一位数直接打印 */
printf("%d", n);
} else {
/* 先递归打印前面几位 */
print(n / 10);
/* 回来再打印末位 —— 顺序是本题关键 */
printf(" %d", n % 10);
}
}
参数即状态: 只带 n; 顺序是本题关键——先递归打印 n/10, 回来再打印末位 n%10, 得到正序
基准情形: 一位数 (n / 10 == 0) 直接打印
任务: 实现 int max_rec(int a[], int n)——最大值 = a[n-1] 与 "前 n-1 个的最大值" 里较大的那个 (与之前"先假设再修正"同源)
样例: a = {3, 9, 1, 7, 5} → 输出 9
任务: 判断字符串是否回文——两端字符相等, 且中间那一段也是回文 (回文用循环双指针更自然; 这题的价值是练习"把递归定义直接翻译成代码")
先想参数: int is_pal( ??? )——子问题是"首尾去掉后剩下的中间一段", 要几个参数才能刻画它? 需要复制一个新字符串吗? 想清楚再打开 VS Code 对照
样例: "level" → 1, "hello" → 0
int max_rec(int a[], int n)
{
int rest;
if (n == 1) { /* 基准情形 */
return a[0];
}
rest = max_rec(a, n - 1); /* 递归: 前 n-1 个的最大值 */
return a[n - 1] > rest ? a[n - 1] : rest;
}
递归情形里先信任 max_rec(a, n-1) 是对的, 再拿 a[n-1] 和它比——这就是"信任递归"
int is_pal(char s[], int l, int r)
{
if (l >= r) { /* 基准: 0 或 1 个字符 */
return 1;
}
return s[l] == s[r] && is_pal(s, l + 1, r - 1);
}
参数即状态: l、r 刻画当前子串——不复制字符串, 只移动两个下标
定义即代码: 把"首尾相等且中间是回文"逐字翻译出来, 就是函数体
任务: 在升序数组 a 的某个区间里查找 key——每次砍一半, 与分治同构; 找到返回下标, 找不到返回 -1
先想参数: int bin_search( ??? )——子问题是什么? 哪几个量随递归变化、哪几个全程不变? 想清楚再打开 VS Code 对照骨架里的签名
样例: 在 {2, 5, 8, 12, 19} 中找 8 → 下标 2; 找 7 → -1
int bin_search(int a[], int low, int high, int key)
{
int mid;
if (low > high) { /* 基准: 没找到 */
return -1;
}
mid = (low + high) / 2;
if (a[mid] == key) {
return mid;
}
if (a[mid] > key) {
return bin_search(a, low, mid - 1, key);
}
return bin_search(a, mid + 1, high, key);
}
参数即状态: 递归函数的参数要能完整描述"当前子问题"——low、high 刻画查找区间, key 与 a 全程不变
每次调用区间必然缩小——这保证递归一定终止; 下一页分治法正是这个思想的放大版
任务: C(m,n) = 从 m 个元素中选出 n 个的方案数。实现 long comb(int m, int n)——按"选/不选第 m 个元素"分成两类: C(m,n) = C(m-1,n-1) + C(m-1,n); 对比 8-2 用 factorial 的公式算法: 递归定义有时比公式更直接
样例: comb(5, 2) → 10
任务: 有 n 级台阶, 每次可以走 1 或 2 级——从第 0 级走到第 n 级共有多少种走法? 实现 int climb(int n)
样例: climb(4) → 5 (走法: 1111、112、121、211、22)
long comb(int m, int n)
{
if (n == 0 || n == m) { /* 基准情形 */
return 1;
}
return comb(m - 1, n - 1) + comb(m - 1, n);
}
同一行里两个递归调用——先展开一层看: "选中第 m 个"与"不选第 m 个"两类, 加起来
int climb(int n)
{
if (n <= 1) { /* 基准情形 */
return 1;
}
return climb(n - 1) + climb(n - 2);
}
生活问题→递归定义: 走到第 n 级的方法数 = 走到 n-1 的方法数 + 走到 n-2 的方法数
分治法 (divide and conquer): 把大问题拆成几个同类的小问题, 分别解决, 再合并结果
三步: 分 (拆) → 治 (解决小问题) → 合 (拼起来)
生活例子: 整理一摞试卷——先按分数段分堆, 每堆再各自整理
排序中的分治 (快速排序): 选一个基准, 把数组划分成"小的在左、大的在右"两段, 再对两段各自递归——不需要合并 (划分完位置就对了); 过程见下一页
("分成两半各自排好再合并"是另一种排序——归并排序, 课下可以了解)
递归常常是分治法的自然结果——快速排序正是如此
① 划分: 选基准 5, 小的放左、大的放右——5 的位置就此一次到位
② 治: 左段 [2, 1, 3] 和右段 [9, 8] 各自递归——同样先划分、再递归
③ 合: 什么都不用做——每次划分都把基准放到最终位置, 递归结束时整个数组就有序了
用递归做排序: 选一个基准, 小的放左、大的放右, 再对左右递归
void quicksort(int a[], int low, int high)
{
int middle;
if (low >= high) { /* 基准情形: 只剩 0 或 1 个元素 */
return;
}
middle = split(a, low, high); /* 划分: 小的在左, 大的在右 */
quicksort(a, low, middle - 1); /* 递归排左半 */
quicksort(a, middle + 1, high); /* 递归排右半 */
}
平均 O(n log n), 比之前三种排序的 O(n^2) 快得多
split 是划分函数, 下一页展开
int split(int a[], int low, int high)
{
int part_element = a[low];
for (;;) {
while (low < high && part_element <= a[high]) {
high--;
}
if (low >= high) {
break;
}
a[low++] = a[high];
while (low < high && a[low] <= part_element) {
low++;
}
if (low >= high) {
break;
}
a[high--] = a[low];
}
a[high] = part_element;
return high;
}
split: 以 a[low] 为基准, 把小元素换到左边、大的换到右边, 返回基准的最终下标
完整可运行程序: code/qsort-split.c (无指针版, 全部用数组下标)