递归入门
递归是函数直接或间接调用自己。链表、树、图上的很多算法,以及分治、回溯,用递归表达往往最自然。
本文解决什么问题
- 递归三要素如何保证「能停、算对」
- 调用栈在干什么
- 递归与循环如何互化
- 常见坑:爆栈、重复计算、缺少终止条件
递归三要素
- 终止条件(基准情形):什么时候不再调用自己
- 问题规模缩小:每次调用都更接近终止
- 返回结果可组合:用子问题答案拼出当前答案
缺终止条件 → 无限递归直至栈溢出;规模不缩小 → 同样爆栈或算不对。
例子:阶乘
定义:,且 (通常也令 )。
long long factorial(int n) {
if (n < 0) {
/* 视需求报错;这里简单返回 -1 表示非法 */
return -1;
}
if (n <= 1) {
return 1; /* 终止 */
}
return n * factorial(n - 1); /* 规模 n → n-1 */
}
long long factorial(int n) {
if (n < 0) return -1;
if (n <= 1) return 1;
return n * factorial(n - 1);
}
时间 ,递归深度 ,因此额外栈空间也是 。
调用栈在干什么
以 factorial(3) 为例,可以想象栈帧一层层压入,再一层层返回:
factorial(3)
└─ factorial(2)
└─ factorial(1) → 返回 1
← 返回 2 * 1 = 2
← 返回 3 * 2 = 6
每一层都要保存:返回地址、局部变量、参数等。深度太大时,操作系统给线程的栈不够用,就会栈溢出(stack overflow)。
提示
树的深度优先遍历若写递归,树很歪、深度接近 时,可能比「显式栈 + 循环」更容易爆栈。嵌入式或默认栈较小的环境更要小心。
例子:斐波那契(先理解,再谈优化)
定义:。
long long fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
这个写法语义正确,但有大量重复计算:fib(n) 的递归树会 指数膨胀,时间约 量级( 为黄金比), 左右就明显变慢。
改进方向(后续动态规划章节会系统讲):
- 改成自底向上循环
- 或加记忆化(memo),同一 只算一次
/* 迭代版:O(n) 时间,O(1) 额外空间 */
long long fib_iter(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
long long c = a + b;
a = b;
b = c;
}
return b;
}
递归与迭代可以互化
理论上,递归都能用显式栈改成循环;许多尾递归还能直接 改成普通循环。选择标准通常是:
| 更适合递归 | 更适合循环 / 显式栈 |
|---|---|
| 问题定义本身递归(树、分治) | 深度很大,担心爆栈 |
| 代码更短、更贴定义 | 热路径上要抠性能与常数 |
| 深度可控(如 ) | 需要精确控制内存 |
再看一个结构上的递归:链表长度
typedef struct Node {
int data;
struct Node *next;
} Node;
int list_len(const Node *head) {
if (head == NULL) return 0; /* 空表:终止 */
return 1 + list_len(head->next); /* 去掉头结点 */
}
对很长的链表,这种写法深度为 ,生产代码里更常见的是 while 计数。这里用来体会「结构递归」:子问题是「去掉头之后的链表」。