【编程珠玑】【第一章】rand()函数实现原理和习题解答

rand()函数的实现方法有很多,一种最简单的实现原理是这样的:

static unsigned u_seed = 1u ;          //你不设置种子,种子的初值就为1
int rand(void){
  return u_seed=u_seed * 1234u + 5678u ;//这里的两个常数是有讲究的,会影响伪随机数的生成质量
}
void srand(unsigned seed){
   u_seed = seed ;                  //想生成不一样的序列,需要选择不同的起点(seed)
}

原理就是这样,假如初始种子为1,那么1*1234+5678会得到一个伪随机数6912,再把6912作为下次的种子带入式中,6912*1234+5678 就得到了序列中的下一个伪随机数……,种子u_seed应该是静态的,是为了保证每次调用的起点都不一样,得到的随机数也不一样;变量种子应该是无符号类型的,因为每次得到的种子都会比上一个大,这样来保证数值溢出时不会得到负数,因为unsigned类型不存在溢出的问题而int类型的溢出是未定义行为;因此想得到不同的随机序列,就要从不同的起点(seed)出发,好的序列会把所有的可能值都经历一遍。

rand()函数的取值范围是[0, RAND_MAX],那么什么是RAND_MAX呢?通常我们会发现linux下的RAND_MAX值为2147483647(7FFFFFFF31个随机位最大值)windows下的RAND_MAX值为32767(0x7FFF15bit位的最大值),事实上其值和操作系统是无关的,RAND_MAX的值由编译器确定,具体写在limits.h中。

我们假设RAND_MAX=32767, 要求返回至少30个随机位,对应范围为:[0, 1073741823],其中2^30=1073741824,所以这个范围也可被视作[0, 3FFFFFFF]。此时,rand()函数无法满足要求,为此需要对其进行改造,网上给出的答案如下:

int bigrand(){
    return RAND_MAX*rand() + rand();
}
//或者
int bigRand(){
    return (rand()<<15)+rand();
}

可是,RAND_MAX*rand() + rand()的最大值等于32767*32767+32767=1073709056,这个值小于30位二进制所能表示的最大数字1073741823,明显会有一些问题,有的随机值不能取到,通过分析可以发现1073709056+32767恰好等于1073741823,也就是说再加上一个rand()就能够取到30位二进制的最大值,因此给出解决方案:

int bigrand(){
    return RAND_MAX*rand() + rand() + rand();
}

注意,rand()+rand()不等同于2rand(),如果写成return RAND_MAX*rand() + 2*rand(),那么当RAND_MAX偶数时,return语句将永远返回偶数,这样奇偶就不平等了。只有RAND_MAX素数时,它与rand()相乘才不会出现奇偶性问题。

同理,randint返回ab之间的随机数。

int randint( int a, int b ){
    return a + (RAND_MAX * rand() + rand()+rand()) % (b + 1 - a);
}

特别说明:使用bigrand的时候一定要先明确RAND_MAX的最大值是多少?如果是在Linux平台上或者某些特殊编译环境下,RAND_MAX值为2147483647(7FFFFFFF),能够表示31个二进制位的任何随机数,此时使用rand能够满足要求。而使用bigrand,因为其内部的乘法可能造成溢出,使得返回的int类型数字为负数,解决办法可以自定义unsigned。

问题一:描述:程序的输入包含两个整数mn,其中m<n。输出是0~n-1范围内m个随机整数的有序列表,不允许重复。从概率的角度说,我们希望得到没有重复的有序序列,其中每个选择出现的概率相等。

讨论这些问题前有两个假设:

  1.有一个函数bigrand()能够返回很大范围内的随机整数(远远大于mn)

  2.另一个函数randint(i,j)能够返回i..j范围内均匀选择的随机整数。

有三种解决方案:使用概率计算、逐个随机插入、内部乱序抽取,接下来逐一介绍。

