Skip to content

第 41 章 分治排序——归并排序与快速排序


🏗️ 前情回顾:上一章你学会了 sort(),知道它背靠快速排序,平均 O(n log n)。但光会调用还不够——当题目说"请用归并排序求逆序对"或"实现快排的 partition 过程"时,你必须懂原理。这一章,我们深入两种经典的分治排序:归并排序和快速排序,手写实现,彻底搞懂 O(n log n) 是怎么来的。


🎯 本章目标

学完这一章,你能:

  • 理解分治思想:把大问题拆成小问题,分别解决后合并
  • 手写归并排序(Merge Sort),理解它永远是 O(n log n) 且稳定
  • 手写快速排序(Quick Sort),理解选基准和分区过程
  • 知道快排最坏 O(n²) 的原因以及随机化避免方法
  • 对比归并排序和快速排序的优缺点
  • 为接下来用归并排序求逆序对打好基础

📖 故事引入

假设你是班长,老师让你把全班 40 份试卷按学号从小到大排好。你一个人面对 40 份乱糟糟的卷子,怎么排最快?

你可能会这样做:把 40 份试卷分成两堆,每堆 20 份,分别让两个小组长去排。两个小组长又各分一半给四个副组长……直到每人手里只有 1 份(天然有序)。然后一层一层往上传:左右两堆都是有序的,合并起来就行。

这就是归并排序——典型的"分而治之":分一半 → 分别排 → 合并。

换一种思路:你不分给人,而是随手抽一份试卷当"标杆"(比如学号 25),然后把学号小于 25 的放左边,大于 25 的放右边。左右两堆再分别用同样的方法排……

这就是快速排序——选基准 → 分左右 → 递归。


🧱 知识讲解

41.1 分治思想回顾

分治法(Divide and Conquer) 的三部曲:

  1. 分(Divide):把原问题拆成若干个规模更小的同类子问题
  2. 治(Conquer):递归地解决每个子问题。如果子问题足够小(比如只剩 1 个元素),直接解决
  3. 合(Combine):把子问题的解合并成原问题的解

你已经在第 26 章学过递归了,分治其实就是"先递归,再合并"。

41.2 归并排序(Merge Sort)

核心思想:把数组从中间切成两半,递归排序左右两半,然后把两个有序的子数组合并成一个有序数组。

过程图解[38, 27, 43, 3, 9, 82, 10]):

               [38,27,43,3,9,82,10]
              /                    \
      [38,27,43,3]           [9,82,10]
      /          \           /        \
  [38,27]     [43,3]      [9,82]    [10]
   /    \      /    \      /   \       |
[38]  [27]  [43]  [3]   [9]  [82]   [10]
   \  /       \  /        \  /        |
  [27,38]    [3,43]      [9,82]      [10]
       \      /              \        /
      [3,27,38,43]          [9,10,82]
                \           /
            [3,9,10,27,38,43,82]

C++ 实现

cpp
const int MAXN = 100005;
int a[MAXN], tmp[MAXN];          // tmp 是合并用的临时数组

// 合并两个有序子数组 a[l..mid] 和 a[mid+1..r]
void merge(int l, int mid, int r) {
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j])
            tmp[k++] = a[i++];
        else
            tmp[k++] = a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];   // 左边有剩余
    while (j <= r)   tmp[k++] = a[j++];   // 右边有剩余
    for (int p = l; p <= r; p++)          // 拷贝回原数组
        a[p] = tmp[p];
}

// 归并排序主函数
void mergeSort(int l, int r) {
    if (l >= r) return;                   // 只剩 1 个元素,天然有序
    int mid = l + (r - l) / 2;            // 防止 (l+r) 溢出
    mergeSort(l, mid);                    // 递归排序左半
    mergeSort(mid + 1, r);                // 递归排序右半
    merge(l, mid, r);                     // 合并
}

🎯 复杂度:无论什么数据,总是 O(n log n)。空间 O(n)(需要临时数组)。稳定

为什么是 O(n log n)?

  • 递归树有 log₂n 层(每次对半分)
  • 每层合并的开销是 O(n)(扫描所有元素)
  • 总复杂度 = O(n) × O(log n) = O(n log n)

41.3 快速排序(Quick Sort)

