Lesson 0407 · 排序算法:归并与快排

④ 数据结构与算法 — 第 7 课

上节课:堆 —— 只维护极值,就能把「反复取最小」做到 O(log n)。
这节课:把 sort 拆开,自己写出两个 O(n log n) 的排序。这是「分治」思想的第一次实战,也是后面学逆序对、第 k 小、外部排序的地基。
三句话速览
  1. 归并:先分到底,再把两个有序段合并(要 tmp)。
  2. 快排:按基准分区,基准一次归位,再递归两边。
  3. 快排最坏 O(n²),固定端点 + 数据有序就会踩中。

1. 你用了一年 sort,它内部到底是什么

从 0002 课的 sort(a.begin(), a.end()) 开始,你已经用了几十次。但有两个问题一直没回答:

今天自己写两个 O(n log n) 的排序,你就知道了:没有任何排序能在 O(n log n) 以下完成比较排序(这是理论下界),而下面这两个算法正好达到它,只是用的「分治方式」不同。

2. 先回顾一下你已经会的三种排序

算法思路平均最坏额外空间稳定
冒泡排序相邻两个比大小,反复交换O(n²)O(n²)O(1)稳定
选择排序每轮选出最小值放到前面O(n²)O(n²)O(1)不稳定
插入排序把每个元素插进前面已排好的部分O(n²)O(n²)O(1)稳定
归并排序分成两半各自排好,再合并O(n log n)O(n log n)O(n)稳定
快速排序选个基准,小的扔左边大的扔右边O(n log n)O(n²)O(log n)不稳定
什么叫「稳定」?
值相等的两个元素,排序后原来的先后顺序保持不变。
例:按分数排序时,Alice 和 Bob 都是 90 分,稳定排序保证原来排在前面的那个还在前面。
什么时候重要?多关键字排序——先按班级排,再按成绩稳定排序,同分的同学内部还保持班级顺序。

3. 分治:先拆小,再合起来

两个算法都基于同一个套路,叫分治(divide and conquer):

  1. 分:把大问题拆成两个更小的同类问题
  2. 治:小到只剩 1 个元素时,它天然有序(这就是递归出口)
  3. 合:把两个小答案合成大答案

区别只在第三步:归并是「先分到底,再合并有序段」;快排是「先按基准分好,再分别递归」。这也是很多人的第一个分治题(P1908 逆序对)的核心。

4. 归并排序

4.1 最关键的动作:合并两个有序数组

假设左半边和右半边各自已经有序,怎么合成一个有序的?——两个指针各指一边,每次取较小的那个。

左:1 4 7        右:2 3 9

比较 1 和 2 → 取 1
比较 4 和 2 → 取 2
比较 4 和 3 → 取 3
比较 4 和 9 → 取 4
比较 7 和 9 → 取 7
右边还剩 9  → 取 9
结果:1 2 3 4 7 9
void merge(vector<int>& a, int l, int mid, int r, vector<int>& tmp) {
    int i = l, j = mid + 1;
    int 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 t = l; t <= r; t++) a[t] = tmp[t];   // 拷回原数组
}
为什么必须用 tmp 暂存?
合并时是「边读边写」同一个数组,直接往 a[l..r] 里写会覆盖掉还没读的元素。
所以先写进临时数组,最后整段拷回。这也正是归并排序需要 O(n) 额外空间的原因。
顺带一提:a[i] <= a[j] 里那个等号就是「稳定」的开关——写成 < 就不再稳定了。

4.2 递归:分成两半,各自排好

void mergeSort(vector<int>& a, int l, int r, vector<int>& tmp) {
    if (l >= r) return;            // 只剩 0 或 1 个元素,天然有序,递归出口

    int mid = (l + r) / 2;
    mergeSort(a, l, mid, tmp);         // 左半边排好
    mergeSort(a, mid + 1, r, tmp);     // 右半边排好
    merge(a, l, mid, r, tmp);          // 再把两半合并
}

