洗牌算法 | 青训营笔记

303 阅读3分钟

洗牌算法 | 青训营笔记

这是我参与「第四届青训营」笔记创作活动的的第六天。记录一下洗牌算法。其实这是青训营第五天课上讲的内容,当时觉得这个内容很简单,没必要写笔记记录一下。但是睡了一觉起来后发现,昨天课上讲的知识点已经开始遗忘了。果然好记性还是不如烂笔头。

要写一个洗牌的算法,有很多种写法。下面鄙人记录了三种方法,并分别进行分析。

例子一,使用sort函数

具体代码如下,使用sort函数
sort方法可以传递一个参数 compareFunction,该参数用来指定按某种顺序进行排列的函数

  • 如果 compareFunction(a, b) 小于 0 ,那么 a 会被排列到 b 之前;
  • 如果 compareFunction(a, b) 等于 0 ,a 和 b 的相对位置不变。
  • 如果 compareFunction(a, b) 大于 0 , b 会被排列到 a 之前。
  • compareFunction(a, b) 必须总是对相同的输入返回相同的比较结果,否则排序的结果将是不确定的 利用上述原理,使用Math.random生成一个随机数与0.5进行比较来决定返回-1或1。
function shuffle(signArray){
    return [...signArray].sort(() => 
        Math.random() > 0.5 ? -1 : 1
    );
}

该方法输入得到牌的顺序为
[3,4,5,6,7,8,9,10,11,12,13,14,15]
洗牌后得到的结果为
[14, 13, 6, 4, 10, 3,5, 8, 12, 15, 11, 7,9]
乍一看,确实完成了洗牌这一需求。那么这个方法到底有没有保证洗牌的公平性呢?下面让我们来验证一下:

function shuffle(signArray){
    return [...signArray].sort(() => 
        Math.random() > 0.5 ? -1 : 1
    );
}

let sign = [3,4,5,6,7,8,9,10,11,12,13,14,15]


//验证
const result = Array(sign.length).fill(0);

for(let i = 0; i < 1000000; i++) {
  const c = shuffle(sign);
  for(let j = 0; j < sign.length; j++) {
    result[j] += c[j];
  }
}

let tempObject = {}
for(let i in result){
    tempObject[sign[i]] = result[i]
}
console.table(tempObject)

我们进行1000000次洗牌,将每一次洗牌得到的结果按数组下标相加,如果这个算法足够公平的话,那么结果数组0-13下标上的求和结果应该是相差无几的。

image.png

但是结果却并非我们所设想的那样。不同下标上的结果值相差甚远。由此可见,这个算法并非是完全公平的洗牌算法。那么是为什么会不完全公平呢?因为sort排序方法是从左往右开始的,而这就会导致小的数字更加容易被放到左边,而大的数字更加容易被放到右边。

例子二,公平的洗牌

我们先看代码

function shuffle(signArray){
    const tempArray = [...signArray];
    for(let i = tempArray.length; i > 0; i--) {
        let idx = Math.floor(Math.random() * i);
        [tempArray[idx], tempArray[i - 1]] = [tempArray[i - 1], tempArray[idx]];
    }
    return tempArray;
}

这段代码的思路我们用几张图来阐述一下

image.png

image.png

image.png

红框不断缩小,蓝框不断变大。红框中的牌为待洗牌,蓝框中的牌为已洗牌。这样就能保证每一张牌被随机的概率都是一样的,也就能保证公平了。

我们使用验证方法来验证。

//公平的洗牌
function shuffle(signArray){
    const tempArray = [...signArray];
    for(let i = tempArray.length; i > 0; i--) {
        let idx = Math.floor(Math.random() * i);
        [tempArray[idx], tempArray[i - 1]] = [tempArray[i - 1], tempArray[idx]];
    }
    return tempArray;
}

let sign = [3,4,5,6,7,8,9,10,11,12,13,14,15]




//验证
const result = Array(sign.length).fill(0);

for(let i = 0; i < 1000000; i++) {
  const c = shuffle(sign);
  for(let j = 0; j < sign.length; j++) {
    result[j] += c[j];
  }
}

console.table(result)

image.png

可以看到,每个位置出现的牌的值相加后,值都是相差无几,说明该算法是公平的洗牌。
我们也可以用数学归纳法来证明。当有一张牌时,牌被抽取的概率为11\frac{1}{1},有两张牌时,某一张牌被抽取的概率为12\frac{1}{2},以此类推,有N张牌时,某一张牌被抽取的概率为1N\frac{1}{N}。这样每一张牌在每一次抽取时被抽取的概率都是一样的,洗牌实际上就是多次抽牌而已,所以洗牌也能保证公平了。

例子三,使用生成器来实现

话不多说,直接上代码。

//公平洗牌优化版
function * shuffle(signArray){
    const tempArray = [...signArray]
    for(let i = tempArray.length; i > 0; i--) {
        let idx = Math.floor(Math.random() * i);
        yield tempArray[idx]
        tempArray.splice(idx,1)
    }
}

利用yield 将抽中的牌返回,并将抽中的牌从待抽牌中删除。验证后,同样是公平的。

image.png

当然也可以将不使用生成器,只是需要多开辟一个数组空间,将每次抽取的牌添加另一个数组中,然后从本数组中删除。

//开辟另一个数组的公平洗牌
function shuffle(signArray){
    const tempArray = [...signArray]
    let arr = []
    for(let i = tempArray.length; i > 0; i--){
        let idx = Math.floor(Math.random() * i);
        arr.push(tempArray[idx])
        tempArray.splice(idx,1)
    }
    return arr
}

同样,经验证,该算法是公平的

image.png