相关笔记: 2.2 算法分析

概览

本节系统介绍了分治法(divide-and-conquer)这一核心算法设计范式,并以归并排序(merge sort)为完整案例,展示了分治策略”分解—解决—合并”三步骤的实际运用。内容涵盖 MERGE 过程的详细伪代码与执行过程演示、MERGE-SORT 的递归结构、递归关系式(recurrence)的建立与求解,以及通过递归树(recursion tree)方法直观理解归并排序 的时间复杂度。

  • 分治法将问题分解为更小的子问题,递归求解后合并结果,是许多高效算法的基础设计范式
  • 归并排序遵循分治三步骤:将数组一分为二、递归排序两个子数组、合并两个有序子数组
  • MERGE 过程 时间内将两个有序子数组合并为一个有序数组,是归并排序的核心操作
  • 归并排序的最坏情况运行时间由递归关系式 描述,其解为
  • 通过递归树方法可以直观理解: 层,每层代价为 ,总代价为
  • 相比插入排序,归并排序用 因子替换了 因子,对大规模输入优势显著

知识结构总览

graph TB
    A["2.3 分治法"] --> B["分治法范式"]
    A --> C["归并排序算法"]
    A --> D["MERGE 过程"]
    A --> E["递归关系式分析"]
    A --> F["递归树方法"]

    B --> B1["分解 Divide"]
    B --> B2["解决 Conquer"]
    B --> B3["合并 Combine"]
    B --> B4["基准情况 Base Case"]

    C --> C1["MERGE-SORT 伪代码"]
    C --> C2["递归分解过程"]
    C --> C3["合并回溯过程"]
    C --> C4["完整执行示例"]

    D --> D1["MERGE 伪代码"]
    D --> D2["临时数组 L 和 R"]
    D --> D3["主合并循环"]
    D --> D4["尾部复制"]
    D --> D5["时间复杂度 Θ(n)"]

    E --> E1["递归关系式的建立"]
    E --> E2["D(n)、C(n) 的分析"]
    E --> E3["T(n) = 2T(n/2) + Θ(n)"]
    E --> E4["主定理预告"]

    F --> F1["递归树的构造"]
    F --> F2["逐层代价分析"]
    F --> F3["总代价计算"]
    F --> F4["与插入排序的对比"]

核心思想

核心思想

本节的核心思想是分治策略:许多有用的算法具有递归结构——为了解决给定问题,递归地调用自身来处理与之密切相关的更小子问题。分治法在每一步执行三个特征操作:分解(将问题划分为更小的子问题)、解决(递归地求解子问题)、合并(将子问题的解组合为原问题的解)。当问题规模足够小时直接求解(基准情况),无需继续递归。归并排序是分治法的经典范例,其最坏情况运行时间 远优于插入排序

1. 分治法范式

分治法(Divide and Conquer)

分治法是一种递归式的算法设计范式,其核心思路是将原问题分解为若干个与原问题结构相同但规模更小的子问题,递归地求解这些子问题,最后将子问题的解合并为原问题的解。

分治法在每一步执行三个操作:

  1. 分解(Divide): 将问题划分为一个或多个与原问题相同形式但规模更小的子问题
  2. 解决(Conquer): 递归地求解各子问题。当子问题规模足够小时,直接求解(基准情况
  3. 合并(Combine): 将子问题的解合并为原问题的解

分治法的直觉理解:整理书架

想象你要将书架上 1000 本乱序的书按字母顺序排列。分治法的思路是:

  • 分解: 把书架从中间分成左右两半,每半约 500 本
  • 解决: 分别整理左半边和右半边(各自再继续分治)
  • 合并: 两半都整理好后,像拉链一样将两排有序的书合并为一排

当书架上只剩 1 本书时,它天然就是有序的——这就是基准情况。

2. 归并排序(MERGE-SORT)

归并排序(Merge Sort)

归并排序是分治法的经典应用,对子数组 进行排序。其三个步骤为:

  • 分解: 计算中点 ,将 分为
  • 解决: 递归调用 MERGE-SORT 分别排序
  • 合并: 调用 MERGE 将两个已排序的子数组合并为一个有序的

基准情况:当 时,子数组至多包含一个元素,天然有序,直接返回。

MERGE-SORT 伪代码:

算法执行流程

  1. p >= r,子数组至多一个元素,直接返回
  2. 计算中点 q = floor((p+r)/2)
  3. 递归排序左半部分 A[p..q]
  4. 递归排序右半部分 A[q+1..r]
  5. 调用 MERGE 合并两个有序子数组
flowchart TD
    A["MERGE-SORT(A, p, r)"] --> B{"p >= r?"}
    B -- 是 --> C["返回(基准情况)"]
    B -- 否 --> D["q = floor((p+r)/2)"]
    D --> E["递归 MERGE-SORT(A, p, q)"]
    E --> F["递归 MERGE-SORT(A, q+1, r)"]
    F --> G["MERGE(A, p, q, r)"]
MERGE-SORT(A, p, r)
1  if p ≥ r                            // 零个或一个元素?
2      return
3  q = ⌊(p + r)/2⌋                     // A[p..r] 的中点
4  MERGE-SORT(A, p, q)                 // 递归排序 A[p..q]
5  MERGE-SORT(A, q + 1, r)             // 递归排序 A[q+1..r]
6  // 将 A[p..q] 和 A[q+1..r] 合并为 A[p..r]
7  MERGE(A, p, q, r)

归并排序的完整执行过程

对输入数组 )执行归并排序:

