洗牌算法

洗牌算法或者说随机乱序算法在很多情形下可以应用到,比如棋牌游戏,歌曲乱序等等。

对于棋牌游戏,我们希望在发牌时,每个玩家发到的牌的情况都是差不多的,不会一直特别好的牌,也不会一直特别差的牌。更准确的说,对于每张牌,我们希望这张牌出现在牌组中不同的位置上是等概率的。

​1​ Fisher-Yates

Fisher-Yates 洗牌算法最初由 Ronald Fisher 和 Frank Yates 1938 年发表在他们的著书《Statistical tables for biological, agricultural and medical research》中。算法描述把数字 1 - N 随机打乱的步骤是:

  1. 准备写下两个序列:未决序列 {1,2, .. N}  和已决序列 (初始状态为空){}。;

  2. 从 1 到未决序列的长度间选择一个数字 k;

  3. 将未决序列的第 k 个数移至已决序列尾端;

  4. 重复步骤 2 直至未决序列为空;

  5. 已决序列即打乱后的序列。

假设现在需要打乱数字 1 - 8,于是写下两个序列:

未决序列

已决序列

{1,2,3,4,5,6,7,8}

{}

现在未决序列的长度为 8,所以从 1 - 8 中随机选一个 k 值 3,将未决序列的第 3 个数(现在是 3)移至已决序列:

未决序列

已决序列

{1,2,3,4,5,6,7,8}

{3}

现在未决序列的长度为 7,所以从 1 - 7 中随机选一个 k 值 6,将未决序列的第 6 个数(现在是 7)移至已决序列:

未决序列

已决序列

{1,2,3,4,5,6,7,8}

{3,7}

重复这个步骤,直到未决序列为空,我们得到了一个随机的排列——已决序列:


未决序列

已决序列

{1,2,3,4,5,6,7,8}

{3,7,2,5,8,1,6,4}

 

如果用伪代码来描述这个算法可以稍微“聪明”点,因为数组可以就地交换,不需要维护两个数组:

-- To shuffle an array a of n elements (indices 0..n-1):
for i from n−1 downto 1 do
     j ← random integer such that 0 ≤ j ≤ i
     exchange a[j] and a[i]

或者换个方向:

-- To shuffle an array a of n elements (indices 0..n-1):
for i from 0 to n−2 do
     j ← random integer such that i ≤ j < n
     exchange a[i] and a[j]

 

在这个算法中,因为只使用了一个数组的空间,所以空间复杂度是 O(n)。只遍历一次就能打乱数组,时间复杂度是 O(n)。

​2​ 概率分析

Fisher-Yates 算法其实可以等效为不放回抽样模型。不放回抽样即在逐个抽取个体时,每次被抽到的个体不放回总体中参加下一次抽取的方法。Fisher-Yates 算法随机选中的元素同样是不放回的,选中即确定在排列中的位置。

不放回抽样有个特性:每个抽样的元素在被抽取的次序上是等概率的。所以经过 Fisher-Yates 算法后,每个元素在所有位置上的概率也是一样的。

我们可以进行简单的概率计算来验证这一点:

现有四种花色的 A、2、3、4、5、6、7、8、9、10、J、Q、K 共计 52 张牌(移除大小王),随机打乱这 52 张牌,所有可能出现的排列顺序共计有 52! 种。假设取红桃 A 来分析,计算红桃 A 在不放回抽牌的情况下出现在 52 个位置上的概率。

出现在第 1 个位置:1 / 52;

出现在第 2 个位置: (51 / 52) * (1 / 51) = 1 / 52;

出现在第 3 个位置:(51 / 52) * (50 / 51) * (1 / 50) = 1 / 52;

发现规律了,以此类推,红桃 A 出现在第 52 个位置的概率也是 1 / 52。

​3​ 程序验证(Lua)

首先定义一副扑克牌。我们用一个字节来表示一张牌,取高 4 位为花色,低 4 位为牌值。四种花色的 A、2、3、4、5、6、7、8、9、10、J、Q、K 定义为:

local poker = {
    0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
    0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D,
    0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D,
    0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D,
}

 

实现 Fisher-Yates 算法:

local function shuffle(poker)
  for i = 1, #poker - 1 do
    local r = math.random(i, #poker)
    local t = poker[i]
    poker[i] = poker[r]
    poker[r] = t
  end
end

 

假设花色 1 为红桃,牌值 1 为 A,那么红桃 A 的数值为 0x11。循环 1000000 次洗牌,每次找出红桃 A 的位置,并将相应下标的 count 计数加 1。

local loop = 1000000
local count = {}
for i = 1, loop do
        local t = deepcopy(poker)
        shuffle(t)
        for k,v in ipairs(t) do
                if v == 0x11 then
                        count[k] = count[k] or 0
                        count[k] = count[k] + 1
                        break
                end
        end
end

 

对每个位置出现的次数/总循环次数 1000000,即为红桃 A 出现在每个位置上的概率。

local result = ''
for i = 1, #count do
        result = result .. ' ' .. count[i] / loop
end
print(result)

 

运行结果:

0.019282 0.019079 0.019306 0.01923 0.019104 0.01905 0.018902 0.019208 0.019321 0.019094 0.019425 0.019356 0.019352 0.019463 0.019177 0.019432 0.019271 0.019066 0.019431 0.0194 0.019154 0.019006 0.018965 0.019471 0.019178 0.019193 0.019086 0.019273 0.019274 0.019146 0.019259 0.018649 0.019286 0.019283 0.019304 0.019242 0.019525 0.019049 0.019115 0.019392 0.019281 0.019285 0.019164 0.019376 0.019388 0.019465 0.019034 0.019298 0.019343 0.019197 0.019004 0.019366

 

可以看到每个位置概率都为 0.019,即 1 / 52。

​4​ 参考资料

  1. Wiki. Fisher–Yates shuffle

  2. Wiki.Simple random sample

  3. The intuition behind Fisher-Yates shuffling

posted @ 2020-03-20 00:12  法号胖子  阅读(689)  评论(0编辑  收藏  举报