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

零、简述:

最近在工作中,发现一个排序错乱的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实现

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,490评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,581评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,830评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,957评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,974评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,754评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,464评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,357评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,847评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,995评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,137评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,819评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,482评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,023评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,149评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,409评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,086评论 2 355

推荐阅读更多精彩内容