对基本有序序列的排序:从 Timsort 到 Powersort
对基本有序序列的排序:从 Timsort 到 Powersort
在基于比较的排序算法里,quicksort 一直是非常重要的一类算法。它平均可以做到 O(n log n),实现也比较简单,因此很多通用排序实现都会采用 quicksort 或它的变体。
不过,quicksort 有一个明显的问题:它通常不是稳定排序。
所谓稳定排序,是指如果两个元素的排序 key 相同,那么排序之后,它们原来的相对次序仍然保持不变。
例如有两个元素:
(10, A)
(10, B)
它们的 key 都是 10。如果排序算法是 stable 的,那么排序后仍然应该是:
(10, A)
(10, B)
而不能变成:
(10, B)
(10, A)
这在很多实际场景里非常重要。
比如我们先按照姓名排序,再按照部门排序。如果第二次排序是稳定的,那么同一个部门中的人仍然会保持之前按姓名排好的顺序。因此,当排序需要稳定性时,就不能简单地使用普通的 quicksort。
这时,一个很自然的选择是 merge sort。
Merge sort 为什么适合稳定排序
Merge sort 的基本思想是分治。
假设有一段数据,先把它不断二分:
8 个元素
↓
4 + 4
↓
2 + 2 + 2 + 2
↓
1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
然后再从下往上合并。
两个已经有序的序列合并起来非常简单。只需要同时看两个序列当前最前面的元素,每次取更小的那个放到结果中即可。
例如:
A = 1 4 7
B = 2 3 8
合并过程就是:
1
1 2
1 2 3
1 2 3 4
1 2 3 4 7
1 2 3 4 7 8
整个 merge 过程只需要线性扫描两个序列,因此复杂度是 O(n)。
而 merge sort 一共需要大约 log₂n 层合并,所以总复杂度是:
O(n log n)
而且这个复杂度在最坏情况下也是成立的。
另一个优点是,merge sort 很容易做成稳定排序。两个元素 key 相同时,只要优先取左边序列里的元素,就可以保持它们原来的相对次序。
所以,从理论性质上看,merge sort 非常适合实现 stable sort。
不过它也有缺点。
最明显的一点是:合并时通常需要额外空间。
如果要合并两个数组片段,最直观的实现是先把数据复制到临时空间,再把合并结果写回原数组。相比能够原地工作的 quicksort,这会增加额外的内存开销和数据复制。
除此之外,传统 merge sort 还有一个更值得优化的地方:
它并不关心原始数据是不是已经部分有序。
现实中的数据往往不是完全随机的
传统算法分析经常假设输入数据没有任何规律。
但真实世界中的数据往往并不是这样。
例如:
1 2 3 4 8 9 10 5 6 7 11 12 13
这段数据虽然整体上没有排好序,但已经包含了多个连续的有序片段:
[1 2 3 4]
[8 9 10]
[5 6 7]
[11 12 13]
现实中这样的情况非常常见。
原因也很简单:数据往往不是一次性随机生成的,而是逐渐积累起来的。
例如:
- 一批原本已经排好序的数据又插入了少量新数据;
- 多个已经排好序的数据块被拼接到一起;
- 原有序列只有少数元素发生变化;
- 日志、时间序列、数据库结果本来就具有一定顺序。
因此,很多时候我们面对的并不是一个完全随机的数组,而是:
一个已经“差不多排好”的数组。
如果还按照普通 merge sort 的方式,把这些已经有序的片段重新拆成一个个元素,再从头合并,就浪费了原数据本身已经提供的信息。
Timsort 就是从这个观察出发的。
Timsort:先寻找已经有序的片段
Timsort 的第一步不是立即开始排序,而是先扫描数组,寻找其中天然存在的有序片段。
这种连续的有序片段叫作 run。
例如:
1 3 5 7 2 4 6 8 9 10
可以看成三个 run:
[1 3 5 7]
[2 4 6]
[8 9 10]
每个 run 本身已经有序,所以没有必要再处理它们内部的顺序。
接下来只需要把这些 run 合并起来即可。
从这个角度看,Timsort 本质上仍然是 merge sort,只不过它不再机械地把数组拆成固定大小的片段,而是:
尽量复用输入数据中本来就已经有序的部分。
如果原数组本来已经完全有序:
1 2 3 4 5 6 7 8
那么扫描之后会发现整个数组就是一个 run。
这时根本不需要做任何 merge,只需要一次线性扫描就可以结束排序。
因此,对于本来就已经有序或接近有序的数据,Timsort 可以比普通 merge sort 少做很多工作。
找到 run 之后,真正困难的问题才出现
找到 run 本身并不困难。
真正麻烦的是:
这些 run 应该按照什么顺序合并?
假设我们扫描得到三个 run:
A
B
C
它们的长度分别是:
1000, 10, 10
如果先合并 A 和 B,那么需要处理大约:
1000 + 10 = 1010
个元素。
得到一个长度 1010 的新 run 后,再和 C 合并,又需要处理:
1010 + 10 = 1020
个元素。
总共参与 merge 的数据量大约是:
1010 + 1020 = 2030
但如果先合并两个小 run:
10 + 10 = 20
然后再把 20 和 1000 合并:
20 + 1000 = 1020
总代价变成:
20 + 1020 = 1040
两种合并顺序的差别非常大。
所以,合并 run 时有一个基本原则:
应该尽可能先合并大小接近、规模较小的 run,避免一个大 run 被反复参与 merge。
这其实和普通 merge sort 为什么高效是同一个原因。
Merge sort 本质上是在构造一棵平衡树
可以把 merge sort 的合并过程看成一棵树。
例如 8 个元素:
8
/ \
4 4
/ \ / \
2 2 2 2
/ \ / \ / \ / \
1 1 1 1 1 1 1 1
每个元素从叶子一路向上,大概只会参与 log₂n 次 merge。
这就是为什么总复杂度是 O(n log n)。
如果这棵树很不平衡,比如不断把很小的 run 合到一个巨大 run 上:
1
\
2
\
3
\
4
\
...
那么前面的元素就会被重复搬动很多次,性能会明显变差。
因此,对 Timsort 来说,找到 run 之后,其实是在解决另一个问题:
如何一边扫描 run,一边构造一棵尽量平衡的 merge tree?
Timsort 的做法:用一个栈保存 run
Timsort 按照从左到右的顺序扫描数组。
每找到一个 run,就把它放进一个栈中。
假设当前栈顶的三个 run 长度依次是:
X Y Z
其中 Z 是刚刚扫描到、位于最上面的 run。
Timsort 会观察这几个 run 的长度关系,并根据一组规则决定现在是否需要进行合并。
这些规则的具体形式在不同版本中略有差异,但核心目标是一致的:
不希望栈里出现“下面很小、上面很大”的结构,也不希望一个很大的 run 很早就开始反复参与 merge。
换句话说,希望 run 的规模从栈顶向栈底快速增长。
理想情况下,可以形成类似:
5
8
13
21
34
55
89
...
这样的增长。
这和 Fibonacci 数列很接近。
为什么这种结构很好?
因为如果 run 大小呈指数级增长,那么一个很长的数组也只需要很少的栈元素。
比如即使数组最多有 2^64 个元素,能够同时存在栈里的 run 数量仍然很有限。
这就解决了另一个工程问题:
用固定大小的栈就能够保存所有尚未合并的 run。
Timsort 的问题:局部规则不一定保证全局结构
Timsort 原来的设计依赖一个重要假设:
只要每次检查栈顶几个 run,并维护它们之间的长度关系,那么整个栈都会一直保持良好的增长结构。
这个想法看起来很合理,但后来发现并不完全成立。
例如,可以构造这样一组 run 长度:
92 28 20 6 4 8 1
先看前五个:
92 28 20 6 4
它们可以满足原本预期的长度关系。
但当 8 入栈后,因为 8 比前面的某个 run 更大,会触发一次合并。
例如:
6 + 4 = 10
栈变成:
92 28 20 10 8
这时候原本较深位置的关系可能已经被破坏了:
20 + 10 > 28
也就是说:
栈顶的一次局部变化,可能破坏更靠下位置原本满足的条件。
而后续新 run 入栈时,并不一定马上触发对这些更深位置的重新检查。
于是,原本用来证明“run 大小呈 Fibonacci 式增长”的假设可能不再成立。
这件事的重要性并不在于排序结果会错。
真正的问题是:
如果不能严格保证 run 的增长规律,那么之前计算出来的最大栈深度也可能不够。
对于 C 或 Java 这种使用固定大小数组保存 run stack 的实现来说,如果理论栈上限算小了,就可能出现越界问题。
后来在对 OpenJDK 中的 Timsort 做形式化验证时,这个问题被发现了。
Python 和 Java 后来分别采用了不同的修复方法。
Python 的方案主要是:发生合并以后,不只检查最新的栈顶关系,还会再向前检查,确保之前的局部合并没有破坏更深位置的不变量。
Java 则一度选择直接增大 run stack 的容量上限,后来又对这个上限进行过进一步修正。
这些修复最终都可以解决问题,但也暴露了 Timsort merge policy 的一个特点:
它依赖一组相对复杂的局部规则,正确性和栈深度上限并不是一眼就能看出来的。
这正是 Powersort 想解决的问题。
Powersort:既然目标是接近 merge sort,不如直接参考 merge sort 的树
普通 merge sort 有一个很大的优点:
它的合并结构非常清楚。
整个数组不断二分,天然形成一棵平衡二叉树。
因此,对于长度为 n 的数组,树的深度最大就是:
log₂n
如果最多处理 2^64 个元素,那么树的深度也不会超过 64。
这个上限非常直观。
既然 Timsort 的目标本来也是让 run 的合并过程尽量接近一棵平衡的 merge tree,那么一个自然的问题就是:
为什么还要通过复杂的 run 长度规则去“猜”这棵树?能不能直接利用这棵树的结构?
Powersort 就采用了这种思路。
一棵并不存在的“虚拟 merge tree”
Powersort 并不会真的在内存中建立一棵树。
它只是把整个数组想象成一棵普通 merge sort 使用的完全二叉树。
例如数组可以想象成:
整个数组
/ \
左半 右半
/ \ / \
... ... ... ...
现在假设扫描到了两个相邻 run:
AAAAAA | BBBBBBB
这两个 run 之间有一个边界。
Powersort 会根据这个边界以及两个 run 在整个数组中的位置,计算:
这个边界在理想的 merge tree 中大约对应哪一层。
这个层级就叫 power。
可以把它粗略理解成:
- power 较小:这个分界位于比较靠近叶子的层级,应该比较早合并;
- power 较大:这个分界更接近树根,应该晚一些合并。
例如,一段小区域内部的两个 run:
... AAA | BBB ...
它们可能在很低的层级就应该合并。
而位于整个数组左右两大部分之间的边界:
左边很大一块 | 右边很大一块
则应该一直保留到接近最后再合并。
这正好对应普通 merge sort 的行为。
Powersort 如何决定什么时候 merge
Timsort 需要反复检查:
X
Y
Z
几个 run 的长度关系。
而 Powersort 只需要为相邻 run 计算 power,并让这些 power 在栈中保持正确的单调关系。
如果新加入一个 run 后,power 的顺序被破坏,就说明:
当前栈顶的一些 run 按照理想 merge tree 的结构,本来就应该先合并。
于是把它们 merge 掉,再继续。
这样一来,算法的决策逻辑就从:
根据 X、Y、Z 的长度猜应该怎么合并
变成:
根据它们在理想 merge tree 中的位置决定合并层级
这也是 Powersort 最核心的变化。
Powersort 为什么更容易分析
Timsort 为了证明 run stack 不会太深,需要通过 run 长度之间的不变量,推导出类似 Fibonacci 的增长速度,然后再根据总数组大小反推出栈深度上限。
逻辑大概是:
run 长度规则
↓
指数式增长
↓
栈深度有限
Powersort 的逻辑则简单得多:
power 对应 merge tree 的层级
↓
merge tree 深度最多 log₂n
↓
栈深度最多也是这个量级
如果 n 不超过 2^64,那么最多也就是几十层。
所以 Powersort 最大的价值,并不一定是“每种情况下都比 Timsort 快”。
实际上,两者的实际性能非常接近。
Powersort 有时会因为合并结构更合理而略快,但它也需要额外计算 power,而且理想 merge tree 对现实 run 的拟合也不可能永远完美,所以某些情况下也可能略慢。
它更重要的优势是:
merge policy 更容易理解,最坏情况下的栈空间上限也更清晰。
Powersort 并不是重新发明了一套排序算法
理解这一点很重要。
Powersort 并不是把 Timsort 整个推翻重写。
它主要改变的是:
多个 run 应该按照什么顺序合并。
其它很多重要优化,Powersort 仍然沿用了 Timsort 的设计。
包括:
- 寻找天然 run;
- 处理逆序 run;
- minrun;
- 小数组使用插入排序;
- merge 时减少临时空间;
- Galloping mode。
因此,可以把 Powersort 理解成:
保留 Timsort 大部分工程优化,只替换了 run 的合并策略。
对很短的 run 使用插入排序
虽然 Timsort 希望利用天然 run,但如果原始数据非常杂乱,就可能出现大量很短的 run。
例如:
3 1 4 2 6 5 8 7 ...
如果每两个元素就形成一个 run,那么 run 数量会非常多。
这对 merge 来说并不划算。
因此,Timsort 会设定一个最小 run 长度,也就是 minrun。
通常这个值会落在 32 到 64 左右。
如果扫描得到的天然 run 太短,就会继续向后取一些元素,然后使用插入排序把这一小段扩展成一个至少接近 minrun 大小的有序 run。
为什么这里使用插入排序?
因为虽然插入排序最坏是 O(n²),但当 n 很小时,它的实际开销非常低:
- 算法简单;
- 分支少;
- 内存访问连续;
- CPU cache 友好。
因此,很多复杂排序算法都会在数据块足够小时切换到插入排序。
这也是一个很典型的工程原则:
大 O 复杂度并不能完整描述小规模数据上的真实性能。
为什么 minrun 通常在 32 到 64 之间
Minrun 并不是随便选的。
它还有一个目的:
尽量让最终 run 的数量接近 2 的整数幂。
因为 merge sort 的树如果比较平衡,效率通常更好。
假设最终有:
64 个 run
就可以非常自然地:
64
↓
32
↓
16
↓
8
↓
4
↓
2
↓
1
不断两两合并。
如果 run 数量稍微超过 2 的整数幂,有时会造成一部分合并树明显不平衡。
因此,Timsort 会根据整个数组长度动态计算 minrun,让:
n / minrun
尽量落在一个适合构造平衡 merge tree 的范围内。
实际计算非常便宜,只需要利用 n 的高位和低位信息即可完成。
逆序 run 也可以利用
现实数据不一定只有正序片段。
例如:
9 8 7 6 5
虽然它不是升序,但它同样具有非常明显的结构。
如果把它当成普通乱序数据重新排序,就浪费了这个结构。
所以 Timsort 在扫描 run 时,会判断当前片段是:
递增
还是:
递减
如果发现连续递减:
9 8 7 6 5
直接把它反转:
5 6 7 8 9
就变成了一个有序 run。
反转的复杂度只有 O(n)。
相比重新排序要便宜得多。
当然,如果要求 stable,需要特别小心相同 key 的元素,因为简单反转可能改变相同元素的相对次序。
因此实际实现中会对“严格递减”和“相等元素”做适当处理。
Merge 时也没有必要处理所有元素
即使两个 run 需要合并,也不代表它们的所有元素都需要参与。
例如:
A = 1 2 3 10 11 12
B = 4 5 6 7 8 9 13 14
最终结果显然是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
这里 A 开头的:
1 2 3
已经确定比 B 中所有需要插到前面的元素都小,所以这部分完全不需要移动。
B 末尾的:
13 14
也已经确定在最终结果最后,因此同样不需要参与 merge。
真正需要重新排列的只有:
A: 10 11 12
B: 4 5 6 7 8 9
所以在正式 merge 前,可以先通过二分查找缩小真正需要处理的区间。
这在数据“基本有序”的情况下特别有效。
为什么只需要复制其中一个 run
传统 merge sort 往往给整个合并区间准备一份临时空间。
但 Timsort 可以更节省。
假设需要合并:
A
B
只需要把其中较短的一段复制到临时 buffer 中。
例如 A 更短:
A → 临时空间
原数组中 A 的位置就被空了出来。
然后可以从前往后,把 A 和 B 合并回原数组。
如果 B 更短,则可以复制 B,并从后往前进行 merge。
这样,额外空间只需要:
min(len(A), len(B))
最坏情况下不超过整个待排序数组的一半。
相比给完整 merge 区域分配额外空间,要节省很多。
Galloping:当一边连续胜出时,不要再一个个比较
普通 merge 的过程是:
比较 A[0] 和 B[0]
取一个
再比较
再取一个
再比较
...
但有时候会出现一种情况:
A 中连续很多元素都比 B 当前元素小
比如:
A = 1 2 3 4 5 6 7 8
B = 100 101 102
如果仍然每次比较:
1 < 100
2 < 100
3 < 100
4 < 100
...
其实很浪费。
很明显 A 中会有一大段元素可以直接复制。
于是 Timsort 引入了 Galloping mode。
当发现连续多次都从同一个 run 取元素时,就暂时停止一个个比较,改成快速搜索:
B 当前这个元素在 A 中应该插到什么位置?
可以先指数式扩大搜索范围,再通过二分查找确定边界。
找到以后:
A 中一整段数据
就可以一次性复制过去。
这对于两个 run 的值域明显错开的情况很有效。
当然,Galloping 也不是任何时候都更快,因为二分搜索本身也有开销。
所以 Timsort 会设置一个阈值。
只有连续从同一边取元素达到一定次数后才进入 Galloping。
而且这个阈值还会根据之前 Galloping 的效果动态调整。
如果 Galloping 经常有效,就更容易进入这种模式;如果效果不好,就提高进入门槛。
从 Timsort 到 Powersort,真正变化的是什么
到这里,可以比较清楚地看出整个演化过程。
最开始的问题是:
我们需要一个高效的 stable sort。
Merge sort 很适合,但普通 merge sort 不会利用已有顺序。
于是 Timsort 引入天然 run:
扫描已有顺序
↓
直接把 run 拿来 merge
接下来又出现新的问题:
run 应该按照什么顺序 merge?
Timsort 的答案是:
根据相邻 run 的长度关系
维护一组经验性的不变量
这种方法实际效果很好,但规则比较复杂,而且对 run stack 上限的证明并不直观。
Powersort 则换了一个角度:
既然最终希望得到接近平衡 merge sort 的结构
↓
那就直接参考一棵理想 merge tree
↓
根据 run 所在位置计算 power
↓
按照 power 决定什么时候 merge
因此,Powersort 不是完全不同于 Timsort 的另一种排序算法。
更准确地说:
它是对 Timsort 中 run 合并策略的一次重新设计。
至于天然 run、minrun、插入排序、逆序处理、临时 buffer、Galloping 等大量工程优化,仍然可以继续沿用。
从这个角度理解,Timsort 和 Powersort 的关系就会清楚很多:
Merge sort
↓
利用已有 run
↓
Timsort
↓
重新设计 merge policy
↓
Powersort
它们背后的共同思想都是:
不要把现实中的数据看成完全随机。数据本身已经存在的顺序,就是可以利用的信息。
参考资料
https://www.baeldung-cn.com/cs/sorting-algorithms-efficiency-comparison

浙公网安备 33010602011771号