一、递归的本质
1.1 什么是递归
递归就是函数调用自身。一个递归函数通常包含两部分:
-
终止条件:什么时候停止递归
-
递推公式:如何将大问题转化为小问题
c
// 阶乘的递归实现
int factorial(int n) {
if (n <= 1) return 1; // 终止条件
return n * factorial(n - 1); // 递推公式
}
1.2 递归的底层原理:系统栈
每次函数调用,系统都会在栈上分配空间,存储:
-
函数的参数
-
局部变量
-
返回地址
当函数调用结束时,栈帧被弹出,程序回到调用点继续执行。
以 factorial(3) 为例:
text
调用 factorial(3):
栈: [fact(3)]
调用 factorial(2):
栈: [fact(3), fact(2)]
调用 factorial(1):
栈: [fact(3), fact(2), fact(1)]
返回 1
栈: [fact(3), fact(2)]
计算 2 * 1 = 2,返回
栈: [fact(3)]
计算 3 * 2 = 6,返回
栈: []
递归深度 :栈中最多同时存在的栈帧数量。深度过大(如递归10000次)会导致栈溢出。
1.3 递归 vs 迭代
| 对比项 | 递归 | 迭代 |
|---|---|---|
| 代码可读性 | 简洁直观 | 相对复杂 |
| 空间复杂度 | O(递归深度) | O(1) |
| 性能 | 函数调用开销大 | 循环开销小 |
| 适用场景 | 树、分治、回溯 | 简单重复计算 |
二、递归的经典问题:汉诺塔
2.1 问题描述
有三根柱子(A、B、C),A柱上有n个大小不同的圆盘,大的在下,小的在上。要求把所有圆盘从A移动到C,每次只能移动一个圆盘,且大圆盘不能放在小圆盘上面。求移动步骤。
2.2 递归思路
要把n个盘子从A移到C:
-
先把上面 n-1 个盘子从A移到B(借助C)
-
把最大的盘子从A移到C
-
再把 n-1 个盘子从B移到C(借助A)
text
终止条件:n == 1 时,直接移动
2.3 代码实现
c
#include <stdio.h>
// 汉诺塔递归实现
void hanoi(int n, char from, char to, char aux) {
if (n == 1) {
printf("移动 1 号盘: %c -> %c\n", from, to);
return;
}
// 将 n-1 个盘子从 from 移到 aux
hanoi(n - 1, from, aux, to);
// 移动最大的盘子
printf("移动 %d 号盘: %c -> %c\n", n, from, to);
// 将 n-1 个盘子从 aux 移到 to
hanoi(n - 1, aux, to, from);
}
int main() {
int n = 3;
printf("汉诺塔 %d 个盘子移动步骤:\n", n);
hanoi(n, 'A', 'C', 'B');
return 0;
}
运行结果:
text
汉诺塔 3 个盘子移动步骤:
移动 1 号盘: A -> C
移动 2 号盘: A -> B
移动 1 号盘: C -> B
移动 3 号盘: A -> C
移动 1 号盘: B -> A
移动 2 号盘: B -> C
移动 1 号盘: A -> C
复杂度:移动次数 = 2ⁿ - 1,时间复杂度 O(2ⁿ)
三、分治法(Divide and Conquer)
3.1 分治法的三步骤
分治法将大问题分解成若干个相同的小问题,递归解决后合并结果。
| 步骤 | 说明 |
|---|---|
| 分解 | 将原问题分解成若干个子问题 |
| 解决 | 递归解决子问题(子问题足够小时直接解决) |
| 合并 | 将子问题的解合并成原问题的解 |
3.2 典型应用
| 算法 | 分解 | 合并 | 时间复杂度 |
|---|---|---|---|
| 归并排序 | 分成两半 | 合并两个有序数组 | O(n log n) |
| 快速排序 | 按基准分区 | 不需要合并 | O(n log n) |
| 二分查找 | 取中间点 | 不需要合并 | O(log n) |
| 汉诺塔 | 分成n-1和1 | 简单组合 | O(2ⁿ) |
| 最大子数组 | 分成左右和跨中 | 取最大值 | O(n log n) |
3.3 归并排序(分治法经典)
c
#include <stdio.h>
#include <stdlib.h>
// 合并两个有序子数组
void merge(int arr[], int left, int mid, int right, int temp[]) {
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) temp[k++] = arr[i++];
while (j <= right) temp[k++] = arr[j++];
for (i = left; i <= right; i++) {
arr[i] = temp[i];
}
}
// 归并排序(分治)
void mergeSort(int arr[], int left, int right, int temp[]) {
if (left >= right) return; // 终止条件:只有一个元素
int mid = left + (right - left) / 2;
// 分解
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
// 合并
merge(arr, left, mid, right, temp);
}
int main() {
int arr[] = {8, 3, 5, 1, 6, 2, 7, 4};
int n = sizeof(arr) / sizeof(arr[0]);
int *temp = (int*)malloc(n * sizeof(int));
printf("原数组: ");
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
mergeSort(arr, 0, n - 1, temp);
printf("排序后: ");
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
free(temp);
return 0;
}
四、递归的优化:尾递归与记忆化
4.1 尾递归
尾递归是指递归调用是函数的最后一个操作,编译器可以优化为迭代,避免栈溢出。
c
// 普通递归阶乘(不是尾递归,因为最后还有乘法)
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// 尾递归阶乘
int factorialTail(int n, int result) {
if (n <= 1) return result;
return factorialTail(n - 1, n * result);
}
4.2 记忆化递归(避免重复计算)
斐波那契数列的普通递归有大量重复计算,用记忆化优化。
c
#define MAX 100
int memo[MAX];
int 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];
}
| 版本 | 时间复杂度 | 说明 |
|---|---|---|
| 普通递归 | O(2ⁿ) | 大量重复计算 |
| 记忆化递归 | O(n) | 每个数只算一次 |
| 迭代 | O(n) | 最优 |
五、递归的常见陷阱
5.1 栈溢出
递归深度过大时,系统栈空间耗尽。
c
// 递归深度10000,可能导致栈溢出
void deepRecursion(int n) {
if (n <= 0) return;
deepRecursion(n - 1);
}
解决方案:
-
改用迭代
-
增加栈大小(编译器选项)
-
使用尾递归(部分编译器优化)
5.2 重复计算
斐波那契普通递归的重复计算问题。
5.3 终止条件错误
忘记终止条件或条件错误会导致无限递归。
c
// 错误:n==0时没有正确返回
int badFactorial(int n) {
return n * badFactorial(n - 1); // n=0时永远不停止
}
六、递归与分治的应用场景
| 场景 | 推荐 | 原因 |
|---|---|---|
| 树/图的遍历 | 递归 | 结构天然适合递归 |
| 排序(归并/快排) | 分治 | 经典应用 |
| 二分查找 | 递归/迭代均可 | 简单 |
| 动态规划 | 递归+记忆化 | 自顶向下思考 |
| 回溯(八皇后、迷宫) | 递归 | 需要状态恢复 |
| 分治(最大子数组) | 递归 | 分解合并清晰 |
七、小结
这一篇我们学习了递归与分治:
| 概念 | 核心要点 |
|---|---|
| 递归本质 | 系统栈的压入与弹出 |
| 递归要素 | 终止条件 + 递推公式 |
| 汉诺塔 | 经典递归问题,O(2ⁿ) |
| 分治法 | 分解 → 解决 → 合并 |
| 归并排序 | 分治法的典型应用 |
| 尾递归 | 可被编译器优化为循环 |
| 记忆化 | 避免重复计算 |
递归思考模板:
text
function(问题):
if (问题足够小):
直接解决
else:
分解成子问题
递归解决子问题
合并子问题的解
下一篇我们讲动态规划。
八、思考题
-
递归函数的空间复杂度为什么等于递归深度?
-
汉诺塔问题中,移动 n 个盘子需要多少步?为什么?
-
如何判断一个问题适合用递归解决?递归的缺点是什么?
-
用递归实现二分查找,并分析其空间复杂度。
欢迎在评论区讨论你的答案。