int main() {
    vector<int> a = {7, 3, 9, 1, 4, 2, 8};
    vector<int> tmp(a.size());
    mergeSort(a, 0, a.size() - 1, tmp);
    for (int x : a) cout << x << " ";   // 1 2 3 4 7 8 9
}

4.3 为什么是 O(n log n)

第 1 层:7 3 9 1 4 2 8                    (1 段,8 个元素)
第 2 层:7 3 9 1 | 4 2 8                  (2 段)
第 3 层:7 3 | 9 1 | 4 2 | 8              (4 段)
第 4 层:7 | 3 | 9 | 1 | 4 | 2 | 8        (8 段,每段只剩 1 个,到底了)

然后一路合并回去:
3 7 | 1 9 | 2 4 | 8   →   1 3 7 9 | 2 4 8   →   1 2 3 4 7 8 9

5. 手推:同一组数,两种排序各走一遍

就用 4.2 节 main 里那组数:7 3 9 1 4 2 8。下面两张表里每一行,都是你可以拿纸笔自己划出来的状态。

5.1 冒泡:每轮把当前最大的「冒」到末尾

规则只有一句:从左到右两两比,前面比后面大就交换。一轮下来,剩下部分里最大的那个一定停在了最右边。

轮次这一轮之后数组变成什么
初始—7 3 9 1 4 2 8
第 1 轮9 冒到最后3 7 1 4 2 8 9
第 2 轮8 停在倒数第二3 1 4 2 7 8 9
第 3 轮7 归位1 3 2 4 7 8 9
第 4 轮4 归位,前四个已经就绪1 2 3 4 7 8 9
第 5 轮前三个两两比过,一次都没换1 2 3 4 7 8 9

盯着第 3 轮和第 4 轮:第 3 轮结束时是 1 3 2 4 7 8 9,只有 3 和 2 还是反的;第 4 轮只交换了这一次,数组就有序了。
第 5 轮一次交换都没有——这就是「冒泡可以提前收工」的信号(本课的写法没有加这个优化,所以代码里仍然会跑满 n−1 轮)。

5.2 归并:先把段分到 1 个元素,再一段段并回来

4.3 节画的是「层」,这里换成「第几轮合并」,看数组每一轮长什么样。

轮次这一轮做的事数组变成什么
初始一直分到每段 1 个元素7 | 3 | 9 | 1 | 4 | 2 | 8
第 1 轮每两段合并成一段3 7 | 1 9 | 2 4 | 8
第 2 轮相邻两段再合并1 3 7 9 | 2 4 8
第 3 轮最后两段合并1 2 3 4 7 8 9

看出区别了吗?冒泡一轮只让一个元素归位,一共要走 4 轮多;归并的每一轮都在合并很多小段,所以 3 轮就到头。这就是 O(n²) 和 O(n log n) 的差别在「表上的行数」里的样子。

5.3 五种排序放一起对照

算法最好平均最坏稳定性
冒泡排序O(n²)O(n²)O(n²)稳定
选择排序O(n²)O(n²)O(n²)不稳定
插入排序O(n)O(n²)O(n²)稳定
归并排序O(n log n)O(n log n)O(n log n)稳定
快速排序O(n log n)O(n log n)O(n²)不稳定

「最好」这一列,原文只写明了归并(「最好最坏都一样」)。其余几格是按本课讲的写法推的:冒泡、选择每一轮都要把剩下的部分扫完,最好也省不掉,仍是 O(n²);插入在数组已经有序时,每个元素只跟前面比一次就停,是 O(n);快排每次都能均匀划分时是 O(n log n)。平均和最坏两列,和原文完全一致。

6. 快速排序

6.1 最关键的动作:partition(分区)

选一个元素当基准(pivot),把区间整理成三部分:[小于等于基准] [基准] [大于基准]。整理完,基准就已经在它最终该在的位置上了,不用再动。

