这个算法是我前几天才听说的,觉得挺有意思,来写一写。好像出处是TAOCP,但我没看过。#(快哭了)
有的时候我们需要打乱一个排列的顺序,比方说在机器学习里面我们通常都会对一个数据集进行shuffle。以前我就用过numpy里面的random.shuffle。但是我当时就没有仔细想过类似这样一个shuffle是如何实现的。我们先看一下Knuth Shuffle的C伪代码,非常简短:
for (int i = n - 1; i >= 0; --i)
swap(a[i], a[rand() % (i + 1)]);
其中n
是数组的长度,a
是待shuffle的数组,swap()
交换数组的两项。这里后面对rand()
进行一些说明。
首先,我们需要思考一下一个shuffle算法应该满足哪些要求。复杂度不应该太高,OK,Knuth Shuffle满足这个要求。更重要的,通常我们希望这个shuffle之后的序列足够”随机“,这里就出现了公平的概念。什么是公平?我们把这个序列视为1~n的一个排列,那么所有排列就有 n ! n! n!个。如果shuffle后每个排列出现的可能性相同,即 1 n ! \frac{1}{n!} n!1,那么就称这个shuffle算法是公平的。实际上这个算法的精髓就在于它的公平性上。
先思考一下,如果我们要获得一个1~n的随机排列,而且生成每种排列都是等可能的,要怎么做?实际上这并不困难。逐一考虑每个位置,对于第一个位置,我们随便等可能地从1~n里挑出一个数,扔到第一个位置。对于第二个位置,我们从剩下的n - 1个数里再等可能地选一个放进去。以此类推,直至放完。
设这样操作得到的排列是
p
1
p
2
.
.
.
p
n
p_1p_2...p_n
p1p2...pn,即第一次挑出了数字
p
1
p_1
p1,第i次挑出数字
p
i
p_i
pi。再记shuffle后第i个位置的值是
X
i
X_i
Xi。那么根据过程便有
P
(
X
1
=
p
1
)
=
1
n
,
P
(
X
2
=
p
2
∣
X
1
=
p
1
)
=
1
n
−
1
,
⋯
,
P
(
X
i
=
p
i
∣
X
1
=
p
1
,
X
2
=
p
2
,
.
.
.
,
X
i
−
1
=
p
i
−
1
)
=
1
n
−
(
i
−
1
)
P(X_1 = p_1) = \frac{1}{n}, P(X_2 = p_2 | X_1 = p_1) = \frac{1}{n - 1}, \cdots, P(X_i = p_i | X_1 = p_1, X_2 = p_2, ..., X_{i-1} = p_{i-1}) = \frac{1}{n-(i-1)}
P(X1=p1)=n1,P(X2=p2∣X1=p1)=n−11,⋯,P(Xi=pi∣X1=p1,X2=p2,...,Xi−1=pi−1)=n−(i−1)1,把它们乘起来就得到
P
(
X
1
=
p
1
,
X
2
=
p
2
,
.
.
.
,
X
n
=
p
n
)
=
1
n
∗
1
n
−
1
∗
⋯
1
2
∗
1
=
1
n
!
P(X_1 = p_1, X_2 = p_2, ..., X_n = p_n) = \frac{1}{n} * \frac{1}{n-1} * \cdots \frac{1}{2} * 1 = \frac{1}{n!}
P(X1=p1,X2=p2,...,Xn=pn)=n1∗n−11∗⋯21∗1=n!1。好的。
再来看Knuth Shuffle算法,它其实就是上述过程,只不过是为了代码书写方便从最后一个位置开始向前挑。这里对于rand()
的要求便是它产生的随机数应该是均匀的,或者说rand() % (i + 1)
对应着能等可能地产生0 ~ i这i + 1个整数的均匀分布。a[rand() % (i + 1)]
即从剩下的i + 1个数里面随机挑选了一个出来。我们不希望任何一个数被重复挑选,这个数已经被挑过了,怎么办?swap
一举两得,它不仅把挑出来的数放置在当前正在考虑的位置i上,而且使得数组当前的
[
0
,
i
)
[0, i)
[0,i)项是剩下的还没被挑选的数,
[
i
,
n
)
[i, n)
[i,n)项是已经安排好位置的数,因为i是递减的,每个数被安排好位置之后便不再会受到影响。
明白了这个过程,也可以正着来写。这回我们让数组项对应的下标是 [ 1 , n ] [1, n] [1,n],数组前面一部分是已经安排好的,后面一部分是等待被挑选的。
for (int i = 1; i <= n; ++i)
swap(a[i], a[i + rand() % (n - i + 1)]); // select an index from [i, n]