blog icon indicating copy to clipboard operation
blog copied to clipboard

JavaScript数组乱序

Open mengxiong10 opened this issue 7 years ago • 0 comments

关于JavaScript数组乱序

Array.prototype.sort

常见的一个写法是利用Array.prototype.sort

function shuffle1 (arr) {
  return arr.concat().sort(function () {
    return Math.random() - 0.5
  })
}

问题

这个排序算法并不是完全随机的.如果是完全随机的那么数组[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]中的元素出现在每个位置的概率是一样的. 下面测试任意一个元素在多次测试后在各个位置的概率.

const test1 = Array.from({ length: 10 }).map((v, i) => i)
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const test2 = Array.from({ length: 20 }).map((v, i) => i)
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]

function stat (arr, fn) {
  const count = arr.slice().fill(0)
  const len = 100000
  const key = arr[Math.random() * arr.length | 0]
  for (let i = 0; i < len; i++) {
    const shuffleArray = fn(arr.slice())
    count[shuffleArray.indexOf(key)]++
  }
  count.forEach((v, i) => {
    count[i] = (v / len * 100).toFixed(2) + '%'
  })
  console.log(`${key}在${len}次测试中出现在各个位置的概率:\n`, JSON.stringify(count))
}

stat(test1, shuffle1)
stat(test2, shuffle1)

// 元素8在100000次测试中出现在各个位置的概率:
// ['0.40%', '0.40%', '0.80%', '1.53%', '3.07%', '5.92%', '11.44%', '20.20%', '31.14%', '25.09%']
// 元素10在100000次测试中出现在各个位置的概率:
// ["11.40%","11.21%","7.76%","4.72%","3.26%","3.02%","3.20%","3.86%","5.00%","6.30%","7.20%","5.18%","3.58%","2.19%","1.58%","1.33%","1.50%","2.63%","5.04%","10.06%"]

如上看到结果并不随机, test1元素保留在自身位置概率大, test2概率也很不平均. ECMAScript中关于Array.prototype.sort(comparefn)的标准,其中并没有规定具体的实现算法. 而v8引擎对于数组长度小于等于10使用的是插入排序, 长数组是快速排序.

/**
 * 比较函数
 * @param {*} a 
 * @param {*} b 
 */
const compare = function (a, b) {
  return Math.random() - 0.5
}

/**
 * 插入排序
 * @param {Array} arr
 */
const insertion = function (arr) {
  const len = arr.length
  for (let i = 1; i < len; i++) {
    const key = arr[i]
    let j
    for (j = i - 1; j >= 0 && compare(arr[j], key) > 0; j--) {
      arr[j + 1] = arr[j]
    }
    arr[j + 1] = key
  }
  return arr
}

/**
 * 快速排序
 * @param {*} arr 
 */
const quickSort = function (arr) {
  const exch = function (arr, i, j) {
    [arr[i], arr[j]] = [arr[j], arr[i]]
  }
  const partition = function (arr, start, end) {
    const key = arr[start]
    let i = start
    let j = end + 1
    while (true) {
      while (compare(arr[++i], key) < 0) {
        if (i === end) {
          break
        }
      }
      while (compare(key, arr[--j]) < 0) {}
      if (i >= j) {
        break
      }
      exch(arr, i, j)
    }
    exch(arr, start, j)
    return j
  }
  const sort = function (arr, start, end) {
    if (end - start <= 0) {
      return
    }
    const j = partition(arr, start, end)
    sort(arr, start, j - 1)
    sort(arr, j + 1, end)
  }
  const len = arr.length
  sort(arr, 0, len - 1)
  return arr
}

如上插入排序越后面的元素,交换到越前的位置的概率越小, 所以元素分布在原地的概率大. 快速排序因为在每次的迭代中,它至少会把一个元素摆到它最后的位置去.第一次迭代的基准,由于每个元素放在它左边和右边的概率是50%, 所以它的位置概率应该是靠近数组中间高,显然也不能完全随机.

Fisher–Yates Shuffle

正确解法应该是 Fisher–Yates Shuffle,复杂度 O(n)

/**
 * Fisher-Yates shuffle
 * @param {Array} array
 */
function shuffle2 (array) {
  const arr = [].concat(array)
  const len = arr.length
  for (let i = len - 1; i > 0; i--) {
    let j = (i + 1) * Math.random() | 0;
    [arr[i], arr[j]] = [arr[j], arr[i]]
  }
  return arr
}

每次从数组中随机选一个元素出来放到新数组, 循环完成的新数组就是完全随机的,如上Fisher–Yates Shuffle 只是每次随机的元素交换到数组最后面,这样就不用新建一个额外的数组. 测试:

stat(test1, shuffle2)
stat(test2, shuffle2)
// 元素1在100000次测试中出现在各个位置的概率:
// ["10.04%","9.99%","10.01%","9.91%","9.93%","10.09%","10.04%","9.94%","10.05%","10.01%"]
// 元素16在100000次测试中出现在各个位置的概率:
// ["5.11%","4.99%","4.96%","4.88%","4.89%","5.02%","5.07%","4.99%","5.07%","4.99%","4.96%","4.96%","5.03%","5.00%","5.10%","4.96%","5.05%","4.95%","4.99%","5.03%"]

随机性的数学归纳法证明 引用

对 n 个数进行随机:

  1. 首先考虑 n = 2 的情况,根据算法,显然有 1/2 的概率两个数交换,有 1/2 的概率两个数不交换,因此对 n = 2 的情况,元素出现在每个位置的概率都是 1/2,满足随机性要求。
  2. 假设有 i 个数, i >= 2 时,算法随机性符合要求,即每个数出现在 i 个位置上每个位置的概率都是 1/i。
  3. 对于 i + 1 个数,按照我们的算法,在第一次循环时,每个数都有 1/(i+1) 的概率被交换到最末尾,所以每个元素出现在最末一位的概率都是 1/(i+1) 。而每个数也都有 i/(i+1) 的概率不被交换到最末尾,如果不被交换,从第二次循环开始还原成 i 个数随机,根据 2. 的假设,它们出现在 i 个位置的概率是 1/i。因此每个数出现在前 i 位任意一位的概率是 (i/(i+1)) * (1/i) = 1/(i+1),也是 1/(i+1)。
  4. 综合 1. 2. 3. 得出,对于任意 n >= 2,经过这个算法,每个元素出现在 n 个位置任意一个位置的概率都是 1/n。

mengxiong10 avatar Mar 12 '18 12:03 mengxiong10