深度剖析Tim Sort - Pyhon及Chrome引擎v8使用的高效排序算法

你有没有好奇过,Array.sort()方法的背后,浏览器跑的是什么算法呢?

提到排序算法,我们的第一反可能是冒泡排序、插入排序、快速排序、归并等经典排序算法。推荐一篇很棒的博客,里面列举比较了10大排序算法(附有很棒的动图),这里就不多说了。

Chrome浏览器引擎v8使用的则是不属于任何经典排序算法的Tim Sort。2002年Python的主要贡献之一的Tim Peters为这门最近非常热门的编程语言创造了这个高效的混合算法。它是根据现实中大量的数据分析,决定在什么情况下用什么算法组合达到大概率最优解。相较快速排序最坏情况下达到O(n2)的时间复杂度,它最坏情况也只是O(nlog n)而已,最优则可达到O(n)。以下为10大经典算法的复杂度比较(转自博客):
10大经典算法的复杂度比较
相关概念:

  • 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
  • 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
  • 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
  • **空间复杂度:**是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。

在组合最优,并且保持稳定性的思维基础上,Tim决定在在数据量较小的时候使用插入排序。插入排序(Insertion Sort)是从前向后(或者从后向前)挨个将数据在之前已排序的数组中(从当前位置到最初开始的位置扫描)找到相应位置并插入。以下动图为插入排序演示:

插入排序

为了减少操作的次数,插入排序还有升级版-二分插入排序(Binary Sort)。即数据在试图找到自己在前面已排序的部分中的位置时,用比较高效的二分查找。

在这里插入图片描述

当数据量较大时,Tim sort就用常用的分治思想,分组进行二分插入排序,再用归并排序(Merge Sort)合并。归并算法中,数据被划分成多个小组(称为run),然后先每个run内先行排序,再将两个已排序的run通过其他方法合并成一个更大的已排序run,直到所有小组都合并为止。以下动图为最基础的归并排序过程:

归并排序

分组,计算min run

经典归并算法中,我们常将2个数据分为一组。但Tim通过实验发现,适当提高初始分组的大小会有明显的性能提升,但每组也不能太大,将分组定为32~64左右比较合适。至于为什么是32到64之间,呵呵,主要因为他用的python性能测试工具只能测2的幂次数据量。

这个初始分组的大小称为“min run”。为了追求之后合并的平衡,分组的数量最好是2的幂次方个。因此,min run由总长度不断除以2,直到这个值小于64,并向上或向下取整(并非四舍五入)得出:

function merge_compute_minrun(n)
{
   
    var r = 0;           /* becomes 1 if any 1 bits are shifted off */
    while (n >= 64) {
   
        r |= n & 1;
        n >>= 1;
    }
    return n + r;
}

接下来便开始分run,进行二分插入排序。但是等一下,在开始二分插入排序前,还有一些优化的空间。Tim在现实世界的实验数据中观察发现,数据极少是真随机排列的,多数情况下会有一大块连续数据是升序(包含等值的数据)或降序的。

如果是升序的话,因为我们采用的是二分插入而非基础的插入排序,哪怕本身顺序是对的,二分的机制也需要通过多次比较来找到“啊原来我保持之前的位置就好”,而浪费不少比较的操作。

如果是降序的话,在插入排序中会浪费大量的比较和数据挪动的操作,不如直接将数据整块反转。但如果这块数据里有等值的两个数据的话,反转会导致两者位置对换,也就失去了“稳定性”。

因此,Tim sort会在每个初始run进行二分插入排序之前,试图找出每个run按降序或升序排列的尽可能多的数据。当这些数据是是严格降序时,进行反转操作变成升序。

这里的run大小是不确定的,是natural run。如果运气不好可能遇到2个降序的数字,然后第3个数据比第2个数据大,即变成升序。那这个natural run就只有2位,那它就太小了。根据我们之前的讨论,太小的run不利于整体的效率。所以Tim sort会检查run的大小,如果比min run小,它就强制将run设置为min run的大小,并进行二分插入排序。这边有一个例外,当到最后一组时,难免会遇到比min run小的情况,此时因为是最后一组了,所以没办法就不纠结run大小的问题了,二分排序一下就让它去吧。

归并顺序

经典的归并会将两个相邻的数组依次比较直到所有数据找到他们自己合适的位置,且通常需开辟一大段临时内存空间来进行。Tim发现这样非常浪费内存而且多许多不必要的操作。因此提出了很多复杂而有意思的优化。让我们通过一系列的问题来看这些优化是如何做的。

首当其冲的问题是,哪两个run合并比较高效?

假设有三个run,分别为A:1000个数据,B:2000个数据,C:1000个数据,直觉上两个数据量差不多大的run A和C合并会比较平衡(有人说这是有实验证明的,可惜我没找到对应的研究证明)。那么问题来了,如果正好不巧A、B、C里都有同一个数据,那A、C先合并,势必会导致B的这个数据在C的之后,Tim sort强调很多遍的稳定性也就不存在了。因此Tim依旧选择只有两个相邻的run可以合并。

不过,即使有“相邻”的条件,我们可以选择这些相邻run的合并顺序来提高效率,同时又不失稳定性。这里Tim提出了一个两个相邻run的合并条件,通过比较3个而非只有2个相邻run的长度(用A、B、C来代表它们):

  1. A > B + C
  2. B > C

满足以上两个条件的两个run B和C就可以确定是左右相邻中比较小的两个run,先合并这些较小的run,这样就有比较高的几率跟相邻的run长度相近,整个合并的过程就比较平衡了。到最后实在不巧没有满足条件的run了,再用最基础的合并方式解决。

合并的方式 - Gallop mode

当在合并大块的已经排过序的run时,我们不难想象有很大的可能性如果按顺序比较,会需要比较很多次才能找到正确的位置。比如:

A: [0, 2, 4, 6, 8, 10, …, 100]

B: [57, 77, 97, …]

如果57从0开始比较,将比较58/2 = 29次才能找到它的位置。找到57的位置后,又需要找(78 - 58)/2=10次才能找到第二个数字77的位置。这种情况下我们就要考虑用一些特殊手段减少这些无意义的比较。另一个考虑是临时空间的使用,如果在合并57到A里时,A的前29个数据不需要加入合并的过程,但因为它因为归并算法的关系占用了额外29块内存空间,那是非常浪费计算资源的。

因此Tim sort加入了Gallop模式(不知道中文该翻什么,极速模式?)。Gallop模式可以看作是种搜索算法,即单个数据在一个长有序列表中快速找到它的位置。

首先选择从左侧还是右侧某个位置开始,根据这个位置上数据的大小选择向左或向右继续比较。如果没找到比它大的数,下一次比较的位置较前一次增长2的幂次方(比如从A[7]进入Gallop模式,那A[7]的数据还是比较小的话,继续用A[9]比,还小就跟A[11]比,然后是A[15]、A[23]、A[35]… 直到找到比较大的数为止。假设这个较大的数是A[35],那我们可以推测我们找的位置在A[23]到A[35]之间。然后Gallop模式再启用二分查找,找到准确的位置腾出内存空间插入数据。

Galloping的复杂度其实跟二分查找一样,只是比起二分,它更偏向于猜测数据的位置不会离起始位置太远。如果假设正确,将会比直接进行二分查找来的快。

以下为从左侧开始galloping的JS精简版(去掉了错误处理等):

function gallop_left(key 
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值