解法一:扫描算法:扫描0~n-1n个数字,通过随机数和概率测试来对每个整数进行选择,最终选择出m个数字,而且同时能保证输出结果是有序的。假设m=2n=5,意味着从0,1,2,3,4这五个数字中等概率随机选择出2个数字来。选择第一个数0的概率为2/5,这个概率可以bigrand()%5来衡量,如果if(bigrand()%5)<2bigrand()%50~4中取值0或者1,这就表示了概率2/5,此时选择数字0,否则不选择0。当我们选择1的时候,要根据前面是否选择了0来决定,如果选择了0,则按照1/4的概率选择1,而未选择0的情况下按照2/4的概率选择1,以此类推。特别的,当m=n的时候,意味这所有的n个数都会被选中,因为bigrand()%m<n一定是成立的。

  从0开始到n-1n个数,以m/n的概率选中选中0:总共n个数,要选出m个;

  对于0:从0开始到n-1n个数,以m/n的概率选中选中0,也即随机数对n取余结果若小于m则选中0,否则放弃0

  对于1:如果选中0,则以(m-1)/(n-1)的概率选择1,即随机数对n-1取余结果若小于m-1则选中1否则放弃1;如果没选中0,则以m/(n-1)的概率选择1

  ……

  对于i:总共还剩下n-i个,还需要选m个,那么选中的概率就是m/(n-i)

    这里的m是需要选出的数字的个数,每选中一个m就减少一个,因为每一次选择后的范围逐渐缩小,所以n-i随着循环变量i的递增而递减。迭代方式的代码如下:

void randSelect (int m,int n){                
if(m<0 || m>=n)                       //相当于     assert(m<=n && m>=0);
return;  
    for(int i=0;i<n;i++)                  //概率用于判断选不选中i
        if((bigrand()%(n-i))<m){          //相当于randint(0,n-i-1)<m
            cout<<i<<"\n";
            m--;
        }
}

  递归方式的代码如下:

void randSelect(int m,int n){       
    if(m<0 || m>=n)                       //相当于assert(m<=n && m>=0);
        return;  
    if(bigRand() % n < m ){                //概率用于判断选不选中n-1
        printf("%d\n",n-1);                //选择n-1,则下面只需要再选m-1 个
        randSelect(m-1,n-1);
    }else{
        randSelect(m,n-1);          //不选择n-1,则还需要再选m 个
    }
}

  其中:if(bigrand()%(n-i)<m) 的就相当于概率:m/(n-i)。可以从数学的角度分析,每个数选中的概率都是m/n

    数:选中概率

    0: m/n

    1:m/n * (m-1)/(n-1) + (1-m/n) * m/(n-1) =m/n;

    2:好多项相加,这里就不写了。。。

    ……

  其中的 srand 语句是我为了在每次运行时能够得到不同的随机数序列而加的。

  不知道这个方法有没有出乎你的意料,算上 for 循环才 4 句话。不过,就是这 4 句话,已经很完美地解决了上面提出的问题,有序、互异、m 个数。这个方法通过顺序扫描 0~n-1 范围内的每个数保证产生的随机数有序和互异。那产生 m 个数怎么保证呢?有没有类似循环的语句来控制产生的随机数的个数。要记住,程序语句只是我们实现某种功能的一种表现形式,这也就是说,我们要实现某种功能,可以由很多种表现形式。下面就来分析一下这个方法是如何保证能够产生 m 个随机数的。

想必大家都已经看到产生的随机数都是在 if 语句中输出的,那也就是说 if 语句控制了产生的随机数的个数。因此,要说明能够产生 m 个随机数,只要说明 if 语句能够执行且仅能执行 m 次。

  首先,在 for 循环没有退出的情况下,if 语句最多能够执行 m 次,因为 if 语句的每次执行都会使 m 的值减 1,当 m=0 时,if 语句就不会再执行了。因为 (bigrand() % (n - i)) 是一个非负数。

  其次,我们分析 if 语句最少能够执行多少次。如果 if 语句开始一直得不到执行,那么 (n-i) 便会一直减小,直到与 m 相等。此时if语句中(bigrand() % (n - i))的值一定会小于(n-i),也即小于m,因此这次if语句得到第一次执行。而此时 for 循环已经执行了 (n-m+1) 次,离推出循环只剩下 (m-1) 次,要使得 if 语句能够执行 m 次,那么接下来的 (m-1) 次循环 if 语句必须都得能够执行。事实是什么样的呢?第一次 if 语句开始执行时,(n-i)=m,执行完时,(n-i) m 都减少 1,从而仍然有 (n-i)=m 成立。这个过程一直持续到 m=0。刚好是 m-1 次。

  我的理解:因为rand()%value的值是(0, value-1),把这个运算看做select [0, value) 。一般n>>m,所以n-i=value一开始也比m大,那么在(0, value)中通过rand运算找出一个小于m的数的机会很多,因为每次都可以从大数一直找到0。如果rand运算没有找到小于m的数(毕竟是random select),那么由于 i 的增大,value将一直减小到m,这时继续select[0, value) 就相当于select[0, m),随便怎么rand()%结果都是小于m的,而且接下来的每次也都是小于m的。因此,我们可以相信 if 语句确实能够执行且只能执行 m 个。

