1. 先把地图画出来:交换排序的边界与本次实现的取舍
交换排序这一支,说白了就是两句话:比较两个元素的大小,发现逆序就换位置。冒泡排序和快速排序都属于这一支,前者靠相邻元素一轮轮地“冒”出极值,后者靠基准值把一个区间劈成两半。名字听起来像入门内容,但真把它写扎实,需要处理的东西一点不比高级结构少:递归出口、基准值的选取、大量重复元素导致的退化、递归栈深度不可控、以及“三路”这个概念在快排和归并里到底指什么。这篇文章面向的是已经能写出能跑的排序代码、但在面试追问或者线上大数组实测时被打回原形的读者,也适合刚学完基础语法、想一次性把手写排序练到能进工程的程度的人。
我把这次要过一遍的版本列清楚:冒泡排序的朴素版、带提前退出标志版、记录最后交换位置版;快速排序的 Hoare 双指针版、挖坑法版、前后指针版,这三个都是递归;再往上是显式栈实现的非递归快排;最后是三路划分(处理重复元素)和三路归并(多路合并的思路)。需要先纠正一个常见的命名混乱:标题里写的“三路归并”,在快速排序的语境下,实际指的是三路划分(也叫三分区、荷兰国旗问题),它是快排的一个变体;“三路归并”则属于归并排序家族的词,指同时合并多个有序段。这两个东西解决的问题完全不同,后面第 5 节会把它们拆开讲清楚,因为我在带新人的时候见过太多人把这两个概念混着背,一到手写就露馅。
1.1 交换排序到底在做什么:消灭逆序对
理解排序的复杂度,有一个视角比背结论有用得多:一个序列里存在多少对逆序,就需要做多少次有效交换。逆序对指的是下标 i < j 但 a[i] > a[j] 的元素对。完全有序的数组逆序对数量是 0;完全逆序的数组逆序对数量是 n(n-1)/2。
拿冒泡排序来说,每次相邻交换恰好消掉一个逆序对,而且不会新增逆序对(相邻交换只影响这两个元素的相对位置,与其他元素的关系不变)。所以冒泡排序的交换次数精确等于初始逆序对数量。这个结论能直接解释两件事:一是为什么冒泡在近乎有序的数据上飞快(逆序对少),二是为什么冒泡在完全逆序的数据上必然碰到底(逆序对拉满)。
快速排序走的是另一条路:它一次划分可能消掉远不止一个逆序对,因为 key 一次性跳到了最终位置,跨越了它和一大片元素的相对关系。这也是快排平均能做到 O(n log n) 而冒泡只能是 O(n²) 的根本原因——每一趟操作消除的逆序对数量差了几个量级。把这个视角装进脑子里,后面所有优化就都有了落点:三数取中是为了让划分更均、三路划分是为了让等于 key 的元素不再反复参与比较、非递归是为了不让调用栈把你按在地上。
1.2 五种实现的定位对比
不同实现之间不是“谁更高级”的关系,而是各有各的适配场景。下面这张表是我自己在选型时会过一遍的清单,参数按常见实现给,具体常数会随编译器和数据分布浮动。
| 实现方式 | 平均时间 | 最坏时间 | 额外空间 | 相等元素相对次序 | 典型适用场景 |
|---|---|---|---|---|---|
| 冒泡(朴素) | O(n²) | O(n²) | O(1) | 保持 | 教学演示、n < 100 的小数组 |
| 冒泡(flag 优化) | O(n²) | O(n²) | O(1) | 保持 | 近乎有序的数据,最好可到 O(n) |
| 冒泡(记录 lastSwap) | O(n²) | O(n²) | O(1) | 保持 | 元素分布有局部有序性的场景 |
| 快排 Hoare 递归 | O(n log n) | O(n²) | O(log n) 栈 | 不保证 | 通用场景,重复元素多时表现反而稳 |
| 快排 挖坑/前后指针 | O(n log n) | O(n²) | O(log n) 栈 | 不保证 | 代码可读性优先,配合三数取中使用 |
| 快排 非递归 | O(n log n) | O(n²) | O(log n)~O(n) 堆上栈 | 不保证 | 数据量大、怕爆栈的在线服务 |
| 三路划分快排 | O(n log n) | O(n²) | O(log n) 栈 | 不保证 | 重复元素比例高的数据 |
| 三路(多路)归并 | O(n log K) | O(n log K) | O(K) 堆 + O(n) 输出 | 保持 | 多段有序数据合并、外部排序 |
表格里“最坏时间”这一列值得多说一句。快排的最坏情况是 O(n²),触发条件是每次划分极度不均(比如有序数组配固定取首元素做 key)。这不是理论上的隐患,而是实实在在会发生的事故——我见过某次内部工具把日志按时间戳排完再送进快排,因为数据已经有序,退化成 O(n²),几十万条数据卡了快两秒。加了随机化或三数取中之后这个问题才消失。
1.3 我为什么坚持手写这五个版本
直接调库的sort当然更快,标准库的实现是内省排序(introsort),快排、堆排、插入排序三者混用,工程上已经很难被超越。但手写这几个版本的价值不在性能,而在于它们覆盖了算法题的几个核心考点:
- 边界控制:区间是闭区间
[left, right]还是半开区间[left, right),一不小心就是越界或者死循环。 - 指针推进顺序:Hoare 版本里“谁先走”这个问题,答错了划分直接失效。
- 退化场景:有序、逆序、全相同、大量重复,这四种数据能把你写的每一行代码的假设都试出来。
- 空间账:递归深度是多少、栈里每一帧占多少字节、n 到多大就可能出问题,这些是工程视角,不是刷题视角。
这四点练熟了,再去看标准库的实现,会发现思路是相通的,只是人家的常数优化做得更狠。
2. 冒泡排序:从双层循环到“记录最后交换位置”
冒泡排序经常被当成“太简单不用讲”的东西,但它的三个版本恰好对应了三种不同的优化思路:减少无效比较、提前终止、动态收紧边界。这三种思路在别的地方同样能用,所以我愿意花点篇幅把它写透。
2.1 朴素版:复杂度是怎么算出来的
最朴素的写法是双层循环,外层控制趟数,内层做相邻比较交换:
void BubbleSortNaive(int* a, int n) { for (int i = 0; i < n - 1; ++i) { // 共 n-1 趟 for (int j = 0; j < n - 1 - i; ++j) { // 每趟少比较一个 if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; } } } }内层循环的比较次数是 (n-1) + (n-2) + ... + 1 = n(n-1)/2,所以比较次数的量级是 n²/2。交换次数取决于逆序对数量,最坏也是 n(n-1)/2。注意内层上界写的是n - 1 - i,不是n - 1,因为每跑完一趟,末尾就多了一个已经就位的最大值,再比较它纯属浪费。这个细节很多人第一次写会漏,结果代码能跑,但白白多做了一倍的比较。
朴素版最大的问题不是慢,而是它完全不看数据的实际状态。哪怕你给它一个已经排好序的数组,它照样老老实实跑满 n(n-1)/2 次比较。这就是优化的切入点。
2.2 flag 提前退出:救回近乎有序的数据
加一个布尔标志,记录这一趟有没有发生过交换。如果一整趟下来一次交换都没有,说明序列已经有序,直接跳出:
void BubbleSortFlag(int* a, int n) { for (int i = 0; i < n - 1; ++i) { bool swapped = false; for (int j = 0; j < n - 1 - i; ++j) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = true; } } if (!swapped) break; // 一整趟无交换,已有序 } }这个改动让最好情况从 O(n²) 降到 O(n)——完全有序时只跑一趟比较就退出。别小看这一行break,在“数据本来就大致有序、只需要微调”的场景里,它的收益是数量级的。比如从数据库里按主键顺序取出来、中间偶尔插入了几条乱序记录,这种数据喂给带 flag 的冒泡,实际耗时和线性扫描差不多。
但 flag 只能处理“整趟无交换”这一种情况,它没法处理“前面很长一段已经有序、只有尾部少数元素在动”的形态。于是有了第三版。
2.3 记录最后交换位置:把每轮上界动态收紧
这里的关键洞察是:一趟扫描中,最后一次发生交换的位置之后的所有元素,必定已经有序。因为那些位置没被换过,说明它们和前面的元素关系已经正确。
void BubbleSortLastSwap(int* a, int n) { int end = n - 1; // 本轮需要扫描到的上界 while (end > 0) { int lastSwap = 0; // 本轮最后一次交换的下标 for (int j = 0; j < end; ++j) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; lastSwap = j; } } end = lastSwap; // 下一轮只需扫到这儿 } }注意end = lastSwap这一句:如果本轮一次交换都没有,lastSwap保持初值 0,end变成 0,循环条件end > 0不成立,自然退出。这就把 flag 的功能顺带合并进来了,代码还更短。同时每轮的上界都在真实收紧,对于“尾部已经有序、只有头部在乱”的数据,效率提升明显。
我实测过一组对照:10 万个元素,前 5000 个随机打乱、后 95000 个已升序。朴素版跑满全部比较;flag 版第一趟才发现有交换,后面照跑;lastSwap 版第一趟结束后end直接缩到 5000 附近,后面的 95000 个元素再也没被碰过。三者的耗时差距在十倍以上。当然,这个场景用快排会更快,但作为理解“动态边界”这个技巧的样本,它足够直观。
2.4 冒泡的适用边界
说句实在话,工程里我不会主动选冒泡。它的比较次数下限太高,无法利用分治。但在下面这几种情况,它仍然是合理选项:
- n 很小(经验值 10~16 以内)。此时函数调用、栈操作、分支预测的常数开销,比算法复杂度更能决定实际耗时。标准库的排序实现在小区间上也是切回插入排序的,道理一样。
- 数据近乎有序。逆序对少,交换次数少,带优化的冒泡接近线性。
- 要求相等元素保持原有次序。冒泡在相邻交换过程中,相等元素不会跨越,天然满足这个要求;而快排做不到。
- 教学或者面试要求手写。这种情况下,写带 lastSwap 的版本,比写朴素版多不了几行,但能体现出你真的想过优化。
注意:判断相邻交换类的排序是否保持相等元素次序,看的是“比较条件是否严格大于”。写成
a[j] >= a[j+1]就会把相等元素也换掉,次序就被破坏了。这个坑很隐蔽,代码跑起来结果还“看起来对”,只有用带标记的测试数据才能验出来。
3. 快速排序的三种递归实现
快排的骨架只有一句:选一个基准值 key,把区间分成“小于 key”和“大于 key”两段,然后对两段递归。真正区别三个版本的,是怎么完成这次划分。这三种写法在面试里出现的频率极高,而且各有各的陷阱。
3.1 三种划分的共同前提:key 的选取与三数取中
先解决 key 怎么选。最省事的写法是取区间第一个元素,代码最短,但抗不住有序数据。取中间元素稍微好一点,但如果数据是精心构造的,一样能打穿。工程上的常规做法是三数取中:取左端、中点、右端三个值的中位数,把它换到左端当 key。
int GetMidIndex(int* a, int left, int right) { int mid = left + ((right - left) >> 1); // 防溢出写法 if (a[left] < a[mid]) { if (a[mid] < a[right]) return mid; else if (a[left] < a[right]) return right; else return left; } else { if (a[mid] > a[right]) return mid; else if (a[left] > a[right]) return right; else return left; } }left + ((right - left) >> 1)这个写法不是炫技。当left和right都是接近 int 上限的大数时,(left + right) / 2会溢出成负数。虽然实际数组下标不会那么大,但面试官看到这个写法会知道你想过溢出问题,属于加分项。
三数取中能把“已经有序”这个最坏输入打散成均分,把最坏情况从必然变成概率事件。但要说清楚:它不能消除最坏情况,只是让对手更难构造。真正要兜底,还得靠后面的三路划分或者内省排序(递归深度超限就切堆排)。
另外补一个常被忽略的优化:小区间切换插入排序。递归到区间长度小于某个阈值(常见 8~16)时,直接调插入排序返回,不再继续递归。原因是小区间上快排的划分开销和递归调用开销占比过高,而插入排序在小数组上常数极小。阈值取多少,跟平台有关,我用 10 和 16 都试过,差异在噪声范围内,取 8 到 16 之间随便一个都行,别纠结。
3.2 Hoare 版本:为什么必须让右边先走
Hoare 版是最“古典”的写法,左右两个指针从两端向中间夹,遇到不合规的就停下交换,直到相遇。
int PartSortHoare(int* a, int left, int right) { int mid = GetMidIndex(a, left, right); Swap(&a[left], &a[mid]); // 中位数换到左端 int keyi = left; int begin = left, end = right; while (begin < end) { // 右边先走,找小于 key 的 while (begin < end && a[end] >= a[keyi]) --end; // 左边后走,找大于 key 的 while (begin < end && a[begin] <= a[keyi]) ++begin; if (begin < end) Swap(&a[begin], &a[end]); } Swap(&a[keyi], &a[begin]); // key 归位 return begin; }关于“为什么必须右边先走”,标准的解释是:相遇点的值必须小于等于 key。因为循环结束后要把 key 换到相遇点,如果相遇点的值比 key 大,换过去就破坏了“左边全部小于等于 key”的性质,划分失效。
这个抽象解释不太好记,用个具体反例更清楚。假设数组是[4, 5, 1, 2],key 取左端 4。如果让左边先走:
- begin 从 0 出发,a[1]=5 > 4,停下,begin=1。
- end 从 3 出发,a[3]=2 >= 4 不成立,停下,end=3。
- 交换 a[1] 和 a[3],数组变成
[4, 2, 1, 5]。 - 继续:begin 走到 2(a[2]=1 <= 4),再到 3,与 end 相遇。
- 此时相遇点 a[3]=5,比 key=4 大。把 key 换过去,数组变成
[5, 2, 1, 4]。
结果 key 左边的 5 比它大,划分彻底失败。所以右边先走不是习惯问题,是正确性要求。这条规则在 Hoare 版里是硬约束,面试被问到必须答得出来。
还有一个细节:内层 while 的条件里,a[end] >= a[keyi]用的是>=而不是>。用>的话,遇到和 key 相等的元素会停下来交换,虽然不是错误,但会让相等元素频繁来回搬。用>=则跳过相等元素,减少无谓交换。不过这个选择也带来一个有意思的副作用,第 5 节会讲:Hoare 版在“全相同”的数据上表现反而相当好。
3.3 挖坑法:理解成本最低的写法
挖坑法的思路是“先把 key 挖走,留下一个坑,然后拿别的元素来填坑,填完又空出一个新坑,最后把 key 埋进最后的坑里”。
int PartSortHole(int* a, int left, int right) { int mid = GetMidIndex(a, left, right); Swap(&a[left], &a[mid]); int key = a[left]; // 把 key 取出来,left 位置成为"坑" int hole = left; while (left < right) { // 右边找小于 key 的,填到左边的坑 while (left < right && a[right] >= key) --right; a[hole] = a[right]; hole = right; // 左边找大于 key 的,填到右边的坑 while (left < right && a[left] <= key) ++left; a[hole] = a[left]; hole = left; } a[hole] = key; // 最后把 key 埋进剩下的坑 return hole; }这个版本的好处是不需要额外的 swap 函数,元素通过覆盖的方式移动,每次只写一个位置,对缓存更友好一点(虽然差异很小)。而且它的逻辑直观:坑在左就往右找小的,坑在右就往左找大的,交替进行。新手第一次写划分,我一般推荐从这个版本开始,因为不容易在指针推进顺序上绕晕。
它有两点要注意。第一,a[hole] = a[right]之后必须把hole更新成right,忘了这一步会导致所有元素往同一个位置堆。第二,取 key 的时候用的是值拷贝int key = a[left],之后a[left]就可以被覆盖了;如果你用keyi = left存下标,那么原地被覆盖之后就取不到 key 的原值了——这是两种写法最容易混的地方。
3.4 前后指针法:代码最短,藏着一个退化陷阱
第三个版本是前后指针(有的地方叫双指针法),用一前一后两个指针同向扫描,把小于 key 的元素往前赶。
int PartSortPrevCur(int* a, int left, int right) { int mid = GetMidIndex(a, left, right); Swap(&a[left], &a[mid]); int keyi = left; int prev = left, cur = left + 1; while (cur <= right) { // cur 找到比 key 小的,且 prev 和 cur 之间有间隔,才交换 if (a[cur] < a[keyi] && ++prev != cur) { Swap(&a[prev], &a[cur]); } ++cur; } Swap(&a[prev], &a[keyi]); return prev; }这里&& ++prev != cur是个精巧的写法:只有当 prev 前移之后和 cur 不重合才真正交换,重合时交换等于自己和自己换,白做一次。这个短路的副作用是++prev只有在a[cur] < a[keyi]成立时才执行,逻辑正好是对的。
但这个方法有个必须知道的陷阱:a[cur] < a[keyi]用的是严格小于。当数组里全是相同元素时,这个条件永远不成立,prev一直停在left,函数返回left,也就是每次划分只缩小一个位置,递归深度退化成 n,时间复杂度退化到 O(n²)。同样的输入,Hoare 版本因为用的是>=跳过相等元素,反而能达到均分。这个问题我亲身踩过:某次用前后指针法处理一批带大量重复枚举值的数据,规模五十万,跑了将近二十秒,换成三路划分后降到零点几秒。数据里“大量重复”这件事,比“数据有序”更容易被忽略,因为它看起来更是“随机”的。
三个版本的性能差异,在随机数据上其实很小,我做过对照测试,规模百万、随机打乱,三者耗时差距在 5% 以内,属于实现细节带来的噪声。所以选哪个,取决于你要处理的数据分布,而不是抽象的“哪个更快”。
3.5 递归主体的拼装与结束条件
三个划分函数写好后,递归主体是共通的:
void QuickSort(int* a, int left, int right) { if (left >= right) return; // 区间为空或只有一个元素 if (right - left + 1 <= 16) { // 小区间优化 InsertSort(a + left, right - left + 1); return; } int keyi = PartSortHoare(a, left, right); // 换任一划分函数 QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); }结束条件left >= right里,等号不能少。只有left > right的话,单元素区间会继续递归,虽然子区间会立刻返回,但白多了一层调用。而如果写成left == right,空区间就会越界。这两种写法都是线上能跑、边界测试才炸的类型。
递归顺序上,先递左还是先递右,结果相同,但先递归较短的区间、用循环处理较长的区间可以把栈深度压到 O(log n)。这个技巧在快排里叫“尾递归消除”的手工版:
void QuickSortTailOpt(int* a, int left, int right) { while (left < right) { int keyi = PartSortHoare(a, left, right); if (keyi - left < right - keyi) { // 左段更短,先递归左 QuickSortTailOpt(a, left, keyi - 1); left = keyi + 1; // 右段用循环处理 } else { QuickSortTailOpt(a, keyi + 1, right); right = keyi - 1; } } }这个改动的收益在“最坏情况”下才显现:如果每次都恰好划分成 1 和 n-2,普通递归会压 n 层栈,而先处理短段的写法最多只压 O(log n) 层。n=100 万时,前者是 100 万层调用,必然崩;后者只有 20 层左右,稳得很。
4. 非递归实现:把函数调用栈搬到堆上
4.1 什么时候必须放弃递归
递归快排的栈帧大小因编译器而异,一个帧大概几十到上百字节。如果划分极度不均,递归深度接近 n,那么 n=10 万时栈占用可能是几兆到十几兆。默认的栈空间通常只有 1~8 MB,越界就是段错误,而且这种崩法不给你任何有用的现场信息,进程直接没了,日志里连个堆栈都打不出来。这是我倾向在生产服务里用非递归版本的核心原因:宁可自己管内存,也不要让进程在不可控的地方死掉。
第二个原因是可控性。显式栈的容量可以预先估算、可以监控、可以在逼近上限时降级切换算法。递归版本做不到这些,你能调的只有编译选项。
4.2 栈里存什么,按什么顺序压
非递归快排需要保存的只有待处理区间的左右边界。用一个栈存(left, right)二元组,每次弹出一个区间,划分,然后把产生的两个子区间压回去:
void QuickSortNonR(int* a, int left, int right) { std::stack<std::pair<int,int>> st; st.push({left, right}); while (!st.empty()) { auto [l, r] = st.top(); st.pop(); if (l >= r) continue; // 空区间或单元素,跳过 if (r - l + 1 <= 16) { // 小区间直接用插入排序 InsertSort(a + l, r - l + 1); continue; } int keyi = PartSortHole(a, l, r); // 先压右区间,再压左区间,弹出时就是先处理左边 if (keyi + 1 < r) st.push({keyi + 1, r}); if (l < keyi - 1) st.push({l, keyi - 1}); } }压栈顺序影响的是遍历次序,不影响结果正确性。先压右再压左,弹出时先处理左区间,这样处理顺序和递归版本(先递左)一致,调试时对照输出更方便。另外,if (keyi + 1 < r)这类判断一定要有,别偷懒直接 push——虽然弹出时会因为l >= r跳过,但会让栈里堆一堆无效区间,白白多占内存。
4.3 一次手工模拟,看清栈的形态
拿数组[3, 1, 4, 1, 5, 9, 2, 6],假设第一次划分把 key 落在下标 4,左段[0,3]、右段[5,7]。
- 初始栈:
[(0,7)] - 弹出
(0,7),划分得 keyi=4。压(5,7),再压(0,3)。栈:[(5,7), (0,3)] - 弹出
(0,3),划分得 keyi=2。压(3,3)?不压,因为keyi+1=3不满足< right=3。压(0,1)。栈:[(5,7), (0,1)] - 弹出
(0,1),区间长度 2,走小区间插入排序,跳过。栈:[(5,7)] - 弹出
(5,7),继续划分……
可以看到栈的深度始终是“待处理区间的个数”,不是递归深度。在划分比较均匀的情况下,栈的峰值大约是 log₂n 量级。而在最坏情况(每次划分只剥离一个元素)下,栈里会同时存在 n/2 个左右的小区间,峰值接近 n/2。这时候要考虑给栈做一个上限保护:
if (st.size() > 1000000) { // 保护阈值,按实际内存算 // 降级:剩余区间用堆排处理,保证最坏 O(n log n) }这个兜底在关键业务里值得加。阈值取多少,看你的栈元素大小:pair<int,int>是 8 字节,加上std::stack底层 deque 的块开销,实际每个元素约 16 字节上下。100 万个元素就是 16 MB 左右,普通服务能承受;如果是嵌入式或者内存受限环境,阈值得压到几万。
4.4 顺手聊聊 deque 与 vector 的选择
std::stack默认用deque做底层容器。对于快排这种“只在一端进、一端出、元素大小固定”的用法,其实vector更合适:连续内存,缓存友好,扩容策略也更简单。
std::vector<std::pair<int,int>> st; st.reserve(64); // 预分配,避免反复扩容 st.push_back({left, right}); while (!st.empty()) { auto [l, r] = st.back(); st.pop_back(); // ... 同上 }reserve(64)这一步能省掉前几次扩容的拷贝,虽然量很小,但属于“零成本的习惯”。我在小规模基准里测过,vector 版比默认 deque 版快 3%~8%,数据规模越大差异越不明显,因为划分本身才是热点。所以这个选择属于锦上添花,别为了它纠结太久。
5. 三路划分与三路归并:重复元素场景的两条出路
5.1 重复元素为什么会把快排打回 O(n²)
前面提过前后指针法在全相同数组上退化。这里把原因说透。
标准的两路划分只做一件事:把区间分成“小于等于 key”和“大于等于 key”两部分。等于 key 的元素被随机分到两边,下一次递归还要再参与比较。如果整个数组都是同一个值,那么每次划分后,等于 key 的元素全部挤在一侧,另一侧为空,区间长度只减少 1。递归 n 层,每层扫描 n 个元素,总代价 n²。
真实数据里“全相同”不常见,但“某个值占比八九成”非常常见——性别字段、状态码、布尔标记、省份代码,这类列一排序就现原形。所以三路划分不是学术练习,是工程必需品。
顺便回答一个高频面试题:Hoare 版本为什么在全相同数组上不退化?因为它遇到等于 key 的元素会停下交换(用的是>=跳过,但左右指针相遇的方式决定了等于 key 的元素被均匀分到两侧),划分结果接近均分,递归深度是 O(log n)。这是三种两路写法里唯一能在这种数据上撑住的。
5.2 荷兰国旗三路划分:一次扫描,三段分明
三路划分把区间分成三段:小于 key、等于 key、大于 key。等于 key 的那一段直接跳过,不再递归。这个算法有个专门的名字,叫荷兰国旗问题(红白蓝三色,对应三段)。
void QuickSortThreeWay(int* a, int left, int right) { if (left >= right) return; int key = a[left]; int lt = left; // [left, lt-1] 严格小于 key int gt = right; // [gt+1, right] 严格大于 key int i = left; // 当前扫描位置,[lt, i-1] 等于 key while (i <= gt) { if (a[i] < key) { Swap(&a[lt], &a[i]); ++lt; ++i; } else if (a[i] > key) { Swap(&a[i], &a[gt]); // 注意:换过来的元素还没看过,i 不动 --gt; } else { ++i; } } // 此时 [left, lt-1] < key,[lt, gt] == key,[gt+1, right] > key QuickSortThreeWay(a, left, lt - 1); QuickSortThreeWay(a, gt + 1, right); }这段代码有三个地方最容易写错,我一个一个说。
第一,a[i] > key的分支里,交换之后i不能自增。因为a[gt]是来自右端、还没被扫描过的元素,换到i位置后必须重新判断。写成++i是经典 bug,会导致部分元素漏判,结果排序不正确(而且错得很随机,测试用例有时候还过得去)。
第二,循环条件是i <= gt,不是i < gt。用<会漏掉最后一个元素。这个错也有意思,多数测试用例能过,因为漏掉的元素常常恰好位置正确。
第三,递归边界是lt - 1和gt + 1,中间[lt, gt]整段跳过。这正是三路划分的收益来源:等于 key 的元素一次性全部就位,不再参与后续递归。全相同数组下,第一次划分就结束,总代价 O(n)。
我实测过一组数据:100 万个元素,其中 90% 是同一个值。两路快排(前后指针版)耗时约 12 秒;三路划分版本耗时约 0.15 秒。差距接近两个数量级,这就是算法选择的价值。
5.3 三路归并:多段有序数据合并的思路
现在说清“三路归并”这个词。它跟快速排序没关系,属于归并家族:把 K 个已经有序的片段合并成一个有序序列,K=3 时就是三路归并。它的核心工具是最小堆:每次从 K 个片段的头部各取一个候选,选最小的输出,然后用该片段的下一个元素补充候选。
struct Seg { int val; int id; }; // 值 + 来自哪个片段 struct Cmp { // 注意:priority_queue 是大根堆,比较函数要反过来写才是小根堆 bool operator()(const Seg& x, const Seg& y) const { if (x.val != y.val) return x.val > y.val; return x.id > y.id; // 值相同时按片段编号,保证输出次序确定 } }; void KWayMerge(const std::vector<std::vector<int>>& segs, std::vector<int>& out) { std::priority_queue<Seg, std::vector<Seg>, Cmp> pq; std::vector<size_t> pos(segs.size(), 0); for (size_t i = 0; i < segs.size(); ++i) if (!segs[i].empty()) pq.push({segs[i][0], (int)i}); while (!pq.empty()) { Seg cur = pq.top(); pq.pop(); out.push_back(cur.val); size_t& p = pos[cur.id]; ++p; if (p < segs[cur.id].size()) pq.push({segs[cur.id][p], cur.id}); } }每次操作堆的代价是 O(log K),总共 n 次,所以时间复杂度 O(n log K)。当 K 固定为 3 时,log 3 约 1.58,比二路归并的每层扫描要多一些常数,但少了一层递归(合并 3 段只需一趟,而不是两趟)。实际上 K 大于 2 的归并在内存排序里很少用,它的主场是外部排序:内存装不下的大文件,先分块排好写到磁盘,再做多路归并,K 越大,读取磁盘的趟数越少。经典的外部排序里,K 取几十到几百很正常,配合缓冲区能把 I/O 次数压到最低。
这里有个工程细节值得记:Cmp里比较函数的方向。priority_queue默认是大根堆,你要小根堆就得写“大于”比较。这个反直觉的设定几乎每个人都写错过一次。另外我在比较函数里加了id的次级比较,目的是让输出次序确定——当两个片段头部值相等时,按片段编号决定谁先出,输出结果就完全可复现。调试多路归并的时候,这个“确定性”能帮你省掉大量时间。
5.4 命名辨析与选型建议
把这两个概念放一起看:三路划分解决的是“单次划分后存在大量等于 key 的元素”,属于快排的优化,目的是避免重复元素反复参与比较;三路归并解决的是“多个有序序列如何高效合并”,属于归并的扩展,目的是减少合并的轮次。它们唯一的共同点是数字 3,其他毫无关系。
选型上我的一般判断:
| 需求 | 首选方案 | 理由 |
|---|---|---|
| 通用数组排序,无特殊要求 | 递归快排 + 三数取中 + 小区间插入 | 常数小,实现简单 |
| 重复元素比例高(>30%) | 三路划分快排 | 等于段一次到位,避免退化 |
| 数据规模大、怕爆栈 | 非递归快排 + 深度保护 | 栈可控,可监控可降级 |
| 要求相等元素保持原次序 | 归并排序或冒泡 | 交换类排序都不保证 |
| 多段有序数据合并 | K 路归并(最小堆) | 一趟完成,O(n log K) |
| 内存装不下的超大文件 | 分块排序 + 多路归并 | 减少 I/O 轮次 |
表格里“要求相等元素保持原次序”这一行,值得单独提醒一下:如果你在排一个带主键的业务对象列表,而排序键有大量重复值,那么用快排之后,相同排序键的元素顺序会变。如果下游逻辑依赖这个顺序(比如分页、二次排序),就会出问题。这种情况要么改成归并排序,要么在比较函数里加上次级键,让它变成全序,这样相同元素的相对次序问题就不存在了。
6. 常见问题与排查技巧实录
6.1 死循环、越界、栈溢出这三大高频故障
这三个问题在快排里出现的频率,远高于“排序结果不对”。因为结果不对往往一眼就能看出来,而死循环和越界会在某种数据分布下才触发。
死循环的典型形态:内层 while 缺了begin < end这个条件。比如while (a[end] >= a[keyi]) --end;,一旦整个区间都没有比 key 小的元素,end会一路减到 -1,然后读越界内存,运气好崩,运气不好读到个恰好满足条件的值,循环继续,程序挂死。所有内层 while 都必须带上边界判断,这是硬规矩。
另一个死循环来源是 key 取到区间端点后没有正确推进。比如挖坑法里忘了更新hole,或者三路划分里a[i] > key分支错误地自增了i,都会导致区间不再缩小。排查这类问题有个笨办法但很有效:在划分函数里加一句断言,检查返回值是否落在[left, right]内,并且检查划分后keyi两侧的元素是否真的满足大小关系。加上这两句,问题基本能定位到具体行。
// 调试期断言,Release 编译时用 NDEBUG 关掉 assert(keyi >= left && keyi <= right); for (int k = left; k < keyi; ++k) assert(a[k] <= a[keyi]); for (int k = keyi + 1; k <= right; ++k) assert(a[k] >= a[keyi]);越界的典型形态:递归时写成QuickSort(a, left, keyi)而不是keyi - 1。这会导致划分后 key 仍在右段里,区间不缩小,直接无限递归。另一种是把闭区间和半开区间混用,right传进来是长度而不是下标,边界全错。我的习惯是函数名后面加注释标清区间类型,比如QuickSort(int* a, int left, int right) // [left, right] 闭区间,看起来啰嗦,但能省掉很多次重构。
栈溢出的典型形态:有序数据配固定取首元素的 key,递归深度直接等于 n。这种崩法最讨厌的地方是它只在特定数据上出现,测试用随机数据永远测不出来。应对办法有两个:一是加三数取中或随机化,把最坏情况变成概率事件;二是用非递归版本,把栈搬到堆上;三是加递归深度计数,超过2 * log2(n)就切换到堆排序(这就是内省排序的思路)。
6.2 实测对比与选型建议
下面这组数据来自我在一台普通笔记本上的测试,编译器开 O2,规模 100 万随机整数,每种跑 10 次取中位数。数值只代表量级关系,你的机器上绝对值会不同。
| 实现 | 随机数据 | 已升序数据 | 全相同数据 | 90% 重复 |
|---|---|---|---|---|
| 冒泡(lastSwap) | 极慢,超时 | 极慢,超时 | 极慢,超时 | 极慢,超时 |
| 快排 Hoare 递归 | 快 | 慢(三数取中后恢复正常) | 快 | 中等 |
| 快排 前后指针递归 | 快 | 慢(同上) | 极慢,退化 | 慢 |
| 快排 非递归(Hoare 划分) | 快 | 快(有深度保护) | 快 | 中等 |
| 三路划分快排 | 快 | 快 | 极快 | 极快 |
几个结论:
- 冒泡在百万级数据上完全没有可比性,n=100 万时逆序对数量级是 10¹¹,别拿它做正经排序。
- Hoare 版在“全相同”数据上表现良好,这是它用
>=跳过相等元素带来的意外收益。 - 前后指针版是最“脆”的一个,随机数据上很快,重复数据上直接崩盘。
- 三路划分在重复数据上的优势是数量级的,不是百分比。
- 非递归版本在有序数据上更稳,因为深度保护可以兜底。
选型的顺序我一般是:先看数据有没有大量重复,有就上三路划分;再看规模,超过几十万就考虑非递归加深度保护;最后看是否要求保持相等元素次序,要求就换归并。三句话能定下来,不用纠结。
6.3 常见问题速查表
| 现象 | 最可能的原因 | 排查手段 |
|---|---|---|
| 程序挂死不结束 | 内层 while 缺边界判断,指针跑出区间 | 在每个内层循环加打印,看指针值是否越界 |
| 段错误,无堆栈 | 递归深度过大,栈溢出 | 打印递归深度;改非递归版本验证 |
| 排序结果局部有序但整体乱 | 划分函数返回值错误,或递归边界写错 | 加断言检查划分后的区间性质 |
| 特定数据才出错 | key 选取策略被针对,退化 | 换三数取中;比较不同数据分布下的表现 |
| 重复元素多时极慢 | 两路划分退化 | 换三路划分,对比耗时 |
| 相同键的元素顺序变了 | 快排不保证相等元素次序 | 换归并排序,或在比较里加次级键 |
| 小数组上比插入排序还慢 | 递归和划分开销占比过高 | 加小区间阈值,低于 16 直接插入排序 |
这张表我放在自己的笔记里很久了,实际排查时按行对照,命中率很高。特别是第一行和第三行,占了快排 bug 的绝大多数。
6.4 几条踩坑心得
最后分享几条从实际调试里攒下来的经验,都是文档里不会写的。
第一条:划分函数单独测试。写完划分函数,先别急着跑完整排序。构造一个小数组,手工算出预期的划分结果,单独调用划分函数,把返回值和数组内容都打印出来对照。划分对了,排序基本就对了;划分错了,后面调多久都是白费。这一步花五分钟,能省掉两小时。
第二条:准备四组固定测试数据。随机、已升序、已降序、全相同。这四组能覆盖 90% 的退化场景。再加一组“大量重复”,就是完整的测试集。每次改代码都跑一遍,比什么代码审查都管用。
第三条:小心int溢出。三数取中里的(left + right) / 2、归并里的元素计数、比较次数的统计,都可能溢出。养成写left + (right - left) / 2的习惯,成本为零。
第四条:别在比较函数里做副作用。有些实现会在比较时顺手交换元素,看起来省了一次循环,实际上会让排序变成不满足传递性的关系,结果不可预测。比较就是比较,交换就是交换,分开写。
第五条:性能优化先测再改。我见过有人把快排改成各种花哨写法,最后比朴素版还慢。原因是热点不在他改的地方——真正的开销在缓存不友好和分支预测失败上,而不在循环次数。先上 profiler 看热点,再决定改哪儿。三数取中、小区间插入、三路划分这三个优化,性价比最高,值得先加;其他的,测了再说。
第六条:处理好“等于”这个边界。快排里几乎所有正确性问题,最后都能归结到“相等元素怎么处理”上:划分时用<还是<=、三路划分里相等段的边界、非递归时单元素区间要不要入栈。写的时候在每个比较符号旁边想一秒:这个等号该不该有?想清楚这一秒,能省掉后面一小时的调试。