阶段 2 · 排序与分治 · 第 10 章

排序:冒泡 → 归并 → 快排

排序本身用 sort 一行就完了。真正要学的是归并里那个「合并」,和自定义 cmp 的那个等号。

例题:数组排序 · 多关键字排序 建议用时:110 分钟
欢迎来到阶段 2

比赛里排序一律用 sort,一行搞定,没人手写。所以这一章的目标不是「学会排序」。

真正要拿走的是三样东西:

  1. 归并排序里的那个「合并」 —— 它是第 11 章求逆序对的核心零件, 也是第 2 章「大问题 = 小问题 + 一步真活 + 小问题」最标准的一次现身。
  2. 快排的「划分」 —— 以及为什么基准必须随机选。
  3. 自定义 cmp 的正确写法 —— 一个多写的等号能让程序直接崩溃, 这是 C++ 竞赛最经典的坑之一。

第 3 项尤其重要:它每年都在考场上放倒一批人,而且崩得莫名其妙。

前半场 · 三种排序

1 冒泡:所有人的第一个排序算法

相邻两个一比,左边大就换。扫一趟,最大的就沉到最右边;扫 n 趟就排好了。

bubble.cpp冒泡 O(n²)
输入(stdin)
输出
点「运行 ▶」看结果

那个 if (!swapped) break; 是常见的小优化:一整趟没换过就说明已经有序,提前收工。 它能让「已经排好序的输入」变成 O(n),但改变不了最坏情况 —— 逆序输入照样 O(n²)。

2 实测:n² 有多可怕

同题对比:冒泡 vs std::sort
先跑 3 万。跑完改成 5 万、8 万 —— 冒泡是 O(n²),n 翻倍它慢四倍;sort 几乎没反应。
冒泡
std::sort

本机实测:

n冒泡归并(手写)快排(手写)std::sort
10 0000.04 秒0.02 秒0.02 秒0.02 秒
30 0000.88 秒0.04 秒0.04 秒0.04 秒
50 0002.74 秒0.07 秒0.06 秒0.06 秒
1 000 000大约 3 小时0.14 秒0.13 秒0.12 秒
✓ 说句实话:后三列的时间几乎全花在读输入上

n = 5 万时,归并真正排序只用了几毫秒,剩下的都是 cin 读那 5 万个数的时间。

这也是个值得知道的事实:当算法足够快之后,输入输出就成了瓶颈。 所以那几份代码开头都有 ios::sync_with_stdio(false); cin.tie(nullptr); —— 这两行能让 cin 快好几倍,数据量大的题目里是必需品。

3 ★ 归并排序:分治的标准形状

★ 关键的一步
排 [l, r]  =  排左半边  +  把两个有序的半边合并起来  +  排右半边
              ↑小问题     ↑一步真活                    ↑小问题

三要素(还是第 1 章那三条):

  • 职责mergeSort(l, r)a[l..r] 排好序
  • 边界l >= r 时只有 0 或 1 个元素,本来就有序
  • 递推:先排好两半(信任它们),再合并

唯一真正干活的地方是「合并」,切的时候什么都没做。

合并为什么快?因为两边都已经有序了 —— 各伸一根手指指着开头, 每次比一下,取小的那个往前挪。这就是第 7 章的双指针,O(n) 走完。

merge.cpp归并 O(n log n)
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么是 O(n log n)
第 0 层:1 段,长 n        合并总量 n
第 1 层:2 段,各长 n/2    合并总量 n
第 2 层:4 段,各长 n/4    合并总量 n
...
第 k 层:切到长度 1 为止   一共 log₂n 层

每一层的合并总量都是 n,一共 log₂n 层,所以是 n·log₂n。

n = 100 万时,n² = 一万亿,而 n·log₂n = 两千万 —— 相差五万倍。

⚠ 两个实现细节
  1. **临时数组 tmp 要开在函数外面。**写在函数里的话,每层递归都要新建一个 vector,光分配内存就比排序还慢。
  2. 合并时用 a[i] <= a[j] 而不是 <相等时优先取左边的, 这样相等元素的相对顺序不变 —— 这叫稳定排序,下一步会讲它为什么重要。