解法二:基于集合的算法:在一个初始为空的集合里面插入随机整数,直到个数足够。这是一种最简单的方法,核心问题是如何实现集合S,集合是一个容器,它其中包含的元素值是唯一的。我们可以考虑有序链表、二叉树等数据结构,在这里使用c++容器类set来简化实现,基本思路是用set 保存每次选择的随机数,然后下次选择的时候,先判断set当中是否存在了该数值,如果存在则抛弃,不存在则添加进set,直到set中元素数目达到m为止。当m较小时(远小于n),可以达到m*logm。(c++ 模板库当中set 的插入可以达到O(logm) )

#include <iostream>  
#include <cstdlib>  
#include <set>  
int bigrand(){  
    return RAND_MAX*rand()+rand();  
}  
void gensets(int m,int n){  
    set<int> s;
    set<int>::iterator iter;
    while(s.size() < m)
        s.insert(bigrand() % n);          //相当于s.insert(randint(0,n-1));
    for(iter=s.begin(); iter!=s.end(); ++iter)
        cout<<*i<<endl;
}  
int main(void){  
    gensets(5,15);  
    return 0;  
}

解法二的改进上面的代码工作没有问题,但是当m接近n且很大时,最后几个数的产生将会很困难,因为会生成大量的重复的数。在编程珠玑第12的习题9题中提到,当m接近n时,基于集合的算法生成的很多随机数都要丢掉,因为他们之前已经存在于集合中了为此R.W.Floyd提出了一个算法,使得即使在最坏情况下也只使用m随机数进行仅m次选择并通过数学证明了该算法保证每个元素被选中的概率相等。Bob Floyd给出的算法即使在最坏情况下也只使用m个随机数,这个函数中只循环了m次,每次生成一个随机数若它不存在于set中则直接添加进set,若存在则把当前循环变量j添加入set中,这样保证了m次循环后集合中一定有m个数字元素,正确性证明暂时不懂。如下(C++实现):

void genfloyd(int m,int n){
    set<int> S;
    set<int>::iterator iter;
    for(int j=n-m;j<n;j++){       //只循环了m次
        int t=bigrand()%(j+1);    //相当于randint(0,j);
        if(S.find(t)==S.end())     //search t
            S.insert(t);          //t not in S
        else
            S.insert(j);          //t in S
    }
    for(iter=S.begin();iter!=S.end();++iter)
        cout<<*iter<<"/n";
}

解法三:把包含整数0~n-1的数组顺序随机打乱,这样前m个数字就相当于是一组随机的m歌数字,然后把这些元素排序后输出。当然,如果产生的随机数不需要按顺序输出,则可以直接在代码中把sort(x,x+m)这一行去掉。

int arr[n];
void genshuf(int m,int n){
    int i,j;
    for(i = 0;i<n;i++)
        arr[i] = i ;
    for(i =0;i<m;i++){
        j = randint(i,n-1);    // randint(int i,int j) 产生i与j 之间的随机数

        int t = x[i];        //swap(x[i],x[j]):交换x[i],x[j]
        x[i] = x[j];
        x[j] = t;
    }
    sort(x,x+m);
    for(i = 0;i<m;i++)
        cout<<x[i]<<"\n";
}

算法需要n个元素的内存空间,O(n+mlogm)的时间(一次初始化和排序)。这个算法的问题是,如果n很大,m很小,对辅助空间arr[n]的浪费太严重。因为开辟了那么大的空间,实质只用了很少一部分。性能通常不如Knuth算法

