TimSort:论Java Arrays.sort的稳定性

TimSort 是一种混合的、稳定的排序算法,结合了归并排序(Merge Sort)和二分插入排序(Binary Insertion Sort)的优点,尤其适用于部分有序的数据。在 Java 中,Arrays.sort() 对对象数组排序时内部使用了 TimSort 算法。

 对于集合的排序实际上也是使用Arrays.sort

如 List.java

    default void sort(Comparator c) {
        Object[] a = this.toArray();
        Arrays.sort(a, (Comparator) c);
        ListIterator i = this.listIterator();
        for (Object e : a) {
            i.next();
            i.set((E) e);
        }
    }

类的作用​

  • 主要用于对数组进行高效且稳定的排序。
  • Java 的 Arrays.sort() 方法在排序对象数组时调用 TimSort。

核心思想​

  1. ​识别自然有序子序列(run)​​:
    遍历数组,找到已有序的连续子序列。
  2. ​扩展短 run​​:
    若 run 长度小于 MIN_MERGE,使用二分插入排序将其扩展到最小长度。
  3. ​合并 run​​:
    通过归并操作将所有 run 合并为完全有序的数组。

TimSort 如何实现非递归的归并排序?

传统的归并排序通常使用递归,将数组不断对半分割,直到子数组只有一个元素,然后逐层返回并合并。这个过程依赖于程序的调用栈(call stack)来管理子问题。

TimSort 的巧妙之处在于它​​用一个自己管理的显式栈(runBaserunLen 数组)替代了递归所需的调用栈​​。整个过程如下:

  1. ​遍历与压栈​​:一个简单的循环从左到右遍历数组,找到或创建小的有序片段(run)。
  2. ​管理子问题​​:每找到一个 run,就调用 pushRun 将其信息压入自己的栈中。
  3. ​智能合并​​:pushRun 之后立即调用 mergeCollapse,根据预设的平衡策略,决定是否要合并栈顶的某些 run。这个合并决策是迭代进行的,而不是递归返回时才发生。

通过这种方式,TimSort 将递归的“分治”思想转换成了一个迭代的过程,避免了递归深度过大可能导致的 StackOverflowError,并且通过 mergeCollapse 的智能合并策略,进一步优化了归并的效率。这就是它实现非递归归并排序的原理。

有状态设计

Arrays.sort没有创建新实例,而是内部递归进行归并排序的时候创建实例。

有状态是因为归并排序需要复制。

这个私有的 TimSort 构造函数主要做了以下几件重要的事情,本质上都是为了初始化排序过程需要的数据结构和状态:

  1. 保存核心排序参数 :

    1. this.a = a; :保存待排序的数组的引用。

    2. this.c = c; :保存用于比较元素顺序的比较器 Comparator 。

  2. 分配临时存储空间 ( tmp 数组) :

    1. TimSort 算法的核心是归并,在归并两个已排序的子序列(称为 "run")时,需要一个临时的存储空间来存放其中一个子序列。构造函数会预先分配这个名为 tmp 的数组。

    2. 为了优化性能和内存使用,它会计算一个合适的初始大小。如果调用者(例如 Arrays.sort )提供了一个足够大的工作区数组 ( work ),它会直接使用,避免重复创建数组。

  3. 分配用于管理 "run" 的栈 ( runBase 和 runLen 数组) :

    1. TimSort 算法会识别出数组中已经排好序的片段(称为 "run"),然后将这些 "run" 合并。它使用一个栈来管理这些待合并的 "run"。

    2. runBase 数组存储每个 "run" 的起始索引。

    3. runLen 数组存储每个 "run" 的长度。

    4. 构造函数会根据待排序数组的长度 len 计算出一个足够大但又不过于浪费的栈深度 stackLen ,然后创建这两个数组。

总而言之, TimSort 的构造函数是一个 初始化和准备 的过程,它建立了一个包含所有排序所需上下文(待排序数组、比较器、临时空间、管理栈)的“工作台”,使得后续的排序步骤可以高效地进行。

关键属性​

属性名 说明
MIN_MERGE (32) 最小 run 长度。短 run 会被扩展至此值,以平衡插入排序和归并排序的效率。
MIN_GALLOP (7) 控制“galloping mode”的阈值,减少连续比较次数。
INITIAL_TMP_STORAGE_LENGTH (256) 临时存储数组的初始大小,用于归并操作。
a 待排序的数组。
c 比较器(若为 null,使用自然顺序)。
tmp 临时数组,用于归并操作。
runBaserunLen 存储 run 的起始位置和长度。stackSize 记录当前栈中 run 的数量。