4 快排:和归并分工正好相反

quick.cpp快排
输入(stdin)
输出
点「运行 ▶」看结果
★ 一句话记住两者的区别
一步真活在哪顺序
归并合并(后处理)先随便切两半,排完再合
快排划分(前处理)先按基准分堆,分完就完事,不用合

归并是「先分后治」,快排是「先治后分」。

快排划分完之后,左边全部 ≤ 基准、右边全部 ≥ 基准,基准本身已经在最终位置上了。 两边各自排好,整体自然有序 —— 不需要任何合并动作。

⚠ 基准必须随机选,否则会被卡到 O(n²)

如果永远取第一个元素当基准,遇到已经排好序的输入会怎样?

每次划分都切成「0 个 + n-1 个」—— 递归 n 层,每层扫 n 个,退化成 O(n²)。

而「已经排好序的数据」在测试数据里太常见了。出题人甚至会专门造这种数据 来卡不随机的快排(这在竞赛圈叫「卡快排」,是有传统的)。

随机选基准之后,要连续几十次都倒霉才会退化,概率低到可以忽略。

5 比赛里怎么写:sort 一行

stl.cppstd::sort
输入(stdin)
输出
点「运行 ▶」看结果
✓ std::sort 凭什么比你手写的快排稳

它是内省排序(introsort):默认走快排, 一旦发现递归太深(说明基准选得不好)就自动切换成堆排序, 数据量小的时候又切成插入排序。

结果是:最坏情况也是 O(n log n) —— 手写快排做不到这一点。

所以:理解要靠手写,比赛要用 sort。

6 单步看两种排序

归并:先切到底,再一层层合并回来
54 帧
第 1 / 54 步
5
2
9
1
5
6
3
8
 
当前区间
[0, 7]
递归深度
0
比较次数
0
蓝紫 = 左半边,橙 = 右半边,绿 = 已经合并好的一段。 注意「合并」是唯一真正干活的地方,切的时候什么都没做。
mergeSort(0, 7):切成 [0, 3] 和 [4, 7] 两半,先把两半各自排好(信任它们)。
  • 「冒泡」那栏:看最大的数一趟一趟往右沉,右边绿色的区域越来越大。 盯住底下的比较次数
  • 「归并」那栏:先一路切到只剩一个元素(那一段什么都没做), 然后一层层合并回来。紫色那一排是合并用的临时数组。

用同一组数据把两栏都播一遍,对比最后的比较次数。 默认那 8 个数(5 2 9 1 5 6 3 8):冒泡比较 25 次,归并 17 次 —— 差距还不明显; 但这个差距是按 n²/2 对 n·log₂n 拉开的,n 一大就是天壤之别。

7 ★ 对拍验证

★ 正确的用法

把「归并」那一栏换成你自己默写的,再点开始。 (想验快排也一样,把代码换成快排即可。)

对拍器
生成器专门造这几种:大量重复元素、已经有序、完全逆序、n=0(空数组)、n=1。最后两个是循环边界的照妖镜 —— 手写排序在空数组上挂掉是很常见的。

值得故意写错的:

  • mergeSort(mid, r)(少了 +1)→ 无限递归,栈溢出
  • while (i <= mid && j <= r) 后面忘了搬剩下的元素 → 少一半数据
  • tmp 拷回原数组时写成 for (p = 0; p <= r; p++) → 把别的区间也覆盖了
  • 快排里 while (i < j && a[j] >= pivot)>= 写成 > → 遇到大量重复元素会死循环

后半场 · 自定义 cmp

8 一句话问题

n 个学生的 (学号, 成绩),按成绩从高到低排序;成绩相同的,按学号从小到大

输入 5
     3 90
     1 85
     5 90
     2 70
     4 85
输出 3 90
     5 90
     1 85
     4 85
     2 70

9 ★ cmp 的三条铁律

★ 关键的一步

1. cmp(a, b) 的含义是「a 必须排在 b 前面吗」。