分解阶段(自顶向下):

MERGE-SORT(A, 1, 8)
├── MERGE-SORT(A, 1, 4)
│   ├── MERGE-SORT(A, 1, 2)
│   │   ├── MERGE-SORT(A, 1, 1) → 基准情况,返回
│   │   ├── MERGE-SORT(A, 2, 2) → 基准情况,返回
│   │   └── MERGE(A, 1, 1, 2) → ⟨3, 12⟩
│   ├── MERGE-SORT(A, 3, 4)
│   │   ├── MERGE-SORT(A, 3, 3) → 基准情况,返回
│   │   ├── MERGE-SORT(A, 4, 4) → 基准情况,返回
│   │   └── MERGE(A, 3, 3, 4) → ⟨7, 9⟩
│   └── MERGE(A, 1, 2, 4) → ⟨3, 7, 9, 12⟩
└── MERGE-SORT(A, 5, 8)
    ├── MERGE-SORT(A, 5, 6)
    │   ├── MERGE-SORT(A, 5, 5) → 基准情况,返回
    │   ├── MERGE-SORT(A, 6, 6) → 基准情况,返回
    │   └── MERGE(A, 5, 5, 6) → ⟨6, 14⟩
    ├── MERGE-SORT(A, 7, 8)
    │   ├── MERGE-SORT(A, 7, 7) → 基准情况,返回
    │   ├── MERGE-SORT(A, 8, 8) → 基准情况,返回
    │   └── MERGE(A, 7, 7, 8) → ⟨2, 11⟩
    └── MERGE(A, 5, 6, 8) → ⟨2, 6, 11, 14⟩

最终 MERGE(A, 1, 4, 8) → ⟨2, 3, 6, 7, 9, 11, 12, 14⟩

3. MERGE 过程

MERGE 过程

MERGE(A, p, q, r) 是归并排序的核心子过程,将两个已排序的相邻子数组 合并为一个有序的 。其前提条件是 ,且两个子数组已经分别有序。

MERGE 的工作方式类似于整理两叠面朝上的扑克牌:每次比较两叠牌顶部的最小牌,将较小者放入输出堆,直到某一叠为空,然后将另一叠剩余的牌全部放入输出堆。

MERGE 伪代码:

MERGE(A, p, q, r)
 1  nL = q - p + 1          // A[p..q] 的长度
 2  nR = r - q              // A[q+1..r] 的长度
 3  let L[0..nL-1] and R[0..nR-1] be new arrays
 4  for i = 0 to nL - 1     // 复制 A[p..q] 到 L
 5      L[i] = A[p + i]
 6  for j = 0 to nR - 1     // 复制 A[q+1..r] 到 R
 7      R[j] = A[q + j + 1]
 8  i = 0                   // i 指向 L 中剩余最小元素
 9  j = 0                   // j 指向 R 中剩余最小元素
10  k = p                   // k 指向 A 中待填充位置
11  // 只要 L 和 R 中都还有未合并的元素,
12  // 就将最小的未合并元素复制回 A[p..r]
13  while i < nL and j < nR
14      if L[i] ≤ R[j]
15          A[k] = L[i]
16          i = i + 1
17      else A[k] = R[j]
18          j = j + 1
19      k = k + 1
20  // 复制 L 的剩余元素到 A 的末尾
21  while i < nL
22      A[k] = L[i]
23      i = i + 1
24      k = k + 1
25  // 复制 R 的剩余元素到 A 的末尾
26  while j < nR
27      A[k] = R[j]
28      j = j + 1
29      k = k + 1