关键方法​

​构造函数​

  • TimSort(T[] a, Comparator c, T[] work, int workBase, int workLen)
    • 初始化实例,设置数组和比较器。
    • 根据数组长度分配临时数组 tmp 和 run 信息数组(runBaserunLen)。

​核心方法​

  • sort(T[] a, int lo, int hi, Comparator c, ...)

    • ​主入口点​​:由 Arrays.sort() 调用。
    • ​小数组处理​​:若长度小于 MIN_MERGE,直接使用二分插入排序。
    • ​主循环​​:
      1. 调用 countRunAndMakeAscending 识别自然 run。
      2. 若 run 过短,用 binarySort 扩展。
      3. 压入 run 栈(pushRun)并合并(mergeCollapse)。
    • ​最终合并​​:循环结束后调用 mergeForceCollapse 完成排序。
  • countRunAndMakeAscending

    • 返回自然 run 的长度,并确保其为升序(降序则反转)。
  • binarySort

    • 对小规模数据执行稳定的二分插入排序。
  • minRunLength

    • 计算最小 run 长度(介于 MIN_MERGE/2MIN_MERGE 之间)。
  • mergeCollapsemergeForceCollapse

    • ​合并规则​​:保持栈中 run 长度满足 runLen[i-2] > runLen[i-1] + runLen[i]
    • 强制合并剩余 run 直到完全有序。
  • mergeLomergeHi

    • 实际归并操作,根据 run 大小选择合并方向(低索引或高索引优先)。

与 ComparableTimSort 的关系​

  • ComparableTimSort​ 是 TimSort 的变体,专为实现了 Comparable 的对象数组设计,直接调用 compareTo 方法,无需显式比较器。

总结​

TimSort 通过以下策略实现高效排序:

  1. ​动态适应数据特性​​:识别自然 run 并智能选择排序策略。
  2. ​平衡合并操作​​:通过栈规则避免低效合并。
  3. ​混合算法优势​​:结合二分插入排序(小数据)和归并排序(大数据)的优点。

其设计使其在各类场景下均表现优异,成为 Java 默认排序算法之一。

sort

static  void sort(T[] a, int lo, int hi,
     Comparator c, T[] work, int workBase, int workLen)

这个静态方法是 TimSort 算法的入口。核心思想:

  1. 找出数组中已存在的有序片段(称为 "run")。
  2. 高效合并这些 run。

第一步:处理小数组(Mini-TimSort)

​条件​​:待排序元素数量 nRemaining < MIN_MERGE(默认 32)

  1. countRunAndMakeAscending(a, lo, hi, c)

    • 从数组开头找到第一个自然有序的 run(升序或降序)。
    • 若为降序,通过 reverseRange 反转为升序。
    • 返回 run 的长度 initRunLen
  2. binarySort(a, lo, hi, lo + initRunLen, c)

    • 对剩余元素(lo + initRunLenhi)使用二分插入排序。
    • 优势:对小规模且部分有序的数据效率极高。

二分插入排序,二分找到后,直接利用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;


第二步:处理大数组(完整 TimSort)

​条件​​:数组长度 ≥ 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。

  1. 最终返回的是循环结束后的 n 加上标志位 r 。这相当于:如果原始的 n 不能被最终的 n 整除(即存在“余数”,导致 r 为1),那么就把 minRun 的长度加 1。这样做可以减少 run 的总数,使其更接近 2 的幂。

用一个例子 n = 65 来看看:

  1. 初始时 n = 65 , r = 0 。

  2. 65 >= 32 ,进入循环。

    1. n 是 65 (奇数), n & 1 是 1。 r |= 1 ,所以 r 变为 1。

    2. n >>= 1 , n 变为 32。

  3. 32 >= 32 ,继续循环。

    1. n 是 32 (偶数), n & 1 是 0。 r |= 0 , r 仍然是 1。

    2. n >>= 1 , n 变为 16。

  4. 16 < 32 ,循环结束。

  5. 返回 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) \approx (1 - 1/(q+1)) * 2^s 。

    结合 q 的范围是 [16, 31] 。

    • 当 q 取最小值16时, q/(q+1) = 16/17 ≈ 0.941 。
    • 当 q 取最大值31时, q/(q+1) = 31/32 ≈ 0.969 。
      这意味着 k 的范围被严格限制在 ceil(0.941 * 2^s) 和 2^s 之间。

    ​举例​​:

    • 假设 s=5 ,那么目标run数是 2^5 = 32 。 k 的下界是 ceil(0.941 * 32) = ceil(30.112) = 31 。
      所以,当目标run数是32时,实际的run数 k 只可能是31或32。
    • 假设 s=6 ,目标run数是 2^6 = 64 。 k 的下界是 ceil(0.941 * 64) = ceil(60.224) = 61 。
      所以,当目标run数是64时,实际的run数 k 被限制在 [61, 64] 这个极小的范围内。

