如何公平的洗牌

今天工作中遇到一个问题,要将数组中的元素随机的、公平的重新排列,乍一看这个问题好像还挺简单的,但实际去写代码时,发现这还是个挺有意思的问题。 因为我们不光要写出算法,而且还要从数学角度上去证明,我们的算法从理论上是符合要求的。 这个问题其实就是咱们扑克牌洗牌的问题,对于一副有54张的牌, 什么是完全随机的、公平的洗牌结果呢?应该是每一张牌在54个位置上出现的概率都是1/54。

那么如何实现每一张牌在54个位置出现的概率都是1/54呢?我们假设54张牌的牌号分别是1 2 3 … 54,并且初始状态,54张牌就是按照升序存放在54个位置。 下面我们给出一个算法,并先从理论角度,来证明它的正确性,然后再从实践的角度,来观测它是否正确。

洗牌算法

for i:=0; i<54; i++{
    // 在[i, 54]区间内取随机数ri,交换位置i与位置ri的牌
}

理论证明

上面给出的算法很简单,只需要几行代码就可以实现。但这几行代码真的可以让我们得到一个公平的洗牌算法吗?下面我们从数学概率的角度来证明它的正确性。

步骤一

位置1是牌号1,我们在[1, 54]区间内取一个随机数r1,把这张牌和当前在r1位置的牌交换位置,这样能保证牌号1在54个位置出现的概率都是1/54

步骤二

对于位置2的牌,此时它的牌号有两种可能性:

  1. 牌号仍为2,那说明在步骤1中,牌号1取随机数r1 != 2,这个概率是53/54
  2. 牌号为1,那说明在步骤1中,牌号1取随机数r1 == 2,而此时牌号2就出在了位置1上,这个概率是1/54

在区间[2, 54]内取一个随机数r2,让第二张牌与r2位置的牌进行交换。此时根据咱们上面分析的那两种情况,我们可知:

  1. 若交换前的位置2牌号为2,那么交换后,牌号2出现在[2, 54]上任一位置的概率都是 53/54 * 1/53 = 1/54
  2. 若交换前的位置2牌号为1,那么交换后,牌号2仍旧在位置1上,这个概率是1/54 * 1 = 1/54

所以总体上,牌号2出现在54个位置的概率都是1/54

此时有人可能会有疑问了,第二次的交换,会对牌号1出现在54个位置的概率有影响吗?我们再全面的分析下牌号1在这两次交换的过程。

第一次交换后

牌号1在位置1的概率是1/54,在位置2的概率是1/54,在位置[3, 54]的概率是52/54

第二次交换后

  1. 牌号1在位置1的概率仍是1/54,因为第二次交换不会涉及位置1
  2. 牌号1在位置2的概率 = 53/54(第一次交换后牌号1在[2, 54]的概率) * 1/53(第二次交换时r2=牌号1所在位置的概率) = 1/54
  3. 牌号1在[3, 54]位置的概率 = 53/54(第一次交换后牌号1在[2, 54]的概率) * 52/53(第二次交换时r2 != 牌号1所在位置的概率) = 52/54

综上,可知第二次交换后,牌号1在54个位置的概率仍旧是1/54

步骤三

在区间[3, 54]内取一个随机数r3,让位置3的牌与r3的位置上的牌交换。

经过前两次交换,牌号3在位置1和位置2的概率都是1/54,所以牌号3仍在位置3的概率是52/54。那么进行第三次交换后牌号3出现在[3, 54]的每个位置的概率都是52/54 * 1/52 = 1/54

综上可知牌号3经过三次交换后,出现在每个位置的概率都是1/54

第三次交换后,不会影响牌号1和牌号2的概率,证明方式和步骤二中给出的方式是一样的,这里就不再详细给出证明了。

步骤i

在区间[i, 54]内取一个随机数ri,让位置i的牌与ri位置上的牌交换。重复步骤i,i++,直到i=53后结束。

实践观测

上面从理论层面证明了这个算法是完全随机、公平的。但实际上,这个算法是不是确实像我们分析的那样呢,我们还是通过程序来验证。

下面给出代码,为了方便,我们假设只有13张牌。我们进行100万次的洗牌,并且把每次洗牌的结果都记下来。通过计算每个位置上,每张牌出现的概率,来观测我们的算法是否有效。

go实现代码

package main

