洗牌问题 - JavaScript算法学习 | 青训营笔记
⛳️前言
这是我参与「第四届青训营」笔记创作活动的的第5天😺
算法是程序设计的灵魂。不管学习哪一门编程语言,除了语法上的学习,学习过程中肯定会包含算法的设计与理解。
我将在这篇笔记中记录青训营前端课程JavaScript相关课上讲到的洗牌问题,并附上相关的代码与分析思路。
❓问题描述
在每轮牌局结束后,我们常常需要通过洗牌重新安排牌的位置,以此打乱牌的现有次序。
而对于洗牌的结果,我们希望每张牌出现在新位置的概率都相同、且这一新位置随机。
那么对于给定数量的一组牌,我们要如何通过编写JavaScript代码达到符合要求的洗牌?
🍏方案一
首先看下面的解决方案:
function shuffle(cards) {
return [...cards].sort(() => Math.random() > 0.5 ? -1 : 1);
}
在这一方案中,通过获得一个随机数和了解该随机数落在的区间,决定相邻牌之间是否要交换位置,达到洗牌的目的。
我们知道,由 Math.random() 生成的随机数在0与1构成的开区间之内:
若随机数大于0.5,则交换相邻牌之间的位置,
否则不交换顺序,保持原状。
提供一组数量一定的牌,并使用该方案进行若干次洗牌,
const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(shuffle(cards));
console.log(shuffle(cards));
console.log(shuffle(cards));
console.log(shuffle(cards));
console.log(shuffle(cards));
观察到以下的结果:
从结果来看,我们已经达到了重新安排牌的位置的目的,每次洗牌的结果也都不相同。但接下来还要检验该方案是否满足“ 每张牌出现在新位置的概率都相同、且这一新位置随机 ”的条件。
🤔解决方案是否正确?
编写下面的一段代码,对方案一的洗牌效果进行进一步的检验:
const result = Array(10).fill(0);
for(let i = 0; i < 1000000; i++) {
const c = shuffle(cards);
for(let j = 0; j < 10; j++) {
result[j] += c[j]*0.1;
}
}
console.table(result);
在这个检验的过程中,我们会使用方案一进行 1000000 次洗牌,
用临时的变量 c 存放每次洗牌后的结果,
随后对新创建的数组 result 进行遍历,由于数组 result 与洗牌后存放结果的数组 c 长度一致,所以遍历时将 c 中存放的牌面的值乘上0.1后累加到 result 中的对应位置上。
🎃检验
我们使用这组索引值与元素值相等的牌组进行检验:
const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
把 1000000 次洗牌看成前进1000000步,牌组长度 10 可以看成有10个不同位置上的人同时前进,每次前进的步长为 1步 * weight ,权重 weight 等于 牌面值*0.1。
如果不同牌面值的牌落到不同位置的概率都相等,那么最终的结果应该是: result 中每个位置上的值都大致等于 [(0+1+2+3+4+5+6+7+8+9)/10]*1000000*0.1 = 450000 。
只要检验结果符合预期,则能说明方案一满足“ 每张牌出现在新位置的概率都相同、且这一新位置随机 ”的条件。
而观察得到的检验结果:
发现与预期并不相同,且在 result 数组中索引值分别为 0 和 9 的位置的值的差距更是超过了 100000 。
🍊结果说明原始位置越靠后和越靠前的牌越不容易被换到远离原位置的区域,即方案一实际上并不满足我们对洗牌的要求。
✔️正确写法
现在要解决的问题是,如何满足“ 每张牌出现在新位置的概率都相同、且这一新位置随机 ”。
🍎方案二
看下面的一个解决方案:
function shuffle(cards) {
const c = [...cards];
for(let i = c.length; i > 0; i--) {
const pIdx = Math.floor(Math.random() * i);
[c[pIdx], c[i - 1]] = [c[i - 1], c[pIdx]];
}
return c;
}
这个方案从后向前进行洗牌。对于第 i (index=i-1) 张牌,将会从第 1 (index=0) 到第 i 张牌随机抽选出一张,与第 i 张牌调换位置。随后,第 i 张牌将不会再改变。
⚠️由于从后往前遍历、且遍历的第一个索引值为 c.length ,而替换操作是从索引值为 c.length-1 的元素开始的,这意味着当前位置的牌有可能和自身替换,即位置不变。
假设一个牌组中有 k 张牌: 对于要被换走的第 k 张牌,其被换到其他 k-1 张牌的概率为 (k-1)/k 。
随后将会从第 1 到第 k 张牌随机抽选出一张,与第 k 张牌调换位置。这个过程中,前k-1张牌中的每张牌被抽中概率相等,(在前k-1张牌被抽中的前提下)都为 1/(k-1) 。
经过计算,第 k 张牌出现在其他除本身外任意位置的概率为:
[(k-1)/k] * [1/(k-1)] = 1/k
而对于其他位置的牌,经过类似计算也可以得到同样的结果,出现在其他除本身外任意位置的概率都为1/k。
🎃检验
我们依旧使用以下牌组进行检验:
const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
针对方案二,得到以下结果:
result 数组中的每个位置上的值都大致等于 450000 ,符合我们在前面推出的预期结果。这说明方案二才是满足条件的洗牌方案。
🍂小结
这篇笔记对比了洗牌问题的两种解决方案,并通过简单的数学分析和进一步的检验区分出了错误方案与正确方案。
对于一种解决问题的方法,我们不能只看表面就判断其正确与否,往往需要更多的分析与检验;对于算法来说也是如此。而测试的重要性也由此体现。💨
2022/7/29