比赛里排序一律用 sort,一行搞定,没人手写。所以这一章的目标不是「学会排序」。
真正要拿走的是三样东西:
- 归并排序里的那个「合并」 —— 它是第 11 章求逆序对的核心零件, 也是第 2 章「大问题 = 小问题 + 一步真活 + 小问题」最标准的一次现身。
- 快排的「划分」 —— 以及为什么基准必须随机选。
- 自定义
cmp的正确写法 —— 一个多写的等号能让程序直接崩溃, 这是 C++ 竞赛最经典的坑之一。
第 3 项尤其重要:它每年都在考场上放倒一批人,而且崩得莫名其妙。
前半场 · 三种排序
1 冒泡:所有人的第一个排序算法
相邻两个一比,左边大就换。扫一趟,最大的就沉到最右边;扫 n 趟就排好了。
点「运行 ▶」看结果
那个 if (!swapped) break; 是常见的小优化:一整趟没换过就说明已经有序,提前收工。
它能让「已经排好序的输入」变成 O(n),但改变不了最坏情况 —— 逆序输入照样 O(n²)。
2 实测:n² 有多可怕
本机实测:
| n | 冒泡 | 归并(手写) | 快排(手写) | std::sort |
|---|---|---|---|---|
| 10 000 | 0.04 秒 | 0.02 秒 | 0.02 秒 | 0.02 秒 |
| 30 000 | 0.88 秒 | 0.04 秒 | 0.04 秒 | 0.04 秒 |
| 50 000 | 2.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) 走完。
点「运行 ▶」看结果
第 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 = 两千万 —— 相差五万倍。
- **临时数组
tmp要开在函数外面。**写在函数里的话,每层递归都要新建一个vector,光分配内存就比排序还慢。 - 合并时用
a[i] <= a[j]而不是<。相等时优先取左边的, 这样相等元素的相对顺序不变 —— 这叫稳定排序,下一步会讲它为什么重要。
4 快排:和归并分工正好相反
点「运行 ▶」看结果
| 一步真活在哪 | 顺序 | |
|---|---|---|
| 归并 | 合并(后处理) | 先随便切两半,排完再合 |
| 快排 | 划分(前处理) | 先按基准分堆,分完就完事,不用合 |
归并是「先分后治」,快排是「先治后分」。
快排划分完之后,左边全部 ≤ 基准、右边全部 ≥ 基准,基准本身已经在最终位置上了。 两边各自排好,整体自然有序 —— 不需要任何合并动作。
如果永远取第一个元素当基准,遇到已经排好序的输入会怎样?
每次划分都切成「0 个 + n-1 个」—— 递归 n 层,每层扫 n 个,退化成 O(n²)。
而「已经排好序的数据」在测试数据里太常见了。出题人甚至会专门造这种数据 来卡不随机的快排(这在竞赛圈叫「卡快排」,是有传统的)。
随机选基准之后,要连续几十次都倒霉才会退化,概率低到可以忽略。
5 比赛里怎么写:sort 一行
点「运行 ▶」看结果
它是内省排序(introsort):默认走快排, 一旦发现递归太深(说明基准选得不好)就自动切换成堆排序, 数据量小的时候又切成插入排序。
结果是:最坏情况也是 O(n log n) —— 手写快排做不到这一点。
所以:理解要靠手写,比赛要用 sort。
6 单步看两种排序
- 「冒泡」那栏:看最大的数一趟一趟往右沉,右边绿色的区域越来越大。 盯住底下的比较次数。
- 「归并」那栏:先一路切到只剩一个元素(那一段什么都没做), 然后一层层合并回来。紫色那一排是合并用的临时数组。
用同一组数据把两栏都播一遍,对比最后的比较次数。
默认那 8 个数(5 2 9 1 5 6 3 8):冒泡比较 25 次,归并 17 次 —— 差距还不明显;
但这个差距是按 n²/2 对 n·log₂n 拉开的,n 一大就是天壤之别。
7 ★ 对拍验证
把「归并」那一栏换成你自己默写的,再点开始。 (想验快排也一样,把代码换成快排即可。)
值得故意写错的:
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 —— 前面都相等了,这里必须给出确定的结论。
点「运行 ▶」看结果
10 亲眼看一个等号让程序崩溃
点「运行 ▶」看结果
std::sort 要求 cmp 满足严格弱序,其中最关键的一条是:
cmp(a, a)必须是false—— 一个元素不可能排在自己前面。
写成 >= 之后,cmp(a, a) 返回 true。在 sort 眼里,
「a 严格排在 a 前面」这个矛盾成立了。
它内部的插入排序会一直往前找「该插在哪」,而在错误的 cmp 下这个条件永远成立 ——
于是指针一路冲出数组边界,读写到别人的内存上。
**崩溃还算走运。**更糟的是没崩,但悄悄改坏了别的变量, 然后你在一个完全无关的地方看到诡异结果,查一整天。
这就是「未定义行为」的可怕之处:它不保证报错,只保证什么都可能发生。
写 cmp 时,相等就必须返回 false。比较符只用 < 或 >,绝不用 <= 或 >=。
11 ★ 对拍:用冒泡当标准答案
因为冒泡只交换相邻元素,而且只在「严格该换」时才换, 所以它天然是稳定的,行为一目了然,不可能有未定义行为。
慢,但绝对可靠 —— 这正是对拍标准答案该有的样子。 (回想第 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 自测
- 洛谷 P1177 排序 —— 排序模板题。先用 sort 交一遍,再用自己手写的归并交一遍
- 洛谷 P1068 分数线划定 —— NOIP2009。标准的多关键字排序,本章后半场的原题
- 洛谷 P1104 生日 —— 三关键字排序 + 稳定性要求。正好练「用输入顺序当兜底关键字」
- 洛谷 P1271 选举学生会 —— 数据范围很大但值域很小 —— 想想能不能不用比较排序(计数排序)
第 11 章:分治求逆序对。
你会发现那道题的答案,就藏在这一章归并排序的合并那一步里 —— 一行代码都不用多写,只要在合并时顺手数一个数。
这是「学会一个零件,白捡一道题」的典型例子。