洗牌算法
低效的随机序列
在模拟操作系统进程调度的时候遇到的问题:
有10个进程随机被分配到时间片,每个进程只会被分配到一次。如何实现。
【方法一】
“产生10以内的一个随机数,看看这个数对应的进程有没有被调度过,没有就轮到它”
存在问题:
可是这样到后面产生的随机数大部分对应的进程都被调度过了,特别是当进程数量越大时,后期效率越低1,因为后面产生的随机数大概率都用过了。
【方法二】
“那可以先产生一个顺序序列,然后用一定的方法把这个序列打乱。打乱后的序列就是我们的调度顺序”
bingo~
洗牌(随机置乱算法)
介绍
排序算法用于将一个序列变成有序的,而洗牌算法则用于将一个序列打“乱”,可以认为是排序算法相反操作。洗牌算法需要借助随机数实现来打“乱”序列。
什么才是“真的乱”
洗牌算法正确性的判断准则(“乱”的判断依据):对于包含n个元素的序列,其全排列有n!种可能。故若序列打乱的结果有 n ! n! n! 种且每种出现的概率一样,则是正确的洗牌算法。因打乱结果的种数肯定不大于n!,故反例有两种情况:
打乱结果的种数小于 n ! n! n! :显然此时全排列中的某些结果无法由洗牌算法产生,故此时的洗牌算法不对;
打乱结果的种数等于 n ! n! n! 但每种的出现的概率不一样。
怎样做到“真的乱”
一副全新的牌,把底牌抽出来插到剩下牌的任意位置间隙,包括上下面(以下把这个过程简称:洗),最少洗多少次以后,就能等价于到这个 n ! n! n!空间呢
n张牌组成的全新牌堆,洗1次,会得到
n
n
n 种可能的结果;
洗2次,会得到
n
∗
(
n
−
1
)
n*(n-1)
n∗(n−1)种结果(减一是因为当第一次插到最底面,等于回到最开始,没有新的排列出现);
洗3次,会得到
n
∗
(
n
−
1
)
∗
(
n
−
2
)
n*(n-1)*(n-2)
n∗(n−1)∗(n−2) 种结果;
洗…;
洗洗更健康…;
洗
n
n
n 次,会得到
n
!
n!
n! 种结果,此时,已经完全充满
n
!
n!
n! 的空间,洗更多次,样本空间不扩充。
原文洗法为:随机调换任意两张牌,洗第一次应该可以产生 n ∗ ( n − 1 ) 2 + 1 \frac{n*(n-1)}{2}+1 2n∗(n−1)+1 种结果,和分析不符。
所以,到这里,可以知道,对于一副新牌,最少只要随机的交换 n n n 次,才能在概率的意义上,让洗牌达到足够的“乱”,那么现在问题来了,如何选择一个好的算法?
一个比较容易想到的,简单粗暴的方法是:
交换牌组中的当前顺序第
x
x
x 和第
y
y
y 张牌,其中x可以等于
y
y
y ,
x
x
x 和
y
y
y 均为
1
∼
n
1\sim n
1∼n 之间的随机整数。这个算法在设计上能够充满整个样本空间,确实存在
n
!
n!
n!种可能性,但是不够好。为什么不够好呢,因为这种算法不能够确保照顾到每一张牌。随机洗牌的随机在于其不确定性,对于
n
n
n 张牌组成的有序排列,经过了
n
n
n 次随机选择,漏掉1只牌从未选过的概率不等于0,而且,随着牌的张数数量增加,这个概率非常可观。
来自Cris_Q的文章,虽然好像说的有点道理,但是想从理论上证明一下 r a n d o m ↔ r a n d o m random\leftrightarrow random random↔random 置乱结束后,元素 i i i 在位置 j j j 的概率怎么就不是 1 n 1 \over n n1 ,在线等大佬指教。
如何验证“真的乱”
置乱效果可视化网站。https://bost.ocks.org/mike/shuffle/compare.html。它展示每一个元素 i i i 置于 j j j 的概率 p p p 用颜色在二维图表 a i j a_{ij} aij 表示。当置乱算法随机性较强,每种结果概率相等时,二维表大部分点呈淡黄色,并且颜色分布均匀。当置乱算法结果不平衡,某种结果出现的概率较大,则图中色块会有明显的线条或渐变等情况。
或者,如果不用数学严格证明概率相等,可以用蒙特卡罗方法2近似地估计出概率是否相等,结果是否足够随机3。
现在就是经典的Fisher–Yates算法登场的时候了。
Fisher-Yates Shuffle(费雪耶兹)算法
简介
原本的Fisher-Yates Shuffle算法是两个数组,一个原数组一个新数组。在原数组里边随机挑一个数放到新数组最后,然后在原数组里边把这个数踢掉,并重复这个过程直到原数组为空。
Knuth-Durstenfeld Shuffle算法是对Fisher-Yates Shuffle算法的改进,一个数组就可以解决问题。在数组里随机找一个数,放到数组最后,在剩下的数里再随机找一个,放到数组倒数第二个,并重复这个过程直到剩下数为空。
由于其原理相似,就是实现方案不一样,现在大家说的Fisher-Yates Shuffle算法,基本都是Knuth-Durstenfeld Shuffle算法。
算法
//pseudocode
//exchange index i and index random which from residue
To shuffle an array a of n elements (indices 0..n-1):
for i from n − 1 downto 1 do
j ← random integer with 0 ≤ j ≤ i
exchange a[j] and a[i]
//cpp
void Knuth_Durstenfeld_Shuffle(vector<int>&arr)
{
for (int i=arr.size()-1;i>=0;--i)
{
srand((unsigned)time(NULL));
swap(arr[rand()%(i+1)],arr[i]);
}
}
#python
import random
def FisherYatesShuffle(arr):
n = len(arr)
i = n-1
while i>0:
randInt = random.randint(0,i)#include 0 and i
b[i],b[randInt]=b[randInt],b[i]
i -= 1
return arr
证明
只需证明每个数字在某个位置的概率相等,都为 1 n 1 \over n n1 :
对于原排列最后一个数字:很显然他在第n个位置的概率是 1 n 1 \over n n1
在倒数第二个位置概率是 n − 1 n ∗ 1 n − 1 = 1 n \frac {n-1}{n} * \frac {1}{n-1} = \frac {1}{n} nn−1∗n−11=n1(第一次没有选中而第二次被选中)
在倒数第 k k k 个位置的概率是 n − 1 n ∗ n − 2 n − 1 ∗ ⋯ ∗ n − k + 1 n − k + 2 ∗ 1 n − k + 1 = 1 n \frac {n-1}{n} *\frac {n-2}{n-1} * \cdots * \frac{n-k+1}{n-k+2} * \frac{1}{n-k+1} = \frac {1}{n} nn−1∗n−1n−2∗⋯∗n−k+2n−k+1∗n−k+11=n1
对于原排列的其他数字也可以同上求得他们在每个位置的概率都是 1 n 1 \over n n1。
分析
这个算法在样本空间上,跟前面简单粗暴的随机抽取一样充满了 n ! n! n! 的样本空间,但是它好在哪里呢?因为它利用了抽卡本身的顺序,"保证照顾"到了每一张原本序列中的卡。而简单粗暴随机抽取存在出现重复位置的可能性,就等于浪费了一次排序的机会。换句话说,其等效抽卡次数因为出现了过去相同的洗法,有效洗牌次数下降,样本空间缩小,无法充满整个n!3空间,所以有效性会下降。而Fisher–Yates算法在原理上保证了不会出现浪费次数,重复选择的情况,导致样本空间一直保持 n ! n! n! ,没有坍缩,这就是其在数学意义上优秀的原因。
python中shuffle()源码截取部分如下
def shuffle(self, x, random=None):
"""Shuffle list x in place, and return None."""
randbelow = self._randbelow
for i in reversed(range(1, len(x))):
# pick an element in x[:i+1] with which to exchange x[i]
j = randbelow(i + 1)
x[i], x[j] = x[j], x[i]
可以看到,python中的shuffle也是用的Fisher-Yate shuffle ,评论说到其被证明是最好的乱序算法,只要随机数产生足够好
inside-out 算法
简介
Inside-Out Algorithm 算法的基本思思是从前向后遍历数据,把位置 i i i 的数据随机插入到前 i i i 个(包括第 i i i 个)位置中(假设为 k k k ),再把原来第 k k k 个位置的数据放到第 i i i 个位置。
其实效果相当于数组中随机位置 k ( k ⩽ i ) k(k \leqslant i) k(k⩽i) 和递增位置 i i i 的数字进行交换。或者说是把遍历到的新元素插入到已乱序序列中,再把被新元素顶出来的数据放到位置 i i i。
算法
#python
import random
def shuffle(lis):
result = lis[:]
for i in range(1, len(lis)):
j = random.randrange(0, i) # j is a random int including 0 but not i
result[i] = result[j]
result[j] = lis[i]
return result
//cpp
function shuffle(array) {
var n = array.length, i = 0, j, k;
while (++i < n) {
k = Math.floor(Math.random() * (i+1)); //k is a random index from 0 to i
t = array[i];
array[i] = array[k];
array[k] = t;
}
}
证明
原数组的第 i i i 个元素在新数组的第 k ( k ⩽ i ) k(k \leqslant i) k(k⩽i)个位置(前 i i i 个位置)的概率是: 1 i ⋅ i i + 1 ⋅ i + 1 i + 2 ⋯ n − 1 n = 1 n \frac{1}{i} \cdot \frac{i}{i+1} \cdot \frac{i+1}{i+2} \cdots \frac{n-1}{n} = \frac{1}{n} i1⋅i+1i⋅i+2i+1⋯nn−1=n1,(即第 i i i 次刚好随机放到了第 k ( k ⩽ i ) k(k \leqslant i) k(k⩽i)个位置,在后面的 n − i n-i n−i 次选择中该数字不被选中)
原数组的第 i i i 个元素在新数组的第 k ( k > i ) k(k > i) k(k>i) 个位置( i + 1 i+1 i+1 以后的位置)的概率是: 1 k ⋅ k k + 1 ⋅ k + 1 k + 2 ⋯ n − 1 n = 1 n \frac{1}{k} \cdot \frac{k}{k+1} \cdot \frac{k+1}{k+2} \cdots \frac{n-1}{n} = \frac{1}{n} k1⋅k+1k⋅k+2k+1⋯nn−1=n1(即第 k k k 次刚好随机放到了该位置,在后面的 n − k n-k n−k 次选择中该数字不被选中)
两种情况中的前 i − 1 i-1 i−1 ( k − 1 k-1 k−1 ) 次选择不影响原数组的第 i i i 个元素在新数组的第 k k k 个位置的概率
分析
和Fisher-Yate 洗牌算法相比,Inside-out算法由于是从前往后遍历,所以可以应对需要乱序数据元素数量大小未知的情况,或者需要乱序的数据是一个动态增加的情况。也就是遍历下标变量 i i i 从小到大,而随机数也只在遍历过的元素中产生,所以有这个优势。
插牌
实践中的洗牌还有一种抽牌插牌的手法,就是抽一张牌或一部分牌,随机插入到牌中的一部分,但是由于数组实现需要移动元素,链表实现需要遍历找位置,效率都不高,就没有实现也没有分析。留着以后有空再回头来看看。
水库抽样
在找资料学习的时候,很多文章都会把水库抽样放在洗牌后面,但是不属于置乱序列的范围。
总结
Fisher-Yates Shuffle i ↔ r a n d o m ( r e s i d u e ) i \leftrightarrow random(residue) i↔random(residue) (already prove positive)
inside-out i ↔ r a n d o m ( a l r e a d y ) i \leftrightarrow random(already) i↔random(already) (already prove positive)
暴力随机交换 r a n d o m ( a l l ) ↔ r a n d o m ( a l l ) random(all) \leftrightarrow random(all) random(all)↔random(all) ( wait for prove negative)
no-name1 i ↔ r a n d o m ( a l l ) i \leftrightarrow random(all) i↔random(all) (already prove negative)
其中 i i i 是遍历下标变量, r e s i d u e residue residue 是原数组中未被遍历到的数据元素, a l r e a d y already already 是原数组中已被遍历到的数据元素。
在这个算法测评中,可以看到各个算法的遍历分布结果。
该方法中分配第 i i i 个进程产生随机数有效的概率 p = n − ( i − 1 ) n p=\frac{n-(i-1)}{n} p=nn−(i−1)。产生有效随机数需要尝试次数期望 E = n n − ( i − 1 ) E=\frac{n}{n-(i-1)} E=n−(i−1)n。总需要尝试次数期望 E = 1 + n n − 1 + n n − 2 + ⋯ + n 2 + n = n [ 1 + 1 2 + 1 3 + ⋯ + 1 n ] E=1+\frac{n}{n-1}+\frac{n}{n-2}+\cdots +\frac{n}{2}+n=n[1+\frac{1}{2}+\frac{1}{3}+ \cdots +\frac{1}{n}] E=1+n−1n+n−2n+⋯+2n+n=n[1+21+31+⋯+n1]。
括号里为调和级数,前 n n n 项和没有简化的计算公式,历史上某位大佬证明它趋近于 l n ( n + 1 ) + 0.577 ln(n+1)+0.577 ln(n+1)+0.577。详见证明。也就是,该方法的时间复杂度为 o ( n l n ( n + 1 ) ) o(nln(n+1)) o(nln(n+1))
仿真比率( t i m e s n times \over n ntimes)如下:
多次仿真结果可见,虽然有明显的上升趋势,但却不是线性上升,符合 l n N lnN lnN 函数图像特征 ↩︎就是用大量实验得到一个结果频率分布,而大量实验的结果频率近似于结果概率。 ↩︎
来自文章分析,说法不严谨,随机粗暴抽取有 n n n^n nn (好像更多)种可能结果(包含重复),有效洗牌次数下降也无法说明这个洗牌次数得到的结果无法充满整个样本空间。 ↩︎ ↩︎