【编程珠玑】【第一章】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((7FFFFFFF,31个随机位最大值),windows下的RAND_MAX值为32767(0x7FFF,15个bit位的最大值),事实上其值和操作系统是无关的,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返回a和b之间的随机数。
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。
问题一:描述:程序的输入包含两个整数m和n,其中m<n。输出是0~n-1范围内m个随机整数的有序列表,不允许重复。从概率的角度说,我们希望得到没有重复的有序序列,其中每个选择出现的概率相等。
讨论这些问题前有两个假设:
1.有一个函数bigrand()能够返回很大范围内的随机整数(远远大于m和n)。
2.另一个函数randint(i,j)能够返回i..j范围内均匀选择的随机整数。
有三种解决方案:使用概率计算、逐个随机插入、内部乱序抽取,接下来逐一介绍。
解法一:扫描算法:扫描0~n-1共n个数字,通过随机数和概率测试来对每个整数进行选择,最终选择出m个数字,而且同时能保证输出结果是有序的。假设m=2,n=5,意味着从0,1,2,3,4这五个数字中等概率随机选择出2个数字来。选择第一个数0的概率为2/5,这个概率可以bigrand()%5来衡量,如果if(bigrand()%5)<2即bigrand()%5在0~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-1这n个数,以m/n的概率选中选中0:总共n个数,要选出m个;
对于0:从0开始到n-1这n个数,以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个元素中的一个,至于替换哪一个,就是均匀的选了。这种方法还适合在总数量未知的情况下使用。在编程珠玑12章12.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;这时1、2两行被选中的概率都是1/2.
如果有第3行,那么以1/3的概率替换line;则第3行被选中的概率是1/3,1、2两行被选中的概率则都是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