美文网首页
一个Bug引发的V8排序算法学习总结

一个Bug引发的V8排序算法学习总结

作者: 邱鹏城 | 来源:发表于2020-04-23 10:04 被阅读0次

    零、简述:

    最近在工作中,发现一个排序错乱的bug,经排查发现是node不同版本下,sort表现不一致导致。为了一探究竟,打开v8的sort源码摸索了半天、结合网上搜索到的文章终于整明白了,此文做个简单总结。
    文中会附上自己用以解决原生不稳定排序问题的方案

    一、发现问题:

    const list = new Array(20).fill(1).map((n, idx) => ({sort: 0, idx: idx}));
    
    list.sort((p, n) => p.sort - n.sort);
    
    console.log(list);
    
    // node 10 输出: [ 10, 0, 2, 3, 4, 5, 6, 7, 8, 9, 1, 11, 12, 13, 14, 15, 16, 17, 18, 19 ]
    
    // node 12 输出:[ 0,  1,  2,  3,  4,  5,  6, 7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ]
    

    可以看到,node 10 输出了一个出乎意料的结果;

    二、问题分析:

    因为sort方法是v8实现的,所以第一反应就是v8的排序算法实现导致这个现象。

    三、查看对应版本的v8源码

    node 10版本的v8

    通过 node -p process.versions.v8 可以查看v8的版本为:6.8.275.32-node.51

    找到v8中Array.prototype.sort对应的代码,查看对应代码

    这里不再展示代码详情,只对排序算法作说明:

    • 首先,入口算法对长度小于10的,直接采用插入排序(原地执行);
    • 当长度大于10的时候,则采用快排,而主元的选择遵循以下规则:当数组长度大于1000,则选择第 200 - 225 中随机一位以及首、尾3个数,对这三个数原地排序后(比如选出 1, 4, 2,则排序成 1, 2, 4),选择中间的数(2)作为主元进行快排,数组长度小于1000时,则直接选择中间的数(去尾数取底,类似于 Math.floor)作为主元
    • 当快排时切分的数组长度小于10的,则直接使用插入排序

    接下来分析代码,看下为什么会出现如示例中的情况(下方代码省略了部分)

    function QuickSort(a, from, to) {
        var third_index = 0;
        while (true) {
          // Insertion sort is faster for short arrays.
          if (to - from <= 10) {
            InsertionSort(a, from, to);
            return;
          }
          // 第一次进来时,to - from = 19
          if (to - from > 1000) {
            third_index = GetThirdIndex(a, from, to);
          } else {
            // 第一次取值为9
            third_index = from + ((to - from) >> 1);
          }
          // Find a pivot as the median of first, last and middle element.
          var v0 = a[from];
          var v1 = a[to - 1];
          var v2 = a[third_index];
          
          // 对比取出的3个数,进行排序
          // 因为comparefn返回永远是0,所以c01=0
          var c01 = comparefn(v0, v1);
          if (c01 > 0) {
            // v1 < v0, so swap them.
            var tmp = v0;
            v0 = v1;
            v1 = tmp;
          } // v0 <= v1.
          var c02 = comparefn(v0, v2);
          // c02=0,所以交换了位置
          if (c02 >= 0) {
            // v2 <= v0 <= v1.
            var tmp = v0;
            v0 = v2;
            v2 = v1;
            v1 = tmp;
          } else {
            // v0 <= v1 && v0 < v2
            var c12 = comparefn(v1, v2);
            if (c12 > 0) {
              // v0 <= v2 < v1
              var tmp = v1;
              v1 = v2;
              v2 = tmp;
            }
          }
          // v0 <= v1 <= v2
          a[from] = v0;
          a[to - 1] = v2;
          var pivot = v1;
          var low_end = from + 1;   // Upper bound of elements lower than pivot.
          var high_start = to - 1;  // Lower bound of elements greater than pivot.
          a[third_index] = a[low_end];
          a[low_end] = pivot;
          // .....more code
        }
    }
    
    function GetThirdIndex(a, from, to) {
        var t_array = new InternalArray();
        // Use both 'from' and 'to' to determine the pivot candidates.
        var increment = 200 + ((to - from) & 15);
        var j = 0;
        from += 1;
        to -= 1;
        for (var i = from; i < to; i += increment) {
          t_array[j] = [i, a[i]];
          j++;
        }
        t_array.sort(function(a, b) {
          return comparefn(a[1], b[1]);
        });
        var third_index = t_array[t_array.length >> 1][0];
        return third_index;
    }
    

    分析上面代码,可以发现示例中,位置错乱的原因发生在快排进行3数排序时,那么可以得出如下结论:

    • 当待排序数组中,数组长度超过10个,并且存在排序优先级相同的多个元素,就可能出现排序不稳定的现象。
    • 具体现象表现在排序后,打乱了原有数组的序,并且对用户来说相对不可预测。

    而v8开发者针对这个现象的回复,则是:因为ES标准中,当sort方法的compare返回为0时,没有严格规定是否交换元素位置,而v8为了最大化排序的性能,使用的插入排序和变种快排的组合(时间复杂度:O(nlogn)、空间复杂度O(1))的不稳定排序,导致了这个问题,在v8的issue 可以看到在这个问题的讨论过程。

    那么对于应用开发者,我们则关注于怎么解决应用上的问题,当应用中已经大量使用了Array.prototype.sort方法的情况下,怎么快速修复?

    下面提供一种通过覆盖原生sort方法修复的思路,有以下特点:

    • 排序仍使用原生sort排序;
    • 原生sort完之后,再将乱序的数组项按原数组的顺序排列,从而消除不稳定的问题;
    • 优点:无需修改业务代码、时间复杂度与原来保持一致;
    • 缺点:因引入一个保存索引的map,所以空间复杂度上升至O(n);
    const v8sort = Array.prototype.sort;
    Array.prototype.sort = function(...args) {
        if (this.length <2) {
            return this;
        }
    
        const idxMap = this.reduce((acc, prev, idx) => {
            acc.set(prev, idx);
            return acc;
        }, new Map());
    
        v8sort.apply(this, args);
    
        // 找出所有连续的等值子序列的起止位置
        const subArray = [];
        const [compareFn] = args;
        let before = this[0];
        let current;
        let start = 0;
        let end = 0;
        for (let i = 1; i<this.length; i++) {
            current = this[i];
            if (compareFn(before, current) === 0) {
                end += 1; 
            } else {
                if (end > start) {
                    subArray.push([start, end]);
                }
                start = i;
                end = i;
            }
            before = current;
        }
        if (end > start) {
            subArray.push([start, end]);
        }
    
        const swap = (arr, a, b) => {
            [arr[a], arr[b]] = [arr[b], arr[a]];
        }
        // 对等值子序列,根据原数组序进行插入排序
        for (const [subStart, subEnd] of subArray) {
            let j;
            for (let i = subStart + 1; i <= subEnd; i++) {
                j = i;
                while (j > subStart && (idxMap.get(this[j]) <= idxMap.get(this[j-1]))) {
                    swap(this, j, j - 1);
                    j--;
                }
            }
        }
    }
    

    node 12版本的v8

    通过 node -p process.versions.v8 可以查看v8的版本为:7.8.279.23-node.31

    该版本v8使用 Torque 的语言替代原来使用js实现的部分功能:
    源码路径 /third_party/v8/builtins/array-sort.tq

    新版本sort使用了稳定的TimSort排序算法,实现较为复杂,可以参考下面这张文章TimeSort实现

    相关文章

      网友评论

          本文标题:一个Bug引发的V8排序算法学习总结

          本文链接:https://www.haomeiwen.com/subject/yehoihtx.html