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

1,044 阅读10分钟

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

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

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 /* 尝试合并的数据 */, a /* 数据想合并进的run */, startIndex)
{
    let lastofs = 0; // 二分查找的起始点
    let ofs = 1;  // 二分查找的中止点
  	const n = a.length;
    if(a[hint] < key) {
      	// 从左向右比较
        const maxofs = n - hint;
        while (ofs < maxofs && a[ofs] < key) {
          lastofs = ofs;
          ofs = (ofs << 1) + 1;
        }
        if (ofs > maxofs) ofs = maxofs;
        lastofs += hint;
        ofs += hint;
    } else {
      	// 从右向左比较
        const maxofs = hint + 1;
        while (ofs < maxofs && a[hint - ofs] < key) {
            lastofs = ofs;
            ofs = (ofs << 1) + 1;
        }
        if (ofs > maxofs) ofs = maxofs;
        let tmp = lastofs;
        lastofs = hint - ofs;
        ofs = hint - tmp;
    }
    
    // 最后进行二分查找
    ++lastofs;
    while (lastofs < ofs) {
        let m = lastofs + ((ofs - lastofs) >> 1);
        if(a[m] < key) lastofs = m+1;
        else ofs = m;
    }
    return ofs; // 返回找到的索引值
}

什么时候用Gallop mode

两个刚开始合并的run非常有可能差的比较多。如(同上例):

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

B: [57, 77, 97, ...]

这种情况下我们先用gallop_left(b[0], a, 0) 确定b[0]在a中的位置,和gallop_right(a[a.length - 1], b, b.length - 1)确定a[0]在b中的位置,然后大大缩短真正需要合并的部分。

以下为JS简化版代码:

function merge(ms /* MergeState 合并状态机 */, a, b) {
    let k = gallop_right(b[0], a, 0);
    if (k < 0) throw -1;
    let merged = a.splice(0, k);
    if (a.length == 0) 
      return merged.concat(b);
  	else
      merged.push(b.shift());
  
    let nb = gallop_left(a[a.length-1], b, b.length - 1);
    if (nb <= 0) throw nb;
  	const lastPart = b.splice(nb);
  	lastPart.unshift(a.pop());
  
    if (a.length <= nb)
        merged = merged.concat(merge_lo(ms, a, b));
    else
        merged = merged.concat(merge_hi(ms, a, b));
  	return merged.concat(lastPart);
}

Gallop mode的特点决定它比较适合于数据跨度不平衡的状况。如果数据跨度没有那么大,那这样跑一段复杂的代码反而会降低效率。相反,像经典版的插入排序这样按顺序比较的方式更好。

因此Tim sort在启用Gallop mode时有条件限制。Python里,只有当一个数据比较了7次仍旧没找到对的位置时才启用。考虑到如果一次合并中多个数据达到启用gallop mode的条件,很有可能意味着这个列表中的数据跨度相比合并的另一组数据要大,因此Tim sort会根据连续触发Gallop模式的频率上下调整触发的门槛(有点像打游戏连击之后有加成,但连击不成就得重来的感觉)。

以下为JS简化版代码:

const MIN_GALLOP = 7;

function merge_lo(
  ms, // MergeState 合并状态机
  a, // 较短的run
  b // 较长的run
) {
	  let min_gallop = ms.min_gallop;
  	let merged = [];

    for (;;) {
        let acount = 0;          /* # of times A won in a row */
        let bcount = 0;          /* # of times B won in a row */

      	// 先依次比,当某个run赢的次数达到进入gallop的门槛
        do {
            if (b[0] < a[0]) {
                merged.push(b.shift());
                ++bcount;
                acount = 0;
                if (b.length == 0) return merged.concat(a);
            } else {
                merged.push(a.shift());
                ++acount;
                bcount = 0;
                if (a.length == 0) return merged.concat(b);
            }
        } while ((acount || bcount) >= min_gallop))

        ++min_gallop;
        do {
            ms.min_gallop = min_gallop -= min_gallop > 1;
            let k = gallop_right(b[0], a, 0);
          	acount = k;
            if (k) {
                if (k < 0) return -1;
                merged = merged.concat(a.splice(0, k));
                if (a.length == 0) return merged.concat(b);
            }
          	merged.push(b.shift());
            if (b.length == 0) return merged.concat(a);

            k = gallop_left(a[0], b, 0);
            bcount = k;
            if (k) {
                if (k < 0) return -1;
              	merged = merged.concat(b.splice(0, k));
                if (b.length == 0) return merged.concat(a);
            }
	          merged.push(a.shift());
          	if (a.length == 0) return merged.concat(b);
        } while (acount >= MIN_GALLOP || bcount >= MIN_GALLOP);
        ms.min_gallop = ++min_gallop;
    }
    return merged;
 }

上文提到的merge_hi函数跟merge_lo是差不多的,就不多说了。

结语

Tim sort的解读就到这里了。而v8上(没心情看源码了真的),并不像Python那样只要能有32-64的min run就开始这套操作,它额外加了一个使用Tim sort的门槛。根据这里的说法,v8把门槛设在了>512,也就是说大部分情况下我们还是享用不到Tim sort的,仍旧在用基础的二分插入排序。

连着看了好几篇解释Tim sort的博文,都感觉写的稀里糊涂,不懂为什么要有这些“骚操作”,于是不得已去看了Python里Tim大叔的源代码。很多年没写过C了,源码看得我头晕眼花,尤其是指针、鸡肋的标签(大学毕业写代码以来从来没用过标签)、特别多的异常处理和内存处理,简直吐血。

虽然大神的神理论改变了我的三观,但是代码写得太过放飞自我了。。。很多代码的篇幅是没有好好整理代码造成的,为后来的维护人员点支🕯️。

参考资料