解法四:先选中前k个元素,然后开始遍历剩下的元素,如第k+1个元素来的时候,以k/(1+k)的概率去替换之前k个元素中的一个,至于替换哪一个,就是均匀的选了。这种方法还适合在总数量未知的情况下使用。在编程珠玑1212.5.10中,介绍了如何在n未知的情况下n对象中随机选择一个,而本题目相当于在n未知或者已知)的情况随机的选出k元素,二者思想是相同的。

对于新来的这个元素,被选中的概率是:k/(k+1) (替换)对于之前的元素,被选中的概率是:(1-k/(k+1))+k/(k+1)* (k-1)/k= 1/(k+1) + (k-1)/(k+1)= k/(+1)(不替换+替换但没被选中替换出去);第k+2个元素来的时候,以k/(k+2)的概率去替换之前k个元素中的一个,时间复杂度:O(n)。

小结:前三种解法各有千秋,解法1的时间复杂度为O(n),因为它需要遍历全部n个元素决定选取与否。但是有时候,你只是为了选择仅仅m个数,但是却花了n那么多时间,感觉有点浪费时间了。所以个人觉得当取的m比较接近于n的时候,比较适合用解法1。但是如果是m 相对于n 来说,小的多的时候,就很适合方法二了,因为当m < n/2 的时候,每次选中抛弃的平均的重复次数,不会超过2次。方法3的话,如果已经保存了一个数组,那么速度就相当的快,也不会担心多花了O(n)的时间了,其他的情况通常不建议使用方法3,空间和时间开销都没有优势。

问题二:描述:在不知道文件总行数的情况下,随机读取文件中的一行。

最直观的做法就是,先读取一次文件,确定总行数n。然后产生一个1-n的随机数m,再读取第m行。显然这是可行的,但是问题是如果文件很大,平均要遍历文件1.5次,效率很低。而且如果文件在不断增长,那么这个方法就不行了。

通过上面的算法的启发,其实也可以只读取一次。

  首先读取第1行,如果只有一行,就结束了,存储到line

  如果有第2行,那么以1/2的概率替换line;这时12两行被选中的概率都是1/2.

  如果有第3行,那么以1/3的概率替换line;则第3行被选中的概率是1/312两行被选中的概率则都是1/2*2/3=1/3.

  ……

  如果有第i行,以1/i的概率替换line

  ……

  直到文件结束。

void GetOneLineRand(const char *fname)  {  
    string line,str_save;  
    ifstream ins(fname);  
    int cnt=1;  
    while(getline(ins,line)){  
        if(cnt==1){  
            str_save = line;  
        }  
        else {  
            if(RandInt(1,cnt)==1)//这里的if(RandInt(1,cnt)==1)里的1,可以是[1,cnt]中任意一个值,概率均为1/cnt。
                str_save = line;  
        }  
        cout<<cnt<<" : "<<line<<endl;  
        cnt++;  
    }  
    cout<<"rand line : "<<str_save<<endl;  
    ins.close();  
}  

问题三:在不知道文件总行数的情况下, 随机读取文件中的k行。先去读k行,保存在一个数组中(假设文件至少有k行),然后每读取一行,都以k/n的概率替换数组中的任意一行,其中n为当前总共读取的行数。

void GetRandLines(const char *fname, int k)  {  
    string * kstr = new string[k], line;  
    ifstream ins(fname);  
    int cnt=1;  
    while(cnt<=k){            ///先读取前k行    
        if(getline(ins,kstr[cnt-1]))   
            cnt++;  
        else    
            break;        ///文件没有k行,直接退出  
    }  
    while(getline(ins,line)) {  
        if(RandInt(1,cnt)<=k){          /// p=k/cnt  
            swap(kstr[RandInt(1,k)-1],line);///随机替换一行  
        }  
        cnt++;  
    }  
    for(int i=0; i<k ;i++){  
        cout<<kstr[i]<<endl;  
    }  
    cout<<endl;  
    delete[] kstr;  
    ins.close();  
}              

参考文章:

http://www.cnblogs.com/luxiaoxun/archive/2012/09/09/2677267.html

原文地址:https://www.cnblogs.com/blastbao/p/8290569.html