④ 数据结构与算法 — 第 7 课
sort 拆开,自己写出两个 O(n log n) 的排序。这是「分治」思想的第一次实战,也是后面学逆序对、第 k 小、外部排序的地基。
从 0002 课的 sort(a.begin(), a.end()) 开始,你已经用了几十次。但有两个问题一直没回答:
cmp,有时候又不用?今天自己写两个 O(n log n) 的排序,你就知道了:没有任何排序能在 O(n log n) 以下完成比较排序(这是理论下界),而下面这两个算法正好达到它,只是用的「分治方式」不同。
| 算法 | 思路 | 平均 | 最坏 | 额外空间 | 稳定 |
|---|---|---|---|---|---|
| 冒泡排序 | 相邻两个比大小,反复交换 | 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) | 不稳定 |
两个算法都基于同一个套路,叫分治(divide and conquer):
区别只在第三步:归并是「先分到底,再合并有序段」;快排是「先按基准分好,再分别递归」。这也是很多人的第一个分治题(P1908 逆序对)的核心。
假设左半边和右半边各自已经有序,怎么合成一个有序的?——两个指针各指一边,每次取较小的那个。
左: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]; // 拷回原数组
}
a[l..r] 里写会覆盖掉还没读的元素。a[i] <= a[j] 里那个等号就是「稳定」的开关——写成 < 就不再稳定了。
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
}
log n 层(8 个元素分成 8→4→2→1,共 3 层)第 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
就用 4.2 节 main 里那组数:7 3 9 1 4 2 8。下面两张表里每一行,都是你可以拿纸笔自己划出来的状态。
规则只有一句:从左到右两两比,前面比后面大就交换。一轮下来,剩下部分里最大的那个一定停在了最右边。
| 轮次 | 这一轮之后 | 数组变成什么 |
|---|---|---|
| 初始 | — | 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 轮)。
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) 的差别在「表上的行数」里的样子。
| 算法 | 最好 | 平均 | 最坏 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | 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)。平均和最坏两列,和原文完全一致。
选一个元素当基准(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,它已经归位
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)。
如果每次选的基准都是当前区间的最大值或最小值,那么每次只能分出一个元素,递归 n 层,退化成 O(n²)。什么时候会发生?数据已经有序,而你固定取端点做基准——最典型的情况。
实测(n = 20000,数据已经升序):
固定取末尾做基准: 103 ms 且递归深度 20000 层(再大就会爆栈)
改成随机基准: 0.0 ms
最省事的改法:分区前随机挑一个位置,先和末尾交换,后面的逻辑一字不改。
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 就是最优解——自己写快排主要是为了理解思想。
| 归并排序 | 快速排序 | |
|---|---|---|
| 核心动作 | 合并两个有序段 | 按基准分区 |
| 递归顺序 | 先递归,后合并 | 先分区,后递归 |
| 平均 / 最坏 | 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(内部是归并,代价是额外空间)。
逆序对 = 满足 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。
快排每次分区后,基准的位置是最终位置。所以只要看基准下标和 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 小的数已经就位
a[l..r] 里写会覆盖还没读的元素;最后拷回也只拷 l..r,别从 0 开始。<=:a[i] <= a[j] 里那个等号去掉,相等的元素就会换位置,归并就不再稳定。partition 的 i 从 l 开始:最后那句 swap(a[i], a[r]) 不能漏,漏了基准就没归位,递归区间也会错。long long:个数可能非常多;样例 6 / 5 4 2 6 3 1 的答案是 11,先拿它对答案。nth_element 的下标是 k - 1:它是「第 k 小(从 1 数)」,写 k 会差一位。std::sort 内部最接近哪种组合?merge + mergeSort 抄进编辑器,用下面这组数据验证:7 3 9 1 4 2 8 → 应输出 1 2 3 4 7 8 9tmp 数组去掉,直接往 a 里 merge,看看结果错在哪——亲手踩一次印象最深。
sort 交(体会 STL 的强大),第二遍用手写快排交。long long;样例是 6 / 5 4 2 6 3 1 → 11。scanf / printf(或者关掉同步流)。nth_element 一行解决;进阶版:自己写按 partition 只递归一边的 O(n) 版本。
7 3 9 1 4 2 8 对着第 5 节的两张表手推一遍,看轮数和数组对不上的是哪一步。[l, r] 吗?k 从 l 开始、最后只拷 l..r 吗?tmp 开够了吗?用的是 vector<int> tmp(a.size()) 吗?<= 还是不小心写成了 <?l >= r。long long 了吗?样例 6 / 5 4 2 6 3 1 能得 11 吗?std::sortstd::sort = 快排 + 堆排 + 插入排序(introsort);要稳定用 stable_sort