int partition(int a[], int l, int r) {
    int pivot = a[r];              // 简单起见,拿最右边那个当基准
    int i = l;                     // i 是"小于等于区"的下一个空位

    for (int j = l; j < r; j++) {
        if (a[j] <= pivot) {
            swap(a[i], a[j]);
            i++;
        }
    }
    swap(a[i], a[r]);              // 基准放到分界点上
    return i;                      // 返回基准的最终下标
}
以 3 7 1 9 4 2 8、基准 = 8(最右)为例:i 一开始在 0

j=0  3 <=8  换到 i=0 位置:3 7 1 9 4 2 8     i=1
j=1  7 <=8  换到 i=1 位置:3 7 1 9 4 2 8     i=2
j=2  1 <=8  换到 i=2 位置:3 7 1 9 4 2 8     i=3
j=3  9  >8  跳过                         i=3
j=4  4 <=8  换到 i=3 位置:3 7 1 4 9 2 8     i=4
j=5  2 <=8  换到 i=4 位置:3 7 1 4 2 9 8     i=5

最后 swap(a[i], a[r]):3 7 1 4 2 8 9
基准 8 落在下标 5,左边全 <=8,右边全 >8,它已经归位

6.2 递归:基准两边分别再排

void quickSort(int a[], int l, int r) {
    if (l >= r) return;            // 0/1 个元素,出口

    int p = partition(a, l, r);    // 基准归位
    quickSort(a, l, p - 1);        // 左边自己排
    quickSort(a, p + 1, r);        // 右边自己排
}

注意它和归并的顺序相反:归并是「先递归到底,再合并」;快排是「先分区,再递归」。快排不需要 tmp 数组,所以额外空间只有递归栈的 O(log n)。

6.3 最坏情况:它真的会退化

如果每次选的基准都是当前区间的最大值或最小值,那么每次只能分出一个元素,递归 n 层,退化成 O(n²)。什么时候会发生?数据已经有序,而你固定取端点做基准——最典型的情况。

实测(n = 20000,数据已经升序):

固定取末尾做基准:  103 ms 且递归深度 20000 层(再大就会爆栈)
改成随机基准:      0.0 ms
两个实际后果
① 不只是慢:递归深度达到 n 时会栈溢出(RE),比 TLE 更难查。
② 洛谷 P1177 这类题的数据对「固定基准的朴素快排」不友好,很多人第一次交快排就 TLE——不是你的逻辑错,是基准选得太老实。

6.4 怎么修:随机基准 / 三数取中

最省事的改法:分区前随机挑一个位置,先和末尾交换,后面的逻辑一字不改。

int partitionRand(int a[], int l, int r) {
    int k = l + rand() % (r - l + 1);   // [l, r] 里随机取一个
    swap(a[k], a[r]);                   // 换到末尾,复用上面的 partition
    return partition(a, l, r);
}

工程上还有「三数取中」(取 l、mid、r 三个里的中位数当基准)等优化。不过平时写题,直接用 std::sort 就是最优解——自己写快排主要是为了理解思想。

7. 归并 vs 快排,和 std::sort 是什么关系

归并排序快速排序
核心动作合并两个有序段按基准分区
递归顺序先递归,后合并先分区,后递归
平均 / 最坏O(n log n) / O(n log n)O(n log n) / O(n²)
额外空间O(n)O(log n)(递归栈)
稳定性稳定不稳定
实际速度稍慢(要拷数组)更快(原地交换,缓存友好)
什么时候选要求稳定;或做链表排序、外部排序一般排序;求第 k 小

那 std::sort 用的是哪个?都不是纯粹的一个——它是 introsort(内省排序):

所以 std::sort 是「平均快 + 最坏也不退化」的组合拳。需要稳定就用 stable_sort(内部是归并,代价是额外空间)。

8. 两个升级用法

8.1 逆序对(归并排序的经典应用)

逆序对 = 满足 i < j 且 a[i] > a[j] 的数对个数(P1908)。暴力是 O(n²),用归并在合并时顺手数出来:

什么时候产生逆序对?合并时,如果要去右边数组的元素,说明它比左边剩下的所有元素都小——这些全是逆序对。

