排序算法之Timesort: 最好的排序算法之一

Timsort 是一个实际的算法,通过将组合插入和归并算法,结合现实世界中数据的特征对合并策略进行修改,最终形成一个高效且稳定的算法。这种工程思想很值得我们学习。

除了下文提到的一些应用,Timsort也被引入Chrome V8,成为Array.prototype.sort的默认算法.
同时也需要注意,Timsort 需要 O(n) 的内存空间,在实际使用时也需要考虑机器的内存和数据的大小。

本文是一篇译文,原文链接: What is Timsort Algorithm?

什么是 Timsort ?

Timsort是一种数据排序算法。它基于这种思想,即现实世界中的数据集几乎总是包含有序的子序列,因此它的排序策略是识别出有序序列,并使用归并排序和插入排序对它们进行进一步排序。就复杂度和稳定性而言,Timsort是最好的排序算法之一。

与“冒泡”或“插入”排序不同,Timsort 相当新 - 它是由 Tim Peters(以他的名字命名)于2002年发明的。并在那之后,它就成为Python、OpenJDK 7和Android JDK 1.5中的标准排序算法。这背后的缘由, 只需看看维基百科这张表就知道了。

image

在上表看似众多的算法中,仅有七个不错的算法(平均或最坏情况的复杂度为O(nlogn)),其中只有两个具有稳定性,并且最优情况的复杂度为O(n)。其一为 Tree sort,第二个就是 Timsort 。
该算法基于这种思想,即现实世界中待排序的数据集一般包含有序(非降序或降序)子数组。通常情况也确实如此。对于拥有这种特征的数据,Timsort 的执行效率领先于软件工程中的所有其他算法。

直奔主题

该算法不涉及任何复杂的数据计算。事实是,Timsort 实际上不是一个独立的算法,而是一个由作者精心编排,由众多算法有效组合而成的混合算法。该算法的运行机制可以简述如下:

  1. 使用一个特定算法将输入数组拆分为多个子数组。
  2. 每个子数组都使用简单的插入排序算法进行排序。
  3. 排序后的子数组通过归并排序算法进行合并。
    与其他算法类似,设计的精妙之处往往隐藏于细节之中,也就是下面要讲的算法和对归并排序的修改。

算法

定义

  • N:输入数组的长度
  • run:输入数组中的一个有序子数组,并且满足非降序或严格降序,即: "a0≤a1≤a2≤…" 或 "a0> a1> a2>…"
  • minrun:run 的最小长度。它的值是根据某种逻辑从 N 计算出。
image

第一步 Minrun 的计算

Minrun的值是基于N得出的,且按照以下几个原则:

  1. 它不应该太大,因为子数组要进行插入排序,而插入排序对于短数组更有效。
  2. 它不应该太小,否则归并排序时合并的次数就变多。
  3. 比较理想的情况是 N/minrun 等于 2 的幂次方(或接近),此时归并排序的执行效率最高。
    在这里,作者引用了自己的实验,结果表明当 minrun > 256 时,不满足第一个原则,在 minrun < 8 时,不满足第二个原则,并当值在32到65之间时算法运行效率最高。这里有个例外:当 N < 64,则 minrun = N,Timsort 变成简单的归并排序。到这里可以发现 minrun 的计算算法非常简单:只要从N中取出六个最高有效位,然后再根据剩余的位中是否有1来决定要不要加1即可。代码大致如下所示:
int GetMinrun(int n)
{
    int r = 0;  /* becomes 1 if the least significant bits contain at least one off bit */
    while (n >= 64) {
        r |= n & 1;
        n >>= 1;
    }
    return n + r;
}

第二步 将数组分成 run 并排序

到目前为止,已经有了一个大小为 N 的输入数组和一个计算出来的 minrun 。该步骤的算法如下:

  1. 将输入数组的起始位置设为起始点。
  2. 在输入数组中搜索 run(即有序数组)。根据定义,该 run 肯定会包括当前元素和后续的元素,但包含多少个后续的元素则是随机的。如果该 run 是严格降序的,则会被转成非降序(只需经过简单的数组反转即可)。
  3. 如果当前 run 的大小小于 minrun ,则从后续元素中取 (minrun — size(run)) 个元素加入该 run。因此,一个run 最终的大小会大于或等于 minrun,其中有一部分子序列(理想情况下是全部)是有序的。
  4. 然后通过插入排序算法对这个部分有序的 run 进行排序,由于 run 的数量很小,因此算法运行速度快且性能高。
  5. 将该 run 的下一个元素所在位置设置起始点。
  6. 如果尚未到达输入数组的末尾,转到第2步,否则该步骤结束。

