Timsort分析- JDK源码分析-自己实现简易TimSort/TimSort 归并排序 堆排序 快速排序 时间对比

一 简单介绍

Timsort是一个最好时间复杂度可以达到O(n),最坏为O(nlgn),平均为O(nlgn)的算法。Java里基本数据类型是用快排的,但是对于引用类型的排序是Timsort和二分插入排序结合的。当数据大小<32的时候用二分插入排序,>32时候用TimSort。

TimSort是一个插入排序和归并排序结合的算法,我们知道归并排序,把一个序列分成两半,分别排序成有序的子序列之后,再合并,这就有一个问题,分成的两半是直接就把长度分成了两半,如果正好把有序的连续段给分开了,不就浪费了这有序的性质吗?TimSort也是分,但是TimSort是利用现实中的数据大多是部分有序的特点来分。TimSort思想非常的简单:

(1)将数组按连续递增或者递减分成一个个分区,称为run。(如果是递减,直接倒置为递增的)

(2)将这些run合并。

二 Timsort详解

下面分析Timsort的细节

(1)关于run的最小长度:

         run的长度有限制,run的长度必须大于一个值,这个值称为minrun:

         ① 如果数组的长度本身就小于一个值,则这个值就是minrun,只需要对其进行二分插入排序。(这个值Tim Peters给的C源码中设为64,在java里设为32,下面均以32为例)

         ②如果数组长度>32:

           为了提高合并的效率,需要run的个数等于或略小于2的幂,minrun长度范围取[16,32],minrun长度的取法为:

             取数组长度的前五位,再加上数组长度的最后一位。

          ③在数组中划分run时,如果一个递增(递减)序列的长度

(2)关于run栈

         Timsort维护一个run栈,每扫描出一个run,将其放入栈中。

         假设run3,run2,run1分别为栈最上面的三个run(栈顶为run3),如果不满足下面两个条件的任一条件,则将其中的两个较短的run合并。

         run1>run2+run3;

        run2>run3;

这样可以使合并尽可能平衡,提高合并的效率。

当扫描到数组的末尾,获得最后一个run,最后一个run也在检查完上述两个条件放入栈中后,就可以对栈中所有的run进行合并了,合并完成后就得到了最终的排序结果。

(3)关于run的合并

        在归并排序中,我们合并时,如果不使用二分查找,有时可能需要将第一个元素与另一个序列的所有元素进行比较,如果序列长度太长,则效率很低,run本身就是有序的,没必要逐个进行比较,故在Timsort里关于run的合并使用到了galloping mode:

          假设合并run1和run2,查找run1的第一个元素在run2中该插入的位置Xi,设置2^k为递增长度,分别与第1,3.....2^k-1比较,再在得到的一个run1存在的范围中进行二分查找,这样比起直接二分查找,缩短了二分查找的范围,提高了效率。

          再以类似的方法查找run2的最后一个元素在run1中该插入的位置Yi。

          这样再合并,可以忽略run2中Xi之前的所有元素和run1中Yi之后的所有元素,提高了合并的效率。

          当然,galloping mode不是一定会在合并中使用到,比如两个序列run1和run2,run2中的某个元素并不一定比run1的前很多个元素大,这时候galloping的效率并不一定比二分查找高,Timsort设置一个最小阈值min_gallop,初始阈值设为7,比如说,在程序中记录一下run2的某个元素比run1的连续元素大的个数,如果大于min_gallop,才使用gallop策略,这个阈值在合并过程中会动态调整。如果合并过程中选择的元素与先前选择的元素是同一个run,则min_gallop减1,如果不是,则min_gallop+1。

  三 Timsort时间复杂度分析

最好情况:待排序数组是有序的,run的长度为数组长度,直接执行二分插入排序,时间复杂度为O(n)

最坏情况:Timsort的最坏情况应比归并排序时间复杂度小,故上界为O(nlogn)

平均情况:O(nlogn)

另,Timsort是稳定的排序算法。

四 TimSort的bug

      TimSort存在bug,考虑下面的run栈:

      从栈顶开始往下分别为30,20,25,80,120;

Timsort分析- JDK源码分析-自己实现简易TimSort/TimSort 归并排序 堆排序 快速排序 时间对比_第1张图片

当30入栈时,需要将20和25合并,栈变为30,45,80,120,这时候栈顶3个run是满足约束条件的,不会继续合并了,但是栈底三个元素是不满足约束条件的。这在java中可能引发数组越界的bug。

为什么不满足约束条件,会可能触发数组越界的错误呢?这不就是降低效率的问题吗?答案是这与Java里申请栈的大小有关。

Java里申请栈是这样的:

Timsort分析- JDK源码分析-自己实现简易TimSort/TimSort 归并排序 堆排序 快速排序 时间对比_第2张图片

129,542,这些数字怎么算出来的:

想要栈中run的长度都满足上面约束条件,则run1=run2+run3+1;即可,就像斐波那契数列一样,

假设这个栈栈顶run长度为16,在栈顶上有一个辅助空run,长度为0,则栈顶下的那个run为16+0+1=17,再下一个为16+17+1=34,接着是52,故5个run的栈,整个数组长度为0+16+17+34+52=119.以此类推,可以算出后面的数,至于为什么申请栈时候,只对len判断了4次,以及设置栈顶run长度为16,不知道,问写jdk的人去。

那么,这个申请栈空间的大小,是根据run1=run2+run3+1算出来的,是很严格的, 一旦不满足这个条件,就导致实际用到的栈超出了我们申请的大小(因为本来应该合并,缩小栈的)。

国外的某个技术团队在论文中算出了最坏情况下用到栈的大小:

比如说当数组长度为67108864时,需要栈为41,但是java里只申请了40,这样就可以去构造一个可能触发数组越界bug的数组。

代码如下:

https://github.com/abstools/java-timsort-bug

      

        

        

 

 

 

 

 

Timsort分析- JDK源码分析-自己实现简易TimSort/TimSort 归并排序 堆排序 快速排序 时间对比_第3张图片

 

 

 

 

 

 

源码:https://github.com/lanelane/algorithm_exercise/blob/master/%E7%AE%97%E6%B3%95-%E8%AF%BE%E5%A0%82%E9%A2%98%E7%9B%AE/src/pers/lane/algorithm/work/MyTimSort.java

你可能感兴趣的:(java,算法)