等概率无重复的从n个数中选取m个数

转载自:点击打开链接

问题描述:程序的输入包含两个整数m和n,其中m<n。输出是0~n-1范围内的m个随机整数,要求:每个数选择出现的概率相等,且按序输出。

学习过概率统计的同学应该都知道每一个数字被抽取的概率都应该为m/n. 那么我们怎么构造出这样的概率呢?在《编程珠玑》上面是这样解析的:

  依次考虑整数0,1,2,.....,n-1,并通过一个适当的随机测试对每个整数进行选择。通过按序访问整数,我们可以保证输出结果是有序的。 假如我们考虑m = 2,n = 5的情况,那么选择的每一个数字的概率都应该是2/5,我们怎么样才能做到呢?不慌张,慢慢来。

  下面给出我的分析过程:在0,1,2,3,4这五个数字中,我们依次对每一个数进行分析,第一次遇到0时,它的选择概率应该是2/5,如果选中了,我们开始测试第二个数1,这个时候因为1选中了,所以1这个数字的选中概率就变小了,变成1/4了,有人说这似乎不对吧,因为题目说让每一个数字选中的概率是一样大的,而现在?一个2/5,一个1/4,这怎么行呢?其实不是这样的,认真思考一下就知道了,数字1选中的概率等于什么? 数字1选中的概率p(1) = 数字0选中的概率 * (1/4) + 数组0没选中的概率*(2/4)这样推算下 (2/5 * 1/4) + (3/5 * 2/4) = 8/20 = 2/5 。这不就一样了吗?呵呵!下面给出来自Knuth的《The Art of Computer Programming, Volume2:Seminumerical Algorithms》的伪代码:

 

[cpp]  view plain  copy
  1. select = m  
  2. remaining = n  
  3. for i = [0,n)  
  4.      if (rand() % remaining) < select  
  5.              print  i  
  6.              select --  
  7.      remaining--  

[cpp]  view plain  copy
  1. int gen(int m,int n)  
  2. {  
  3.     int i, select = m,remaining = n;  
  4.     for(i=0;i<n;i++) {  
  5.         if(rand() % remaining <select) {  
  6.             printf("%d\n",i);  
  7.             select--;  
  8.         }  
  9.         remaining--;  
  10.     }  
  11.     return 0;  
  12. }  

可以优化为这样:

[cpp]  view plain  copy
  1. int genknuth(int m,int n)  
  2. {  
  3.     int i;  
  4.     for(i=0;i<n;i++)  
  5.         if(rand()%(n -i) < m) {  
  6.             printf("%d\n",i);  
  7.             m--;  
  8.         }  
  9.     return 0;  
  10. }  


代码很精简,代码遵守的规则应该是要从r个剩余的整数中选出s个,我们以概率s/r选择下一个数。这个概率的选择方式和我们上面证明的是一样的。所以在程序结束的时候一定会打印出m个数字,且每一个数字的被选择概率相同,为m/n。 首先是一个循环,这个循环确保了输出的数是不重复的,因为每次的i都不一样

其次是m个数,在每次循环中都会用rand()%(n-i)<m来判断这个数是否小于m,如果符合条件则m减1,直到为0,说明已经取到m个数了

再次是如何保证这m个数是等概率取到的

在第一次循环中i=0, n-i=n, 则随机数生成的是0-n-1之间的随机数,那么此刻0被取到的概率为 m/n-1
在第二次循环中i=1,n-i=n-1,则随机数生成的是0-n-2之间的随机数,这时1被取到的概率就和上一次循环中0有没有取到有关系了。假设在上一次循环中,没有取,则这次取到的1的概率为 m/n-2;假设上一次循环中,已经取到了,那么这次取到1的概率为m-1/n-2,所以总体上这次被取到的概率为 (1-m/n-1)*(m/n-2)+(m/n-1)*(m-1/n-2),最后通分合并之后的结果为m/n-1和第一次的概率一样的
同理,在第i次循环中,i被取上的概率也为m/n-1


2、等概率顺序取数据的第二种方法,可以使用集合的思想

由于集合元素不重复,如果按等概率选择一个随机数,不在集合中就把它插入,反之直接抛弃,直到集合元素个数达到m个,同样可以满足要求,并且用C++的STL很容易实现:

[cpp]  view plain  copy
  1. void gensets(int m,int n) {  
  2.     set<int> S;  
  3.     while(S.size() < m)  
  4.         S.insert(rand()%n);  
  5.     set<int>::iterator i;  
  6.     for(i = S.begin();i!=S.end();++i)  
  7.         cout<<*i<<"\n";  
  8. }  

