TimSort算法(JDK)

算法介绍

JDK1.8中,对于列表的排序,java.util.List中提供了sort方法,调用的Arrays.sort(T[],Comparator),Arrays提供的对Object的一种排序方法(这里用的是泛型T,还有Object[]对应的排序方法),在该方法中可以看到使用的是TimSort类的静态方法对数组进行排序,TimSort类的内容就是TimSort算法的实现。

TimSort是一种混合排序算法,内部使用了插入排序(二分插入排序)和归并排序,是在数组中的数据都是部分有序(正序或倒序)的情况下,将两种排序算法进行结合优化带来排序效率的提高。

对于插入排序,其时间复杂度为O(n^2),但在数据基本有序的情况下有教好的排序性能,而二分插入排序是对插入排序的优化,减少比较次数,但没有减少移动次数,时间复杂度为O(n*logn)。

对于归并排序,其时间复杂度为O(n * logn),排序性能比较高,但其缺点是当数组长度比较小时,归并的效率并不高且浪费空间;另一方面是如果两个序列的长度相差较大,一个长序列和一个序列进行归并,其归并带来的排序效率无法很好的体现。

算法步骤

步骤

TimSort排序算法的步骤如下:

  • 如果数组的长度小于MIN_MERGE=32时,直接使用二分插入排序。

  • 将待排序的数组划分成一个个子数组run,数组长度最小阈值为minRun,minRun的值根据数组长度进行计算。

  • 从数组第一位开始获取连续有序的run,为倒序时将数组翻转为正序。如果此时run的长度小于minRun,则使用二分插入算法从下一位进行补充。

  • 将上一步得到的run推入栈中,保存当次run的起始下标位置baseLen[]和run的长度runLen[]。判断栈中的run是否需要合并。当前栈中run数量大于1时进行循环

    • n = stackSize - 2
    • n = 0(run数量为2),runLen[n] <= runLen[n+1]时,将n与n+1的run进行合并(归并排序)
    • n > 0 且 runLen[n-1] <= runLen[n] + runLen[n+1]时,如果runLen[n - 1] < runLen[n + 1],n-1 与 n 合并,否则 n 与 n+1 合并
  • 移动起始下标,重复上述两步遍历到数组尾部。

  • 将栈中剩余的run进行最终合并,归并为一个正序的有序数组。

其中有几个关键点

MIN_MERGE

将要合并序列的大小的最小值。上面提到归并排序的缺点,当数组长度比较小时,归并的效率不高且浪费空间,所以直接采用二分插入排序。

minRun

划分的run对应的数组的最小阈值,其计算逻辑为

  • 当数组长度小于MIN_MERGE,直接返回数组长度,相当不做归并排序只进行插入排序,与上述对MIN_MERGE判断一致。
  • 如果数组长度是2的精准乘方(2^m,2的阶乘),返回MIN_MERGE/2
  • 其余返回整数k,MIN_MERGE/2 <= K <= MIN_MERGE,n/k接近但严格小于2的精准乘方。(严格小于即差值为1)
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); // 奇数二进制低位为1,&1=1;偶数二进制低位为0,&1=0
            n >>= 1; // 向右移一位,相当于除以2
        }
        return n + r;
    }

上述代码为minRun的计算源码,计算逻辑为:当n>=32时,n除以2;当n不为2的阶乘时,r=1,否则r=0(n/2的过程中出现奇数),返回n+r。

由于TimSort在数组长度小于MIN_MERGE=32时直接使用二分插入排序,因为minRun的最小值为MIN_MERGE/2=16。

TimSort中并没有初始化一个堆栈,而是使用两个数据记录每个run的信息,run的起始下标位置信息baseLen[]、run的长度信息runLen[]。runBase[i] +runLen[i] = runBase[i+1]

在run合并的过程中是从后往前的进行合并的,合并后会将baseLen及runLen中的信息进行更新。

图示

TimSort算法的图示如下(为方便演示,假设minRun=4)

image.png

run合并

每当一个run入栈的时候,都会判断栈中的run是否需要进行合并。每次循环都以倒数第二个run做基准进行判断,判断逻辑在步骤中已整理。当判断需要合并时,就会进行一次归并排序,相比普通归并排序从下标0位开始,此时run对应的数组已经是有序的,对归并过程是友好的。