MERGE 过程的逐步执行演示

调用 ,其中

步骤 1:复制到临时数组

步骤 2:主合并循环(第 12-18 行)

迭代比较操作
初始00921? 否, 1
1011022? 是, 2
2111142? 否, 2
3121243? 否, 3
4131345? 是, 4
5231465? 否, 5
624156,循环终止

步骤 3:复制剩余元素

  • ,第 24-27 行循环执行 0 次
  • ,第 20-23 行复制

最终结果:

MERGE 过程的时间复杂度分析

MERGE 在 时间内完成():

  • 第 1-3 行和第 8-10 行:各为常数时间
  • 第 4-7 行的 for 循环:复制 个元素,
  • 第 12-18、20-23、24-27 行的三个 while 循环:每次迭代恰好将一个元素从 复制回 ,且每个元素恰好被复制一次,因此三个循环总共执行 次迭代,每次迭代为常数时间,总计

4. 递归关系式与归并排序分析

递归关系式(Recurrence)

递归关系式(或递归方程)用于描述包含递归调用的算法的运行时间。它将规模为 的问题的运行时间表示为更小规模输入上同一算法的运行时间的函数。

分治算法的递归关系式的一般形式为:

其中:

  • = 子问题的数量
  • = 每个子问题的规模
  • = 分解步骤的代价
  • = 合并步骤的代价

归并排序的递归关系式

对归并排序,设 为最坏情况下排序 个元素所需的运行时间:

  • 分解: 计算中点
  • 解决: 递归求解两个规模为 的子问题,代价为
  • 合并: MERGE 过程在 个元素上运行,

因此归并排序的递归关系式为:

其中 包含了分解的 和合并的 (线性函数主导常数)。

5. 递归树方法

递归树(Recursion Tree)

递归树是一种将递归关系式展开为树形结构的可视化方法,用于直观理解和求解递归关系式。树的每个节点表示一次递归调用的代价,子节点表示递归展开后的子问题代价。

为简化分析,假设 是 2 的幂,且基准情况为 。此时递归关系式可写为:

其中 是规模为 1 时的代价, 是分解和合并步骤中每个数组元素的单位代价。

递归树的构造与逐层分析

为例,构造递归树:

                       T(8)
                      /    \
                 c₂·8        c₂·8
                /    \      /    \
           T(4)    T(4)  T(4)    T(4)
          c₂·4    c₂·4  c₂·4    c₂·4
          / \      / \    / \      / \
        T(2) T(2) T(2) T(2) ...(共 8 个 T(2) 节点)
       c₂·2 c₂·2 c₂·2 c₂·2 ...(每节点代价 c₂·2)
       / \   / \   / \   / \
     T(1) ...(共 16 个 T(1) 叶节点,每节点代价 c₁)

逐层代价分析:

层级(距顶部)节点数每节点代价该层总代价
0(根)1
12
24
(叶)

关键观察: 叶以上的每一层代价都是 ——节点数翻倍但每节点代价减半,两者恰好抵消。

总层数: (从第 0 层到第 层)

总代价计算:

归并排序 vs 插入排序

归并排序用 因子替换了插入排序中的 因子:

由于对数函数的增长速度慢于任何线性函数(即 ),这是一个非常有利的交换。对于足够大的输入,归并排序的 最坏情况运行时间远优于插入排序的

例如,当 时:

  • 插入排序: 次操作
  • 归并排序: 次操作
  • 归并排序快约 50,000 倍

补充理解与拓展

分治法的历史与应用

分治法的思想可以追溯到古代数学。公元 1800 年左右,Joseph-Marie Jacquard 在提花织机中使用了分治思想来分解复杂的编织图案。在计算机科学中,分治法在 1945 年由 John von Neumann 首次在归并排序中正式提出。此后,分治法催生了众多经典算法:快速排序(Hoare, 1962)、Strassen 矩阵乘法(1969)、快速傅里叶变换 FFT(Cooley-Tukey, 1965)等。分治法也是并行计算和分布式系统的理论基础之一——子问题天然可以在不同处理器上独立求解。