第三步 合并

到这个阶段,已经将一个输入数组分割成了多个 run。如果输入数组中的数据是随机的,则 run 的大小接近 minrun;如果数据是有序的(这也是该算法期望的情况),run 的大小将超过 minrun。现在,需要将这些 run 合并,并最终得到一个完全有序的数组,在此过程中,有两个条件必须被满足:

  1. 应该合并大小接近的 run(这样会更有效)。
  2. 算法的稳定性需要维持,即没有无用的调换(例如,两个相邻的相等的数字不应该被交换)。
    这可以通过以下方式实现:
  3. 创建一个空栈,存入第一个 run。
  4. 将当前 run 也存入栈中。
  5. 评估当前的 run 是否应与前一个 run 合并。为此,需要检查两个条件(X,Y,Z分别为栈顶的三个 run):
  • X > Y + Z
  • Y > Z
  1. 如果有一个条件不满足,则将数组 Y 与 X 和 Z 之间的最小者合并,然后继续执行检查,直到两个条件都满足或栈为空(即所有数据均完成排序)。
  2. 如果有满足上述两个条件的 run,转到步骤2,存入栈中,否则结束该步骤。
    这个过程的目的是保持平衡,例如,合并的操作将如下所示:
image

因此可以看出,按这种方式操作后的 run,它的大小就比较适合下一步的归并排序。想象一种理想的情况:一个组 run 的大小分别为 128, 64, 32, 16, 8, 4, 2 和 2(暂时忽略 minrun 的限制)。在这种情况下,前面的 run 都不会触发合并, 直到最后两个 run ,然后从最后两个开始将执行七次完全平衡的合并。

Run的合并

在算法的第三步中,我们将两个连续的 run 合并为一个有序的 run,这个合并的过程需要使用额外的内存。具体步骤如下:

  1. 创建一个临时数组(临时 run),大小取自待合并的 run 中的较小者。
  2. 将较小的 run 复制到临时 run(假设较小的 run 在栈底)。
  3. 将临时 run 和较大的 run 的第一个元素标记为起始位置。
  4. 比较两个起始位置的元素的大小,将较小者移到目标数组中,并将起始位置后移。
  5. 重复步骤4,直到其中一个数组的元素全部取完。
  6. 将未取完的 run 中剩余的所有元素添加到目标数组的末尾。
    注:如果较小者在栈顶,则元素的比较顺序将从右向左。
image

对归并排序的修改

在上述归并排序中,一切似乎都很完美。除了一种情况,想象一下这两个数组的合并:
A = {1,2,3,…,9999,10000}
B = { 20000,20001,….,29999,30000}
上述提到的步骤也适用这种情况,但是要经过10000次的比较和移动。Timsort也为此提供了一种修改版本, 称为"galloping"。
具体如下:

  1. 如上所述开始归并排序。
  2. 元素从临时或较大的 run 到目标数组的移动将被记录。
  3. 如果有许多(在该算法的表示中,该数字为7)的元素都是从同一个 run 中移出,则可以假定下一个元素也将来自相同的 run. 此时 galloping 模式会被打开,即在该 run 上运用二分查找去定位元素,用于数据对比。二分查找比线性搜索更有效,因此查找次数将会少得多。
  4. 最后,当该 run 中的元素不满足条件时(或达到 run 的尽头),则可以批量移动数据(比移动单个元素效率更高)。
    这个解释可能有点模糊,我们来看一下示例:
    A = {1,2,3,…,9999,10000}
    B = {20000,20001,….,29999,30000}
  5. 在前七次对比中,将 run A 中的元素 1,2,3,4,5,6 和 7 与元素 20000 进行比较,发现 20000 一直都是较大者, 所以它们被从 run A 移动到目标数组。
  6. 从下一次比较开始,galloping 模式会被打开:元素 20000 将与 run A 中的的元素 8,10,14,22,38,n+2^i,…,10000 进行比较。可以看到,这样比较的次数将远远少于10000。
  7. 当 run A 为空时,就知道它的所有元素都比 run B 中的小(当然也有可能在 run A 不为空的时候停止)。 run A 中的数据将被移动到目标数组。

到此算法就结束了。

你可能感兴趣的:(排序算法之Timesort: 最好的排序算法之一)