结论先行:list.sort 的快不是「更快的基本排序」,而是「先认输、后出手」------Timsort 先把数据里天然有序的小段(run)挑出来,只对剩下的乱段动手;再按 minrun 规则凑成 32--64 长的初始段,最后两两归并,遇到「一头倒」的段启用 galloping 模式跳过式合并。实测同一份 100 万元素:已排序 6.6 ms、逆序 8.1 ms、纯随机 309.2 ms------同样的数据规模,耗时差 47 倍,这就是「适应数据」四个字的含金量。而它的「稳」(相等元素保持原相对顺序)不是附赠,是归并排序的基因。这篇用 6 组实验把三板斧拆开看。
1. 第一板斧:run 检测------先投降,后作战
Timsort 的哲学:数据很少是完全乱的。真实的待排数据里,藏着大量天然有序的连续段。Timsort 做的第一件事,就是把它们找出来。
实测三种极端输入的 100 万元素排序:
| 输入形态 | 耗时 | 说明 |
|---|---|---|
| 完全有序 | 6.6 ms | 检出 1 个 run,直接收工 |
| 完全逆序 | 8.1 ms | 检出降序 run,翻转即可 |
| 纯随机 | 309.2 ms | 切成 ~16000 个小 run,反复归并 |
逆序只比正序慢 24%------Timsort 检测到「严格递减」的 run 后直接原地翻转,O(n) 搞定。这就是「适应数据」最直观的体现:同样的算法骨架,面对有序数据的开销几乎为零。
再看一组对比实验更能说明问题:把 100 万元素切成每 32 个一组的小有序段再整体打乱(小块 run 失效),耗时 404.2 ms,比纯随机还慢;而切成每 1024 个一组的有序段(大 run 有效),只花 6.1 ms------run 越长,归并树的层数越少,线性省时间。
2. 第二板斧:minrun------凑够一手好牌
真实数据不会自动排成 32 的整数倍。Timsort 的办法是定一个最小 run 长度 minrun,落在 32--64 之间:剩下的零头如果短于 minrun,就强行用二叉插入排序补足到 minrun 再入栈。
为什么偏偏是 32--64?两条约束的平衡:
- 不能太短:run 太短,归并时比较次数按 O(n log r) 涨,r 是 run 数;
- 不能太长:minrun 越长,插入排序的 O(k²) 越吃不消。
minrun 的取值还有讲究:如果 n < 64,minrun 直接取 n(整个一段排完);否则取 32--64 之间、使 n/minrun 接近 2 的幂的那个数------这样归并树的形状最规整, cache 局部性最好。实测逆序 50 万元素只要 3.8 ms,就是「降序 run 检出 + 归并树层数最少」的合力。
3. 第三板斧:galloping------一头倒时跳过式合并
普通归并比较两个 run 的队首,谁小谁先进结果。但如果 A 的前 50 个元素全都小于 B 的队首呢?逐个比较就是浪费。
Timsort 的解法是 galloping(奔驰)模式 :一个 run 连续「赢」过 7 次(MIN_GALLOP_WIN)后,切换成指数步长探测------先用 1 个、2 个、4 个、8 个......二分定位这个 run 能整段吞下多少,然后整段搬过去,跳过中间那些注定输掉的比较。
这就是为什么那种「两个有序序列一大一小拼接」的数据 Timsort 排起来飞快:一头倒的段,用 galloping 几乎可以线性合完。纯随机数据反而享受不到红利------没有哪一头能连续赢,每次都退化成普通归并,这也是随机 309 ms 是「正常价」的原因。
4. 稳定性:归并排序的基因,不是附赠
Timsort 的「稳」来自归并的基本规则:归并时遇到相等元素,永远取左边 run 的。左边 run 的元素本来就排在前面,相等也不插队------相等元素的相对顺序因此被完整保留。
实测验证:
python
pairs = [(1,'a'), (0,'b'), (1,'c'), (0,'d'), (1,'e')]
sorted(pairs, key=lambda x: x[0])
# [(0,'b'), (0,'d'), (1,'a'), (1,'c'), (1,'e')]
# 同 key 的 b/d 与 a/c/e 内部顺序,与原序列完全一致
这个性质是多级排序的地基:先按专业排、再按薪资排,相等专业内的薪资顺序不乱,靠的就是稳定性。快排不保证这个(相等元素的相对位置取决于分区点的运气),所以 Python 从来没把快排放进 list.sort。
5. sorted() 和 list.sort() 差在哪?一次拷贝
| 方式 | 100 万元素耗时 |
|---|---|
sorted(lst) |
313.2 ms |
lst.sort() |
301.0 ms |
sorted 是函数,必须先拷贝一份再原地排;list.sort 是方法,直接排。差的 12 ms 就是那次全表拷贝 。数据量大、又不需要保留原序列时,用 lst.sort();需要原序列时再选 sorted。
6. key 函数的隐藏账单
实测 50 万元素,有没有 key 差 41%:
| 写法 | 耗时 |
|---|---|
| 无 key | 137.4 ms |
key=lambda x: x |
194.4 ms |
Timsort 对每个 key 只算一次(decorate 模式,不是每次比较都回调),但 50 万次 lambda 调用本身就是 50 万次函数调用开销。热点场景把 key 换成 operator.itemgetter 或属性预取,能把这 41% 砍掉一大截。
一句话收尾:Timsort 的三板斧------run 检测让有序数据近乎免费(逆序只比正序慢 24%)、minrun 把归并树修成规整形状、galloping 在一头倒时跳过注定失败的比较;稳定性则是归并基因的白送。它的快不是常数级提速,而是「数据越有结构,它越便宜」的适应式设计。