long long invCnt = 0;                 // 逆序对可能很多,必须 long long

void mergeInv(vector<int>& a, int l, int r, vector<int>& tmp) {
    if (l >= r) return;
    int mid = (l + r) / 2;
    mergeInv(a, l, mid, tmp);
    mergeInv(a, mid + 1, r, tmp);

    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) {
            tmp[k++] = a[i++];
        } else {
            invCnt += mid - i + 1;    // 右边这个比左边剩下的全都小
            tmp[k++] = a[j++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; t++) a[t] = tmp[t];
}

样例 6 / 5 4 2 6 3 1 的答案是 11。

8.2 第 k 小(快排的 partition 变体)

快排每次分区后,基准的位置是最终位置。所以只要看基准下标和 k 谁大:

每层只走一边,总工作量是 n + n/2 + n/4 + ... ≈ 2n,所以期望复杂度是 O(n)。STL 里现成的工具是 nth_element(P1923 可以用它快速通过):

nth_element(a.begin(), a.begin() + k - 1, a.end());
cout << a[k - 1] << endl;      // 第 k 小的数已经就位

9. 坑点清单

10. 小测

Q1. 归并排序最关键的辅助动作是什么?
Q2. 快排里 partition 做完之后,基准元素处于什么状态?
Q3. 对已经升序的数组用「固定取末尾做基准」的快排,复杂度是?
Q4. 归并排序需要 O(n) 的额外空间,根本原因是?
Q5. std::sort 内部最接近哪种组合?

11. 动手练习

题目1:手写归并排序(必做)

把第 4 节的 merge + mergeSort 抄进编辑器,用下面这组数据验证:
7 3 9 1 4 2 8 → 应输出 1 2 3 4 7 8 9
再加一步:把 tmp 数组去掉,直接往 a 里 merge,看看结果错在哪——亲手踩一次印象最深。
题目2:洛谷 P1177 【模板】排序(必做)

第一遍直接用 sort 交(体会 STL 的强大),第二遍用手写快排交。
如果第二遍 TLE,先别怀疑自己的逻辑——把基准改成随机的再交一次。
题目3:洛谷 P1908 逆序对(必做,本课重点)

把 8.1 节的代码补全成完整程序:读入 n 和数组,输出逆序对个数。
两个坑:答案要用 long long;样例是 6 / 5 4 2 6 3 1 → 11。
先写暴力 O(n²) 版本对答案,再换成归并,对比一下 n 大时的差距。
题目4:洛谷 P1923 求第 k 小的数(挑战,选做)

n 很大,输入输出要用 scanf / printf(或者关掉同步流)。
简单版:nth_element 一行解决;进阶版:自己写按 partition 只递归一边的 O(n) 版本。

卡住时的自查顺序

  1. 先用 7 3 9 1 4 2 8 对着第 5 节的两张表手推一遍,看轮数和数组对不上的是哪一步。
  2. 归并:下标是闭区间 [l, r] 吗?k 从 l 开始、最后只拷 l..r 吗?
  3. 归并:tmp 开够了吗?用的是 vector<int> tmp(a.size()) 吗?
  4. 稳定吗?比较写的是 <= 还是不小心写成了 <?
  5. 快排 TLE 了?先把基准改成随机的交一次,再看递归边界 l >= r。
  6. 逆序对用 long long 了吗?样例 6 / 5 4 2 6 3 1 能得 11 吗?

本课小结

下一课预告
0408 图与图的遍历 —— 换一种完全不同的结构:图。先学怎么把图存进邻接表,再学 DFS(一条路走到黑)和 BFS(一层层往外扩),最后用方向数组解决网格图(P5318、P1605、P1443)。
排序里的「分治」会暂时退场,但「用递归把自己拆小」的写法,下一课还要接着用。

← 上一课:堆与优先队列 · 课程对照表

书籍 《算法 (第4版)》第 2.1-2.2 节(归并排序、快速排序)