对基本有序序列的排序:从 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

https://blog.codingnow.com/2026/06/powersort.html

posted @ 2026-09-30 16:33  光風霽月  阅读(2)  评论(0)    收藏  举报