数据结构与算法|第十三章:递归与分治

数据结构与算法|第十三章:递归与分治

  • [第十三章 递归与分治](#第十三章 递归与分治)
    • [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):一个函数直接或间接地调用自身

一个正确的递归必须同时满足三个条件:

  1. 基准条件(Base Case):存在一个或多个无需递归就能直接求解的简单情况
  2. 递归条件(Recursive Case) :每次递归调用都将问题向基准条件推进(问题规模缩小)
  3. 递归调用自身:函数在执行过程中调用自身

经典示例------阶乘:

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    │
    └─────┬─────┘    └─────┬─────┘
          └────────┬────────┘
             ┌─────┴─────┐
             │  合并解    │                    ← 合并
             └───────────┘

分治法的适用条件:

  1. 问题可以分解为同类型的子问题
  2. 子问题相互独立(不重叠)
  3. 子问题的解可以合并为原问题的解
  4. 分解到一定规模后可以直接求解(基准条件)

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 个盘的问题分解为三步------

  1. 将上面 n−1 个盘从 A 移到 B(借助 C)
  2. 将最大的第 n 个盘从 A 移到 C
  3. 将 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 缓存避免重复计算
简单循环 迭代 更高效,无栈开销

下一章我们将进入排序算法专题------从冒泡、选择、插入这些基础排序开始,到希尔排序、归并排序、快速排序的深度解析,建立完整的排序算法知识体系。


上篇:第十二章、图

下篇:第十四章、排序算法(上)--- 比较类排序

相关推荐
梦梦代码精1 小时前
LikeShop 是否安全可靠?——从架构设计到数据表现的系统性分析
数据结构·团队开发·安全性测试
m0_629494731 小时前
LeetCode 热题 100-----21.搜索二维矩阵 II
数据结构·算法·leetcode
平行侠2 小时前
018二进制GCD(Stein算法)- 用位运算代替除法的最大公因数
数据结构·算法
月疯2 小时前
卡尔曼滤波的数学计算流程
算法
黎阳之光2 小时前
黎阳之光:深耕视频孪生核心领域 构筑数字孪生全域数智新标杆
大数据·人工智能·算法·安全·数字孪生
sbjdhjd2 小时前
2026年第十七届蓝桥杯大赛软件赛省赛 Python 大学 B 组 A-F 题 完整题解(小白友好版)
python·算法·职场和发展·蓝桥杯·pycharm·开源·动态规划
nlpming2 小时前
Superpowers 项目全面解析
算法
无限进步_2 小时前
【C++】红黑树完全解析:从概念到插入与平衡维护
java·c语言·开发语言·数据结构·c++·后端·算法
DaMu2 小时前
基于后天九宫八卦阵驱动的AI具身智能体联合协同指挥防御系统:架构与实现
人工智能·算法·架构