核心思想:选一个"基准值"(pivot),把所有小于基准的移到左边,大于基准的移到右边。然后对左右两部分递归地做同样的事。

过程图解[38, 27, 43, 3, 9, 82, 10],选 10 为基准):

第1轮 partition(选10):
  [38,27,43,3,9,82,10]
  → 10放中间 → 左边[3,9] 右边[38,27,43,82]
  结果:[3,9] 10 [38,27,43,82]

递归左[3,9], 选9:
  [3] 9 []

递归右[38,27,43,82], 选82:
  [38,27,43] 82 []

递归继续...

C++ 实现(随机化版本)

cpp
#include <cstdlib>   // rand()
#include <ctime>     // time()

int partition(int l, int r) {
    // 随机选基准,与最右元素交换(避免最坏情况)
    int pivotIdx = l + rand() % (r - l + 1);
    swap(a[pivotIdx], a[r]);

    int pivot = a[r];                     // 基准值
    int i = l - 1;                        // i 指向"小于区"的末尾
    for (int j = l; j < r; j++) {
        if (a[j] <= pivot) {              // 比基准小的
            i++;
            swap(a[i], a[j]);             // 交换到小于区
        }
    }
    swap(a[i + 1], a[r]);                 // 基准放在正确位置
    return i + 1;                         // 返回基准的最终位置
}

void quickSort(int l, int r) {
    if (l >= r) return;
    int p = partition(l, r);              // 分区,p 是基准位置
    quickSort(l, p - 1);                  // 递归左半
    quickSort(p + 1, r);                  // 递归右半
}

🎯 复杂度:平均 O(n log n),最坏 O(n²)(每次基准恰好是最大/最小值)。空间 O(log n)(递归栈)。不稳定

41.4 快速排序的最坏情况与应对

快排什么时候最慢?当每次选的基准都是最大或最小值时——比如对已排序数组 [1,2,3,4,5] 选最右边元素当基准,每次只能排除一个元素,退化为 O(n²)。

应对方法

  1. 随机化:随机选基准(代码如上),最坏概率极低
  2. 三数取中:取 a[l]a[mid]a[r] 的中位数作为基准
  3. 直接用 sort():STL 的 sort() 已经做了这些优化,普通竞赛选手无需操心

41.5 归并 vs 快排:选谁?

归并排序快速排序
时间复杂度总是 O(n log n)平均 O(n log n),最坏 O(n²)
空间复杂度O(n)(需临时数组)O(log n)(递归栈)
稳定性✅ 稳定❌ 不稳定
适合场景求逆序对、外部排序通用排序(sort 的基础)
缓存友好一般✅ 好(原地操作)

📊 关键用途:归并排序最大的价值不在排序本身,而在合并过程中可以顺便做很多事——比如求逆序对、求区间问题。第 43 章你会学到逆序对的两种求法,归并排序就是其中一种。


✋ 动手试试

试试 1:用归并排序对 {5, 2, 4, 7, 1, 3, 2, 6} 排序,在纸上画出递归树,写出每次 merge 后的结果。

试试 2:实现快排的 partition,用 rand() 随机选基准。测试:分别对升序数组、降序数组和随机数组跑一遍,记录运行时间。

试试 3:对比归并排序和快排的执行过程——把两种排序都加上打印语句,输出每步的数组状态,观察它们"做事顺序"的差异。


⚠️ 容易犯的错

错 1:归并排序忘记把 tmp 拷回 a

❌ 归并排序执行完了,数组 a 没变化——忘了 for (int p = l; p <= r; p++) a[p] = tmp[p];

✅ 合并完成后必须拷回去,否则递归上层用的还是旧数据

错 2:快排 partition 用 a[j] < pivot 而非 <=

❌ 用 < 的话,等于 pivot 的元素全部被分到右边,在某些情况下会导致分区极不均匀

✅ 用 <= 把等于 pivot 的元素平均分到两边,分区更均衡

错 3:递归忘了写终止条件

mergeSort(l, r)quickSort(l, r) 里没有 if (l >= r) return;

✅ 必须有终止条件,否则无限递归 → 爆栈(Stack Overflow)

错 4:快排基准始终选最右元素

❌ 对已排序数组会退化到 O(n²)

✅ 至少用随机化 rand(),或者直接用 sort() 不操心


