阅读 5942

卡牌大师:玩转“洗牌算法”,幸运女神在微笑 (*^_^*)

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

承接上一篇:理解 P/NP 问题时,我产生了一种已经触碰到人类认知天花板的错觉?!

我们目前的世界仍是基于 P ≠ NP,所以有理由相信:只要我们把牌洗的足够乱,幸运女神或许就会降临。(生活就像英雄联盟,运气游戏而已~)

102300016s4n007o28on.gif

本篇带来的就是:如何把牌洗的足够乱的 洗牌算法

从青铜到王者,面试和实战都用得到! 点赞收藏 ✨

闲言少叙,直接奥力给!!

青铜洗牌 🤬

题目:给你一副崭新的扑克牌(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 次!因为越往后,生成随机数重复的概率就越大!

只有痴线才会洗这么多次吧~

于是,白银玩家开始操作起来!遥感

关键词:【交换位置】

将牌随机分成两堆,让它们交换,然后再随机分成两堆,再让它们交换,然后再随机分出两堆......这样重复洗十几、二十次后,完成洗牌。

实际上,在现实中,我们玩牌,大部分玩家也是这样去洗的,它也叫【印度洗牌法】(难道是阿三发明的?)

0.gif

代码实现:

// 生成 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 位替换(即替换自身);

IdW3Yw.md.png

这样做,时间复杂度为 O(n),且任意一张牌出现的概率都是 1/52,满足:随机结果覆盖所有情况,随机结果出现概率相等!

数学证明:一张牌被放到第 i 个位置的机率为 P,则 P 会等于前 i-1 个位置都未选到这张牌且第 i 个位置选到这张牌。

IdWw1W.png

代码实现:

// 生成 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),才是永远滴神!

现实中很多扑克高玩都会这样洗吧(一图胜千言)

127614630_14271639688491n.gif

原理:将数组一分为二,再穿插合并,再不断重复这样的操作;

IdnLHG.md.png

研究表明:用鸽尾式洗牌法【洗七次】是最有效的打乱手法!(谁研究的?自行 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]
复制代码

大师洗牌 😏

大师看到这里,笑而不语。

大师说:“把牌洗乱固然重要,但是能不能,把牌洗乱之后,还能发给自己想要的牌?!”

—— 大师,我悟了!这不就是抽奖池嘛!!

Id5SjT.th.png

目标:将 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 函数是这样解释的:

15.8.2.14 random ( )

Returns a Number value with positive sign, greater than or equal to 0 but less than 1, chosen randomly or pseudo randomly with approximately uniform distribution over that range, using an implementation-dependent algorithm or strategy. This function takes no arguments.

简单翻译就是 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 问题作更多探讨~

tcaQQgK54P9EoJkVxkOMXoSiCXdvluNM.gif

欢迎大家点赞👍关注👀评论💬

我是掘金安东尼,输出暴露输入,技术洞见生活,再会~

文章分类
前端