阶段 2 · 排序与分治 · 第 10 章普及组 J

排序:冒泡 → 归并 → 快排

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

需要先学:第 1 章 递归入门:函数怎么调用自己例题:数组排序 · 多关键字排序建议用时:110 分钟
欢迎来到阶段 2

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

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

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

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

前半场 · 三种排序

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

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

bubble.cpp冒泡 O(n²)
// 排序 —— 冒泡
//
// 输入:第一行 n,第二行 n 个整数
// 输出:从小到大排好序的 n 个数
//
// 冒泡的思路简单到不用解释:相邻两个如果左边比右边大,就交换。
// 扫一遍,最大的那个就「冒」到最右边了;再扫一遍,第二大的到位……扫 n 遍就排好了。
//
// 复杂度 O(n²)。n = 1000 时是一百万次比较(眨眼间),
// n = 50000 时是 25 亿次 —— 正文第 3 步会让你亲眼看它卡住。
//
// 那个 `if (!swapped) break;` 是一个常见的小优化:
// 如果一整趟下来一次都没换过,说明已经有序了,可以提前收工。
// 它能让「已经排好序」的数据变成 O(n),但**改变不了最坏情况**(逆序输入还是 O(n²))。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n - 1; i++) {
bool swapped = false;
// 每一趟结束,最右边的 i 个已经就位,不用再看
for (int j = 0; j + 1 < n - i; j++) {
if (a[j] > a[j + 1]) {
swap(a[j], a[j + 1]);
swapped = true;
}
}
if (!swapped) break; // 一趟都没换过,说明已经有序
}
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i == n - 1];
return 0;
}
点一下即可编辑
输入(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 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) 走完。