该算法通过将 q 限制在 [MIN_MERGE/2, MIN_MERGE-1] 范围内,并根据余数是否存在来决定 minrun 是 q 还是 q+1 ,最终确保了run的总数 k 要么恰好是一个2的幂 2^s ,要么是在一个非常贴近 2^s 的极小区间内。这为后续归并操作的平衡性提供了强有力的保证,是Timsort高性能的关键之一。

​主循环(do-while)​

  • ​a. countRunAndMakeAscending(...)
    同 Mini-TimSort,找到下一个自然 run。
  • ​b. 扩展短 run​
    若当前 runLen < minRun,通过 binarySort 强制扩展到 minRun 长度(或剩余元素总数)。
  • ​c. ts.pushRun(lo, runLen)
    将 run 的起始位置和长度压入栈。
  • ​d. ts.mergeCollapse()
    检查栈顶 run 是否满足“栈不变式”(如 runLen[i-2] > runLen[i-1] + runLen[i])。若不满足,调用 mergeAt 合并相邻 run。
  • ​e. 更新索引​
    移动 lonRemaining,准备处理下一个 run。
  1. ​最终合并​

    • ts.mergeForceCollapse():合并栈中剩余 run,直到只剩一个 run,完成排序。

子函数总体说明

mergeCollapse() -> mergeAt(n)

  • ​职责​​:维持栈的平衡。

  • ​操作​​:检查栈顶 run 长度,若不平衡则计算最佳合并点 n,调用 mergeAt(n)

mergeAt(i) -> gallopRight(), gallopLeft(), mergeLo(), mergeHi()

  1. ​优化 1:跳过有序部分​

    • gallopRight:找到 run2 的首元素在 run1 中的插入点,跳过 run1 中已有序部分。

    • gallopLeft:找到 run1 的末元素在 run2 中的插入点,跳过 run2 末尾有序部分。

  2. ​优化 2:选择合并策略​

    • 根据剩余长度选择 mergeLorun1 较短)或 mergeHirun2 较短),最小化临时数组使用。

mergeLo() / mergeHi() -> gallopRight(), gallopLeft()

  • ​实际归并操作​​:逐个比较元素并归并。

  • ​优化 3:Galloping 模式​

    • 若一个 run 的元素连续多次“胜出”,进入飞奔模式,调用 gallopRight/gallopLeft 批量移动数据块。

    • minGallop 动态调整进入/退出此模式的阈值。

gallopLeft() / gallopRight()

  • ​飞奔搜索​​:
    1. 指数级步长(1, 3, 7, 15...)快速定位范围。
    2. 在小范围内执行二分查找,高效定位插入点。

pushRun(int runBase, int runLen)

这个方法非常直接,它的作用是将一个已经排好序的连续片段(run)的信息记录下来,存入一个专门的“待合并区”——也就是代码中的 runBase 和 runLen 数组,它们共同构成了一个栈。

  • runBase : 记录这个 run 在原数组中的起始索引。

  • runLen : 记录这个 run 的长度。

  • stackSize : 记录当前栈中有多少个待合并的 run。

TimSort 的主循环会遍历整个数组,识别或创建这些小的有序片段(run),然后调用 pushRun 把它们一个个推到这个栈上,等待后续的合并操作。

mergeCollapse()

这是 TimSort 算法的精髓所在。每当一个新的 run 被 pushRun 推入栈顶后,mergeCollapse 就会被调用。它的任务是检查栈顶的几个 run 是否满足特定的“平衡”条件(即注释中提到的两个不变式)。

  • ​不变式 1​​: runLen[i - 2] > runLen[i - 1]
  • ​不变式 2​​: 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。这两条规则是:

  1. len(X) > len(Y) + len(Z)

  2. 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 。

mergeForceCollapse

循环结束后,最终强制合并,同样的优化是 先合并小的

    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 通过智能策略避免对部分有序数据的无效操作。

