承接上一篇:理解 P/NP 问题时,我产生了一种已经触碰到人类认知天花板的错觉?!
我们目前的世界仍是基于 P ≠ NP,所以有理由相信:只要我们把牌洗的足够乱,幸运女神或许就会降临。(生活就像英雄联盟,运气游戏而已~)
本篇带来的就是:如何把牌洗的足够乱的 洗牌算法 !
从青铜到王者,面试和实战都用得到! 点赞收藏 ✨
闲言少叙,直接奥力给!!
青铜洗牌 ?
题目:给你一副崭新的扑克牌(54 张),你如何 “洗乱” 它??
咱青铜玩家通常很暴躁!
不就是洗牌嘛!聪明的青铜玩家,先将问题抽象为算法模型!
问题转化为:
已知:一个数组 nums = [1,2,3,4,5,...,51,52,53,54],求解:一个乱序的新数组 radomNums。(简直不能再 nice 了)
然后采用 【暴力抽取】
在 1 至 54 之前随机生成一个整数,然后把它放到新数组里,然后再随机生成一个整数,如果和之前生成的没重复,直接放入新数组,如果和之前重复了,那再随机生成一个......
这样直至数组中所有数字都被抽取放到新数组,最终得解!
代码实现:
// 生成 nums:
let nums=[]
for(let i=1;i<=54;i++){
nums.push(i)
}
// 青铜洗牌算法:
let count = 0
const shuffle = function(nums) {
let radomNums = []
while (radomNums.length < nums.length) {
count++; // count 计数洗牌次数
let rand = randOne()
if(radomNums.includes(rand)){ // 随机数重复
rand = randOne() // 再次生成
}else{
radomNums.push(rand)
}
}
return radomNums
}
// 在 1 至 54 之间任意取一整数;
const randOne= function() {
return Math.floor(Math.random() * 54) + 1;
}
console.log(shuffle(nums))
console.log(count)
// (54) [22, 48, 13, 23, 15, 12, 18, 50, 5, 28, 27, 52, 46, 16, 40, 6, 33, 9, 41, 30, 54, 14, 36, 53, 17, 2, 11, 37, 42, 3, 8, 21, 25, 20, 34, 32, 35, 4, 43, 26, 38, 24, 10, 45, 31, 49, 44, 19, 51, 7, 1, 39, 47, 29]
// 271
完美~ 青铜玩家暴力洗牌,简单直白,在万军丛中直取敌将首级!!
白银洗牌 ?
白银玩家看了青铜玩家的操作,不禁放声大笑!
“痴线~”(sb)
把上述代码拷贝至控制台运行发现,基本上打乱这副扑克牌要洗 200 ~ 300 次!因为越往后,生成随机数重复的概率就越大!
只有痴线才会洗这么多次吧~
于是,白银玩家开始操作起来!遥感
关键词:【交换位置】。
将牌随机分成两堆,让它们交换,然后再随机分成两堆,再让它们交换,然后再随机分出两堆......这样重复洗十几、二十次后,完成洗牌。
实际上,在现实中,我们玩牌,大部分玩家也是这样去洗的,它也叫【印度洗牌法】(难道是阿三发明的?)
代码实现:
// 生成 nums:
let nums=[]
for(let i=1;i<=54;i++){
nums.push(i)
}
// 白银洗牌算法:
const shuffle = function(nums){
let radomNums = JSON.parse(JSON.stringify(nums))
for(let i = 0;i < 20;i++){
let randIndex1 = randOneIndex()
let randIndex2 = randOneIndex()
if(randIndex2 < randIndex1){ // 若 rand2<rand1,二者替换
randIndex1 = randIndex1 + randIndex2
randIndex2 = randIndex1 - randIndex2
randIndex1 = randIndex1 - randIndex2
}
let radomBlock = radomNums.slice(randIndex1,randIndex2 + 1)
radomNums = radomNums.slice(0,randIndex1).concat(radomNums.slice(randIndex2,53)).concat(radomBlock)
}
return radomNums
}
// 在 0 至 53 之间任意取一整数作数组下标;
const randOneIndex= function() {
return Math.floor(Math.random() * 54);
}
console.log(shuffle(nums))
// (54) [30, 9, 7, 28, 29, 39, 45, 46, 47, 48, 49, 50, 51, 52, 24, 25, 26, 27, 40, 42, 43, 44, 38, 31, 14, 8, 41, 22, 32, 19, 20, 1, 2, 10, 11, 12, 13, 16, 15, 53, 23, 3, 4, 5, 6, 21, 17, 18, 33, 34, 35, 36, 37, 42]
白银,永远滴神!只用洗 20 次,就能把牌洗乱!
黄金洗牌 ?
这时,黄金玩家又笑了:“呵呵,你个渣渣白银,根本还没看懂题目!”
“你懂不懂洗牌问题最最关键的 “洗乱” 二字意义所在?!”
黄金洗牌来揭晓答案:
洗 54 张牌,随机结果需覆盖所有情况就应该是 54 张牌的排列方式,A5454,即 54!(54 的阶层)种随机结果。
两两对换:
洗 1 次,会得到 n 种可能的结果;
洗 2 次,会得到 n*(n-1) 种结果;
洗 3 次,会得到 n*(n-1)*(n-2) 种结果;
......
洗 n 次之后,我们才满足了:随机结果【覆盖所有情况】,并且所有随机结果【出现概率相等】。
所以,必须洗 54 次,才能达到目的。
代码实现:
// 生成 nums:
let nums=[]
for(let i=1;i<=54;i++){
nums.push(i)
}
// 黄金洗牌算法:
const shuffle = function(nums) {
// 高手都用 slice(0) 复制数组
const radomNums = nums.slice(0);
let n = radomNums.length;
// 产生的结果有 n! 种可能
for (let i = 0; i < n; i++) {
// 从 i 到 n-1 随机选一个
const rand = randOne(i, n - 1);
// 交换nums数组i和rand下标的两个元素
[ radomNums[i], radomNums[rand] ] = [ radomNums[rand], radomNums[i] ];
}
return radomNums;
};
// 获取闭区间 [n, m] 内的一个随机整数
const randOne= function(n, m) {
return Math.floor(Math.random() * (m - n + 1)) + n;
};
console.log(shuffle(nums))
// (54) [39, 40, 11, 35, 1, 47, 33, 9, 44, 32, 31, 45, 41, 4, 51, 42, 8, 10, 16, 14, 18, 17, 13, 6, 34, 53, 48, 5, 15, 22, 38, 37, 49, 43, 3, 20, 26, 52, 30, 19, 7, 50, 12, 21, 46, 36, 23, 27, 28, 25, 2, 29, 24, 54]
铂金洗牌 ?
正在网吧的铂金大神看到上面这些弟弟,笑出猪叫~~~
但凡上点网,学点攻略,就不至于在洗牌这个问题上没听说过 Fisher-Yates 洗牌算法!
思路:
随机生成 1 至 54 之间的整数,将它和数组的最后一位替换;
然后再在 1 至 53 之间随机生成一位整数,将它和数组的倒数第二位替换;
然后再 1 至 52 之间随机生成一位整数,将它和数组的倒数第三位替换;
......
直至在 1 至 1 之间随机生成一位整数(即 1),将它和数组第 1 位替换(即替换自身);
这样做,时间复杂度为 O(n),且任意一张牌出现的概率都是 1/52,满足:随机结果覆盖所有情况,随机结果出现概率相等!
数学证明:一张牌被放到第 i 个位置的机率为 P,则 P 会等于前 i-1 个位置都未选到这张牌且第 i 个位置选到这张牌。
代码实现:
// 生成 nums:
let nums=[]
for(let i=1;i<=54;i++){
nums.push(i)
}
// 铂金洗牌算法:
const FYShuffle = function (nums) {
const radomNums = nums.slice(0);
let len = radomNums.length;
while (len > 1) {
let rand = Math.floor(Math.random() * len);
len--;
let temp = radomNums[len];
radomNums[len] = radomNums[rand];
radomNums[rand] = temp;
}
return radomNums;
}
console.log(FYShuffle(nums))
// (54) [47, 17, 33, 13, 37, 26, 20, 39, 45, 44, 25, 40, 49, 7, 36, 38, 6, 15, 31, 18, 52, 46, 28, 11, 43, 1, 22, 19, 53, 9, 14, 27, 35, 8, 51, 42, 50, 2, 23, 5, 30, 54, 4, 21, 29, 16, 10, 24, 48, 34, 32, 12, 41, 3]
钻石洗牌 ?
钻石大神拍案站起,惊呼道:鸽尾式洗牌法(Riffle Shuffle),才是永远滴神!
现实中很多扑克高玩都会这样洗吧(一图胜千言)
原理:将数组一分为二,再穿插合并,再不断重复这样的操作;
研究表明:用鸽尾式洗牌法【洗七次】是最有效的打乱手法!(谁研究的?自行 Google)
代码实现:
// 生成 nums:
let nums=[]
for(let i=1;i<=54;i++){
nums.push(i)
}
// 钻石洗牌算法:
const RShuffle = function (arr) {
let radomNums = nums.slice(0);
for(let i = 0;i < 7;i++){
let randIndex = randOneIndex()
let arr1 = radomNums.slice(0,randIndex)
let arr2 = radomNums.slice(randIndex,55)
radomNums = aryJoinAry(arr1 ,arr2)
}
return radomNums;
}
// 两个数组穿插合并
const aryJoinAry = function (ary,ary2) {
var itemAry=[];
var minLength;
//先拿到两个数组中长度较短的那个数组的长度
if(ary.length>ary2.length){
minLength=ary2.length;
}
else{
minLength=ary.length;
}
//将两个数组中较长的数组记录下来
var longAry=arguments[0].length>arguments[1].length?arguments[0]:arguments[1];
//循环范围为较短的那个数组的长度
for (var i = 0; i < minLength; i++) {
//将数组放入临时数组中
itemAry.push(ary[i]);
itemAry.push(ary2[i])
}
//itemAry和多余的新数组拼接起来并返回。
return itemAry.concat(longAry.slice(minLength));
}
// 在 0 至 53 之间任意取一整数作数组下标;
const randOneIndex= function() {
return Math.floor(Math.random() * 54);
}
console.log(RShuffle(nums))
// (54) [1, 4, 19, 2, 38, 51, 6, 37, 15, 9, 45, 43, 7, 52, 16, 21, 39, 53, 24, 26, 44, 54, 40, 5, 32, 10, 46, 22, 33, 27, 3, 11, 18, 28, 47, 12, 17, 29, 8, 13, 41, 30, 48, 14, 34, 31, 20, 23, 49, 35, 25, 42, 50, 36]
大师洗牌 ?
大师看到这里,笑而不语。
大师说:“把牌洗乱固然重要,但是能不能,把牌洗乱之后,还能发给自己想要的牌?!”
—— 大师,我悟了!这不就是抽奖池嘛!!
目标:将 54 张牌打乱后,抽到区间 [1,10] 的概率为 40%,抽到区间 [11,20] 的概率为 20%,抽到区间 [21,30] 的概率为 20%,抽到区间 [31,40] 的概率为 15%,剩下的抽到 [41,54] 的概率为 5%;
思路:我们实现乱序数组的同时,输出一个概率数组。
radomNums = [21,43,12,3,...,8,6,33] // 共 54 项
probabilityNums = [0.02,0.015,...,0.04,0.04,0.15] // 共 54 项,和为 1
然后再通过以下 randomProbability 拿出发牌的值:
function randomProbability(arr1, arr2) {
var sum = 0,
factor = 0,
random = Math.random();
for(let i = arr2.length - 1; i >= 0; i--) {
sum += arr2[i]; // 统计概率总和
};
random *= sum; // 生成概率随机数
for(let i = arr2.length - 1; i >= 0; i--) {
factor += arr2[i];
if(random <= factor) return arr1[i]; // 如果在当前的概率范围内,得到的就是当前概率,返回输出
};
return null;
}
const yourCard = randomProbability(radomNums, probabilityNums)
console.log(yourCard)
王者洗牌 ?
此时,只见身在高处的王者背影。他缓缓转过身来。用手中的权杖指向了洗牌的终极的秘密:random 函数!神圣而又庄重!
你可曾想过,前文处处在使用的 random 函数的机制是什么?
如果我们破解了 random 函数的生成规则,是不是意味着破解了随机!
ES5 对 random 函数是这样解释的:
简单翻译就是 random() 函数产生的值在 [0,1) 之间,具有大致均匀的分布,不用带参数。
进一步了解得知,在 Windows 系统 chrome V8 引擎下,浏览器最终调用一个 rand_s 函数,rand_s 是 Windows 系统提供的一个随机性足够强的随机数发生器。在其他系统下,也是基于当前系统获取高精度的随机数。所以,随机的根本源头是借助了操作系统的能力。
但,MDN 上仍表示 Math.random 是一个伪随机数,它是不安全的。从 V8 的源码可以看到 Math.random 的值来源是 /dev/random,取 64 位,值的可能个数为 2 ^ 64,随机算法相对简单,只是保证尽可能的随机分布。
2 的 64 次方还不够大吗?没错,它还不够!要知道 54 张扑克牌的全排列 54!(阶层)的值远远大于它。所以,它还不能覆盖大量的组合情景。
那,这个世界上有 真随机数 吗?王者莞尔一笑~
目前认为,真随机数需要从现实世界采集,比如 random.org 这个网站是通过采集大气噪音生成随机数。?
还有:量子随机数,原理是 LED 发出随机数的光子,CMOS 捕获光源散粒噪声产生随机序列,量子芯片通过测量光量子态得到的随机数,再进一步来加密信息。
不得不说,产生真随机数,是一件非常有意义的事情。至少在目前世界还未证实 P ≠ NP 的情况下,它是非常有意义的!!
理想世界 ?
有序和无序,或者说,熵增和熵减,是一个不小的难题。
我们平常了解了那么多种排序算法,也理应了解洗牌算法(乱序算法)。
设想下,如果你是上帝,在你设计的人类社会中,你想让人类这个群体时常感到困惑,迷茫,你会怎样打乱各种变因呢?无论人类怎么努力,都看不透你的打乱规则,这或许也只有上帝能做到吧~
也许我们不断精确尺度的过程就是追寻认知上帝规则的过程......
就像本瓜一直以为 1 秒钟就是 1 秒钟,而不知道它实际上被定义为:铯原子的 9192631770 次固有微波振荡次数所需的时间......
ok,以上就是本次分享。有种设想,后续还会对有序无序、有理数无理数、P/NP 问题作更多探讨~
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!