不是「a 比 b 大吗」,也不是「要不要交换」。想不清楚就把这句话念一遍。

2. 两个元素「一样」的时候,必须返回 false

所以比较相等的情况时,只用 <>绝对不要用 <=>=。 下一步会让你亲眼看到违反这条的后果。

3. cmp 里不要改动任何外部状态。

sort 内部会以未指定的顺序调用它任意多次。

多关键字的写法是「上一个分不出胜负,才看下一个」:

bool cmp(const Student& a, const Student& b) {
    if (a.score != b.score) return a.score > b.score;   // 第一关键字:成绩降序
    return a.id < b.id;                                  // 第二关键字:学号升序
}

注意最后一行不带 if —— 前面都相等了,这里必须给出确定的结论。

studentFast.cpp正确的 cmp
输入(stdin)
输出
点「运行 ▶」看结果

10 亲眼看一个等号让程序崩溃

badcmp.cpp⚠ 故意写错的
点运行。它大概率会「异常退出」—— 段错误或者内存报错。而它和正确版本的唯一区别,是 cmp 里多了一个等号。
输出
点「运行 ▶」看结果
⚠ 为什么一个等号能让程序崩溃

std::sort 要求 cmp 满足严格弱序,其中最关键的一条是:

cmp(a, a) 必须是 false —— 一个元素不可能排在自己前面。

写成 >= 之后,cmp(a, a) 返回 true。在 sort 眼里, 「a 严格排在 a 前面」这个矛盾成立了。

它内部的插入排序会一直往前找「该插在哪」,而在错误的 cmp 下这个条件永远成立 —— 于是指针一路冲出数组边界,读写到别人的内存上。

**崩溃还算走运。**更糟的是没崩,但悄悄改坏了别的变量, 然后你在一个完全无关的地方看到诡异结果,查一整天。

这就是「未定义行为」的可怕之处:它不保证报错,只保证什么都可能发生。

★ 这条规则请刻进肌肉记忆

写 cmp 时,相等就必须返回 false。比较符只用 <>,绝不用 <=>=

11 ★ 对拍:用冒泡当标准答案

对拍器
生成器让成绩只有 0~4 五种 —— 重复必然满地都是。如果每个人成绩都不一样,第二关键字根本用不上,「忘了写第二关键字」这种错误就永远抓不住。
✓ 为什么标准答案要用冒泡

因为冒泡只交换相邻元素,而且只在「严格该换」时才换, 所以它天然是稳定的,行为一目了然,不可能有未定义行为。

慢,但绝对可靠 —— 这正是对拍标准答案该有的样子。 (回想第 9 章那句话:标准答案最好用完全不同的思路写。)

值得故意写错的:

  • 忘了第二关键字return a.score > b.score; 一行了事)→ 成绩相同的人顺序随机,对拍立刻抓住
  • >= → 程序崩溃或输出乱掉
  • 两个关键字写反顺序 → 结果完全不对

12 稳定排序:什么时候需要它

★ 「稳定」的定义

排序前相等的元素,排序后相对顺序不变。

  • std::sort 不保证稳定(内部会到处交换)
  • std::stable_sort 保证稳定(代价是需要额外空间,慢一点点)
  • 归并排序天生稳定(合并时相等取左边),快排天生不稳定

什么时候必须要稳定?当题目说「成绩相同的按输入顺序输出」时。

不过更保险的做法是:把「输入顺序」也写成一个关键字

struct Student { int id, score, idx; };   // idx = 输入时的编号
if (a.score != b.score) return a.score > b.score;
return a.idx < b.idx;                     // 用 idx 兜底,就不依赖稳定性了

这样用 sort 也没问题。能不依赖稳定性就不依赖 —— 少一个隐含前提,少一个坑。

13 自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 11 章:分治求逆序对。

你会发现那道题的答案,就藏在这一章归并排序的合并那一步里 —— 一行代码都不用多写,只要在合并时顺手数一个数。

这是「学会一个零件,白捡一道题」的典型例子。