TimSort 是一种混合的、稳定的排序算法,结合了归并排序(Merge Sort)和二分插入排序(Binary Insertion Sort)的优点,尤其适用于部分有序的数据。在 Java 中,Arrays.sort()
对对象数组排序时内部使用了 TimSort 算法。
对于集合的排序实际上也是使用Arrays.sort
如 List.java
default void sort(Comparator super E> c) { Object[] a = this.toArray(); Arrays.sort(a, (Comparator) c); ListIterator
i = this.listIterator(); for (Object e : a) { i.next(); i.set((E) e); } }
类的作用
Arrays.sort()
方法在排序对象数组时调用 TimSort。MIN_MERGE
,使用二分插入排序将其扩展到最小长度。传统的归并排序通常使用递归,将数组不断对半分割,直到子数组只有一个元素,然后逐层返回并合并。这个过程依赖于程序的调用栈(call stack)来管理子问题。
TimSort 的巧妙之处在于它用一个自己管理的显式栈(runBase
和 runLen
数组)替代了递归所需的调用栈。整个过程如下:
pushRun
将其信息压入自己的栈中。pushRun
之后立即调用 mergeCollapse
,根据预设的平衡策略,决定是否要合并栈顶的某些 run。这个合并决策是迭代进行的,而不是递归返回时才发生。通过这种方式,TimSort 将递归的“分治”思想转换成了一个迭代的过程,避免了递归深度过大可能导致的 StackOverflowError
,并且通过 mergeCollapse
的智能合并策略,进一步优化了归并的效率。这就是它实现非递归归并排序的原理。
Arrays.sort没有创建新实例,而是内部递归进行归并排序的时候创建实例。
有状态是因为归并排序需要复制。
这个私有的 TimSort
构造函数主要做了以下几件重要的事情,本质上都是为了初始化排序过程需要的数据结构和状态:
保存核心排序参数 :
this.a = a; :保存待排序的数组的引用。
this.c = c; :保存用于比较元素顺序的比较器 Comparator 。
分配临时存储空间 ( tmp 数组) :
TimSort 算法的核心是归并,在归并两个已排序的子序列(称为 "run")时,需要一个临时的存储空间来存放其中一个子序列。构造函数会预先分配这个名为 tmp 的数组。
为了优化性能和内存使用,它会计算一个合适的初始大小。如果调用者(例如 Arrays.sort )提供了一个足够大的工作区数组 ( work ),它会直接使用,避免重复创建数组。
分配用于管理 "run" 的栈 ( runBase 和 runLen 数组) :
TimSort 算法会识别出数组中已经排好序的片段(称为 "run"),然后将这些 "run" 合并。它使用一个栈来管理这些待合并的 "run"。
runBase 数组存储每个 "run" 的起始索引。
runLen 数组存储每个 "run" 的长度。
构造函数会根据待排序数组的长度 len 计算出一个足够大但又不过于浪费的栈深度 stackLen ,然后创建这两个数组。
总而言之, TimSort 的构造函数是一个 初始化和准备 的过程,它建立了一个包含所有排序所需上下文(待排序数组、比较器、临时空间、管理栈)的“工作台”,使得后续的排序步骤可以高效地进行。
关键属性
属性名 | 说明 |
---|---|
MIN_MERGE (32) |
最小 run 长度。短 run 会被扩展至此值,以平衡插入排序和归并排序的效率。 |
MIN_GALLOP (7) |
控制“galloping mode”的阈值,减少连续比较次数。 |
INITIAL_TMP_STORAGE_LENGTH (256) |
临时存储数组的初始大小,用于归并操作。 |
a |
待排序的数组。 |
c |
比较器(若为 null ,使用自然顺序)。 |
tmp |
临时数组,用于归并操作。 |
runBase 和 runLen |
存储 run 的起始位置和长度。stackSize 记录当前栈中 run 的数量。 |
构造函数
TimSort(T[] a, Comparator super T> c, T[] work, int workBase, int workLen)
tmp
和 run 信息数组(runBase
、runLen
)。核心方法
sort(T[] a, int lo, int hi, Comparator super T> c, ...)
Arrays.sort()
调用。MIN_MERGE
,直接使用二分插入排序。countRunAndMakeAscending
识别自然 run。binarySort
扩展。pushRun
)并合并(mergeCollapse
)。mergeForceCollapse
完成排序。countRunAndMakeAscending
binarySort
minRunLength
MIN_MERGE/2
和 MIN_MERGE
之间)。mergeCollapse
和 mergeForceCollapse
runLen[i-2] > runLen[i-1] + runLen[i]
。mergeLo
和 mergeHi
ComparableTimSort
是 TimSort 的变体,专为实现了 Comparable
的对象数组设计,直接调用 compareTo
方法,无需显式比较器。TimSort 通过以下策略实现高效排序:
其设计使其在各类场景下均表现优异,成为 Java 默认排序算法之一。
static void sort(T[] a, int lo, int hi,
Comparator super T> c, T[] work, int workBase, int workLen)
这个静态方法是 TimSort 算法的入口。核心思想:
条件:待排序元素数量 nRemaining < MIN_MERGE
(默认 32)
countRunAndMakeAscending(a, lo, hi, c)
reverseRange
反转为升序。initRunLen
。binarySort(a, lo, hi, lo + initRunLen, c)
lo + initRunLen
到 hi
)使用二分插入排序。二分插入排序,二分找到后,直接利用array copy
binary Sort
/*
* The invariants still hold: pivot >= all in [lo, left) and
* pivot < all in [left, start), so pivot belongs at left. Note
* that if there are elements equal to pivot, left points to the
* first slot after them -- that's why this sort is stable.
* Slide elements over to make room for pivot.
*/
int n = start - left; // The number of elements to move
// Switch is just an optimization for arraycopy in default case
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;
条件:数组长度 ≥ MIN_MERGE
初始化
new TimSort<>(...)
:创建实例,初始化临时数组 tmp
和 run 栈(runBase
/runLen
)。 TimSort ts = new TimSort<>(a, c, work, workBase, workLen);
计算最小 run 长度
minRunLength(nRemaining)
:确保 nRemaining / minRun
接近或略小于 2 的幂,使归并操作均衡。Timsort 是一种混合排序算法,它通过合并一系列已经排好序的子数组(称为 "run")来完成整个数组的排序。为了让合并过程最高效,理想的情况是每次合并的两个 run 的长度都差不多。 minRunLength 方法的目的就是计算出一个合适的最小 run 长度( minRun ),使得原数组可以被分割成数量接近 2的幂 的 run。这样在后续的归并操作中,可以持续进行大小相近的合并,从而达到最优性能。
int minRun = minRunLength(nRemaining);
private static int minRunLength(int n) {
assert n >= 0;
int r = 0; // Becomes 1 if any 1 bits are shifted off
while (n >= MIN_MERGE) {
r |= (n & 1);
n >>= 1;
}
return n + r;
}
如果 n 不是 k * (2^m) (其中 k 是最终的 n 值,MIN_MERGE=2^5) 这种“干净”的数, r 就会是 1。
最终返回的是循环结束后的 n 加上标志位 r 。这相当于:如果原始的 n 不能被最终的 n 整除(即存在“余数”,导致 r 为1),那么就把 minRun 的长度加 1。这样做可以减少 run 的总数,使其更接近 2 的幂。
用一个例子 n = 65 来看看:
初始时 n = 65 , r = 0 。
65 >= 32 ,进入循环。
n 是 65 (奇数), n & 1 是 1。 r |= 1 ,所以 r 变为 1。
n >>= 1 , n 变为 32。
32 >= 32 ,继续循环。
n 是 32 (偶数), n & 1 是 0。 r |= 0 , r 仍然是 1。
n >>= 1 , n 变为 16。
16 < 32 ,循环结束。
返回 n + r ,即 16 + 1 = 17 。
所以,对于一个长度为 65 的数组,Timsort 会确保每个 run 的长度至少为 17。这样 65 / 17 ≈ 3.82 ,数组会被分成 4 个 run。4 是 2 的幂,非常适合归并。
如果没有 r , minRun 就是 16。 65 / 16 = 4 余 1。这会产生 4 个长度为 16 的 run 和 1 个长度为 1 的 run。合并一个长度为 16 和一个长度为 1 的 run 效率就不那么高了。
注意n < 32是小数组处理的,对于大数组处理minRun的输入一定是n>=32。
在Java的 TimSort 实现中, MIN_MERGE 的值是32。
这个循环 相当于把n分为高5位 q,和剩余位 b,如果b不是0,则会+1
循环结束后 n 的值 q 的范围是 [16, 31](高5位就一定是这个范围,如果n>=32)
当我们把除数从 q 变为 q+1 时,商和余数都会改变。但这个算法的巧妙之处在于,它的目标 不是 去管理余数的大小,而是 确保最终run的总数(即 ceil(N / minrun) )非常接近一个2的幂 。
我们来做一个更严谨的分析:
设数组总长为 N 。设循环右移了 s 次。
我们得到 q = N >> s (即 q = floor(N / 2^s) )和 r (0或1)。 minrun = q + r 。
我们要分析的run数量是 k = ceil(N / minrun) 。
情况A: r = 0
此时 N 的低 s 位全是0, N = q * 2^s 。 minrun = q 。 k = ceil((q * 2^s) / q) = 2^s 。
此时run的数量不多不少,正好是一个2的幂,这是最理想的情况。
情况B: r = 1
此时 N 的低 s 位不全为0, N = q * 2^s + rem ,其中 0 < rem < 2^s 。 minrun = q + 1 。
run的数量 k = ceil( (q * 2^s + rem) / (q + 1) ) 。
我们来为这个表达式找一个上下界:
上界:
N = q * 2^s + rem < q * 2^s + 2^s = (q+1) * 2^s 。
所以 N / minrun = N / (q+1) < ((q+1) * 2^s) / (q+1) = 2^s 。
因为 N / (q+1) 严格小于 2^s ,所以 k = ceil(N / (q+1)) 最多是 2^s 。
下界:
N = q * 2^s + rem > q * 2^s 。
所以 N / minrun = N / (q+1) > (q * 2^s) / (q+1) 。
因此 k = ceil(N / (q+1)) > (q * 2^s) / (q+1) (1 - 1/(q+1)) * 2^s 。
结合 q 的范围是 [16, 31] 。
举例:
该算法通过将 q 限制在 [MIN_MERGE/2, MIN_MERGE-1] 范围内,并根据余数是否存在来决定 minrun 是 q 还是 q+1 ,最终确保了run的总数 k 要么恰好是一个2的幂 2^s ,要么是在一个非常贴近 2^s 的极小区间内。这为后续归并操作的平衡性提供了强有力的保证,是Timsort高性能的关键之一。
do-while
)countRunAndMakeAscending(...)
runLen < minRun
,通过 binarySort
强制扩展到 minRun
长度(或剩余元素总数)。ts.pushRun(lo, runLen)
ts.mergeCollapse()
runLen[i-2] > runLen[i-1] + runLen[i]
)。若不满足,调用 mergeAt
合并相邻 run。lo
和 nRemaining
,准备处理下一个 run。最终合并
ts.mergeForceCollapse()
:合并栈中剩余 run,直到只剩一个 run,完成排序。mergeCollapse() -> mergeAt(n)
职责:维持栈的平衡。
操作:检查栈顶 run 长度,若不平衡则计算最佳合并点 n
,调用 mergeAt(n)
。
mergeAt(i) -> gallopRight(), gallopLeft(), mergeLo(), mergeHi()
优化 1:跳过有序部分
gallopRight
:找到 run2
的首元素在 run1
中的插入点,跳过 run1
中已有序部分。
gallopLeft
:找到 run1
的末元素在 run2
中的插入点,跳过 run2
末尾有序部分。
优化 2:选择合并策略
根据剩余长度选择 mergeLo
(run1
较短)或 mergeHi
(run2
较短),最小化临时数组使用。
mergeLo() / mergeHi() -> gallopRight(), gallopLeft()
实际归并操作:逐个比较元素并归并。
优化 3:Galloping 模式
若一个 run 的元素连续多次“胜出”,进入飞奔模式,调用 gallopRight
/gallopLeft
批量移动数据块。
minGallop
动态调整进入/退出此模式的阈值。
gallopLeft() / gallopRight()
这个方法非常直接,它的作用是将一个已经排好序的连续片段(run)的信息记录下来,存入一个专门的“待合并区”——也就是代码中的 runBase 和 runLen 数组,它们共同构成了一个栈。
runBase : 记录这个 run 在原数组中的起始索引。
runLen : 记录这个 run 的长度。
stackSize : 记录当前栈中有多少个待合并的 run。
TimSort 的主循环会遍历整个数组,识别或创建这些小的有序片段(run),然后调用 pushRun 把它们一个个推到这个栈上,等待后续的合并操作。
mergeCollapse()
这是 TimSort 算法的精髓所在。每当一个新的 run 被 pushRun
推入栈顶后,mergeCollapse
就会被调用。它的任务是检查栈顶的几个 run 是否满足特定的“平衡”条件(即注释中提到的两个不变式)。
runLen[i - 2] > runLen[i - 1]
runLen[i - 3] > runLen[i - 2] + runLen[i - 1]
这些不变式的核心目标是保持栈上 run 的长度大致平衡,避免出现一个非常长的 run 和一个非常短的 run 进行合并,因为那样效率不高。
mergeCollapse
会持续检查栈顶的 run,如果不满足这些条件,它就会选择相邻的两个 run 调用 mergeAt(n)
方法进行合并,直到栈恢复平衡状态。通过这种方式,它能确保合并操作总是在大小相近的 run 之间进行,从而最大化效率。
private void mergeCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
if (n > 0 && runLen[n-1] <= runLen[n] + runLen[n+1] ||
n > 1 && runLen[n-2] <= runLen[n] + runLen[n-1]) {
if (runLen[n - 1] < runLen[n + 1])
n--;
} else if (n < 0 || runLen[n] > runLen[n + 1]) {
break; // Invariant is established
}
mergeAt(n);
}
}
简单来说,该方法遵循两条规则(不变量),并持续检查栈顶的几个 run 是否满足这些规则。如果不满足,就进行合并;如果满足,就暂时跳过,等待新的 run 加入。
让我们把栈顶的三个 run (从栈底到栈顶方向)想象成 X, Y, Z。这两条规则是:
len(X) > len(Y) + len(Z)
len(Y) > len(Z)
当栈上 run 的长度违反了上述任何一条规则时,就需要进行合并。代码中的 if 语句正是用于检查这些违规情况:
if (n > 0 && runLen[n-1] <= runLen[n] + runLen [n+1] ||
n > 1 && runLen[n-2] <= runLen[n] + runLen[n-1]) {
runLen[n-1] <= runLen[n] + runLen[n+1] 检查的是规则 1 ( len(X) > len(Y) + len(Z) ) 是否被违反。
runLen[n-2] <= runLen[n] + runLen[n-1] 检查的是更深一层(W, X, Y)的 run 是否违反了规则 1。
如果以上两个条件都不成立,代码会进入 else if 分支。如果此时 runLen[n] <= runLen[n+1] ,则说明规则 2 ( len(Y) > len(Z) ) 被违反,同样需要合并。
一旦决定合并,算法会优先合并两个长度较小的相邻 run ,以维持整体的平衡。这就是 if (runLen[n - 1] < runLen[n + 1]) 这行代码的作用:
如果 len(X) < len(Z) ,就合并 X 和 Y。
否则,合并 Y 和 Z。
这个合并过程会一直循环,直到栈上所有的 run 都满足那两条不变量为止。
什么时候可以跳过(break)?
当栈顶的 run 已经满足了不变量时,就不需要再进行合并了,可以跳出循环。 else if 中的这个条件负责判断:
} else if (n < 0 || runLen[n] > runLen[n + 1]) { break; // Invariant is established }
这里的 runLen[n] > runLen[n + 1] 正是在检查规则 2 ( len(Y) > len(Z) )。如果这个条件成立,并且前面更复杂的规则 1 检查也通过了,就意味着栈目前是“稳定”的,可以暂时停止合并,继续去数组中寻找下一个 run 。
循环结束后,最终强制合并,同样的优化是 先合并小的
private void mergeForceCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
if (n > 0 && runLen[n - 1] < runLen[n + 1])
n--;
mergeAt(n);
}
}
mergeAt
mergeAt
函数之所以实现复杂,是因为它并非简单的归并操作,而是 TimSort 这一高效、稳定排序算法的核心优化所在。其复杂性旨在为真实世界中常见的部分有序数据提供极致性能。
TimSort 首先将输入数组分解为多个已排序的子序列(称为 "run")。mergeAt
的任务是将栈上相邻的两个 run(例如 run[i]
和 run[i+1]
)合并为一个更大的有序 run。
简单归并排序会逐个比较元素,而 TimSort 通过智能策略避免对部分有序数据的无效操作。
代码逐段解析
准备与栈管理
private void mergeAt(int i) {
int base1 = runBase[i];
int len1 = runLen[i];
int base2 = runBase[i + 1];
int len2 = runLen[i + 1];
runLen[i] = len1 + len2;
if (i == stackSize - 3) {
runBase[i + 1] = runBase[i + 2];
runLen[i + 1] = runLen[i + 2];
}
stackSize--;
第一次优化:gallopRight
int k = gallopRight(a[base2], a, base1, len1, 0, c);
assert k >= 0;
base1 += k;
len1 -= k;
if (len1 == 0) return;
run2
的第一个元素,在 run1
中快速查找其插入位置。run1
中所有小于该元素的区间。第二次优化:gallopLeft
len2 = gallopLeft(a[base1 + len1 - 1], a, base2, len2, len2 - 1, c);
assert len2 >= 0;
if (len2 == 0) return;
run1
的最后一个元素,在 run2
中反向查找插入位置。run2
中所有大于该元素的区间。第三次优化:选择 mergeLo
或 mergeHi
if (len1 <= len2)
mergeLo(base1, len1, base2, len2);
else
mergeHi(base1, len1, base2, len2);
mergeLo
:当 len1 <= len2
时,复制较短的 run1
到临时空间。mergeHi
:当 len1 > len2
时,复制较短的 run2
到临时空间。N/2
,最小化数据拷贝。gallopLeft
函数的复杂性
结合两种搜索策略:
hint
位置以 2^k - 1
步长跳跃,快速定位范围。总结
mergeAt
的复杂性体现了 TimSort 的精髓:
gallop
模式高效处理已有顺序的数据。正是这些设计使 TimSort 成为 Java、Python 等语言标准库的默认排序算法。
gallopLeft
的核心目标是:
在一个已排序的数组(或数组的一部分)中,快速地为一个给定的 key
找到它应该插入的位置。
如果数组中存在与 key
相等的元素,它会返回 最左侧 那个相等元素对应的索引。
这个特性对于保持排序的稳定性至关重要。
函数签名
private static int gallopLeft(
T key,
T[] a,
int base,
int len,
int hint,
Comparator super T> c
)
参数说明
key
:要在数组 a
中查找插入点的元素。a
:进行查找的目标数组。base
:查找范围在数组 a
中的起始索引。len
:查找范围的长度。hint
:一个“提示”索引,表示 key
可能的位置。gallopLeft
的执行过程分为两个主要阶段:
“飞驰模式”(Galloping) 和 二分查找(Binary Search)。
此阶段的目标是利用 hint
快速定位一个包含 key
的较小范围,而不是从头开始进行二分查找。
方向判断
首先,比较 key
和 a[base + hint]
的值:
c.compare(key, a[base + hint]) > 0
,说明 key
在 hint
的右侧。此时,算法会向右“飞驰”。key <= a[base + hint]
,说明 key
在 hint
的左侧或就是 hint
位置的元素。此时,算法向左“飞驰”。指数级步进
算法以指数级增加的步长(1, 3, 7, 15, ...,偏移量由 ofs = (ofs << 1) + 1
计算)进行探测,直到找到一个区间 [lastOfs, ofs]
,使得 key
恰好落在这个区间内。
例如,向右飞驰时,直到满足:
a[base + hint + lastOfs] < key <= a[base + hint + ofs]
。
这种方式使得当 key
的实际位置距离 hint
很远时,也能极快地缩小查找范围。
范围确定
飞驰阶段结束后,已经确定了一个比原始范围 len
小得多的精确范围 [lastOfs, ofs]
。
经典二分查找
在此小范围内,执行一次标准的二分查找来精确定位插入点:
while (lastOfs < ofs)
。c.compare(key, a[base + m]) > 0
,意味着 key
在中间点 m
的右边,因此将搜索范围的左边界更新为 m + 1
。key <= a[base + m]
,意味着 key
在 m
的左边,或者 a[base + m]
就是一个与 key
相等的元素。ofs = m
),而不是立即返回。返回结果
最终,lastOfs
和 ofs
会重合,这个重合点就是 key
的最左插入位置。函数返回该偏移量 ofs
。
gallopLeft
是 TimSort 算法能够适应不同数据分布并保持高效的关键所在。
它通过 “指数搜索 + 二分查找” 的两阶段策略,避免了在数据高度有序或存在大段连续区块时进行逐一比较的低效操作。
通过与 mergeLo
和 mergeHi
的协同工作,它实现了智能的“飞驰模式”,使得 TimSort 在处理真实世界中常见的、部分有序的数据时,性能远超传统的归并排序。
TimSort 是一种混合稳定排序算法,结合了归并排序和插入排序的优点,被应用于 Java 的 Arrays.sort(Object[])
以及 Python 的 list.sort()
和 sorted()
中。mergeLo
方法是其归并操作的核心实现之一。
mergeLo
的主要任务是 原地、稳定地 合并两个已经排好序且相邻的子数组(在 TimSort 中称为 "run")。
Lo 的含义:
这个方法被设计用于 run1
的长度(len1
)小于或等于 run2
的长度(len2
)的场景。这样做是为了优化内存使用,因为它总是将 较短 的 run 复制到临时空间中,从而最小化额外空间开销。
稳定性:
在合并过程中,如果遇到相等的元素,mergeLo
会优先保留原先排在前面的元素(来自 run1
),从而保证了排序的稳定性。
让我们一步步解析代码的执行流程:
1) 初始化与数据准备
// ...
T[] a = this.a; // 减少字段访问,提升性能
T[] tmp = ensureCapacity(len1); // 确保临时数组 tmp 有足够容量
System.arraycopy(a, base1, tmp, cursor1, len1); // 将第一个 run(较短的)完整复制到 tmp 中
// ...
这是 mergeLo
策略的核心:只复制 run1
。现在,原数组 a
的 [base1, base1 + len1)
这段空间就可以作为合并后的目标区域了。
2) 处理特殊情况(Degenerate Cases)
// ...
a[dest++] = a[cursor2++]; // 移动 run2 的第一个元素
if (--len2 == 0) { /* ... */ } // 如果 run2 只有一个元素
if (len1 == 1) { /* ... */ } // 如果 run1 只有一个元素
// ...
代码首先无条件地将 run2
的第一个元素移动到目标位置。这是一个优化,因为 run2
的第一个元素通常小于 run1
的最后一个元素,可以直接放置。随后,代码快速处理了其中一个 run 长度极短(为 1 或 0)的边界情况,避免进入复杂的主循环。
3) 主合并循环:常规合并与"飞驰模式"的切换
这是函数最精妙的部分。它在一个 while(true)
循环中,根据数据的局部有序性,在两种模式间自适应切换。
do {
// ...
if (c.compare(a[cursor2], tmp[cursor1]) < 0) {
a[dest++] = a[cursor2++];
count2++; count1 = 0;
} else {
a[dest++] = tmp[cursor1++];
count1++; count2 = 0;
}
} while ((count1 | count2) < minGallop);
这个 do-while
循环执行的是标准的"一次比较,一次移动"的归并操作。
count1
和 count2
记录了每个 run 连续获胜(即其元素被选中)的次数。
当任何一个 run 连续获胜的次数达到 minGallop
阈值时,循环退出,算法认为数据出现了高度的局部有序性,适合切换到更高效的模式。
飞驰模式 (Galloping Mode):
do {
// ...
count1 = gallopRight(a[cursor2], tmp, cursor1, len1, 0, c);
// ... 批量复制 ...
count2 = gallopLeft(tmp[cursor1], a, cursor2, len2, 0, c);
// ... 批量复制 ...
} while (count1 >= MIN_GALLOP | count2 >= MIN_GALLOP);
目的:
当一个 run 的元素持续小于另一个 run 时,逐个比较就显得低效。飞驰模式通过一种类似二分查找的方式(gallopLeft
/ gallopRight
),快速跳过另一个 run 中一长段连续的元素。
过程:
例如,gallopRight(a[cursor2], tmp, ...)
会在 tmp
(run1
)中快速查找有多少个元素小于 a[cursor2]
。然后通过 System.arraycopy
将这些元素进行 批量复制,极大地提升了效率。
模式切换:
这种模式会一直持续,直到两个 run 的批量复制长度(count1
和 count2
)都小于 MIN_GALLOP
,表明数据的有序性不再明显,此时会退回到常规合并模式。
4) 收尾工作
// ...
if (len1 == 1) { /* ... */ } // run1 还剩一个元素
else if (len1 == 0) { throw new IllegalArgumentException(...); }
else { System.arraycopy(tmp, cursor1, a, dest, len1); } // run2 已耗尽,复制 run1 剩余部分
循环结束后,必然有一个 run 已经被完全合并。这部分代码负责将另一个 run 中剩余的所有元素复制到目标数组的末尾。
算法精髓——自适应性
TimSort 的强大之处在于其自适应性,这在 mergeLo
中通过 minGallop
变量体现得淋漓尽致:
进入飞驰模式:
当数据有序性高时(一个 run 连续获胜),count
迅速达到 minGallop
,进入飞驰模式以加速处理。
惩罚与奖励:
minGallop--
:在飞驰模式中,每次成功的 gallop
都会让 minGallop
减 1,使得下一次更容易保持在飞驰模式。minGallop += 2
:如果飞驰模式效果不佳并退出,minGallop
会加 2,使得下次进入飞驰模式的门槛变高,避免在随机性强的数据上浪费时间。这种机制使得 TimSort 能够动态适应输入数据,无论数据是接近有序还是完全随机,都能提供接近最优的性能。
TimSort 是高度优化的稳定混合排序算法,融合以下技术:
binarySort
)。mergeCollapse
)实现智能合并。gallopLeft
/gallopRight
)适应部分有序数据。优势:对真实世界数据(高度有序或完全随机)均表现卓越。
TimSort 和 DualPivotQuicksort 是两种不同的排序算法,应用在不同的场景下。除了分别用于对象和基本类型数组外,它们的主要差别在于算法核心、稳定性、性能和空间复杂度。
实际上DualPivotQuicksort实现有更多技巧,见:
深入浅出 Arrays.sort(DualPivotQuicksort):如何结合快排、归并、堆排序和插入排序
核心算法逻辑
DualPivotQuicksort (双轴快速排序):
TimSort:
TimSort 是一种混合稳定的排序算法,它结合了归并排序和插入排序。当合并两个已经有序的run时,算法需要逐个比较来自两个run的元素,以决定下一个元素应该放谁,从而保证合并后的序列仍然有序且稳定。这个过程是 顺序的、有状态的 ,后一步的决策依赖于前一步的结果。因此,很难将单个合并操作分解到多个线程中去并行处理而不产生巨大的同步开销。
哪个“更好”取决于评判标准和应用场景:
对于大规模、随机的数据集,在多核CPU上, DualPivotQuicksort 通常更快。 因为它可以利用多核优势进行并行计算,这是它被选为Java基本类型数组(如 int[] , double[] )默认排序算法的原因。
对于部分有序的数据, TimSort 通常表现更好。 TimSort 被设计用来利用数据中已经存在的顺序,在这种情况下,它的比较次数远少于 n log n ,性能非常出色。现实世界中的很多数据都具有这种部分有序的特征。
当需要稳定排序时,必须使用 TimSort 。 稳定排序保证了相等元素的原始相对顺序在排序后不会改变。 DualPivotQuicksort 是不稳定的,而 TimSort 是稳定的。因此,Java中对象数组( Object[] )的 Arrays.sort() 和 Collections.sort() 都使用 TimSort 。
特性 | DualPivotQuicksort | TimSort |
---|---|---|
核心算法 | 双轴快速排序 | 混合归并排序和插入排序 |
稳定性 | ❌ 不稳定 | ✔️ 稳定 |
最坏时间复杂度 | O(n²) | O(n log n) |
平均时间复杂度 | O(n log n) | O(n log n) |
最好时间复杂度 | O(n log n) | O(n) |
空间复杂度 | O(log n) | O(n) |
JDK 用途 | Arrays.sort (基本类型) |
Arrays.sort /Collections.sort (对象) |
结论