1

前言

首先分享下最近面试某鹅厂时遇到的一道题:

// 输入正整数n
// 输出一个最大值为n的无重复且乱序的正整数数组
generateShuffledArray(5)

      ↓ ↓ ↓ ↓ ↓ ↓

[1, 3, 5, 4, 2] or [2, 1, 4, 5, 3]等等

这种问题自然难不倒我😏,一行代码就搞定了

function generateShuffledArray(n) {
  return Array.from({ length: n }, (v, i) => i + 1).sort(() => Math.random() - 0.5);
};

image.png

当时觉得自己写的还挺优雅的,后来在某次机缘巧合下看到篇文章,大致意思是通过这种 Math.random 写法的乱序方式是不稳定的,于是我自己尝试统计了下:

为了方便结果展示,这里使用数字3随机10000次演示
let result = {};
for (let i = 0; i < 1e4; i++) {
  const key = generateShuffledArray(3);
  result[key] ? result[key]++ : result[key] = 1;
}
console.table(result);

image.png

通过结果可以看到,确实有较大的概率会使上面写到的重新排序方法失效,大佬们诚不欺我。
查看 ChromeV8源码 得知,sort 内部使用了一种插入排序和快速排序的混合排序,当数组长度小于等于10时,使用插入排序就会得到上图中的结果(后续具体讲到插入排序再分析why)。

排序算法

由于前端对排序算法的要求不是很高,很多排序场景从设计上来说也不应该由前端实现,所以本文只举例最常见的冒泡排序还有 V8 中用到的插入排序和快速排序这三种。

所有排序过程图片均来自网络,若有侵权请评论联系删除

冒泡排序

思想

相邻数据两两比较,把较大的数据放到后面,就好像是气泡慢慢的浮出了水面一样。

图解

bubble

实现

function bubbleSort(arr) {
  const len = arr.length;
  for (let i = 0; i < len - 1; i++) {
    for (let j = 0; j < len - 1 - i; j++) {
      if (arr[j] > arr[j+1]) [arr[j], arr[j+1]] = [arr[j+1], arr[j]];
    }
  }
  return arr;
};

插入排序

从第二个数据开始依次插入到之前的有序数组中,就像打扑克一样将每次拿到的牌插入到正确的位置。

图解

insert

实现

function insertionSort(arr) {
  for (let i = 1; i < arr.length; i++) {
    const target = arr[i];
    let j = i - 1;
    for (; j >= 0; j--) {
      if (target < arr[j]) arr[j+1] = arr[j];
      else break;
    }
    arr[j+1] = target;
  }
  return arr;
};

为何在 Chrome 中倾向于使用插入排序而不是冒泡排序?它们的时间复杂度都是O(n²),而且也都是稳定的。
因为插入排序是拿数据和有序数组对比,一旦满足条件不再继续比较,且冒泡排序每次都需要中间变量去交换数据。

快速排序

思想

任意选取一个数据(示图中选用数组的第一个数,代码中选用数组的中间数)作为关键数据,然后将所有比它小的数都放到它前面,所有比它大的数都放到它后面,之后再递归排序两边的数据。

图解

quick

实现

function quickSort(arr) {
  if (arr.length < 2) return arr;
  const pivotIndex = arr.length >> 1;
  const pivot = arr.splice(pivotIndex, 1)[0];
  const left = [];
  const right = [];
  for (const item of arr) {
    if (item < pivot) left.push(item);
    else right.push(item);
  }
  return [...quickSort(left), pivot, ...quickSort(right)];
};

如果仔细看过上述源码的同学会发现,在 V8 中为了让算法时间复杂度更贴近O(nlogn),选取关键数据时多做了一步,将头、中、尾三个数先比较取得中位数,使用这个中位数作为关键数据。
因为快速排序的最糟情况就是每次都将最大值或最小值作为关键数据,这样的话使得递归调用栈被拉到最长的O(n)。

对比

排序算法时间复杂度空间复杂度稳定性
冒泡排序O(n²)O(1)稳定
插入排序O(n²)O(1)稳定
快速排序O(nlogn)O(logn)不稳定

结论

看懂了上述的插入排序算法后,最初的问题也能迎刃而解了,最后一个数据在往前插入时有50%的概率是不动的,这也验证了截图里的结果(数字3在最后一位的概率高达50%)。

graph TD
    A[1, 2, 3] -->|50%| B(1, 2, 3)
    A[1, 2, 3] -->|50%| C(2, 1, 3)
    B[1, 2, 3] -->|50%| D(1, 2, 3)
    B[1, 2, 3] -->|50%| E(1, 3, 2)
    C[2, 1, 3] -->|50%| F(2, 1, 3)
    C[2, 1, 3] -->|50%| G(2, 3, 1)
    D[1, 2, 3] -->|50%| H(1, 2, 3)
    D[1, 2, 3] -->|50%| I(2, 1, 3)
    E[1, 3, 2] -->|50%| J(1, 3, 2)
    E[1, 3, 2] -->|50%| K(3, 1, 2)
    F[2, 1, 3] -->|50%| L(2, 1, 3)
    F[2, 1, 3] -->|50%| M(1, 2, 3)
    G[2, 3, 1] -->|50%| N(2, 3, 1)
    G[2, 3, 1] -->|50%| O(3, 2, 1)

这里的推论与截图中实际结果有些出入,是因为 Chrome70+版本 对排序算法有过改进,使用了二分插入排序
二分插入排序和插入排序的区别是在插入到之前的有序数组中时,并不是向前一个个的比较,而是先用二分法查找到第一个大于当前数据的目标index,再进行移位,理解上有点像快速排序和插入排序的结合。

引申

一般情况下用最初的写法就糊弄过去了,不会细究也不一定能意识到这些小细节,如果想实现真正的 shuffle 算法其实也很简单,直接贴代码吧:

function generateShuffledArray(n) {
  const arr = Array.from({ length: n }, (v, i) => i + 1);
  const _arr = [];
  while (arr.length) {
    _arr.push(arr.splice(arr.length * Math.random(), 1)[0]);
  }
  return _arr;
};

image.png


小皇帝James
600 声望7 粉丝

IT吴彦祖