快速排序与三路切分
一、分治的成败全在切分
这一篇对应官方 Lecture 30 & 32(Quick Sort)。
快速排序的骨架和归并排序几乎一样:分区、递归。区别是归并排序在合并时干活,快速排序在切分时干活。
切分做的是这样一件事:选一个轴元素,把数组重新排布成「左边都小于轴、右边都大于轴」,然后返回轴最终落到的位置。之后左右两半各自递归。
于是整个算法的代价被一个量完全决定:切分的均匀程度。
- 如果每次切分都把数组分成两半,递归深度就是 log₂N,总代价 O(N log N);
- 如果每次切分只分离出一个元素,递归深度就是 N,总代价 O(N²)。
而归并排序不存在这个问题——它的切分点固定在中间,永远均匀。快速排序换来了「原地排序、不需要额外缓冲数组」的便利,代价是把切分的均匀性交给了运气和数据形态。
二、两路切分与它的失效
最朴素的写法是拿最后一个元素当轴,用一个指针把小于轴的元素往左推:
1 | /** 两路切分,轴取 a[hi],小于轴的元素往左推 */ |
注意 a[j] < pivot 这个严格小于的判断。它决定了「等于轴的元素」去哪一边:全都留在右边。 这一步是后面两个问题的源头。
用这个切分写快排:
1 | /** 末元素为轴的两路快排:只对较小的一侧递归,较大的一侧改成循环,避免深递归 */ |
这里的「只对较小的一侧递归、较大的一侧改成循环」是一个必须知道的工程细节。如果两侧都递归,切分退化时递归深度会达到 N,10 万个元素的输入足以让调用栈溢出。改成单侧递归之后,递归深度有了 O(log N) 的上界,而比较次数一点没变。
三、实测:两种失效形态
N = 10000,六种输入形态,三种写法的比较次数:
1 | N = 10000 时的键比较次数: |
失效形态一:有序输入
升序和降序那两行,末元素轴版本都是 49995000 次比较——正好等于 10000 × 9999 / 2,也就是最坏情况。
原因很直白:升序数组里,最后一个元素是最大值,切分之后它落到最右端,什么都没分出来。下一轮在剩下的 9999 个元素上重复同样的事。每一轮只减少了 1 个元素的规模,总共 N 轮。
「基本有序」那一行 2065188 次是这个缺陷的温和版本:数据大体有序、夹杂少量扰动,切分虽然不至于每次都最差,但依然严重失衡。
修复:随机轴
把「取最后一个」换成「随机抽一个,换到最后一个位置再切」:
1 | /** 随机轴的两路快排 */ |
升序那一行从 49995000 掉到 154547,降序从 49995000 掉到 163260。 效果是决定性的:随机选轴让轴在有序数组里的秩也变成随机的,于是「每次都分出不均匀的两半」从一个必然事件变成了小概率事件。剩下的 154547 与随机输入的 158665 处在同一量级,说明失效形态已经被彻底消除。
失效形态二:大量等值元素
但随机轴救不了等值输入。
全相等那一行,随机轴版本依然是 49995000 次。 原因不在轴选得好不好——不管随机抽到哪个元素,它的值都等于其他所有元素。问题出在 a[j] < pivot 这个判断上:
1 | 所有元素都等于轴 → 没有任何元素满足 a[j] < pivot |
「十种取值」那一行是这个缺陷的一般形态:5032594 次比较,虽然比全相等好一些,但仍然比随机输入(158665)贵了三十多倍。只有十种取值的数据在实际场景里非常常见——按状态码排序、按年龄段排序、按星期几排序,都属于这一类。
四、三路切分:一次处理完所有等值元素
两种失效形态的根源是同一个:两路切分只会把数组分成「小于轴」和「不小于轴」两块,等值元素被当成「不小于」处理,永远需要再排一次。
三路切分把数组直接分成三块:小于区、等于区、大于区。切完之后,等于区里全是已经就位的元素,它整块跳过、不参与递归。
1 | /** 随机轴的三路切分:小于区 / 等于区 / 大于区,等于区整块跳过 */ |
三个指针的含义可以用一张图理清楚:
flowchart LR
A["小于区<br/>lo .. lt-1"] --> B["等于区<br/>lt .. i-1"]
B --> C["未处理区<br/>i .. gt"]
C --> D["大于区<br/>gt+1 .. hi"]
推进规则只有三条,注意三条各自的下标动作:
| 当前元素 | 动作 | 为什么 |
|---|---|---|
| 小于轴 | 与 lt 交换,lt++ 且 i++ |
换过来的是等于区元素,已处理,i 可以前进 |
| 大于轴 | 与 gt 交换,只 gt-- |
换过来的是未处理元素,i 不能前进 |
| 等于轴 | 只 i++ |
直接扩进等于区 |
大于轴那一支不推进 i 是最容易写错的地方。 从 gt 换过来的元素还没被检查过,如果顺手把 i 往前挪,这个元素就被跳过了。
五、实测:等值数据上的差距
先看等值数据的极端情况。键只有 1、2、3 三种取值,N = 10000:
1 | 三路切分对等值块的剪枝效果(N = 10000,键只有 1..3 三种取值): |
16692168 对比 16691,相差 1000 倍。
这个差距不是常数优化,而是算法量级的变化:
| 数据 | 两路切分 | 三路切分 | 原因 |
|---|---|---|---|
| 全相等 | 49995000 | 10000 | 三路:一趟扫完,等于区就是整个数组 |
| 十种取值 | 5032318 | 37111 | 三路:每一层都把等值块整块消掉 |
| 三种取值 | 16692168 | 16691 | 同上,等值块更大,剪枝收益更高 |
「全相等」那一行尤其干净:三路切分只需要 10000 次比较——正好是 N,一次扫描确认所有元素都等于轴,然后等于区覆盖整个数组,递归两侧都是空区间。这已经是理论下限了,任何基于比较的排序都不可能用少于 N−1 次比较确认 N 个元素已有序。
不过三路切分也不是免费的。回到随机输入那一行:
| 数据 | 两路切分 | 三路切分 |
|---|---|---|
| 随机(键各不相同) | 158665 | 164205 |
小规模示例 [9,4,7,1,8,3,6,2,5] |
比较 20、移动 56 | 比较 32、移动 52 |
在没有重复键的数据上,三路切分反而略贵。 因为每个元素都要走一遍三条分支的判断,而等值区往往小得没有收益。这是一个典型的「用额外判断换最坏情况保障」的取舍。
六、工程上的两个细节
除了前面提到的单侧递归,真实实现里还有两个值得知道的点。
其一:小数组切换成插入排序。 递归到规模很小的子数组时,快速排序的函数调用开销已经超过了它省下的比较次数。而小数组往往接近有序,插入排序在这种输入上只需要线性的比较次数——正好互补。所以工业级实现普遍会设一个阈值(常见是 8 到 16 个元素),低于它就直接用插入排序收尾。
其二:处理等值元素的策略取决于数据。 如果确定数据里的键几乎不重复,两路切分更划算;只要存在大量重复键的可能,就应该上三路切分。判据很实际:
| 数据特征 | 推荐写法 |
|---|---|
| 键几乎不重复、随机分布 | 随机轴 + 两路切分 |
| 存在大量重复键(状态码、年龄段、标签) | 随机轴 + 三路切分 |
| 可能接近有序 | 随机轴是必须的,不随机也要做三数取中 |
| 子数组规模小于阈值 | 切换成插入排序 |
七、小结
| 概念 | 一句话 | 证据 |
|---|---|---|
| 分治的瓶颈 | 切分均匀则 O(N log N),否则 O(N²) | 升序输入 49995000 次 |
| 末元素轴 | 有序输入下每次只分出一个元素 | 升序、降序都是 N(N−1)/2 |
| 随机轴 | 把秩变成随机变量,消除有序输入缺陷 | 升序 49995000 → 154547 |
| 两路切分的盲区 | 「等于轴」被归入右侧,等值块反复重排 | 全相等仍然是 49995000 |
| 三路切分 | 等于区整块跳过,不参与递归 | 全相等只需 10000 次比较 |
| 单侧递归 | 对较小侧递归、较大侧循环,限制栈深 | 比较次数不变,深度降到 O(log N) |
| 取舍 | 无重复键时三路略贵 | 随机输入 164205 vs 158665 |
三条能带走的:
- 同一句「这是 O(N log N) 的算法」可能掩盖一个数量级的失效。 快速排序在有序输入和等值输入上都会退化到 O(N²),而这两种输入在真实数据里都非常常见,不是构造出来的边角情况。
- 随机化只解决「轴选得不好」,不解决「切分本身装不下信息」。 随机轴彻底修好了有序输入的退化,对等值输入却毫无帮助——因为问题不在轴的选择,而在两路切分只有两个出口,装不下「等于轴」这第三种情况。
- 先看数据的键分布,再决定切分方式。 「键几乎不重复」和「键集中在少数取值上」对应两种不同的写法,选错了不会报错,只会在性能上悄悄差出两个数量级。

