跳到正文

排序算法全族

环境:Windows 11 x64,GCC 13.1.0,g++ -std=c++17 -O2。6 种实现先在 6 类数据上与 std::sort 逐一比对确认排对,再计时。

一、先确认排对了

6 种实现 × 6 类数据全部与 std::sort 一致=1

六类数据是刻意选的:完全随机、已升序、完全逆序、全部相等、只有 5 个不同值、多重复段。只测随机数据的话,快排的 O(n²) 退化、堆排的边界错误、归并的缓冲区错位全都测不出来。

二、耗时实测(500 万个 int,单位 ms)

数据分布希尔堆排归并三路快排基数 LSDstd::sort
完全随机850.4926.3450.2406.558.9329.2
已升序128.8300.071.598.8158.551.2
完全逆序143.6337.893.597.7158.240.6
全部相等117.2143.782.94.287.546.2
只有 5 个不同值213.8285.2175.343.580.395.6
多重复段115.7446.181.149.282.638.4

(插入排序在 500 万上是 2.5×10¹³ 量级的操作,估算是几十分钟,跳过;它在 5000 规模上与其他实现全部排对。)

四个必须记住的读数:

  1. 基数排序在整数键上碾压一切:58.9ms,是 std::sort 的 1/5.6。因为它根本不做比较——O(w·n),字长固定就是线性。代价:只适用于可映射成定长键的数据,且要额外缓冲区(这里 500 万 int = 20MB)。
  2. 三路快排在”全部相等”时是 4.2ms,比 std::sort 快 11 倍。因为等于 pivot 的元素被一次性归到中间、不再递归。这是处理”大量重复键”的关键设计,也是为什么很多库排序近年从二路换成三路。
  3. std::sort(introsort)在所有非特殊场景都接近最快:它 = 快选 + 堆排兜底 + 小数组转插入,工程调优到位。默认就该用它。
  4. 堆排在随机数据上最慢(926ms),尽管它也是 O(n log n):每次 pop_heap 都要从顶沉到底,访问模式是跨大步的,缓存极不友好。理论比较次数少,实测完全不是对手。

三、稳定性:我的第一版测试是错的

正确的测法是只用 key 比较(允许相等),然后检查相等元素的下标是否仍递增:

std::sort(introsort)         稳定=0
std::stable_sort(归并)       稳定=1
heapsort(key-only 比较)      稳定=0
三路快排(key-only 比较)      稳定=0
std::sort 用 pair 默认比较     稳定=1   ← 假象!

最后一行是我的第一版实现:用 pair<key, 原始下标> 的默认比较排序,第二键自动打破了所有平局,于是任何排序都”看起来稳定”。

这不是纯粹的失误,而是一个正当的技巧:想在不稳定的排序上获得稳定结果,就把原始下标附加进比较键((key, index))。代价是比较变贵、元素变大。反过来说:如果你需要稳定性,要么用 std::stable_sort,要么显式加下标当第二键,别指望 std::sort。

什么时候真的需要稳定?多关键字排序(先按部门、再按工号,分两次排且第二次必须稳定)、保持原始输入顺序的报表输出、以及”相等元素的其它字段有意义”的场景。

四、std::sort 到底做了什么(introsort)

libstdc++ 的 std::sort 是三段组合,实测能验证每一段:

  1. 快排主体:median-of-3 或更复杂的 pivot 选择,避免有序输入退化。
    • 证据:已升序 51.2ms、完全逆序 40.6ms、随机 329ms——有序数据反而更快,因为分支预测友好,说明它没有被退化惩罚。
  2. 深度限制:递归超过 2·log₂n 层就切换到堆排,保证最坏 O(n log n)。这就是”introsort”的名字来源(introspective)。
  3. 小区间切换插入排序:长度小于阈值(libstdc++ 是 16)时用插入排序。

第三点的实测证据(30 万次排序,每次 n 很小):

n= 8   插入排序  36 ms | std::sort  36 ms
n=16   插入排序  70 ms | std::sort  67 ms
n=32   插入排序 148 ms | std::sort 189 ms
n=64   插入排序 360 ms | std::sort 416 ms

n≤16 时两者持平(因为 std::sort 内部本来就走插入),n=32/64 时我的纯插入排序反而更快(std::sort 在这个尺度上还多做了 partition 的开销)。

为什么 O(n²) 的插入排序在小数组上赢? 因为它的每次比较都是顺序内存访问 + 高度可预测的分支,而快排要做 pivot 选择、递归调用、跨距离交换。大 O 忽略的常数在小尺度上成了主导。

五、各算法的定位(一张决策表)

算法时间(平均/最坏)空间稳定什么时候用
插入O(n²) / O(n²),近有序时 O(n+k)O(1)✅小数组(<16)、近乎有序、在线插入
希尔取决于增量序列,约 O(n^1.3)O(1)❌无栈空间限制、不要稳定性(嵌入式里偶尔用)
堆排O(n log n) / O(n log n)O(1)❌必须原地 + 必须最坏保证时(嵌入式、实时系统)
归并O(n log n) / O(n log n)O(n)✅需要稳定、链表排序、外部排序
快排(三路)O(n log n) / O(n²)O(log n)❌通用;重复键多时三路明显更好
introsortO(n log n) / O(n log n)O(log n)❌就是 std::sort,默认选择
基数 LSDO(w·n) / O(w·n)O(n+k)✅(按位稳定)定长整数/可映射成定长键;n 大时最快
计数O(n+k) / O(n+k)O(k)✅值域 k 很小(成绩分布、直方图)
partial_sortO(n log k)O(k)—只要前 k 个且要有序
nth_elementO(n) 平均O(1)—只要前 k 个、不要求有序(中位数、Top-K)

六、下界与”能不能更快”

  • 比较排序的下界是 Ω(n log n)(决策树有 n! 个叶子,log₂(n!) ≈ n log n)。所以 std::sort 的 329ms 已接近极限。
  • 要突破它只能利用”键的结构”:基数排序、计数排序不是比较排序,它们用 O(w·n) 换掉了 log n。这就是实测里基数排序快 5.6 倍的全部原因。
  • 推论:当你能把排序键压成定长整数(或可字典序比较的字节串)时,基数排序是数量级级别的优化。数据库的 fixed-width key 排序、编译器符号表排序都吃这个红利。
  • 另一个方向是并行:C++17 的 std::execution::par 让 std::sort 变成并行归并/快排,多核线性加速(需要 TBB)。

七、这一节的自查清单

  • 默认用 std::sort;需要稳定用 std::stable_sort,或把下标加进比较键
  • 只要前 k 个用 nth_element(不要求有序)或 partial_sort,不要全排
  • 值域小或键定长 → 计数/基数排序,能拿到数量级收益
  • 测试数据必须包含:已排序、逆序、全相等、少量不同值、含重复段
  • 测稳定性时比较器只能看 key,加了第二键的测试是无效的
  • 手写快排要用三路划分,否则重复键会退化成 O(n²)
  • 递归快排要有深度保护(tail-call 优化 + 堆排兜底),否则对抗性输入能撑爆栈
  • 比较函数里绝不做重活(缓存、字符串构造),std::sort 会调用它 n log n 次
  • 记住堆排的理论优势在真实机器上会被缓存惩罚吃掉

下一节讲哈希表与冲突处理:除留余数与乘法散列的分布实测、开放寻址 vs 链地址、负载因子与 rehash 的代价、以及 std::unordered_map 的四个性能陷阱。