import (
	"fmt"
	"math/rand"
	"time"
)

const (
	cardSize = 13

	shuffleCount = 10000000
)

var random *rand.Rand

func main() {
	cards := [cardSize]string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
	random = rand.New(rand.NewSource(time.Now().Unix()))

	// 记录每个位置上,每张牌出现的次数
	data := [cardSize]map[string]int{}
	for i := 0; i < cardSize; i++ {
		data[i] = map[string]int{"A": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0, "8": 0, "9": 0, "10": 0, "J": 0, "Q": 0, "K": 0}
	}

	// 模拟100万的洗牌,并累计记录每次的结果
	for i := 0; i < shuffleCount; i++ {
		result := shuffle(cards)
		for pos, card := range result {
			data[pos][card]++
		}
	}

	printResult(cards, data)
}

// 洗牌算法
func shuffle(cards [cardSize]string) [cardSize]string {
	for i := 0; i < cardSize; i++ {
		r := i + random.Intn(cardSize-i)
		cards[i], cards[r] = cards[r], cards[i]
	}
	return cards
}

func printResult(cards [cardSize]string, data [cardSize]map[string]int) {
	fmt.Printf("%4s", "牌号:")
	for _, card := range cards {
		fmt.Printf("%7s", card)
	}
	fmt.Println()
	for i := 0; i < cardSize; i++ {
		str := fmt.Sprintf("%6s", fmt.Sprintf("位置%d: ", i+1))
		for _, cnt := range data[i] {
			str += fmt.Sprintf("%6.2f%%", float64(cnt*100)/float64(shuffleCount))
		}
		fmt.Println(str)
	}
}

运行结果:

 牌号:      A      2      3      4      5      6      7      8      9     10      J      Q      K
 位置1:   7.69%  7.72%  7.70%  7.66%  7.69%  7.71%  7.71%  7.68%  7.68%  7.69%  7.68%  7.69%  7.69%
 位置2:   7.71%  7.72%  7.68%  7.71%  7.70%  7.68%  7.68%  7.68%  7.70%  7.69%  7.68%  7.68%  7.68%
 位置3:   7.68%  7.70%  7.70%  7.69%  7.68%  7.70%  7.68%  7.73%  7.69%  7.68%  7.68%  7.69%  7.71%
 位置4:   7.71%  7.69%  7.68%  7.70%  7.69%  7.70%  7.68%  7.69%  7.70%  7.67%  7.70%  7.69%  7.70%
 位置5:   7.70%  7.67%  7.69%  7.68%  7.68%  7.70%  7.69%  7.67%  7.71%  7.71%  7.71%  7.68%  7.71%
 位置6:   7.70%  7.67%  7.70%  7.70%  7.67%  7.71%  7.70%  7.70%  7.69%  7.70%  7.69%  7.69%  7.69%
 位置7:   7.71%  7.69%  7.68%  7.69%  7.68%  7.70%  7.68%  7.70%  7.70%  7.71%  7.69%  7.69%  7.69%
 位置8:   7.69%  7.72%  7.66%  7.69%  7.70%  7.68%  7.69%  7.70%  7.69%  7.67%  7.72%  7.68%  7.70%
 位置9:   7.72%  7.67%  7.68%  7.70%  7.70%  7.70%  7.70%  7.67%  7.71%  7.69%  7.67%  7.70%  7.70%
位置10:   7.69%  7.68%  7.70%  7.70%  7.72%  7.68%  7.70%  7.70%  7.69%  7.68%  7.68%  7.69%  7.68%
位置11:   7.69%  7.69%  7.66%  7.68%  7.71%  7.69%  7.71%  7.69%  7.69%  7.70%  7.68%  7.73%  7.68%
位置12:   7.70%  7.71%  7.66%  7.70%  7.68%  7.68%  7.71%  7.69%  7.68%  7.69%  7.68%  7.70%  7.71%
位置13:   7.68%  7.71%  7.69%  7.67%  7.70%  7.70%  7.68%  7.69%  7.71%  7.68%  7.70%  7.68%  7.69%

理论上,每张牌出现在每个位置的概率都应该是1/13 ≈ 7.69%。从运行结果来看,我们的误差基本都在0.03%以下,这就证明了,我们的算法是正确的。

至此,我们就分别从理论和实践上,都证明了,我们给出的洗牌算法是随机、公平的。