Knuth算法是一种非常常用的洗牌算法,它在很多方面都有着广泛的应用。
本文需要一定的概率论知识,包括排列组合、古典概型和条件概率。

问题分析

洗牌其实是一种取随机的算法,只有每一面牌在序列中的任意位置出现的概率都相同的时候,我们才可以说一个随机算法是公平的。

最容易想到的公平洗牌方式大概是这种:有n张牌,这n张牌自由组合共有n!个组合,每次只要随机且等可能地从这n!种情况内选取一项,就可以得到一种随机的排列,也就是一次洗牌。那么问题就来了,n在小的时候,n!的数值大小仍可计算,而当n逐渐增大到一定值的时候,n!就会变得不可计算了起来,要随机取得一个情况要耗费的开支太大,时间复杂度达到了o(n!)

那么,有没有别的方法可以保证一次洗牌开销没那么大而每张牌在任一位置的概率都相等呢?

算法分析

我们可以借鉴下插入排序的思想。

比如有5张牌需要我去打乱位置,我就直接留五个空位,然后从第一个空位开始,从原来五张牌中随机选择一张,填到1号空位中,然后在原来的五张牌中抹去那张,还剩下4张。接下来来到二号空位,继续从剩下的4张牌中随机选择一张,放入第二个空位中,再在剩余的四张牌中抹去被选中的那一张……依次类推,你会得到一个序列,那就是洗牌的结果。那么,它是公平的吗?让我们来算算它的概率。

  • 任一牌出现在第一个位置的概率,即为它第一次就被选中的概率,为1/5
  • 任一牌出现在第二个位置的概率,即为它第一次落选,但它在第二次被选中的概率,为4/5 * 1/4 = 1/5
  • 任一牌出现在第三个位置的概率,即为它第一、二次落选,但在第三次被选中的概率,为4/5 * 3/4 * 1/3 = 1/5
  • ……

依此类推,跟据条件概率公式推导,你可以发现一张牌出现在任一位置的概率都为1/51/n,说明此洗牌算法是公平的。我们分析这个算法的时间复杂度,可以发现其时间复杂度为o(n),也就是说他遍历一遍牌,就能实现洗牌的操作。但是我们发现,我们仍需要请求额外的存储空间来存储洗完的牌,空间复杂度方面还可以优化,而且,如何标记已经被选择过的牌,也是一个问题,这些都会带来额外的开支。

优化

那么我们如何优化它的效率呢?我们注意到,每次随机选完牌之后,下一次选牌仍是随机的,那么在洗牌前的数据组中,牌的顺序是无关紧要的。我们完全可以把取数据然后放数据的过程,改为交换数据的过程。从第一个位置开始洗牌,从整个数组里所有元素中随机选择一个位置的内容与1号位置的元素交换,完成第一轮的洗牌。第二轮的时候,我们默认1号位置已经完成洗牌,从2~n号位置中选择一个元素与2号位置的元素交换位置,这样二号位置的洗牌也完成了……依此类推,把每个位置的牌都遍历一次,即可完成一次洗牌。这就是Knuth Shuffle洗牌算法。

使用条件概率公式,你可以很轻易地算出,每一张牌出现于任一位置的概率均为1/n,而且,Knuth算法每次会把排完序的数据排除在下一次循环之外,完美解决了已经洗完的部分的标记问题,它不需要额外的存储空间,只需要在交换两个数据的时候使用一个临时变量作为中间交换量即可,效率与空间利用率比之前高了很多。

代码

以下是使用C#实现的Knuth算法,可以把{1, 2, 3, 4, 5}五张牌乱序洗牌后输出。

using System;

namespace KnuthShuffle
{
    class Program
    {
        static void Main(string[] args)
        {
            int[] arr = {1, 2, 3, 4, 5};
            //随机数生成法千千万,这个用的是Unix时间戳做种子
            //你要是想搞事,完全可以换成随机性更强的随机数生成方法
            Random random = new Random(); 
            
            //Knuth洗牌算法其实就下面这么三行,非常简单
            for (int i = 0; i < arr.Length; i++)
            {
                Swap(ref arr[i], ref arr[random.Next(i, arr.Length)]);
            }
            //其实你要极致优化的话,听说从后面开始向前洗牌效率更高(这个是玄学我不确定)
            foreach (var variable in arr)
            {
                Console.WriteLine(variable);
            }
        }

        static void Swap(ref int x, ref int y)
        {
            int temp = x;
            x = y;
            y = temp;
        }
    }
}

它有什么用?

最简单且直观的用途就是:他能帮我们洗牌,然后实现牌类游戏的洗牌操作。

比如对于经典牌类游戏斗地主,你可以把54张牌的牌面塞到一个容器内,然后通过Knuth算法实现洗牌,然后抽出3张作为地主牌后按次序分成3组,每个人拿到的与地主牌就都是随机的,完美的符合随机洗牌的要求,再配合排序算法,可以快速地把牌按顺序排列完毕,就可以来一把紧张刺激的斗地主了。

那么一维的可以,二维的可以吗?事实上当然可以,比如我们同样很熟悉的小游戏扫雷,把雷和空格均匀地洗开之后,再按二维堆叠,就可以形成一幅随机的雷图了,接下来就是跟据数据来判断要在空白格子里填的数据了。

事实上,我们最常见的Knuth Shuffle的运用,还是在我们听歌的那件事上。市场上几乎所有的音乐播放软件的“随机播放”功能,其实都是“伪随机”,就是通过Knuth算法实现的乱序播放歌曲。这类音乐软件会在你打开随机播放功能后对你的歌单进行一次Knuth Shuffle,然后按序播放这个“洗牌后”的歌单里的歌,但是对于用户来说,确实是看起来像随机播放的了。

那么问题来啦,我们怎么判断你的音乐App是使用的“真随机播放”还是“伪随机播放”呢?很简单,打开你的音乐软件的随机播放功能,音乐开始播放后,轻按“下一首”按钮,再轻按“上一首”功能,如果它回到了原来的那首歌,那它毫无疑问是“伪随机播放”了。“真随机播放”的话,切歌时它会随机跳到下一首去,你是回不去的了~

不过话说回来,如果我是要开发“真随机播放”功能的程序员的话,我可能会选择在“随机播放”模式下,把“上一首”按钮和“下一首”按钮的功能都设置成洗完牌后的随机打乱的歌单里的下一首。永远都往下一首播放的话,你也回不去原来那首歌了,体验上来说,因为都是切到下一首没被听过的音乐,应该与每次都生成随机数然后去随机找歌的“真随机播放”体验一致了,而且感觉这种处理方法的开销好像比每次生成随机数的情况要小。(发现华点😏