merge.cpp归并 O(n log n)
// 排序 —— 归并排序(手写)
//
// 输入输出和 bubble.cpp 完全一样。
//
// 这是第 2 章那句话最标准的一次现身:
//
// **大问题 = 小问题 + 一步真活 + 小问题**
//
// 排 [l, r] = 排左半边 + 把两个有序的半边合并起来 + 排右半边
// ↑小问题 ↑一步真活(这是唯一真正干活的地方) ↑小问题
//
// 三要素还是那三个:
// 职责:mergeSort(l, r) 把 a[l..r] 排好序
// 边界:l >= r 时只有 0 或 1 个元素,本来就有序,直接返回
// 递推:先排好两半,再合并
//
// 合并两个有序数组为什么快?因为两边都是有序的,
// 只要各拿一根手指指着开头,每次比一下、取小的那个往前挪 —— 这就是第 7 章的双指针。
// 合并 n 个元素只要 O(n)。
//
// 复杂度:一共分 log n 层,每层合并的总量是 n,所以 O(n log n)。
// n = 50000 时约 78 万次操作,而冒泡是 25 亿次 —— 差三千多倍。
//
// ⚠ 那个临时数组 tmp 不要写在函数里面。
// 写在里面的话每层递归都要新建一个 vector,光是分配内存就慢一大截。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a, tmp;
void mergeSort(int l, int r) {
if (l >= r) return; // 边界:0 或 1 个元素,天然有序
int mid = l + (r - l) / 2;
mergeSort(l, mid); // 排左半边(信任它)
mergeSort(mid + 1, 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];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
a.assign(n, 0);
tmp.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
if (n > 0) mergeSort(0, n - 1);
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i == n - 1];
return 0;
}
点一下即可编辑
输入(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快排
// 排序 —— 快速排序(手写)
//
// 输入输出和 bubble.cpp 完全一样。
//
// 快排也是分治,但和归并**分工正好相反**:
//
// 归并:随便切成两半(一步真活在「合并」)
// 快排:先选一个基准,把小的甩到左边、大的甩到右边(一步真活在「划分」),
// 划分完之后两边各自排好,就整体有序了 —— 不需要合并。
//
// 一句话:**归并是「先分后治」,快排是「先治后分」。**
//
// ⚠ 基准(pivot)一定要随机选!
// 如果永远取第一个元素当基准,遇到**已经排好序**的数据,
// 每次划分都会切出「0 个 + n-1 个」这种最坏的形状,复杂度退化成 O(n²)。
// 而「已经排好序的数据」在真实测试数据里太常见了 ——
// 出题人甚至会专门造这种数据来卡不随机的快排。
//
// 随机选基准之后,退化的概率低到可以忽略(要连续几十次都倒霉才行)。
//
// 平均复杂度 O(n log n),常数比归并小,所以标准库的 sort 底子就是它。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a;
mt19937 rng(20241010u); // 固定种子,保证同一份输入每次跑出来完全一样
void quickSort(int l, int r) {
if (l >= r) return;
// 随机挑一个基准,换到最左边
int p = l + (int)(rng() % (unsigned)(r - l + 1));
swap(a[l], a[p]);
long long pivot = a[l];
// Hoare 划分:两根指针从两头往中间夹(第 7 章的对撞双指针)
int i = l, j = r;
while (i < j) {
while (i < j && a[j] >= pivot) j--; // 从右边找第一个 < pivot 的
while (i < j && a[i] <= pivot) i++; // 从左边找第一个 > pivot 的
if (i < j) swap(a[i], a[j]);
}
swap(a[l], a[i]); // 基准归位,此刻 a[i] 就在它最终的位置上
quickSort(l, i - 1);
quickSort(i + 1, r);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
if (n > 0) quickSort(0, n - 1);
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i == n - 1];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 一句话记住两者的区别
一步真活在哪 顺序
归并 合并(后处理) 先随便切两半,排完再合
快排 划分(前处理) 先按基准分堆,分完就完事,不用合

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

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

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

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

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

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

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

5比赛里怎么写:sort 一行

stl.cppstd::sort
// 排序 —— 直接用 std::sort
//
// 输入输出和前面几份完全一样。
//
// 比赛里排序**一律用 sort**,别手写。理由很实在:
// 1. 它是内省排序(快排 + 堆排 + 插入排序的混合体),
// 递归太深时自动切换成堆排,**最坏情况也是 O(n log n)** —— 手写快排做不到这一点。
// 2. 它经过了极致的常数优化,比你手写的快。
// 3. 一行代码没有出错空间。
//
// 那为什么前面还要手写归并和快排?
// 因为**归并排序的那个「合并」是第 11 章求逆序对的核心**,
// 而快排的「划分」思想在很多题里会单独用到(比如求第 k 大)。
// 手写一遍是为了理解,比赛的时候用 sort。
//
// 记住排序的复杂度下界:基于比较的排序,最快就是 O(n log n),
// 这是数学上证明过的,不存在「更聪明的比较排序」。
// (能突破这个下界的只有计数排序、基数排序那种不比较的方法,它们对数据有额外要求。)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
sort(a.begin(), a.end()); // 就这一行
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i == n - 1];
return 0;
}
点一下即可编辑
输入(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。最后两个是循环边界的照妖镜 —— 手写排序在空数组上挂掉是很常见的。
// 排序 —— 归并排序(手写)
//
// 输入输出和 bubble.cpp 完全一样。
//
// 这是第 2 章那句话最标准的一次现身:
//
// **大问题 = 小问题 + 一步真活 + 小问题**
//
// 排 [l, r] = 排左半边 + 把两个有序的半边合并起来 + 排右半边
// ↑小问题 ↑一步真活(这是唯一真正干活的地方) ↑小问题
//
// 三要素还是那三个:
// 职责:mergeSort(l, r) 把 a[l..r] 排好序
// 边界:l >= r 时只有 0 或 1 个元素,本来就有序,直接返回
// 递推:先排好两半,再合并
//
// 合并两个有序数组为什么快?因为两边都是有序的,
// 只要各拿一根手指指着开头,每次比一下、取小的那个往前挪 —— 这就是第 7 章的双指针。
// 合并 n 个元素只要 O(n)。
//
// 复杂度:一共分 log n 层,每层合并的总量是 n,所以 O(n log n)。
// n = 50000 时约 78 万次操作,而冒泡是 25 亿次 —— 差三千多倍。
//
// ⚠ 那个临时数组 tmp 不要写在函数里面。
// 写在里面的话每层递归都要新建一个 vector,光是分配内存就慢一大截。
#include <bits/stdc++.h>
using namespace std;
vector<long long> a, tmp;
void mergeSort(int l, int r) {
if (l >= r) return; // 边界:0 或 1 个元素,天然有序
int mid = l + (r - l) / 2;
mergeSort(l, mid); // 排左半边(信任它)
mergeSort(mid + 1, 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];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
a.assign(n, 0);
tmp.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
if (n > 0) mergeSort(0, n - 1);
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i == n - 1];
return 0;
}
点一下即可编辑