对run归并的处理逻辑在JDK1.8的java.util.TimSort.mergeAt()方法。简化的流程如下:

  • 找出run2的第一个元素在run1的位置(偏移个数),如比run1的第一个元素小则为0,获取run1对比起始下标
  • 找出run1的最后一个元素在run2的位置,获取run2移动对比长度
  • 从run1对比起始下标(dest)开始,将run1剩余元素拷贝到数组tmp中(下标cursor1)
  • 从run2起始下标(cursor2)开始循环与tmp[cursor1]对比,如a[cursor2]=tmp[cursor1],则a[dest]=tmp[cursor1],cursor1加1,dest加1
  • 循环结束,如tmp中存在未遍历元素,将剩余元素从数组dest位置拷贝覆盖。

下图为上述TimSort算法演示图中第一次run合并的过程。

image.png

上图run2的第一个元素比run1的第一个元素小,则run1全部被拷贝进tmp,若将run1第一个元素修改为2(比4小),则如下图,run1的第一个元素2及run2的最后一个元素18是不需要比较移动的。这种处理方式能减少比较和移动次数,提高归并排序的性能,也是归并排序在待归并序列有序情况下的优势。

image.png

复杂度及稳定性

TimSort算法主流程的源码如下:

static  void sort(T[] a, int lo, int hi, Comparator 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;  // Arrays of size 0 and 1 are always sorted

        // 数组长度小于MIN_MERGER,不进行归并处理
        if (nRemaining < MIN_MERGE) {
            // 从lo位置开始找出有序的序列,如果是倒序装换为正序
            int initRunLen = countRunAndMakeAscending(a, lo, hi, c);
            // 二分插入排序,lo+iniRunLen为起始位置(因为这之前的都是有序且正序的)
            binarySort(a, lo, hi, lo + initRunLen, c);
            return;
        }

        /**
         *实例化TimSort对象
         * March over the array once, left to right, finding natural runs,
         * extending short natural runs to minRun elements, and merging runs
         * to maintain stack invariant.
         */
        TimSort ts = new TimSort<>(a, c, work, workBase, workLen);
        // run最小长度
        int minRun = minRunLength(nRemaining);
        do {
            // Identify next run
            int runLen = countRunAndMakeAscending(a, lo, hi, c);

            // run长度小于minRun,使用插入法进行补充
            if (runLen < minRun) {
                int force = nRemaining <= minRun ? nRemaining : minRun;
                binarySort(a, lo, lo + force, lo + runLen, c);
                runLen = force;
            }

            // 将run推入栈中,并判断是否需要合并
            ts.pushRun(lo, runLen);
            ts.mergeCollapse();

            // Advance to find next run
            lo += runLen;
            nRemaining -= runLen;
        } while (nRemaining != 0);

        // 循环结束,将栈中剩余的序列合并
        assert lo == hi;
        ts.mergeForceCollapse();
        assert ts.stackSize == 1;
    }

从源码中可以看出,当待排序数组长度小于MIN_MERGER且已经是有序时,只进行一次查找有序序列的过程(countRunAndMakeAscending),是对数组的一次遍历,此时不需要进行二分插入排序也不需要进行归并。即最好情况下TimSort的时间复杂度为O(n)。

平均来看,使用了二分插入排序和归并排序,TimSort的时间复杂度为O(n*logn)。

在归并的过程使用了临时数组tmp存放待比对移动的元素,其空间复杂度为O(n)。

在二分插入即归并的过程中,对于大小相同的元素,没有改变其先后顺序,因此TimSort是一种稳定的排序算法。

快速排序的平均时间复杂度也为O(n*logn),但快速排序是一种不稳定的排序,无法保证排序前后相同大小元素的有序性,这在某些场景下会出现影响。

TimSort与ComparableTimSort

java.util包下还有一个类似的类ComparableTimSort,是对实现了Comparable接口的对象的TimSort排序应用,而不是显式地传进去一个Comparator对象,流程一致。

你可能感兴趣的:(TimSort算法(JDK))