这个算法的主要问题是,如果抛弃已存在的元素的次数过多,相当于多次产生随机数并进行集合操作,性能将明显下降。比如当n=100而m=99,取第99个元素时,算法“闭着眼睛乱猜整数,直到偶然碰上正确的那个为止”(《编程珠玑(续)》,13.1节)。虽然这种情况会在“从一般到特殊”提供解决方案,但下面的Floyd算法明显规避了产生随机数超过m次的问题。

  习题12.9提供了一种基于STL集合的随机数取样方法,可以在最坏情况下也只产生m个随机数:限定当前从中取值的区间的大小,每当产生重复的随机数,就把这一次迭代时不会产生的第一个随机数拿来替换。

[cpp]  view plain  copy
  1. int genfloyd(int m,int n){  
  2.     set<int> S;  
  3.     set<int>::iterator i;  
  4.     for(int j = n-m; j<n;j++) {  
  5.         int t = rand()%(j+1);  
  6.         if(S.find(t) == S.end())  
  7.             S.insert(t);  
  8.         else  
  9.             S.insert(j);  
  10.     }  
  11.     for(i=S.begin();i!=S.end();++i)  
  12.         cout<<*i<<"\n";  
  13. }  

从“打乱顺序”出发

  这是个来源于实际的想法:将所有n个元素打乱,取出前m个。更快的做法是,打乱前m个即可。对应的C++代码如下:

 
 
  1. int genshuf(int m,int n)  
  2. {  
  3.     int i,j;  
  4.     int *x = new int[n];  
  5.     for(i = 0;i<n;i++)  
  6.         x[i] = i;  
  7.     for(i = 0;i<m;i++) {  
  8.         j = randint(i,n-1);  
  9.         //randint产生i到n-1之间的随机数  
  10.         int t = x[i];x[i] = x[j];x[j] = t;  
  11.     }  
  12.     //sort(x,x+m);  
  13.     //sort是为了按序输出   
  14.     for(i=0;i<m;i++)  
  15.         cout<<x[i]<<"\n";  
  16. }      

当然了,这个题目还有其他的解法,这是在网上看到的其他的解法。他们将这样的问题抽象的定义为蓄水池抽样问题。其思路是这样的,先把前k个数放入蓄水池中,对第k+1,我们以k/(k+1)的概率决定是否要把它换入蓄水池,换入时我们可以随机挑选一个作为替换位置,这样一直到样本空间N遍历完,最后蓄水池中留下的就是结果。这样的方法得到的结果也是正确的,且每一个数字被选择的概率也是k/n。

这个问题其实还可以扩展一下:

  如何从n个对象(可以以此看到这n个对象,但事先不知道n的值)中随机选择一个?比如在不知道一个文本中有多少行,在这样的情况下要求你随机选择文件中一行,且要求文件的每一行被选择的概率相同。 在知道n这个总对象个数的情况下,谁都知道概率是1/n. 但是我们现在不知道,怎么办呢?

  考虑这样是不是可以,我们总是以1/i的概率去选择每一次遍历的对象,比如从1,2,3,4,5,6,....,N, 每一次遍历到x时,总是以1/x的概率去选择它.

整体思路如下:

  我们总选择第一个数字(文本行),并以概率1/2选择第二个(行),以1/3选择第三行,也就是说设结果为result,遍历第一个时result = 1,第二个时以1/2的概率替让result = 2,这样一直遍历概率性的替换下去,最终的result就是你的结果。他被选择的概率就是1/n。

  证明思路如下:

  第x个数被选择的概率等于x被选择的概率 * (x+1没被选择的概率) * (x+2没有被选择的概率) *......*(N没有被选择的概率)  具体化一下

  2被选择的概率 = 1/2  * 2/3 * 3/4 * 4/5 .....* (n-1/n) 我想你知道答案了吧? 对! 是1/n.这样就可以在不知道N的大小的情况下等概率的去选择任意一个对象了!

参考伪代码如下:

i = 0
while  more input lines
          with probability 1.0/++i
           choice =  this  input line
print choice
[cpp]  view plain  copy
  1. Init : a reservoir with the size: k   
  2.   
  3.                      for   i= k+1 to N  
  4.   
  5.                             M=random(1, i);  
  6.   
  7.                             if( M < k)  
  8.   
  9.                                     SWAP the Mth value and ith value  
  10.   
  11.                       end for  

猜你喜欢

转载自blog.csdn.net/suhiymof/article/details/75267927