Python 的 sorted 为什么又快又稳?Timsort 的归并秘密一次讲透
系列:Python 底层设计逻辑|类型:原理拆解型|分发:CSDN / 博客园 / 腾讯云
字数:约 1700 字
结论先行:Python 的 sorted 和 list.sort 用的是 Timsort——一种"先找有序段、再把有序段归并起来"的混合排序算法。它在最好情况下只要 O(n)(数据本来就接近有序),最坏情况也是稳定的 O(n log n),而且是稳定排序。三个特性叠在一起,让它成了现实数据(大量部分有序)上的最优解之一。下面用四个实验,把它的运作方式拆开看。
1. 先看一个反常现象:接近有序的数据,排序快得离谱
import random, time
# 数据 A:完全随机
a = [random.random() for _ in range(100000)]
# 数据 B:基本有序,只打乱 1% 的元素
b = list(range(100000))
for _ in range(1000):
i, j = random.randrange(100000), random.randrange(100000)
b[i], b[j] = b[j], b[i]
t0 = time.perf_counter(); a.sort(); t1 = time.perf_counter()
t2 = time.perf_counter(); b.sort(); t3 = time.perf_counter()
print(f'随机数据:{(t1-t0)*1000:.1f} ms')
print(f'接近有序:{(t3-t2)*1000:.1f} ms')
典型输出:
随机数据:48.2 ms
接近有序:6.9 ms
同样的十万个元素,只把"有序程度"改了改,速度差了 7 倍。 普通快排在这里帮不上忙——它的分区操作可不关心你数据有序无序,永远老老实实 O(n log n)。Timsort 能吃到这个红利,靠的是它独特的两段式结构。
2. 第一步:run——先把数据切成"天然有序段"
Timsort 开工第一件事,不是比较,是扫描。它从左往右找"本来就递增(或严格递减)的连续段",每个段叫一个 run(有序段):
data = [1, 3, 5, 7, 2, 4, 6, 9, 8]
# 扫描结果:
# run1 = [1, 3, 5, 7] 天然递增,白捡
# run2 = [2, 4, 6, 9] 天然递增,白捡
# run3 = [8] 长度不够,后面讲
数据里的"有序片段"是被直接认领的,一趟扫描零比较成本。现实数据为什么快?因为现实数据到处是有序片段:时间序列、日志、按某字段排过的表、甚至只是"大部分人对的"——run 越长,归并的趟数越少。
递减的段也有讲究:[9, 6, 3] 这种严格递减段会被原地翻转成递增段收编——反正反转是 O(n) 且不破坏稳定性,白捡一个 run 干嘛不要。
注意一个细节:扫描出的 run 如果太短(不足最小长度,通常 32~64),Timsort 会用插入排序把它强行续长到最小长度。为什么是插入排序?因为短段上插入排序常数极小,而且稳定——这两点正是下一步归并最需要的。
3. 第二步:merge——把有序段两两归并
有了若干个有序段,剩下就是经典的归并:两个有序序列合成一个有序序列,每个元素只看一次,O(n) 搞定。
[1, 3, 5, 7] + [2, 4, 6, 9] → [1, 2, 3, 4, 5, 6, 7, 9]
但 Timsort 的归并段位管理有个聪明设计——栈(stack)。新产生的 run 压栈,每次压完检查栈顶几个 run 的长度是否满足约束,不满足就立刻合并,不让小段无限堆积。
为什么这么设计?如果无脑"等所有 run 找齐再一次性归并",内存里要同时驻留全部 run;而用栈随产随消,内存占用可控,合并次序也更有计划性。这是 Timsort 能在超大数组上稳定工作的关键。
4. 关键性质:稳定,而且知道" gallop 模式"
Timsort 有两个性质值得单独说。
性质一:稳定排序。 相等元素的相对顺序在排序后保持不变:
pairs = [(1, 'a'), (2, 'b'), (1, 'c'), (2, 'd')]
sorted(pairs, key=lambda p: p[0])
# [(1, 'a'), (1, 'c'), (2, 'b'), (2, 'd')]
# 两个 1、两个 2 的内部顺序都没变
稳定不是白送的:run 阶段只收严格递增段(相等元素不会被切开),归并时相等时优先取左边段。两个细节合力,才保住稳定性。Python 官方把"稳定"写进语言承诺,sorted 你敢拿去做多级排序,靠的就是它。
性质二:gallop(奔驰)模式。 归并过程中如果发现"某个 run 的元素连续大批量地小于另一个 run 的当前元素",Timsort 不等一个一个比,直接二分查找定位边界,整段搬过去:
left = [1, 2, 3, ..., 900]
right = [950, 951, ..., 999]
这种形态下,right 的前 50 个元素会被 gallop 一次抱走,而不是傻比 50 次。这就是为什么"两段拼一块"的数据 Timsort 也吃得开——真实数据里的有序块,经常是一出手就连续赢。
5. 和快排比:一张表看定位差异
| 维度 | 快排(QuickSort) | Timsort |
|---|---|---|
| 最好情况 | O(n log n) | O(n)(数据已有序) |
| 最坏情况 | O(n²)(劣化风险) | 稳定 O(n log n) |
| 平均情况 | O(n log n) | O(n log n) |
| 稳定性 | 不稳定 | 稳定 |
| 额外内存 | O(log n) 栈 | O(n) 归并缓冲 |
| 部分有序数据 | 无感 | 大幅提速 |
| 现实数据表现 | 好 | 更好 |
快排不是输在平均性能,是输在确定性和适应性:最坏劣化、不稳定、不认有序数据。Timsort 用"多花一份归并内存"换来这三样——在现实世界,这笔交易几乎总是划算的。Java、Python、Android、V8 纷纷投奔 Timsort,就是这个道理。
6. 实战启示:怎么让 sorted 替你省时间
知道底层机制,用法上就能占便宜:
- 提交前先看一眼数据形态:日志、时间戳、ID 列表多半接近有序,直接排,Timsort 会自动吃红利,不用自己预处理;
- 多级排序用
key元组,别排多趟:sorted(data, key=lambda x: (x.a, x.b))一趟搞定,靠稳定性保证正确; - 别迷信"快排更快"的传言:在 Python 里
sorted就是官方给出的最优解,自写快排只会更慢更不稳; - 海量数据注意内存:Timsort 要 O(n) 归并缓冲,内存吃紧时分块排再归并,是教科书标准做法。
7. 一句话总结
- Timsort = 扫有序段(run) + 插入排序续长短段 + 栈管理下随产随消的归并;
- 三块拼起来,换来最好 O(n)、最坏稳定 O(n log n)、排序稳定三个稀缺性质;
- 它赢在适应现实数据——部分有序的场景直接吃到 3~7 倍提速;
sorted的"又快又稳"不是运气,是这套组合拳设计的必然结果。
排序算法学了十几种,最后工业界选中的是这位"会认数据脸色"的。看懂 Timsort,你就看懂了算法工程化的核心思路:不追求理论极限,追求现实数据上的最优期望。
上一篇:《用了三年 Python,我才知道 slots 能让对象瘦身一半:一个内存实验讲透》。关注不迷路。

浙公网安备 33010602011771号