数据结构与算法|第十三章:递归与分治
- [第十三章 递归与分治](#第十三章 递归与分治)
-
- [13.1 递归的基本原理](#13.1 递归的基本原理)
-
- [13.1.1 递归的三个条件](#13.1.1 递归的三个条件)
- [13.1.2 递归的执行过程](#13.1.2 递归的执行过程)
- [13.1.3 递归树分析](#13.1.3 递归树分析)
- [13.2 递归的陷阱与优化](#13.2 递归的陷阱与优化)
-
- [13.2.1 堆栈溢出](#13.2.1 堆栈溢出)
- [13.2.2 重复计算与记忆化](#13.2.2 重复计算与记忆化)
- [13.2.3 尾递归优化](#13.2.3 尾递归优化)
- [13.3 分治法(Divide and Conquer)](#13.3 分治法(Divide and Conquer))
-
- [13.3.1 核心思想](#13.3.1 核心思想)
- [13.3.2 主定理(Master Theorem)](#13.3.2 主定理(Master Theorem))
- [13.4 经典实战](#13.4 经典实战)
-
- [13.4.1 汉诺塔(Hanoi)](#13.4.1 汉诺塔(Hanoi))
- [13.4.2 快速排序](#13.4.2 快速排序)
- [13.4.3 归并排序](#13.4.3 归并排序)
- [13.4.4 最大子数组和(分治法)](#13.4.4 最大子数组和(分治法))
- [13.4.5 快速幂(Pow(x, n))](#13.4.5 快速幂(Pow(x, n)))
- 总结与预告
上篇:第十二章、图
第十三章 递归与分治
在之前的十二章中,我们系统学习了各种数据结构------从线性表到二叉树,从堆到图。你可能会注意到一个反复出现的模式:树的遍历、DFS、归并排序、快速排序......它们都使用了同一个核心技巧------递归。
递归不是一种数据结构,而是一种算法设计思想。它是"分治法"的基础,是函数式编程的灵魂,也是让无数初学者头疼的"思维门槛"。本章将从递归的本质出发,带你彻底理解"递归思维",并逐层深入到分治策略和主定理。
13.1 递归的基本原理
13.1.1 递归的三个条件
递归(Recursion):一个函数直接或间接地调用自身。
一个正确的递归必须同时满足三个条件:
- 基准条件(Base Case):存在一个或多个无需递归就能直接求解的简单情况
- 递归条件(Recursive Case) :每次递归调用都将问题向基准条件推进(问题规模缩小)
- 递归调用自身:函数在执行过程中调用自身
经典示例------阶乘:
java
/**
* 递归计算 n 的阶乘
* 条件1(基准):n == 0 时,0! = 1
* 条件2(推进):n! = n × (n-1)! ,每次 n 减 1
* 条件3(自调):factorial(n) 调用 factorial(n-1)
*/
public long factorial(int n) {
if (n == 0) return 1; // 基准条件
return n * factorial(n - 1); // 递归条件 + 自身调用
}
缺少基准条件 → 无限递归 → StackOverflowError!
13.1.2 递归的执行过程
递归的底层机制依赖于调用栈 (Call Stack)。每次函数调用都会创建一个栈帧(Stack Frame),包含参数、局部变量和返回地址。
以 factorial(3) 为例:
调用顺序(压栈): 返回顺序(弹栈):
factorial(3) → 6
└→ 3 * factorial(2) ← 3 × 2 = 6
└→ 2 * factorial(1) ← 2 × 1 = 2
└→ 1 * factorial(0) ← 1 × 1 = 1
└→ return 1
弹栈
压栈
返回1
返回1
返回2
返回6
factorial(3) 栈帧
factorial(2) 栈帧
factorial(1) 栈帧
factorial(0) 栈帧
返回 1
最终结果: 6
13.1.3 递归树分析
递归的复杂度分析比迭代复杂得多。最直观的方法是画出递归树(Recursion Tree)------每个结点表示一次递归调用,其子结点表示它发起的子调用。
斐波那契数列的递归树(fib(5)):
fib(5)
fib(4)
fib(3)
fib(3)
fib(2)
fib(2)
fib(1)
fib(2)
fib(1)
fib(1)
fib(0)
java
/** 斐波那契:朴素递归(极其低效) */
public int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2); // 两个子问题 → 指数级爆炸
}
树中有大量重复结点(如
fib(3)出现 2 次,fib(2)出现 3 次),时间复杂度 O(2ⁿ)。这是递归的经典反面教材------重叠子问题导致指数爆炸。
13.2 递归的陷阱与优化
13.2.1 堆栈溢出
每次递归调用都会在调用栈中压入一个栈帧。Java 默认栈大小约 1MB,当递归深度过大时:
Exception in thread "main" java.lang.StackOverflowError
缓解策略:
| 策略 | 说明 | 示例 |
|---|---|---|
| 改用迭代 | 消除递归,用循环替代 | 斐波那契的迭代版 O(n) |
| 尾递归优化 | 让递归调用成为函数最后一条语句 | 见 [13.2.3](#策略 说明 示例 改用迭代 消除递归,用循环替代 斐波那契的迭代版 O(n) 尾递归优化 让递归调用成为函数最后一条语句 见 13.2.3 增大栈空间 -Xss2m(治标不治本) JVM 参数 记忆化搜索 缓存已计算结果,避免重复 见 13.2.2) |
| 增大栈空间 | -Xss2m(治标不治本) |
JVM 参数 |
| 记忆化搜索 | 缓存已计算结果,避免重复 | 见 [13.2.2](#策略 说明 示例 改用迭代 消除递归,用循环替代 斐波那契的迭代版 O(n) 尾递归优化 让递归调用成为函数最后一条语句 见 13.2.3 增大栈空间 -Xss2m(治标不治本) JVM 参数 记忆化搜索 缓存已计算结果,避免重复 见 13.2.2) |
13.2.2 重复计算与记忆化
当递归树中存在大量重叠子问题 时,可以用 记忆化(Memoization) 缓存中间结果。
java
/**
* 斐波那契:记忆化递归(自顶向下 DP)
* 时间复杂度 O(n),空间 O(n)
*/
public int fibMemo(int n) {
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
return fibMemoRec(n, memo);
}
private int fibMemoRec(int n, int[] memo) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n]; // 命中缓存
memo[n] = fibMemoRec(n - 1, memo) + fibMemoRec(n - 2, memo);
return memo[n];
}
记忆化将 O(2ⁿ) 降为 O(n),这是动态规划的核心思想之一。
13.2.3 尾递归优化
尾递归(Tail Recursion):递归调用是函数体中最后执行的语句 ,且返回值不参与其他运算。
java
/** 普通递归阶乘:返回值参与乘法运算,不是尾递归 */
public long factorial(int n) {
if (n == 0) return 1;
return n * factorial(n - 1); // ← 乘法在递归返回之后
}
/** 尾递归阶乘:使用累加器 accumulator */
public long factorialTail(int n) {
return factorialTailRec(n, 1);
}
private long factorialTailRec(int n, long acc) {
if (n == 0) return acc;
return factorialTailRec(n - 1, n * acc); // ← 递归调用是最后一条语句
}
尾递归优化原理 :编译器/解释器可以复用当前栈帧,用新参数覆盖旧参数后跳转回函数开头,相当于把递归转换成了循环。但Java 目前不支持尾递归优化(JVM 层面未实现),这一特性主要存在于 Scala、Kotlin、Haskell 等语言中。
13.3 分治法(Divide and Conquer)
13.3.1 核心思想
分治法:将原问题分解 为若干个规模较小的同类子问题,递归 求解这些子问题,然后将子问题的解合并为原问题的解。
分治法 = 分解(Divide) + 解决(Conquer) + 合并(Combine) \text{分治法} = \text{分解(Divide)} + \text{解决(Conquer)} + \text{合并(Combine)} 分治法=分解(Divide)+解决(Conquer)+合并(Combine)
┌───────────┐
│ 原问题 n │
└─────┬─────┘
┌────────┴────────┐
┌─────┴─────┐ ┌─────┴─────┐
│ 子问题 n/2 │ │ 子问题 n/2 │ ← 分解
└─────┬─────┘ └─────┬─────┘
递归求解 递归求解 ← 解决
┌─────┴─────┐ ┌─────┴─────┐
│ 解 1 │ │ 解 2 │
└─────┬─────┘ └─────┬─────┘
└────────┬────────┘
┌─────┴─────┐
│ 合并解 │ ← 合并
└───────────┘
分治法的适用条件:
- 问题可以分解为同类型的子问题
- 子问题相互独立(不重叠)
- 子问题的解可以合并为原问题的解
- 分解到一定规模后可以直接求解(基准条件)
13.3.2 主定理(Master Theorem)
分治法的时间复杂度通常可以表示为以下递推式:
T ( n ) = a ⋅ T ( n b ) + f ( n ) T(n) = a \cdot T\left(\frac{n}{b}\right) + f(n) T(n)=a⋅T(bn)+f(n)
其中:
- a a a:每次递归调用的子问题个数
- b b b:每个子问题的规模是原来的 1 / b 1/b 1/b
- f ( n ) f(n) f(n):分解和合并的工作量
主定理给出三种情况下的渐近界:
比较 f ( n ) f(n) f(n) 与 n log b a n^{\log_b a} nlogba 的增长速度:
| 情况 | 条件 | 结论 |
|---|---|---|
| Case 1 | f ( n ) = O ( n log b a − ε ) f(n) = O(n^{\log_b a - \varepsilon}) f(n)=O(nlogba−ε) | T ( n ) = Θ ( n log b a ) T(n) = \Theta(n^{\log_b a}) T(n)=Θ(nlogba) |
| Case 2 | f ( n ) = Θ ( n log b a ) f(n) = \Theta(n^{\log_b a}) f(n)=Θ(nlogba) | T ( n ) = Θ ( n log b a log n ) T(n) = \Theta(n^{\log_b a} \log n) T(n)=Θ(nlogbalogn) |
| Case 3 | f ( n ) = Ω ( n log b a + ε ) f(n) = \Omega(n^{\log_b a + \varepsilon}) f(n)=Ω(nlogba+ε) 且满足正则条件 | T ( n ) = Θ ( f ( n ) ) T(n) = \Theta(f(n)) T(n)=Θ(f(n)) |
通俗理解:分解的叶子工作量( n log b a n^{\log_b a} nlogba) vs 合并的开销( f ( n ) f(n) f(n))谁占主导?
经典套用:
| 算法 | 递推式 | a | b | f(n) | n log b a n^{\log_b a} nlogba | 情况 | 复杂度 |
|---|---|---|---|---|---|---|---|
| 归并排序 | T ( n ) = 2 T ( n / 2 ) + O ( n ) T(n)=2T(n/2)+O(n) T(n)=2T(n/2)+O(n) | 2 | 2 | O(n) | n n n | Case 2 | O(n log n) |
| 二分查找 | T ( n ) = T ( n / 2 ) + O ( 1 ) T(n)=T(n/2)+O(1) T(n)=T(n/2)+O(1) | 1 | 2 | O(1) | 1 1 1 | Case 2 | O(log n) |
| 遍历二叉树 | T ( n ) = 2 T ( n / 2 ) + O ( 1 ) T(n)=2T(n/2)+O(1) T(n)=2T(n/2)+O(1) | 2 | 2 | O(1) | n n n | Case 1 ( n 1 > O ( 1 ) n^{1} > O(1) n1>O(1)) | O(n) |
| 快速排序(平均) | T ( n ) = 2 T ( n / 2 ) + O ( n ) T(n)=2T(n/2)+O(n) T(n)=2T(n/2)+O(n) | 2 | 2 | O(n) | n n n | Case 2 | O(n log n) |
| Strassen 矩阵乘法 | T ( n ) = 7 T ( n / 2 ) + O ( n 2 ) T(n)=7T(n/2)+O(n^2) T(n)=7T(n/2)+O(n2) | 7 | 2 | O(n²) | n log 2 7 ≈ n 2.81 n^{\log_2 7} \approx n^{2.81} nlog27≈n2.81 | Case 1 | O(n²·⁸¹) |
13.4 经典实战
13.4.1 汉诺塔(Hanoi)
问题:三根柱子 A、B、C。A 上有 n 个由小到大叠放的圆盘,每次只能移动一个盘且大盘不能放小盘上。将所有盘从 A 移到 C。
分治策略:将 n 个盘的问题分解为三步------
- 将上面 n−1 个盘从 A 移到 B(借助 C)
- 将最大的第 n 个盘从 A 移到 C
- 将 n−1 个盘从 B 移到 C(借助 A)
java
/**
* 汉诺塔递归解法
* @param n 盘子数量
* @param from 起始柱
* @param to 目标柱
* @param aux 辅助柱
*/
public void hanoi(int n, char from, char to, char aux) {
if (n == 1) {
System.out.println("盘 1: " + from + " → " + to);
return;
}
hanoi(n - 1, from, aux, to); // n-1 个盘移到辅助柱
System.out.println("盘 " + n + ": " + from + " → " + to);
hanoi(n - 1, aux, to, from); // n-1 个盘移到目标柱
}
复杂度 : T ( n ) = 2 T ( n − 1 ) + O ( 1 ) T(n) = 2T(n-1) + O(1) T(n)=2T(n−1)+O(1),解为 O(2ⁿ)。n=64 时即使每秒移动一次,也需要约 5800 亿年。
13.4.2 快速排序
快速排序是分治法最优雅的体现------选一个 pivot,将数组分为"小于 pivot"和"大于 pivot"两部分,递归排序。
java
/**
* 快速排序(双指针分区)
* @param arr 待排序数组
* @param low 左边界(含)
* @param high 右边界(含)
*/
public void quickSort(int[] arr, int low, int high) {
if (low >= high) return; // 基准条件:区间长度为 0 或 1
int pivotIndex = partition(arr, low, high);
quickSort(arr, low, pivotIndex - 1); // 递归排序左半
quickSort(arr, pivotIndex + 1, high); // 递归排序右半
}
/**
* 分区:将 arr[low..high] 以首个元素为 pivot 分成两部分
* @return pivot 的最终位置
*/
private int partition(int[] arr, int low, int high) {
int pivot = arr[low];
int left = low;
int right = high;
while (left < right) {
// 从右往左找第一个小于 pivot 的
while (left < right && arr[right] >= pivot) right--;
// 从左往右找第一个大于 pivot 的
while (left < right && arr[left] <= pivot) left++;
// 交换
if (left < right) {
swap(arr, left, right);
}
}
// pivot 归位
swap(arr, low, left);
return left;
}
private void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
算法分析:平均 O(n log n),最坏 O(n²)(每次 pivot 都是最值)。空间 O(log n)(递归栈)。快速排序的详细优化(三路快排、随机 pivot)将在第 14 章展开。
13.4.3 归并排序
归并排序是"先分后合"的典型------先递归地将数组拆到单个元素,再两两合并有序子数组。
java
/**
* 归并排序
* @param arr 待排序数组
* @param left 左边界(含)
* @param right 右边界(含)
* @param temp 辅助数组
*/
public 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); // 合并两个有序子数组
}
private void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left; // 左子数组指针
int j = mid + 1; // 右子数组指针
int k = left; // 辅助数组指针
// 两路归并
while (i <= mid && j <= right) {
temp[k++] = (arr[i] <= arr[j]) ? arr[i++] : 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];
}
}
算法分析 :时间严格 O(n log n),空间 O(n)(辅助数组)。稳定排序。归并排序在处理链表排序 和外部排序时优势明显。
快速排序 vs 归并排序:
| 维度 | 快速排序 | 归并排序 |
|---|---|---|
| 分治方向 | 自顶向下(先分区后递归) | 自底向上(先递归后合并) |
| 时间(平均) | O(n log n) | O(n log n) |
| 时间(最坏) | O(n²) | O(n log n) |
| 空间 | O(log n)(递归栈) | O(n)(辅助数组) |
| 稳定性 | 不稳定 | 稳定 |
| 常数因子 | 小(实际更快) | 较大 |
| 适合场景 | 数组、内存排序 | 链表、外部排序 |
13.4.4 最大子数组和(分治法)
问题:给定整数数组,找出具有最大和的连续子数组,返回其最大和。(LeetCode 53)
分治解法 :将数组二分,最大子数组要么在左半、要么在右半、要么横跨中点。
java
/**
* 最大子数组和 ------ 分治法 O(n log n)
*/
public int maxSubArray(int[] nums) {
return maxSubArrayDivide(nums, 0, nums.length - 1);
}
private int maxSubArrayDivide(int[] nums, int left, int right) {
if (left == right) return nums[left]; // 基准条件
int mid = left + (right - left) / 2;
// 左半部分的最大子数组和
int leftMax = maxSubArrayDivide(nums, left, mid);
// 右半部分的最大子数组和
int rightMax = maxSubArrayDivide(nums, mid + 1, right);
// 横跨中点的最大子数组和
int crossMax = maxCrossingSum(nums, left, mid, right);
return Math.max(Math.max(leftMax, rightMax), crossMax);
}
/** 计算横跨中点 mid 的最大子数组和 */
private int maxCrossingSum(int[] nums, int left, int mid, int right) {
// 从中点向左扩展,找最大和
int leftSum = Integer.MIN_VALUE;
int sum = 0;
for (int i = mid; i >= left; i--) {
sum += nums[i];
leftSum = Math.max(leftSum, sum);
}
// 从中点向右扩展,找最大和
int rightSum = Integer.MIN_VALUE;
sum = 0;
for (int i = mid + 1; i <= right; i++) {
sum += nums[i];
rightSum = Math.max(rightSum, sum);
}
return leftSum + rightSum;
}
对比 :分治法 O(n log n),而经典的 Kadane 算法(动态规划)只需 O(n)。这里展示分治法主要是帮助理解"横跨中点"这一分治模式。
13.4.5 快速幂(Pow(x, n))
问题 :计算 x n x^n xn(n 为整数,可为负数)。(LeetCode 50)
分治策略 : x n = { x n 2 × x n 2 , n 为偶数 x n − 1 2 × x n − 1 2 × x , n 为奇数 x^n = \begin{cases} x^{\frac{n}{2}} \times x^{\frac{n}{2}}, & n \text{ 为偶数} \\ x^{\frac{n-1}{2}} \times x^{\frac{n-1}{2}} \times x, & n \text{ 为奇数} \end{cases} xn={x2n×x2n,x2n−1×x2n−1×x,n 为偶数n 为奇数
java
/**
* 快速幂 ------ 分治法 O(log n)
* @param x 底数
* @param n 指数(可为负)
*/
public double myPow(double x, int n) {
// 处理负指数
long N = n; // 防止 n = Integer.MIN_VALUE 时取反溢出
if (N < 0) {
x = 1 / x;
N = -N;
}
return fastPow(x, N);
}
private double fastPow(double x, long n) {
if (n == 0) return 1.0; // 基准条件
double half = fastPow(x, n / 2); // 递归计算一半
if (n % 2 == 0) {
return half * half; // 偶数:直接平方
} else {
return half * half * x; // 奇数:多乘一个 x
}
}
算法分析 : T ( n ) = T ( n / 2 ) + O ( 1 ) T(n) = T(n/2) + O(1) T(n)=T(n/2)+O(1),主定理 Case 2,时间复杂度 O(log n)。相比朴素循环的 O(n),这是指数级优化。
总结与预告
本章我们从递归的本质出发,深入到分治策略和主定理,建立了算法设计的第一个系统思维框架:
- 13.1 递归原理:三要素(基准条件/递归条件/自身调用)、调用栈机制、递归树分析
- 13.2 陷阱与优化:StackOverflow、重复计算→记忆化、尾递归(Java 不支持 TCO)
- 13.3 分治法:分解→解决→合并,主定理 Master Theorem 快速判断复杂度
- 13.4 经典实战:汉诺塔(O(2ⁿ))、快速排序/归并排序(O(n log n))、最大子数组和、快速幂(O(log n))
递归 vs 迭代的选择指南:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 树/图的遍历 | 递归 | 代码简洁,天然匹配树的结构 |
| 分治算法 | 递归 | 本身就是递归定义 |
| 深度过大 | 迭代 | 避免 StackOverflow |
| 有重叠子问题 | 记忆化递归 / DP | 缓存避免重复计算 |
| 简单循环 | 迭代 | 更高效,无栈开销 |
下一章我们将进入排序算法专题------从冒泡、选择、插入这些基础排序开始,到希尔排序、归并排序、快速排序的深度解析,建立完整的排序算法知识体系。
上篇:第十二章、图