为什么v8引擎的sort排序函数会如此高效

1,671 阅读4分钟

排序算法介绍

关于排序应该大家都不陌生,在我们平时写我们的业务代码时,会经常有对数据排序的需求,那我们前端所使用的排序函数就是js引擎所提供的sort函数,它可以直接调用,也可以通过传一个函数入参,来提供待排序数据的先后顺序依据。

但是js引擎所实现的排序和我们知道的那些经典的排序算法有什么不同呢,先给大家简单罗列一些排序算法。

ps:这里不详细介绍各个排序算法的思想

排序算法时间复杂度空间复杂度原地排序稳定排序
冒泡排序O(n*2)O(1)
插入排序O(n*2)O(1)
选择排序O(n*2)O(1)
归并排序O(nlogn)O(n)
快速排序O(nlogn)O(1)
桶排序O(n)O(n)

所谓原地排序就是在排序的时候,只需要常量级的额外内存空间,即空间复杂度为O(1);而稳定排序是指,如果待排序的数据列表中,存在相等的数据,排序完成后,这两个相等的数据项,它们的前后位置不发生改变,即为稳定。

因为我们实际要排序的数据可能不单纯只是数字,比如要排序订单数据,这些订单数据已经按照日期排好序了,现在我们要按照金额再排序一次,但是排序后金额相同的数据仍要按照之前的日期排,这就需要我们的算法的是稳定排序了。

sort函数

在分析sort函数之前,先简单介绍了一些排序算法,可以看出,时间复杂度最低的为桶排序,但这个算法对数据的要求比较苛刻,所以不能做到通用。

而快速排序和归并排序的时间复杂度要比冒泡、插入、选择排序的时间复杂度低,那理论上应该优先使用这两种算法,来实现我们的sort函数,但事实并不是这样,先说一下结论,v8引擎实现的sort函数,是由插入排序快速排序,(不同引擎实现sort会有所不同)这两种排序来工作的,这也是它高效的原因所在。

sort函数源码解读

作为一个工业级的sort函数,里面会有相当多的细节,及边界处理,所以这里我只对该函数的实际的核心功能做一个解读。

先看一下sort函数的大概内容

function InnerArraySort(array, length, comparefn) {
    // In-place QuickSort algorithm.
    // For short (length <= 22) arrays, insertion sort is used for efficiency.

    if (!IS_CALLABLE(comparefn)) {
      comparefn = function (x, y) {
        if (x === y) return 0
        if (%_IsSmi(x) && %_IsSmi(y)) {
          return %SmiLexicographicCompare(x, y)
        }
        x = TO_STRING(x)
        y = TO_STRING(y)
        if (x == y) return 0
        else return x < y ? -1 : 1
      }
    }

    // 插入排序
    var InsertionSort = function InsertionSort(a, from, to){}

    // 取分区点下标
    var GetThirdIndex = function (a, from, to) {}

    // 快排
    var QuickSort = function QuickSort(a, from, to) {}

    if (length < 2) return array

    QuickSort(array, 0, length)

    return array
  }

1. 判断是否有指定顺序排序的函数,也就是sort函数的入参

if (!IS_CALLABLE(comparefn)) {
      comparefn = function (x, y) {
        if (x === y) return 0
        if (%_IsSmi(x) && %_IsSmi(y)) {
          return %SmiLexicographicCompare(x, y)
        }
        x = TO_STRING(x)
        y = TO_STRING(y)
        if (x == y) return 0
        else return x < y ? -1 : 1
      }
    }

调用sort函数时,内部会先判断一下有没有入参,如果没有传入自定义的comparefn,则函数内部会默认初始化一个,它的功能是先将传入数组中的每项转成字符串,然后取出其诸个字符,比较该字符的unicode码,第一项的比第二项的小,则返回-1,相等返回0,否则返回1,按照此规则对整个数据项进行排序。

所以使用sort函数时,会出现一些匪夷所思的奇怪结果。比如下面这个:

var nums = [10,2,6,1,9]

nums.sort()

按照我们的常规想法,结果应该返回的是:

nums.sort()

// 理想结果:

[1,2,6,9,10]

// 实际返回:

[1,10,2,6,9]

这里实际是先将里面的数据toString, 然后获取出每项字符串的首个字符的unicode码,比较码点大小,如果首字符unicode相等的话,再比较这两个字符串的第二个字符,直到得出大小结果。

比如,'10'的首个字符为'1','1'的unicode为49,这个值要小于其它数据项的unicode,因此除了另一个项1,10要排在其它的几个项前面。

然后'10'与'1'比较,首个字符unicode相等,然后比第二个字符,'1'没有第二个字符,而'10'第二个字符为'0','0'的unicode为48,所有1排在10前。

['10','2','6','1','9'] 首字符对应码点 [49,50,54,49,57]

2. 判断待排序数组的长度,小于2的话直接返回,退出函数

if (length < 2) return array

3. 数组长度大于1,开始执行QuickSort

// 快排
    var QuickSort = function QuickSort(a, from, to) {
      while (true) {
        // Insertion sort is faster for short arrays.
        if (to - from <= 10) {
          InsertionSort(a, from, to)
          return
        }
        ....
       }
     }

当执行QuickSort时,会先判断待排序数组的长度,如果长度小于10,则调用InsertionSort来完成排序

  // 插入排序
    var InsertionSort = function InsertionSort(a, from, to) {
     // 分区,已排序,待排序
     
     // 遍历未排序区间
      for (var i = from + 1; i < to; i++) {
        var element = a[i]
        // 从后往前遍历已排序区间,找到要插入的位置
        for (var j = i - 1; j >= from; j--) {
          var tmp = a[j]
          // 调用comparenfn判断两个数组先后顺序
          var order = comparefn(tmp, element)
          if (order > 0) {
            a[j + 1] = tmp
          } else {
            break
          }
        }
        a[j + 1] = element
      }
    }

InsertionSort函数就是一个标准的插入排序算法了,通过分区(已排序和未排序区间),遍历未排序区间,每次从未排序区间头部取一个数据,然后将它插入到已排序区间的合适位置,直到未排序区间为空,实现数据的整体排序,时间复杂度为O(n*2)。

4. 如果待排序数组长度大于10,使用快速排序

快排是利用分治的算法思想,然后使用递归来实现,比如升序,通过取一个随机的数据项作为分区点,然后将数组分成三部分,小于这个分区点的数据,放在分区点左侧,大于分区点的数据放在右侧。然后递归的遍历左右两个序列,完成排序。

总结

sort函数使用了两种排序算法(插入排序、快速排序),虽然插入排序的时间复杂度O(n*2)要远大于快排的0(nlogn),但是时间复杂度代表的只是执行代码所消耗的时间随着数据规模增长的变化趋势。

如果只是小规模的数据,O(n*2)所消耗的时间不一定要比O(nlogn)多,因此像插入、冒泡这些排序算法可以用在小规模的数据中,而大规模数据则是使用归并或者快排。

所以sort函数高效的地方,就在于,处理不同规模的数据选择不同的合适的排序算法