文/kingkai
编程珠玑上关于抽样问题的章节,提出了很多随机从N个数中抽取M个数(不重复)的方法。这里一一进行分析,并给出部分推导。
抽样广泛应用于工程实践中,在样本空间非常大时,性能的因素会显得非常明显。比如,总每日的检索Query中抽样不重复的100个。对于这个命题,如果不精心设计。很可能演变成很多粗糙的实现。
void genKnuth(int m, int n) { int i; for(i = 0; i < n; i++) { if(bigrand()%(n-i) < m) { cout << i << " "; m--; } } }
该抽样的原理是顺序遍历N中的每一个数字。以5个数中抽取2个数为例。
例如对于1,其被选中的概率是2/5
对于2,其被选中的概率是条件概率之和:即1被选中条件下(2/5)同时2被选中(1/4)的概率,与1未被选中条件下(1-2/5)同时2被选中(2/4)的概率,(2/5)*(1/4) + (1-2/5)*(2/4) = 2/5
以此类推。最终每个数字被选中的概率均为2/5。即N/M。
算法即是模拟这个抽样过程。对于数字i,假设之前已经抽样出m个数字,则可供抽取的样本空间为(N-i),在其中要选出(M-m)个数字,i被抽样选中的该率即为(M-m)/(N-i)。对于第一次抽样,i=0;m=0。
但是这个算法的效率在N远大于M时非常低。Bigrand算法被执行了N次。
void genset(int m, int n) { set<int> S; while(S.size() < m) S.insert(bigrand()%n); set<int>::iterator it; for(it = S.begin(); it != S.end(); it++) { cout << *it << " "; } }
这个算法利用Set的特性成功地将计算缩小至了M的规模上。但有两个缺陷:
1. Set占用的内存在M较大时较为显著。
2. 当M与N接近时,while循环的执行效率明显降低,因为出现与Set集合中非重复数字的概率降低了。
void getfloyd(int m, int n) { set<int> S; set<int>::iterator I; for(int j= n-m; j<n; j++) { int t = bigrand()% (j+1); if(S.find(t) == S.end()) S.insert(t); else S.insert(j); } }
这个算法的关键在于对于每次循环中备选队列中的最后一个数字(j),有两种概率被抽中。一是自身被选中(1/j),二是由于再次选中Set中的数字(共计j-1个),而被抽中。
以M=2, N=5为例。(0,1,2,3,4)
第一次Shuffle的备选集合是(0,1,2,3)。每个数字被选中的概率均为1/4。
第二次Shuffle的备选集合是(0,1,2,3,4)。
对于(0,1,2,3),如果第一次未被选中,还有一次被选中的机会,为条件概率(1-1/4)*(1/5), 经过两次Shuffle,被选中的概率为1/4+(1-1/4)*(1/5)= 2/5。
对于(4),其被选中的概率为Set中已有的集合(1/5)和其自身(1/5) = 2/5。
经过这个改进算法,可以解决上述M接近N时的效率问题。
void genshuf(int m, int n) { int i, j, temp; int *x = new int[n]; for(i = 0; i < n; i++) x[i] = i; for(i = 0; i < m; i++) { j = randint(i + 1, n-1); temp = x[i]; x[i] = x[j]; x[j] = temp; } sort(x, x + m); for(i = 0; i < m; i++) cout << x[i] << " "; }
举例说明,对于N个数,以第1个数n1为例。
在第一次Shuffle过程中,n1落在第一个int[0]位置的概率为1/N。而一旦落入int[0],就不再受后续Shuffle的影响了。
看n1落在第二个位置int[1]上的概率。它由条件概率之和构成,即n1在第一次Shuffle中落入int[1]且第二次Shuffle保持不变的概率与n1在第一次Shuffle中落入int[2..n-1]且第二次Shuffle被换回至int[1]的概率。计算式为(1/N)*(1/N-1) + (N-2/N)*(1/N-1) = 1/N。
以此类推,n1落在int[0…n-1]任意一个位置的概率均为1/N,是一个标准的随机概率。这样经过Shuffle后,选取前M个结果,即满足需求。