你的数据从来不是随机的——三种语言的排序实测

0 阅读4分钟

你的数据从来不是随机的——三种语言的排序实测

排序算法的教科书分析默认输入是随机的。但真实数据不是:日志按时间递增、ID 单调、分页结果已排序、合并的是几条有序流、状态字段只有几个取值。

我用六种数据形态各测了 200 万个整数。

Python 的 Timsort:快一到二十四倍

数据形态              sorted()     相对随机
--------------------------------------------
完全随机             752.8 ms       1.00x
已排序                38.1 ms       0.05x    ← 快 20 倍
完全逆序              31.2 ms       0.04x    ← 快 24 倍
99% 有序              79.6 ms       0.11x
只有 10 种值         136.3 ms       0.18x
锯齿状(1000 段)       86.9 ms       0.12x

逆序比正序还快。

因为 Timsort 的第一步是扫描出「自然有序段」(run)。遇到一整段严格递减的序列,它只要原地反转就得到一个升序 run——这是 O(n) 的操作,然后归并阶段几乎无事可做。

「99% 有序」这一行最有现实意义:只要数据大体有序,哪怕有两万个元素被打乱,也能快 9.5 倍。

Go 的 pdqsort:已排序时快 90 倍

数据形态        slices.Sort   sort.Slice(闭包)    倍数
----------------------------------------------------
完全随机          197.8 ms         357.6 ms     1.81x
已排序              2.2 ms           5.2 ms     2.36x
完全逆序            3.6 ms           8.3 ms     2.32x
99% 有序           42.0 ms         111.7 ms     2.66x
只有 10 种值        33.3 ms          58.9 ms     1.77x
锯齿状             51.3 ms         117.7 ms     2.30x

2.2 毫秒排完 200 万个已排序的整数——相对随机数据的 197.8 毫秒,快了 90 倍。

这基本就是一次 O(n) 扫描的时间。Go 1.19 之后 sort 换成了 pdqsort(pattern-defeating quicksort),它在每轮分区前会检测数据模式:已经有序就直接返回,有大量重复值就切换到三路分区。

那为什么 sort.Slice 慢 2 倍多

这是 Go 里一个很实在的坑。右边那一列是:

go

sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })

sort.Slice 接收 interface{} 和一个比较闭包,内部用反射构造 swap 函数,每一次比较都是一次无法内联的闭包调用。

而 slices.Sort(a)(Go 1.21+ 的泛型版本)在编译期就为 int 特化了整个算法,比较和交换全部内联。

同一个算法、同一份数据,就因为比较函数能不能内联,差了 1.8 到 2.7 倍。

C 的 qsort 输给 C++ 的 std::sort,原因一模一样

数据形态              C qsort    C++ std::sort   倍数
-----------------------------------------------------
完全随机             285.8 ms       151.2 ms    1.89x
已排序                66.5 ms        22.5 ms    2.96x
完全逆序              78.1 ms        16.3 ms    4.78x
99% 有序             106.7 ms        25.4 ms    4.21x
只有 10 种值         158.4 ms        45.5 ms    3.48x

qsort 的签名是:

c

void qsort(void *base, size_t n, size_t size, int (*cmp)(const void*, const void*));

比较函数是个运行时的函数指针。 排 200 万个元素要比较约 4000 万次,每次都是一次间接调用——不能内联、污染分支预测器、还要通过 void* 做两次解引用。

std::sort 是模板,比较器的类型在编译期已知,整个比较被内联成一条 cmp 指令。

C 和 C++ 排序性能的差距,跟算法没关系,跟「比较函数能不能内联」有关系。

这也是为什么 C++ 里传 lambda 比传函数指针快:lambda 是独一无二的类型,能内联;函数指针不能。

一个额外观察

std::sort 在已排序数据上也快了 6.7 倍(22.5 vs 151.2 ms)。introsort 并不像 Timsort 那样显式检测有序段,这个收益来自别处:数据有序时交换次数大幅减少,而且分区的比较结果高度可预测,分支预测器全程命中。

所以即使算法本身不自适应,有序数据依然更快——只是快 6 倍而不是 90 倍。

实践清单

1. 压测要用真实形态的数据。 用 rand() 生成的测试数据会系统性地低估你的排序性能,也会掩盖「换个算法能快 10 倍」的机会。

2. Go 里用 slices.Sort,别用 sort.Slice。 Go 1.21+ 的泛型版本,免费的 2 倍。

3. C++ 里传 lambda 或函数对象,别传函数指针。 同样是免费的 2-4 倍。

4. C 里如果性能关键,考虑手写特化版本,或者用宏生成——qsort 的通用性代价是实打实的。

5. 数据本来就有序就别排。 听起来是废话,但「从数据库 ORDER BY 取出来,在内存里又 sort 一遍」是很常见的代码。

6. 合并多个有序序列用归并,不要拼起来重排。 k 路归并是 O(n log k),重排是 O(n log n)。

最后

排序是被研究得最透的算法问题,但工程上的性能差距往往不在算法本身:

在于比较函数能不能内联,以及你的数据到底长什么样。

而后者,是教科书的复杂度分析里永远不会出现的变量。