📝 练习

基础题

1. 选择题

(1)归并排序的时间复杂度是:
A. O(n)   B. O(n log n)   C. O(n²)   D. O(log n)

(2)快速排序最坏情况出现在:
A. 数组已经排好序   B. 数组随机   C. 数组所有元素相同   D. 取决于选的基准

(3)归并排序和快排的共同特点是:
A. 都是稳定排序   B. 都需要额外 O(n) 空间   C. 都用了分治思想   D. 最坏都是 O(n²)

2. 填空题

(1)分治法的三部曲是:_____ → _____ → _____。

(2)归并排序需要 O(_____) 的额外空间(用大 O 表示)。

(3)快排中,随机选基准的目的是 ____________。

提高题

3. 编程题 — 手写归并排序

输入 n 和 n 个整数,用归并排序将它们升序排列并输出。要求:完整实现 mergeSort()merge() 函数,不允许使用 sort()

4. 编程题 — 手写快速排序

同上题,但用快速排序实现。要求实现 partition()quickSort(),使用随机化基准。

挑战题

5. 编程题 — 前 k 小的数

输入 n 个数和一个整数 k(1 ≤ k ≤ n ≤ 10⁵),输出最小的 k 个数(升序排列)。

提示:利用快排的 partition 思想——如果基准位置恰好是 k,那它左边的就都是最小的 k-1 个数。不需要完整排序!

(这其实就是"快速选择"算法 QuickSelect,平均 O(n))


🧠 本章小结

分治法 = 分 → 治 → 合

归并排序:
  分:从中间切开
  合:合并两个有序数组
  总是 O(n log n),稳定,需要 O(n) 临时空间

快速排序:
  分:选基准,小的放左,大的放右
  合:不需要!(左右递归完就排好了)
  平均 O(n log n),最坏 O(n²),随机化可避免最坏情况

手写它们的意义:理解分治、求逆序对、快速选择等变种
竞赛中推荐:直接用 sort() 除非题目有特殊要求

📝 配套练习

共4题。归并1题(核心)+快排1题+分治视角2题。。★核心(课堂必做) ◆拓展(课后练习)

级别题号链接覆盖知识点
★ 核心luogu-P1908https://hydro.ac/p/luogu-P1908归并排序、逆序对、mid-i+1
◆ 拓展luogu-P1177https://hydro.ac/p/luogu-P1177手写快排、划分过程
◆ 拓展luogu-P1115https://hydro.ac/p/luogu-P1115分治视角最大子段和
◆ 拓展luogu-P1010https://hydro.ac/p/luogu-P1010递归输出、分治表达

💡 练习建议:先完成 1 道★核心题,确保掌握本章基本方法;再完成 3 道◆拓展题,覆盖不同变式和细节。


配套练习

共4题。归并1题(核心)+快排1题+分治视角2题。★核心(课堂必做) · ◆拓展(课后练习)

级别题号链接覆盖知识点
★ 核心luogu-P1908https://hydro.ac/p/luogu-P1908归并排序、逆序对、mid-i+1
◆ 拓展luogu-P1177https://hydro.ac/p/luogu-P1177手写快排、划分过程
◆ 拓展luogu-P1115https://hydro.ac/p/luogu-P1115分治视角最大子段和
◆ 拓展luogu-P1010https://hydro.ac/p/luogu-P1010递归输出、分治表达

练习建议:先在课堂完成 1 道★核心题,掌握本章基本方法;课后完成 3 道◆拓展题,覆盖不同变式和细节。

自查清单

  • [ ] 我能解释分治思想并应用于排序
  • [ ] 我能手写归并排序(含 merge 函数)
  • [ ] 我能手写快速排序(含 partition 函数)
  • [ ] 我知道快排最坏 O(n²) 的原因和随机化避免方法
  • [ ] 我能对比归并和快排的时间/空间/稳定性
  • [ ] 我理解什么时候竞赛中选 sort()、什么时候要手写

🚀 下章预告:前面四种排序(冒泡、选择、插入、归并、快排)都是靠"比较"来排序的。有没有不比较也能排序的算法?有——计数排序和基数排序,它们在特定条件下能达到惊人的 O(n)!下一章,我们见识一下这些"不走寻常路"的排序方式。