1 引言
考虑计算阶乘的问题:n! = n × (n-1) × ... × 2 × 1。我们可以用循环实现:
c
int factorial(int n) {
int result = 1;
for (int i = 1; i <= n; i++) {
result *= i;
}
return result;
}
但也可以换一种思路:n! = n × (n-1)!,即阶乘可以用自身来定义。这种"自己定义自己"的方式就是递归。
c
int factorial(int n) {
if (n <= 1) return 1; /* 基本情况 */
return n * factorial(n - 1); /* 递归调用 */
}
这段代码简洁优雅,但它背后发生了什么?为什么函数可以调用自己?这就是本章要探讨的问题。
2 递归的数学基础:数学归纳法
2.1 数学归纳法简介
数学归纳法(Mathematical Induction)是证明与自然数有关的命题的一种方法,包含两个步骤:
-
基础步骤:证明命题对最小的自然数(通常是1或0)成立
-
归纳步骤:假设命题对某个自然数k成立,证明它对k+1也成立
2.2 递归与归纳法的对应关系
递归函数的设计思想与数学归纳法惊人地一致:
| 数学归纳法 | 递归函数 |
|---|---|
| 证明基础情况成立 | 定义基本情况(递归终止条件) |
| 假设命题对k成立 | 假设递归调用能解决规模更小的子问题 |
| 证明命题对k+1成立 | 利用子问题的解构造原问题的解 |
c
/* 阶乘的递归实现与数学归纳法的对应 */
int factorial(int n) {
/* 基础步骤:证明 n=1 时成立 */
if (n <= 1) return 1;
/* 归纳步骤:假设 factorial(n-1) 正确,
那么 factorial(n) = n * factorial(n-1) 也正确 */
return n * factorial(n - 1);
}
这种对应关系不是巧合------递归的本质就是用数学归纳法的思想来设计算法。当我们写递归函数时,实际上是在做两件事:
-
写出临界条件(基础情况)
-
找这一次和上一次的关系(归纳步骤)
2.3 用数学归纳法理解递归的正确性
以阶乘为例,我们可以用归纳法证明递归函数正确:
-
n=1时:函数返回1,正确
-
假设函数对n-1正确 :即
factorial(n-1)返回(n-1)! -
那么对n :函数返回
n * factorial(n-1) = n × (n-1)! = n!,正确
重要 :写递归函数时,不要试图跟踪每一层调用的细节,而应该相信递归调用能正确解决规模更小的子问题。这正是数学归纳法的核心思想。
3 递归的栈实现
3.1 函数调用的底层机制
在计算机底层,函数调用是通过栈来实现的。当一个函数调用另一个函数时:
-
将实参、返回地址等信息压栈
-
为被调函数的局部变量分配空间
-
将控制转移到被调函数
当被调函数返回时:
-
保存返回值
-
释放局部变量空间
-
根据返回地址跳回调用函数
3.2 递归调用时的栈帧变化
递归调用本质上就是同一个函数的多次嵌套调用 ,每次调用都会在栈上创建一个新的栈帧(Stack Frame)。
以计算 factorial(3) 为例,栈的变化过程:
text
/* 调用序列 */
factorial(3)
→ factorial(2)
→ factorial(1)
→ 返回1
← 返回 2*1 = 2
← 返回 3*2 = 6
栈帧变化示意图:
text
调用 factorial(3) 时:
栈顶 → [factorial(3)的栈帧] ← n=3, 等待递归返回
调用 factorial(2) 时:
栈顶 → [factorial(2)的栈帧] ← n=2
[factorial(3)的栈帧]
调用 factorial(1) 时:
栈顶 → [factorial(1)的栈帧] ← n=1,满足终止条件,直接返回
[factorial(2)的栈帧]
[factorial(3)的栈帧]
factorial(1) 返回后:
栈顶 → [factorial(2)的栈帧] ← 收到返回值1,计算2*1=2后返回
[factorial(3)的栈帧]
factorial(2) 返回后:
栈顶 → [factorial(3)的栈帧] ← 收到返回值2,计算3*2=6后返回
factorial(3) 返回后:栈空
3.3 递归深度与栈溢出
每次递归调用都会消耗栈空间来存储局部变量和返回地址。如果递归层次太深,可能导致栈溢出(Stack Overflow)。
c
/* 危险!递归深度过大 */
void infinite_recursion(int n) {
int large_array[1000]; /* 每个栈帧4KB */
infinite_recursion(n + 1); /* 无限递归 */
}
在常见的系统中,栈大小通常只有几MB。因此,当递归深度达到数千层时,程序就可能崩溃。
4 递归 vs 迭代:以阶乘为例
4.1 阶乘的递归实现
c
#include <stdio.h>
/* 递归版阶乘 */
long factorial_recursive(int n) {
if (n <= 1) return 1; /* 基本情况 */
return n * factorial_recursive(n - 1); /* 递归调用 */
}
4.2 阶乘的迭代实现
c
/* 迭代版阶乘 */
long factorial_iterative(int n) {
long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
4.3 对比分析
| 对比维度 | 递归版 | 迭代版 |
|---|---|---|
| 代码简洁性 | 简洁,直接对应数学定义 | 略复杂 |
| 可读性 | 高,易于理解算法思想 | 需要理解循环逻辑 |
| 执行效率 | 较低(函数调用开销) | 高(无额外开销) |
| 内存占用 | O(n) 栈空间 | O(1) 额外空间 |
| 适用场景 | n较小,追求代码清晰 | n较大,追求性能 |
测试对比:
c
#include <stdio.h>
#include <time.h>
int main(void) {
int n = 20;
clock_t start, end;
start = clock();
long r1 = factorial_recursive(n);
end = clock();
printf("递归:结果=%ld,时间=%ldms\n", r1, end - start);
start = clock();
long r2 = factorial_iterative(n);
end = clock();
printf("迭代:结果=%ld,时间=%ldms\n", r2, end - start);
return 0;
}
当 n 较小时,两者差异不大;但当 n 增大时,递归的额外开销会变得明显。
5 斐波那契数列:递归的陷阱
5.1 斐波那契数列的定义
斐波那契数列(Fibonacci sequence)定义为:
-
F(1) = 1
-
F(2) = 1
-
F(n) = F(n-1) + F(n-2) (n ≥ 3)
5.2 递归实现
c
#include <stdio.h>
/* 递归版斐波那契 */
long fibonacci_recursive(int n) {
if (n <= 2) return 1; /* 基本情况 */
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2); /* 递归调用 */
}
这段代码直接对应数学定义,简洁优美。但是......它存在严重的问题。
5.3 递归调用的爆炸性增长
让我们分析一下 fibonacci_recursive(5) 的调用过程:
text
fibonacci(5)
├── fibonacci(4)
│ ├── fibonacci(3)
│ │ ├── fibonacci(2)
│ │ └── fibonacci(1)
│ └── fibonacci(2)
└── fibonacci(3)
├── fibonacci(2)
└── fibonacci(1)
调用次数统计:
-
fibonacci(5) 调用 fibonacci(3) 2次
-
fibonacci(5) 调用 fibonacci(2) 3次
-
总调用次数:15次(而 n=5 只需要计算5个数!)
更惊人的是,这个数字增长极快:
-
n=10 时,fibonacci(3) 被调用 21 次
-
n=20 时,fibonacci(3) 被调用 2584 次
-
n=30 时,fibonacci(3) 被调用 317811 次!
5.4 为什么会有如此多的重复计算?
原因是递归树中包含了大量重复的子问题。每个 fibonacci(k) 被多次重复计算,导致时间复杂度高达 O(2ⁿ)------指数级爆炸。
5.5 迭代实现
c
/* 迭代版斐波那契 */
long fibonacci_iterative(int n) {
if (n <= 2) return 1;
long a = 1, b = 1, c;
for (int i = 3; i <= n; i++) {
c = a + b;
a = b;
b = c;
}
return b;
}
时间复杂度 O(n) ,空间复杂度 O(1)。
5.6 性能对比
| n | 递归版(调用次数) | 迭代版(循环次数) |
|---|---|---|
| 10 | 约 177 次 | 8 次 |
| 20 | 约 21891 次 | 18 次 |
| 30 | 约 2692537 次 | 28 次 |
| 40 | 约 3.3 亿次 | 38 次 |
| 50 | 约 400 亿次 | 48 次 |
结论:对于斐波那契数列,递归实现是灾难性的,绝对不应用在实际代码中。
6 递归与迭代的综合对比
| 对比维度 | 递归 | 迭代 |
|---|---|---|
| 代码简洁性 | 通常更简洁,直接对应数学定义 | 需要额外的循环控制变量 |
| 可读性 | 问题分解清晰 | 需要理解循环逻辑 |
| 执行效率 | 有函数调用开销 | 高效 |
| 内存占用 | 可能占用大量栈空间 | 通常 O(1) |
| 适用范围 | 树形结构、分治算法 | 线性处理、简单循环 |
| 重复计算 | 可能重复计算子问题 | 通常不会 |
| 尾递归优化 | 某些编译器支持优化 | 不适用 |
6.1 什么时候用递归?
-
问题天然具有递归结构(如树的遍历、汉诺塔)
-
代码可读性优先于性能
-
问题规模较小,递归深度可控
-
分治算法(如快速排序、归并排序)
6.2 什么时候用迭代?
-
性能要求高
-
递归深度可能很大(如超过 1000 层)
-
存在大量重复计算(如斐波那契)
-
内存受限的环境(如嵌入式系统)
7 如何写好递归函数
7.1 递归函数的三要素
-
明确函数功能:清楚定义函数要做什么
-
确定基本情况:找到递归的终止条件
-
找到递推关系:将原问题分解为子问题
7.2 设计步骤
以计算数组元素之和为例:
c
/* 问题:计算数组 arr[0..n-1] 的和 */
/* 1. 明确功能:sum(arr, n) 返回数组前n个元素的和 */
int sum(int arr[], int n) {
/* 2. 基本情况:空数组和为0 */
if (n <= 0) return 0;
/* 3. 递推关系:sum(arr, n) = sum(arr, n-1) + arr[n-1] */
return sum(arr, n - 1) + arr[n - 1];
}
7.3 尾递归优化
尾递归(Tail Recursion)是指递归调用是函数的最后一步操作。某些编译器可以对尾递归进行优化,将其转换为迭代,从而避免栈溢出。
c
/* 普通递归(不是尾递归) */
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1); /* 最后一步是乘法,不是递归调用 */
}
/* 尾递归版本 */
int factorial_tail(int n, int accumulator) {
if (n <= 1) return accumulator;
return factorial_tail(n - 1, n * accumulator); /* 最后一步是递归调用 */
}
/* 包装函数 */
int factorial(int n) {
return factorial_tail(n, 1);
}
尾递归的好处:编译器可以复用当前栈帧,不需要为每次调用创建新栈帧。
8 常见错误与注意事项
8.1 忘记基本情况
c
/* 错误:没有终止条件 */
int bad_recursion(int n) {
return n * bad_recursion(n - 1); /* 无限递归,栈溢出 */
}
8.2 基本情况永远不会到达
c
/* 错误:n 永远不等于 1 */
int wrong_recursion(int n) {
if (n == 1) return 1; /* 如果初始 n < 1,这个条件永远不会满足 */
return n * wrong_recursion(n - 2); /* n=5 → 3 → 1 ✓,但 n=4 → 2 → 0 → -2 ... ✗ */
}
8.3 递归深度过大
c
/* 危险:递归深度 = n,当 n=100000 时必然栈溢出 */
int sum(int n) {
if (n <= 0) return 0;
return n + sum(n - 1);
}
8.4 重复计算子问题
如前面斐波那契的例子,应该避免这种指数级爆炸。
9 综合示例:汉诺塔
汉诺塔是递归的经典案例:
c
#include <stdio.h>
/* 将 n 个盘子从 A 移到 C,借助 B */
void hanoi(int n, char A, char B, char C) {
if (n == 1) {
printf("将盘子从 %c 移到 %c\n", A, C);
return;
}
/* 1. 将上面 n-1 个盘子从 A 移到 B,借助 C */
hanoi(n - 1, A, C, B);
/* 2. 将最大的盘子从 A 移到 C */
printf("将盘子从 %c 移到 %c\n", A, C);
/* 3. 将 B 上的 n-1 个盘子移到 C,借助 A */
hanoi(n - 1, B, A, C);
}
int main(void) {
int n = 3;
printf("移动 %d 个盘子的步骤:\n", n);
hanoi(n, 'A', 'B', 'C');
return 0;
}
这个问题的递归解法直接对应问题的自然分解,如果用迭代实现会复杂得多。
10 本章小结
本章系统介绍了递归函数的原理与应用:
1. 递归的数学基础
-
递归与数学归纳法一脉相承
-
基础情况对应归纳基础,递归调用对应归纳假设
2. 递归的栈实现
-
每次递归调用创建新的栈帧
-
递归深度过大导致栈溢出
3. 递归 vs 迭代
-
阶乘:递归简洁,迭代高效,两者均可
-
斐波那契:递归有大量重复计算,绝对应该用迭代
4. 递归的适用场景
-
✅ 树形结构遍历
-
✅ 分治算法
-
✅ 问题天然有递归定义
-
❌ 线性问题、深度很大、有重复子问题
5. 写好递归的要点
-
明确功能
-
确定基本情况
-
找到递推关系
-
相信递归调用(不要跟踪细节)