Skip to the content.

你的数组乱序够乱了吗?

前言

数组乱序很容易?

给定一个数组,例如[1,2,3,4],如何实现乱序排列? 我看到过一个利用 sort 方法实现的方案:arr.sort(() => Math.random() - 0.5), 第一眼看过去,不禁感叹: 妙啊~ 一行代码轻松实现! 这个示例来自JavaScript 专题之乱序

其实这样得到的乱序排列还是不够乱的,所谓的不够乱说的是每一个数出现在每一个位置上的几率不是均等的,后面会讲到。

其实当我第一眼看到这个实现方式的时候,还会有一个疑问,sort 参数怎么还是个变化的值,难道每次比较都会根据参数变化一回排序规则?

带着这个疑问我重新阅读了一下 MDN关于 sort 的 api 讲解。

sort

sort()   方法用原地算法对数组的元素进行排序,并返回数组。默认排序顺序是在将元素转换为字符串,然后比较它们的 UTF-16 代码单元值序列时构建的

由于它取决于具体实现,因此无法保证排序的时间和空间复杂性。

arr.sort([compareFunction])
-   `firstEl`

    第一个用于比较的元素。

-   `secondEl`

    第二个用于比较的元素。

从中我至少获得了三条非常有价值的信息:

  1. 不同的浏览器厂商对 sort 的实现算法不尽相同,因为 ECMA 只对效果作了说明,并没有限制具体的实现方式。

  2. sortcompareFunction 函数接收两个参数firstElsecondEl, 通常我们排序数字类型的数组的做法是arr.sort((a, b) => a - b), 表示正序从小到大。 所以相当于桢哥排序过程是两两数字比较的,我们可以通过动态切换排序规则(正序、逆序)来达到乱序的目的。

验证够不够乱

上文也说到了,这样的乱序可能并不能达到理想的效果。 我们不妨写个方法验证下。

function randomArr(arr) {
  arr.sort(() => Math.random() - 0.5);
  return arr;
}

const TestCount = func => {
  const times = 10000000;
  const count = new Array(4).fill(0).map(() => new Array(4).fill(0));
  for (let i = 0; i < times; i++) {
    func.call(null, [0, 1, 2, 3]).forEach((n, i) => count[n][i]++);
  }
  console.table(count.map(n => n.map(n => ((n / times) * 100).toFixed(2) + "%")));
};

TestCount(randomArr);

当前执行环境:google chrome 版本 98.0.4758.80(正式版本) (x86_64)

image.png

可见,这样的乱序方法并不能保证每个数字在各个位置上的概率平均。 较高的概率出现在对角线上。说明出现在原位置上的概率更高。

是什么导致数字出现在原位置的概率更高呢?? 我们来一探究竟。

插入排序

如果要追究这个问题所在,就必须了解 sort 函数的原理,然而 ECMAScript 只规定了效果,没有规定实现的方式,所以不同浏览器实现的方式还不一样。

为了解决这个问题,我们以 v8 为例,v8 在处理 sort 方法时,当目标数组长度小于 10 时,使用插入排序;反之,使用快速排序和插入排序的混合排序。

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];
      var order = comparefn(tmp, element);
      if (order > 0) {
        a[j + 1] = tmp;
      } else {
        break;
      }
    }
    a[j + 1] = element;
  }
}

我们假设数组为[1,2,3]来逐层分析整个过程。

注意此时 sort 函数底层是使用插入排序实现,InsertionSort 函数的 from 的值为 0,to 的值为 3。

插入排序认为第一个元素是有序的,那么 i 从 1 开始。

  1. i=1,此时comparefn(1,2), 有 50%的概率 2 和 3 不变位置,返回[1,2,3],有 50%的概率返回[2,1,3]
  2. i = 2,我们再进行一次分析,a[i] 的值为 3 ,此时内层循环比较 comparefn(2,3),有 50%的概率顺序不变,返回 [1,2,3], 比较结束。 有 50%的概率变成 [1,3,2], 此时 3 还没有找到正确的位置,j--, 开始比较 comparefn(1,3), 又会有 50%概率返回[1,3,2][3,1,2]

所以综合可得到下面的表格

数组 i = 1 i = 2 总计    
[1, 2, 3] 50% [1, 2, 3] 50% [1, 2, 3] 25% [1, 2, 3]    
    25% [1, 3, 2] 12.5% [1, 3, 2]    
    25% [3, 1, 2] 12.5% [3, 1, 2]    
  50% [2, 1, 3] 50% [2, 1, 3] 25% [2, 1, 3]    
    25% [2, 3, 1] 12.5% [2, 3, 1]    
    25% [3, 2, 1] 12.5% [3, 2, 1]    

实现更乱的乱序

Fisher–Yates

遍历数组,随机取出一个数与其交换位置。

function shuffle(a) {
  for (let i = a.length - 1; i; i--) {
    let j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

我的方法

我的最初想法就是,从数组里面随机取出一位塞入新的数组,返回组成的新数组。

我们写个例子测试下这中方式:

const n = 100000;
// 计算每个位置出现的次数
const count = new Array(5).fill(0);
for (let i = 0; i < n; i++) {
  const arr = [1, 2, 3, 4, 5];
  const newArr = [];
  while (arr.length) {
    newArr.push(arr.splice(~~(Math.random() * arr.length), 1)[0]);
  }
  count[newArr.indexOf(1)]++;
}
console.log("1在每个位置出现的次数:", count);

image.png

可见,1 出现在各个位置上的次数基本持平,所以用这种方式也是可以的。

总结

也许你还有更好的实现数组乱序的方式,不妨在评论区留下你的答案。

点个 ⭐️ 支持一下,一起交流成长呀。

返回首页