代码逐段解析

  1. ​准备与栈管理​

    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--;
    • 获取两个 run 的起始位置和长度。
    • 更新栈信息,合并 run 并减少栈大小。
  2. ​第一次优化:gallopRight

    int k = gallopRight(a[base2], a, base1, len1, 0, c);
    assert k >= 0;
    base1 += k;
    len1 -= k;
    if (len1 == 0) return;
    • 取出 run2 的第一个元素,在 run1 中快速查找其插入位置。
    • 通过指数级步长(1, 3, 7, 15...)跳过 run1 中所有小于该元素的区间。
    • 跳过部分无需参与后续合并,减少比较次数。
  3. ​第二次优化:gallopLeft

    len2 = gallopLeft(a[base1 + len1 - 1], a, base2, len2, len2 - 1, c);
    assert len2 >= 0;
    if (len2 == 0) return;
    • 取出 run1 的最后一个元素,在 run2 中反向查找插入位置。
    • 跳过 run2 中所有大于该元素的区间。
    • 精确缩小需合并的范围。
  4. ​第三次优化:选择 mergeLomergeHi

    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 函数的复杂性

结合两种搜索策略:

  1. ​指数搜索(Galloping)​​:从 hint 位置以 2^k - 1 步长跳跃,快速定位范围。
  2. ​二分搜索​​:在指数搜索确定的范围内精确查找插入点。
    这种“先粗后精”的方式对结构化数据效率远超纯二分搜索。

总结

mergeAt 的复杂性体现了 TimSort 的精髓:

  • ​适应性​​:通过 gallop 模式高效处理已有顺序的数据。
  • ​效率​​:减少无效比较和数据移动,优化内存分配。

正是这些设计使 TimSort 成为 Java、Python 等语言标准库的默认排序算法。

gallopLeft 

gallopLeft 的核心目标是:
在一个已排序的数组(或数组的一部分)中,快速地为一个给定的 key 找到它应该插入的位置。
如果数组中存在与 key 相等的元素,它会返回 ​​最左侧​​ 那个相等元素对应的索引。
这个特性对于保持排序的稳定性至关重要。

函数签名

private static  int gallopLeft(
    T key, 
    T[] a, 
    int base, 
    int len, 
    int hint, 
    Comparator c
)

参数说明

  • key:要在数组 a 中查找插入点的元素。
  • a:进行查找的目标数组。
  • base:查找范围在数组 a 中的起始索引。
  • len:查找范围的长度。
  • hint:一个“提示”索引,表示 key 可能的位置。
    这是 TimSort 适应性的关键,它假设数据具有局部性,即下一个要插入的元素很可能在前一个元素附近。

gallopLeft 的执行过程分为两个主要阶段:
​“飞驰模式”(Galloping)​​ 和 ​​二分查找(Binary Search)​​。


阶段一:飞驰模式(指数式搜索)

此阶段的目标是利用 hint 快速定位一个包含 key 的较小范围,而不是从头开始进行二分查找。

  1. ​方向判断​
    首先,比较 keya[base + hint] 的值:

    • 如果 c.compare(key, a[base + hint]) > 0,说明 keyhint 的右侧。此时,算法会向右“飞驰”。
    • 如果 key <= a[base + hint],说明 keyhint 的左侧或就是 hint 位置的元素。此时,算法向左“飞驰”。
  2. ​指数级步进​
    算法以指数级增加的步长(1, 3, 7, 15, ...,偏移量由 ofs = (ofs << 1) + 1 计算)进行探测,直到找到一个区间 [lastOfs, ofs],使得 key 恰好落在这个区间内。
    例如,向右飞驰时,直到满足:
    a[base + hint + lastOfs] < key <= a[base + hint + ofs]
    这种方式使得当 key 的实际位置距离 hint 很远时,也能极快地缩小查找范围。


阶段二:二分查找

  1. ​范围确定​
    飞驰阶段结束后,已经确定了一个比原始范围 len 小得多的精确范围 [lastOfs, ofs]

  2. ​经典二分查找​
    在此小范围内,执行一次标准的二分查找来精确定位插入点:

    • 循环条件为 while (lastOfs < ofs)
    • 在查找过程中:
      • 如果 c.compare(key, a[base + m]) > 0,意味着 key 在中间点 m 的右边,因此将搜索范围的左边界更新为 m + 1
      • 如果 key <= a[base + m],意味着 keym 的左边,或者 a[base + m] 就是一个与 key 相等的元素。
        为了找到 ​​最左侧​​ 的插入点,算法会继续在左半部分查找(ofs = m),而不是立即返回。
        这确保了即使找到一个匹配项,也会继续向左探索是否还有更早的匹配项。
  3. ​返回结果​
    最终,lastOfsofs 会重合,这个重合点就是 key 的最左插入位置。函数返回该偏移量 ofs