来源:T. H. Cormen et al., Introduction to Algorithms, 4th ed., MIT Press, 2022, Section 4.1; D. E. Knuth, The Art of Computer Programming, Vol. 3: Sorting and Searching, Addison-Wesley, 1973.

递归树方法的数学基础

递归树方法是求解分治递归关系式的多种技术之一。第 4 章将系统介绍三种主要方法:(1) 代入法(substitution method)——先猜测解的形式,再用数学归纳法证明;(2) 递归树法(recursion-tree method)——将递归展开为树,逐层求和;(3) 主定理(master theorem)——直接给出形如 的递归关系式的渐近解。本节使用的递归树方法虽然直观,但在形式化证明中需要结合归纳法才能严格成立。主定理则提供了最便捷的”公式化”求解途径。

来源:T. H. Cormen et al., Introduction to Algorithms, 4th ed., MIT Press, 2022, Chapter 4 “Divide-and-Conquer”.


易混淆点与辨析

"分治法"与"递归"的混淆

初学者常将分治法等同于递归,认为”递归就是分治”。

分治法(Divide and Conquer)递归(Recursion)
本质一种算法设计策略,强调”分解—解决—合并”三步骤一种程序实现技术,函数调用自身
关系分治法通常使用递归来实现递归可用于实现分治法,也可用于其他目的(如遍历)
判定标准是否将问题分解为独立子问题后合并是否存在函数的自调用
  • ❌ “递归算法就是分治算法”
  • ✅ “分治法是一种利用递归实现的算法设计范式,其核心特征是将问题分解为独立子问题、递归求解后合并结果。并非所有递归算法都是分治算法(如递归求阶乘并不分解问题)”

"MERGE 的 "与"归并排序的 "的混淆

初学者常混淆 MERGE 子过程和整个归并排序的时间复杂度。

  • ❌ “归并排序的时间复杂度是 ,因为 MERGE 是 的”
  • ✅ “MERGE 单次调用确实是 ,但归并排序需要递归地调用 MERGE 共 次(分布在 层上,每层合并总量为 个元素),因此总时间为

直觉理解:MERGE 只负责”合并两个有序数组”这一步操作,而归并排序需要先递归地将数组不断一分为二直到单个元素,再逐层合并回去。递归树清楚地展示了这一点——每一层合并的总代价都是 ,共有 层。


习题精选

题号核心考点难度
2.3-1归并排序的完整执行过程⭐⭐
2.3-2MERGE-SORT 基准情况的边界条件⭐⭐
2.3-3MERGE 过程的循环不变式证明⭐⭐⭐
2.3-4递归关系式的数学归纳法证明⭐⭐⭐
2.3-5递归版本的插入排序⭐⭐

视频学习指南

资源链接对应内容备注
MIT 6.006 Lecture 3: Divide and Conquerhttps://www.youtube.com/watch?v=4mzE4Wz4BmQ分治法、归并排序、递归关系式Erik Demaine 教授
MIT 6.006 Lecture 2: Sortinghttps://www.youtube.com/watch?v=FEWfLb5ZQgk插入排序与归并排序对比Erik Demaine 教授
河南大学《算法导论》中文字幕版https://www.bilibili.com/video/BV1H4411B7FY2.3 分治法、归并排序中文授课,适合入门
Abdul Bari - Merge Sorthttps://www.youtube.com/watch?v=mB5Hb4kZKmk归并排序动画演示直观的逐步动画

教材原文

教材原文摘录

“Many useful algorithms are recursive in structure: to solve a given problem, they recurse (call themselves) one or more times to handle closely related subproblems. These algorithms typically follow the divide-and-conquer method: they break the problem into several subproblems that are similar to the original problem but smaller in size, solve the subproblems recursively, and then combine these solutions to create a solution to the original problem.”

“The merge sort algorithm closely follows the divide-and-conquer method. In each step, it sorts a subarray A[p : r], starting with the entire array A[1 : n] and recursing down to smaller and smaller subarrays.”

“When an algorithm contains a recursive call, you can often describe its running time by a recurrence equation or recurrence, which describes the overall running time on a problem of size n in terms of the running time of the same algorithm on smaller inputs.”

“Compared with insertion sort, whose worst-case running time is , merge sort trades away a factor of for a factor of . Because the logarithm function grows more slowly than any linear function, that’s a good trade.”


参见 Wiki

分治法