【算法】TimSort排序
一、前言
经过60多年的发展,科学家和工程师们发明了很多排序算法,有基本的插入算法,也有相对高效的归并排序算法等,他们各有各的特点,比如归并排序性能稳定、堆排序空间消耗小等等。但是这些算法也有自己的局限性比如快速排序最坏情况和冒泡算法一样,归并排序需要消耗的空间最多,插入排序平均情况的时间复杂度太高。在实际工程应用中,我们希望得到一款综合性能最好的排序算法,能够兼具最坏和最好时间复杂度(空间复杂度的优化可以靠后毕竟内存的价格是越来越便宜),于是基于归并和插入排序的TimSort
就诞生了,并且被用作Java
和Python
的内置排序算法。
二、简介
Timsort
是一个自适应的、混合的、稳定的排序算法,融合了归并算法和二分插入排序算法的精髓,在现实世界的数据中有着特别优秀的表现。它是由Tim Peter
于2002
年发明的,用在Python
这个编程语言里面。这个算法之所以快,是因为它充分利用了现实世界的待排序数据里面,有很多子串是已经排好序的不需要再重新排序,利用这个特性并且加上合适的合并规则可以更加高效的排序剩下的待排序序列。
当Timsort
运行在部分排序好的数组里面的时候,需要的比较次数要远小于,也是远小于相同情况下的归并排序算法需要的比较次数。但是和其他的归并排序算法一样,最坏情况下的时间复杂度是的水平。但是在最坏的情况下,Timsort
需要的临时存储空间只有,在最好的情况下,需要的额外空间是常数级别的。从各个方面都能够击败需要空间和稳定时间的归并算法。
三、算法描述
3.1 限制
在最初的Tim
实现的版本中,对于长度小于64
数组直接进行二分插入排序,不会进行复杂的归并排序,因为在小数组中插入排序的性能已经足够好。在Java
中有略微的改变,这个阈值被修改成了32
,据说在实际中32
这个阈值能够得到更好的性能。
3.2 二分插入排序
插入排序的逻辑是将排好序的数组之后的一个元素不停的向前移动交换元素直到找到合适的位置,如果这个新元素比前面的序列的最小的元素还要小,就要和前面的每个元素进行比较,浪费大量的时间在比较上面。采用二分搜索的方法直接找到这个元素应该插入的位置,就可以减少很多次的比较。虽然仍然是需要移动相同数量的元素,但是复制数组的时间消耗要小于元素间的一一互换。
比如对于[2,3,4,5,6,1]
,想把1
插入到前面,如果使用直接的插入排序,需要5
次比较,但是使用二分插入排序,只需要2
次比较就直到插入的位置,然后直接把2,3,4,5,6
全部向后移动一位,把1
放入第一位就完成了插入操作。
3.3 Run
首先介绍其中最重要的一个概念run
。所谓的run
就是一个连续上升(此处的上升包括两个元素相等的情况)或者下降(严格递减)的子串。
比如对于序列[1,2,3,4,3,2,4,7,8]
,其中有三个run
,第一个是[1,2,3,4]
,第二个是[3,2]
,第三个是[4,7,8]
,这三个run
都是单调的,在实际程序中对于单调递减的run
会被反转成递增的序列。
在合并序列的时候,如果run
的数量等于或者略小于2
的幂次方的时候,效率是最高的;如果略大于2
的幂次方,效率就会特别低。所以为了提高合并时候的效率,需要尽量控制每个run
的长度,定义一个minrun
表示每个run
的最小长度,如果长度太短,就用二分插入排序把run
后面的元素插入到前面的run
里面。对于上面的例子,如果minrun=5
,那么第一个run
是不符合要求的,就会把后面的3
插入到第一个run
里面,变成[1,2,3,3,4]
。
在执行排序算法之前,会计算出这个minrun
的值(所以说这个算法是自适应的,会根据数据的特点来进行自我调整),minrun
会从32
到64
(包括)选择一个数字,使得数组的长度除以minrun
等于或者略小于2
的幂次方。比如长度是65
,那么minrun
的值就是33
;如果长度是165
,minrun
就是42
(注意这里的Java
的minrun
的取值会在16
到32
之间)。
这里用Java
源码做示范:
private static int minRunLength(int n) {
assert n >= 0;
int r = 0; // 如果低位任何一位是1,就会变成1
while (n >= 64) { // 改成了64
r |= (n & 1);
n >>= 1;
}
return n + r;
}
3.4 合并
在归并算法中合并是两两分别合并,第一个和第二个合并,第三个和第四个合并,然后再合并这两个已经合并的序列。但是在Timsort
中,合并是连续的,每次计算出了一个run
之后都有可能导致一次合并,这样的合并顺序能够在合并的同时保证算法的稳定性。
在Timsort
中用一个栈来保存每个run
,比如对于上面的[1,2,3,4,3,2,4,7,8]
这个例子,栈底是[1,2,3,4]
,中间是[3,2]
,栈顶是[4,7,8]
,每次合并仅限于栈里面相邻的两个run
。
3.4.1 合并条件
为了保证Timsort
的合并平衡性,Tim
制定一个合并规则,对于在栈顶的三个run
,用X
、Y
和Z
分别表示他们的长度,其中X
在栈顶,必须始终维持一下的两个规则:
一旦有其中的一个条件不被满足,Y
这个子序列就会和X
于Z
中较小的元素合并形成一个新run
,然后会再次检查栈顶的三个run
看看是否仍然满足条件。如果不满足则会继续进行合并,直至栈顶的三个元素(如果只有两个run
就只需要满足第二个条件)满足这两个条件。
所谓的合并的平衡性就是为了让合并的两个数组的大小尽量接近,提高合并的效率。所以在合并的过程中需要尽量保留这些run
用于发现后来的模式,但是我们又想尽量快的合并内存层级比较高的run
,并且栈的空间是有限的,不能浪费太多的栈空间。通过以上的两个限制,可以将整个栈从底部到顶部的run
的大小变成严格递减的,并且收敛速度和斐波那契数列一样,这样就可以应用斐波那契数列和的公式根据数组的长度计算出需要的栈的大小,一定是比要小的,其中N
是数组的长度。
在最理想的情况下,这个栈从底部到顶部的数字应该是128、64、32、16、8、4、2、2
,这样从栈顶合并到栈底,每次合并的两个run
的长度都是相等的,都是完美的合并。
如果遇到不完美的情况比如500、400、1000
,那么根据规则就会合并变成900、1000
,再次检查规则之后发现还是不满足,于是合并变成了1900
。
3.4.2 合并内存消耗
不使用额外的内存合并两个run
是很困难的,有这种原地合并算法,但是效率太低,作为trade-off
,可以使用少量的内存空间来达到合并的目的。
比如有两个相邻的run
一前一后分别是A
和B
,如果A
的长度比较小,那么就把A
复制到临时内存里面,然后从小到大开始合并排序放入A
和B
原来的空间里面不影响原来的数据的使用。如果B
的长度比较小,B
就会被放到临时内存里面,然后从大到小开始合并。
另外还有一个优化的点在于可以用二分法找到B[0]
在A
中应该插入的位置i
以及A[A.length-1]
在B
中应该插入的位置j
,这样在i
之前和j
之后的数据都可以放在原地不需要变化,进一步减小了A
和B
的大小,同时也是缩减了临时空间的大小。
3.4.3 加速合并
在归并排序算法中合并两个数组就是一一比较每个元素,把较小的放到相应的位置,然后比较下一个,这样有一个缺点就是如果A
中如果有大量的元素A[i...j]
是小于B
中某一个元素B[k]
的,程序仍然会持续的比较A[i...j]
中的每一个元素和B[k]
,增加合并过程中的时间消耗。
为了优化合并的过程,Tim
设定了一个阈值MIN_GALLOP
,如果A中连续MIN_GALLOP
个元素比B
中某一个元素要小,那么就进入GALLOP
模式,反之亦然。默认的MIN_GALLOP
值是7
。
在GALLOP
模式中,首先通过二分搜索找到A[0]
在B
中的位置i0
,把B
中i0
之前的元素直接放入合并的空间中,然后再在A
中找到B[i0]
所在的位置j0
,把A
中j0
之前的元素直接放入合并空间中,如此循环直至在A
和B
中每次找到的新的位置和原位置的差值是小于MIN_GALLOP
的,这才停止然后继续进行一对一的比较。
3.4.4 GALLOP模式
GALLOP
搜索元素分为两个步骤,比如我们想找到A
中的元素x
在B
中的位置
第一步是在B
中找到合适的索引区间使得x
在这个元素的范围内
第二步是在第一步找到的范围内通过二分搜索来找到对应的位置。
通过这种搜索方式搜索序列B
最多需要次的比较,相比于直接进行二分搜索的次比较,在数组长度比较短或者重复元素比较多的时候,这种搜索方式更加有优势。
这个搜索算法又叫做指数搜索(exponential search),在Peter McIlroy
于1993
年发明的一种乐观排序算法中首次提出的。
四、JAVA中算法实现
在java
中Collections
的排序操作就是Timsort
算法的实现
public static <T extends Comparable<? super T>> void sort(List<T> list) {
list.sort(null);
}
Collections.sort
方法调用的是List.sort
方法,List.sort
方法如下:
@SuppressWarnings({"unchecked", "rawtypes"})
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c); // Arrays的排序方法
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
在List.sort
方法实现中,排序使用的是Arrays#sort(T[], java.util.Comparator<? super T>)
方法,所以Collections
的sort
操作最终也是使用Arrays#sort(T[], java.util.Comparator<? super T>)
方法。
public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a);
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c);
else
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
Arrays#sort(T[], java.util.Comparator<? super T>)
方法使用了3种排序算法:
方法 | 描述 |
---|---|
java.util.Arrays#legacyMergeSort | 归并排序,但可能会在新版本中废弃 |
java.util.ComparableTimSort#sort | 不使用自定义比较器的TimSort |
java.util.TimSort#sort | 使用自定义比较器的TimSort |
关于LegacyMergeSort
/**
* Old merge sort implementation can be selected (for
* compatibility with broken comparators) using a system property.
* Cannot be a static boolean in the enclosing class due to
* circular dependencies. To be removed in a future release.
*/
static final class LegacyMergeSort {
private static final boolean userRequested =
java.security.AccessController.doPrivileged(
new sun.security.action.GetBooleanAction(
"java.util.Arrays.useLegacyMergeSort")).booleanValue();
}
该定义描述是否使用LegacyMergeSort
,即历史归并排序算法,默认为false
即不使用。所以Arrays.sort
只会使用java.util.ComparableTimSort#sort
或java.util.TimSort#sort
,这两种方法的实现逻辑是一样的,只是java.util.TimSort#sort
可以使用自定义的Comparator
,而java.util.ComparableTimSort#sort
不使用Comparator
而已。
顺便补充一下,Comparator
是策略模式的一个完美又简洁的示例。总体来说,策略模式允许在程序执行时选择不同的算法。比如在排序时,传入不同的比较器(Comparator),就采用不同的算法。
4.1 TimSort.sort
static <T> void sort(T[] a, int lo, int hi, Comparator<? super T> c, T[] work,
int workBase, int workLen) {
assert c != null && a != null && lo >= 0 && lo <= hi && hi <= a.length;
//待排序的数组长度
int nRemaining = hi - lo;
if (nRemaining < 2)
return; //长度为0或1的数组无需排序
// 如果数组长度小于32(即MIN_MERGE,TimSort的Python版本里这个值为64),直接用binarySort排序
if (nRemaining < MIN_MERGE) {
//找到第一个run,返回其长度
int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
//第一个run已排好序,因此binarySort的参数start=lo+initRunLen
binarySort(a, lo, hi, lo + initRunLen, c);
return;
}
TimSort<T> ts = new TimSort<>(a, c, work, workBase, workLen);
//最小run长度
int minRun = minRunLength(nRemaining);
do {
// 找run
int runLen = countRunAndMakeAscending(a, lo, hi, c);
// 如果run长度小于minRun,将其扩展为min(nRemaining,minRun)
if (runLen < minRun) {
int force = nRemaining <= minRun ? nRemaining : minRun;
binarySort(a, lo, lo + force, lo + runLen, c);//扩展run到长度force
runLen = force;
}
ts.pushRun(lo, runLen);// 将run保存到栈中
ts.mergeCollapse();// 根据规则合并相邻的run
// 继续寻找run
lo += runLen;
nRemaining -= runLen;
} while (nRemaining != 0);
// Merge all remaining runs to complete sort
assert lo == hi;
ts.mergeForceCollapse();//最后收尾,将栈中所有run从栈顶开始依次邻近合并,得到一个run
assert ts.stackSize == 1;
}
判断数组的大小,小于32
使用二分插入排序,数组大于32
时,先算出一个合适的大小,在将输入按其升序和降序特点进行了分区。排序的输入的单位不是一个个单独的数字,而是一个个的块-分区。其中每一个分区叫一个run
。针对这些run
序列,每次拿一个run
出来按规则进行合并。每次合并会将两个run
合并成一个run
。合并的结果保存到栈中。合并直到消耗掉所有的run
,这时将栈上剩余的run
合并到只剩一个run
为止。这时这个仅剩的run
便是排好序的结果。
4.2 run过程
找出最大的递增或者递减的个数,如果递减,则此段数组严格反一下方向
private static <T> int countRunAndMakeAscending(T[] a, int lo, int hi, Comparator<? super T> c) {
assert lo < hi;
int runHi = lo + 1;
if (runHi == hi)
return 1;
//找到run的结束位置,如果是下降的序列将其反转
if (c.compare(a[runHi++], a[lo]) < 0) {
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) < 0)
runHi++;
reverseRange(a, lo, runHi);
} else {
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) >= 0)
runHi++;
}
return runHi - lo;//返回值为run的长度
}
4.3 binarySort排序过程
在使用二分查找位置,进行插入排序。start
之前为全部递增数组,从start+1
开始进行插入,插入位置使用二分法查找。最后根据移动的个数使用不同的移动方法。
/**
* 二分排序
*
* @param a 数组
* @param lo 待排序部分(有序部分+无序部分)的最低位(包含)
* @param hi 最高位(不包含)
* @param start 无序部分的最低位
* @param c 比较函数即排序的依据
*/
private static <T> void binarySort(T[] a, int lo, int hi, int start, Comparator<? super T> c) {
assert lo <= start && start <= hi;
if (start == lo)
start++;
for ( ; start < hi; start++) {//接下来就是二分插入的过程
T pivot = a[start];
int left = lo;
int right = start;
assert left <= right;
while (left < right) {
int mid = (left + right) >>> 1;
if (c.compare(pivot, a[mid]) < 0)
right = mid;
else
left = mid + 1;
}
assert left == right;
int n = start - left;//n表示要移动的元素数量
//优化插入过程,当要移动的元素数量为1或2时,可以直接交换元素位置;
//否则将left后的元素往后挪一位再插入,方式是通过arraycopy函数复制
switch (n) {
case 2: a[left + 2] = a[left + 1];
case 1: a[left + 1] = a[left];
break;
default: System.arraycopy(a, left, a, left + 1, n);
}
a[left] = pivot;
}
}
4.4 TimSort排序过程
4.4.1 minRunLength方法
在执行排序算法之前,会计算minRun
的值,minRun
会从[16,32]区间中选择一个数字,使得数组的长度除以minRun
等于或者略小于2
的幂次方。比如长度是65
,那么minRun
的值就是17
;如果长度是174
,minRun
就是22
。minRunLength()
函数代码如下:
private static int minRunLength(int n) {
assert n >= 0;
int r = 0; // 如果n的低位有任何一位为1,r就会置1
while (n >= 32) {
r |= (n & 1);
n >>= 1;
}
return n + r;
}
4.4.2 pushRun方法
存run
是通过两个栈,分别保存run
的起始位置和长度,可以看pushRun()
函数代码:
private int stackSize = 0; // 栈中run的数量
private final int[] runBase;
private final int[] runLen;
private void pushRun(int runBase, int runLen) {
this.runBase[stackSize] = runBase;
this.runLen[stackSize] = runLen;
stackSize++;
}
4.4.3 mergeCollapse方法
这里的合并规则如下:假设栈顶三个run
依次为X
,Y
,Z
,X
为栈顶run
,要求它们的长度满足及两个条件。其实这就是TimSort
算法的精髓所在了,它通过这样的方式尽力保证合并的平衡性,即让待合并的两个数组尽可能长度接近,从而提高合并的效率。通过这两个条件限制,保证了栈中的run
从栈底到栈顶是从大到小排列的,并且合并的收敛速度与斐波那契数列一样。可以看mergeCollapse()
函数代码:
private void mergeCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
//条件一不满足的话,Y就会和X、Z中较小的run合并
if (n > 0 && runLen[n-1] <= runLen[n] + runLen[n+1]) {
if (runLen[n - 1] < runLen[n + 1])
n--;
mergeAt(n);
}
//条件二不满足的话,Y就和X合并
else if (runLen[n] <= runLen[n + 1]) {
mergeAt(n);
} else {
break; // Invariant is established
}
}
}
关于归并方法和对一般的归并排序做出了简单的优化。假设两个run
是run1
,run2
,先用gallopRight
在run1
里使用binarySearch
查找run2
首元素的位置k
,那么run1
中k
前面的元素就是合并后最小的那些元素。然后,在run2
中查找run1
尾元素的位置len2
,那么run2
中len2
后面的那些元素就是合并后最大的那些元素。最后,根据len1
与len2
大小,调用mergeLo
或者mergeHi
将剩余元素合并。
@SuppressWarnings("unchecked")
private void mergeAt(int i) {
assert stackSize >= 2;
assert i >= 0;
assert i == stackSize - 2 || i == stackSize - 3;
int base1 = runBase[i];
int len1 = runLen[i];
int base2 = runBase[i + 1];
int len2 = runLen[i + 1];
assert len1 > 0 && len2 > 0;
assert base1 + len1 == base2;
/*
* Record the length of the combined runs; if i is the 3rd-last
* run now, also slide over the last run (which isn't involved
* in this merge). The current run (i+1) goes away in any case.
*/
runLen[i] = len1 + len2;
if (i == stackSize - 3) {
runBase[i + 1] = runBase[i + 2];
runLen[i + 1] = runLen[i + 2];
}
stackSize--;
/*
* Find where the first element of run2 goes in run1. Prior elements
* in run1 can be ignored (because they're already in place).
*/
int k = gallopRight((Comparable<Object>) a[base2], a, base1, len1, 0);
assert k >= 0;
base1 += k;
len1 -= k;
if (len1 == 0)
return;
/*
* Find where the last element of run1 goes in run2. Subsequent elements
* in run2 can be ignored (because they're already in place).
*/
len2 = gallopLeft((Comparable<Object>) a[base1 + len1 - 1], a,
base2, len2, len2 - 1);
assert len2 >= 0;
if (len2 == 0)
return;
// Merge remaining runs, using tmp array with min(len1, len2) elements
if (len1 <= len2)
mergeLo(base1, len1, base2, len2);
else
mergeHi(base1, len1, base2, len2);
}
最后归并还有没有归并的run
,知道run
的数量为1
。
五、总结
总结一下上面的排序的过程:
- 如果长度小于
64
直接进行插入排序 - 首先遍历数组收集每个元素根据特定的条件组成一个
run
- 得到一个
run
之后会把他放入栈中 - 如果栈顶部几个的
run
符合合并条件,就会触发合并操作合并相邻的两个run
留下一个run
- 合并操作会使用尽量小的内存空间和
GALLOP
模式来加速合并
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器