值得故意写错的:

  • 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

第一行是人数,之后每行一个「学号 成绩」。 注意 90 分的两个人(3 号和 5 号)、85 分的两个人(1 号和 4 号)—— 成绩一样时,学号小的排前面。

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 —— 前面都相等了,这里必须给出确定的结论。


4.(写法,不是规矩)cmp 也可以就地写成一个 lambda。

后面的章节里会大量出现这种形状,第一次见容易以为是新语法:

sort(v.begin(), v.end(), [&](const Student& a, const Student& b) {
    if (a.score != b.score) return a.score > b.score;
    return a.id < b.id;
});

它和上面那个具名的 cmp 是同一件事,只是把函数写在了用它的地方。 开头那个 [&] 是「捕获列表」:写 [&] 表示「函数体里用到的外部变量,直接按引用用」—— 比如按「另一个数组里的值」排序时就需要它(第 19 章那三份排序策略就是这么写的)。 ⚠ 上面三条铁律对 lambda 一字不改地成立,尤其是第 2 条那个等号。

studentFast.cpp正确的 cmp
// 多关键字排序 —— sort + 自定义 cmp(正确写法)
//
// 输入输出和 studentBrute.cpp 完全一样。
//
// ============ cmp 函数的三条铁律 ============
//
// 1. **cmp(a, b) 的含义是「a 必须排在 b 前面吗」**,返回 true 就是要排前面。
// 不是「a 比 b 大吗」,也不是「要不要交换」。想不清楚就念一遍这句话。
//
// 2. **两个元素「一样」的时候必须返回 false。**
// 这是最要命的一条 —— 下一份 badcmp.cpp 会让你亲眼看到违反它的后果。
// 所以比较相等的情况时,用 `<` 和 `>`,**绝对不要用 `<=` 或 `>=`**。
//
// 3. **cmp 里不要写会改变外部状态的东西**(比如计数、输出)。
// sort 内部会以未指定的顺序调用它任意多次。
//
// 多关键字的写法就是「上一个关键字分不出胜负时,才看下一个」:
//
// if (a.score != b.score) return a.score > b.score; // 第一关键字
// return a.id < b.id; // 第二关键字
//
// 注意最后一行**不带 if** —— 前面都相等了,这里必须给出确定的结论。
#include <bits/stdc++.h>
using namespace std;
struct Student { int id, score; };
bool cmp(const Student& a, const Student& b) {
if (a.score != b.score) return a.score > b.score; // 成绩高的在前(降序)
return a.id < b.id; // 成绩相同,学号小的在前(升序)
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<Student> v(n);
for (int i = 0; i < n; i++) cin >> v[i].id >> v[i].score;
sort(v.begin(), v.end(), cmp);
for (int i = 0; i < n; i++) cout << v[i].id << " " << v[i].score << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

badcmp.cpp⚠ 故意写错的
点运行。它大概率会「异常退出」—— 段错误或者内存报错。而它和正确版本的唯一区别,是 cmp 里多了一个等号。
// ⚠ 这是一份**故意写错**的 cmp —— 它会让 std::sort 直接崩溃
//
// 点「运行 ▶」跑一下。它多半会「异常退出」——
// 段错误、double free、堆损坏……具体是哪一种取决于运气,
// 但**基本不会**是「安安静静给你一个错误的排序结果」。
//
// 错在哪:`return a.score >= b.score;` —— 多了一个等号。
//
// ============ 为什么一个等号能让程序崩溃 ============
//
// std::sort 要求 cmp 满足「严格弱序」,其中最关键的一条是:
//
// **cmp(a, a) 必须是 false**(一个元素不可能排在自己前面)。
//
// 写成 `>=` 之后,cmp(a, a) 返回 true —— 在 sort 眼里,
// 「a 严格排在 a 前面」。这个矛盾会让它内部的插入排序**跑出数组边界**:
// 它一直往前找「比当前元素小的位置」,而在错误的 cmp 下这个条件永远成立,
// 于是指针一路冲出数组,读写到别人的内存上。
//
// 崩溃还算走运 —— 更糟的情况是**没崩,但悄悄改坏了别的变量**,
// 然后你在一个完全无关的地方看到诡异的结果,查一整天。
//
// ============ 记住这条规则 ============
//
// **写 cmp 时,相等就必须返回 false。比较符只用 < 或 >,绝不用 <= 或 >=。**
//
// 这是 C++ 竞赛里最经典的一个「未定义行为」陷阱,几乎每个人都会踩一次。
// 现在踩,比在考场上踩好。
#include <bits/stdc++.h>
using namespace std;
struct Student { int id, score; };
bool badCmp(const Student& a, const Student& b) {
return a.score >= b.score; // ← 错!相等时返回了 true
}
int main() {
// 造一批成绩大量重复的数据 —— 相等元素越多,这个错误暴露得越快
const int n = 2000;
vector<Student> v(n);
mt19937 rng(12345);
for (int i = 0; i < n; i++) { v[i].id = i + 1; v[i].score = (int)(rng() % 3); }
cout << "拿 " << n << " 个学生(成绩只有 0/1/2 三种)去 sort,cmp 用的是 >= …\n";
cout.flush();
sort(v.begin(), v.end(), badCmp); // ← 这里大概率就崩了
cout << "居然没崩。但这不代表它是对的 —— 未定义行为的意思就是「什么都可能发生」,\n";
cout << "换一组数据、换一个编译器、换一个优化等级,它随时会翻脸。\n";
cout << "前 5 个:\n";
for (int i = 0; i < 5 && i < n; i++) cout << " " << v[i].id << " " << v[i].score << "\n";
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
⚠ 为什么一个等号能让程序崩溃

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

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

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

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

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

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

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

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

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

对拍器
生成器让成绩只有 0~4 五种 —— 重复必然满地都是。如果每个人成绩都不一样,第二关键字根本用不上,「忘了写第二关键字」这种错误就永远抓不住。
// 多关键字排序 —— sort + 自定义 cmp(正确写法)
//
// 输入输出和 studentBrute.cpp 完全一样。
//
// ============ cmp 函数的三条铁律 ============
//
// 1. **cmp(a, b) 的含义是「a 必须排在 b 前面吗」**,返回 true 就是要排前面。
// 不是「a 比 b 大吗」,也不是「要不要交换」。想不清楚就念一遍这句话。
//
// 2. **两个元素「一样」的时候必须返回 false。**
// 这是最要命的一条 —— 下一份 badcmp.cpp 会让你亲眼看到违反它的后果。
// 所以比较相等的情况时,用 `<` 和 `>`,**绝对不要用 `<=` 或 `>=`**。
//
// 3. **cmp 里不要写会改变外部状态的东西**(比如计数、输出)。
// sort 内部会以未指定的顺序调用它任意多次。
//
// 多关键字的写法就是「上一个关键字分不出胜负时,才看下一个」:
//
// if (a.score != b.score) return a.score > b.score; // 第一关键字
// return a.id < b.id; // 第二关键字
//
// 注意最后一行**不带 if** —— 前面都相等了,这里必须给出确定的结论。
#include <bits/stdc++.h>
using namespace std;
struct Student { int id, score; };
bool cmp(const Student& a, const Student& b) {
if (a.score != b.score) return a.score > b.score; // 成绩高的在前(降序)
return a.id < b.id; // 成绩相同,学号小的在前(升序)
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<Student> v(n);
for (int i = 0; i < n; i++) cin >> v[i].id >> v[i].score;
sort(v.begin(), v.end(), cmp);
for (int i = 0; i < n; i++) cout << v[i].id << " " << v[i].score << "\n";
return 0;
}
点一下即可编辑
✓ 为什么标准答案要用冒泡

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

慢,但绝对可靠 —— 这正是对拍标准答案该有的样子。 (回想第 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 章:分治求逆序对。

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

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