gallopLeft 是 TimSort 算法能够适应不同数据分布并保持高效的关键所在。
它通过 ​​“指数搜索 + 二分查找”​​ 的两阶段策略,避免了在数据高度有序或存在大段连续区块时进行逐一比较的低效操作。
通过与 mergeLomergeHi 的协同工作,它实现了智能的“飞驰模式”,使得 TimSort 在处理真实世界中常见的、部分有序的数据时,性能远超传统的归并排序。

TimSort.mergeLo

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 循环执行的是标准的"一次比较,一次移动"的归并操作。

  • count1count2 记录了每个 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, ...) 会在 tmprun1)中快速查找有多少个元素小于 a[cursor2]。然后通过 System.arraycopy 将这些元素进行 ​​批量复制​​,极大地提升了效率。

  • ​模式切换​​:
    这种模式会一直持续,直到两个 run 的批量复制长度(count1count2)都小于 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 是高度优化的稳定混合排序算法,融合以下技术:

    1. ​基本框架​​:归并排序。
    2. ​小数组/短 run 处理​​:二分插入排序(binarySort)。
    3. ​归并策略​​:通过栈不变式(mergeCollapse)实现智能合并。
    4. ​性能加速​​:飞奔模式(gallopLeft/gallopRight)适应部分有序数据。

    ​优势​​:对真实世界数据(高度有序或完全随机)均表现卓越。

    TimSort 和 DualPivotQuicksort 对比

    TimSort 和 DualPivotQuicksort 是两种不同的排序算法,应用在不同的场景下。除了分别用于对象和基本类型数组外,它们的主要差别在于算法核心、稳定性、性能和空间复杂度。

    实际上DualPivotQuicksort实现有更多技巧,见:

    深入浅出 Arrays.sort(DualPivotQuicksort):如何结合快排、归并、堆排序和插入排序


    核心算法逻辑

    ​DualPivotQuicksort (双轴快速排序):​

    • 是对经典快速排序算法的改进:
      • 传统快速排序选择一个“轴点”(pivot),将数组分为两部分(小于轴点的和大于轴点的)。
      • 双轴快速排序选择两个轴点,将数组分为三部分:小于第一个轴点的、在两个轴点之间的、大于第二个轴点的,然后递归排序。
    • 优势:
      • 比单轴快速排序性能更好,能更好地处理数据分布,减少递归深度。
      • 对于非常小的数组,会切换到插入排序(Insertion Sort)以提高效率。

    ​TimSort:​

    • 是一种混合(Hybrid)排序算法,结合了归并排序(Merge Sort)和插入排序(Insertion Sort)的优点:
      • 首先在数据中寻找已排好序的连续子序列(称为“自然运行”)。
      • 如果 run 太短,使用二分插入排序(Binary Insertion Sort)扩展。
      • 合并这些 runs(类似归并排序),通过维护 run 的栈并遵循特定规则来平衡合并成本。
    • 设计目标:在真实世界数据(通常包含部分有序片段)上表现优异。


     

    TimSort 是一种混合稳定的排序算法,它结合了归并排序和插入排序。当合并两个已经有序的run时,算法需要逐个比较来自两个run的元素,以决定下一个元素应该放谁,从而保证合并后的序列仍然有序且稳定。这个过程是 顺序的、有状态的 ,后一步的决策依赖于前一步的结果。因此,很难将单个合并操作分解到多个线程中去并行处理而不产生巨大的同步开销。

    哪个“更好”取决于评判标准和应用场景:

    1. 对于大规模、随机的数据集,在多核CPU上, DualPivotQuicksort 通常更快。 因为它可以利用多核优势进行并行计算,这是它被选为Java基本类型数组(如 int[] , double[] )默认排序算法的原因。

    2. 对于部分有序的数据, TimSort 通常表现更好。 TimSort 被设计用来利用数据中已经存在的顺序,在这种情况下,它的比较次数远少于 n log n ,性能非常出色。现实世界中的很多数据都具有这种部分有序的特征。

    3. 当需要稳定排序时,必须使用 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(对象)

    结论

    • ​DualPivotQuicksort​​:
      适用于基本类型,追求极致平均性能且不要求稳定性。
    • ​TimSort​​:
      适用于对象排序,需稳定性和有保障的最坏情况性能。

    你可能感兴趣的:(Java,算法,排序算法,算法,数据结构,java,开发语言,后端)