#C语言中的随机函数分析与生成m个不重复随机数算法比较
一说起随机函数,恐怕又有人说这是老生长谈了……一般很多人都形成了自己的固定格式,因为随机数用处比较大,用的时候比较多,拿过来就用了。但是新手不这么干,他们总是抱有疑惑,我就是一个新手,而且较菜……为了让跟我一样的菜鸟看明白,我会尽量的说得让高手们不屑一顾:)
计算机的好处是精确,所以它不擅长模拟信号,但它的缺点也是如此。于是在一些模拟问题上计算机遇到麻烦了……比如所随机数,因为函数嘛,计算过程总会是确定的,确定的算法就会生成确定的结果。各种编程语言返回的随机数(确切地说是伪随机数)实际上都是根据递推公式计算的一组数值,当序列足够长,这组数值近似满足均匀分布。c的标准函数库提供一随机数生成器`rand`(定义在`stdlib.h`),能返回`0`-`RAND_MAX`之间均匀分布的伪随机整数(`RAND_MAX`至少为`32767`,一般都默认为`32767`)。
例如:
```C
#include<stdio.h>
#include<stdlib.h>
void main()
{
for(int i=0;i<10;i+)
printf("%d\n",rand());
}
```
如果我们是第一次运行,而且对其不太清楚,那么它生成的基本上算是`0`-`RAND_MAX`之间的等概率随机数列了。但是如果你第二次运行的时候会发现输出结果仍和第一次一样。:(原来`rand()`生成伪随机数时需要一个种子(计算伪随机序列的初始数值)才行,如果种子相同就会得到相同的序列结果(这就是函数的好处T_T)。这个“优点”被有的软件利用于加密和解密。加密时,可以用某个种子数生成一个伪随机序列并对数据进行处理;解密时,再利用种子数生成一个伪随机序列并对加密数据进行还原。这样,对于不知道种子数的人要想解密就需要多费些事了。当然,这种完全相同的序列对于你来说是非常糟糕的。要解决这个问题,需要在每次产生随机序列前,先指定不同的种子,这样计算出来的随机序列就不会完全相同了。
`srand()`用来设置`rand()`产生随机数时的随机数种子。在调用`rand()`函数产生随机数前,必须先利用`srand()`设好随机数种子(`seed`), 如果未设随机数种子, `rand()`在调用时会自动设随机数种子为`1`(有人说默认是`0`,困惑中)。上面的两个例子就是因为没有设置随机数种子,每次随机数种子都自动设成相同值 ,进而导致`rand()`所产生的随机数值都一样。(可能有人知道TC中的随机函数`random`,可是`random`函数并不是`ANSI C`标准,所以说,`random`函数不能在`gcc`,`vc`等编译器下编译通过。我们可以自己编一个^0^)我们需要使程序每一次使用的种子都不一样,现在主要问题是种子`srand`的选择是不是接近随机(不存在完全随机),你也可以人为指定种子数。`Windows 9x/NT`的游戏`FreeCell`就允许用户指定种子数,这样用户如果一次游戏没有成功,下次还可以以同样的发牌结果再玩一次。但我们还是喜欢系统自动生成……最简单的方法就是利用系统时间了(几乎所有的人都这么做),因为时间的数值随时间变化而变化,运行两次,一般不会出现前一次和后一次相同的局面,`time(NULL)`会返回一个表示当前系统时间的整数(它在`time.h`中,`time()`函数求出来的是自`unix元年1970年1月1日`到现在的秒数,有的嫌麻烦会写作`time(0)`)。我们这么来写:
```C
#include<stdio.h>
#include<stdlib.h>
#include<time.h>
void main()
{
srand((int)time(0));
for(int x=0;x<10;x++)
printf("%d\n",rand());
}
```
据说如果软件一直开两天,种子会有`1/(60*60*24)`个可能会重复,虽说这对于一般人已经绝对足够了,但总使人不舒服,于是我在网上看到有这么写的:
```C
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/timeb.h>
void main(void)
{
int i;
unsigned int seedVal;
struct timeb timeBuf;
ftime(&timeBuf);
seedVal=((((unsigned int)timeBuf.time&0xFFFF)+
(unsigned int)timeBuf.millitm)^
(unsigned int)timeBuf.millitm);
srand((unsigned int)seedVal);
for(i=0;i<10;++i)
printf("%d\n",rand());
}
```
(下面是关于它的解释,但我也不是太明白,引用过来大家分析一下)
> 上面的程序先是调用_ftime()来检查当前时间,并把它的值存入结构成员`timeBuf.time`中,当前时间的值`从1970年1月1日开始`以秒计算。在调用了`_ftime()`之后,在结构`timeBuf`的成员`millitm`中还存入了当前那一秒已经度过的毫秒数,但在`DOS`中这个数字实际上是以百分之一秒来计算的。然后,把毫秒数和秒数相加,再和毫秒数进行异或运算。当然也可以对这两个结构成员进行更多的计算,以控制`seedVal的`取值范围,并进一步加强它的随机性,但上例用的逻辑运算就已经足够了。
看下面代码:
```C
void main()
{
for(int i=0;i<100000;i++)
{
srand( (unsigned)time( NULL ) );
printf("%d\n",rand());
}
}
```
为什么总是生成一个数???因为此程序产生一个随机数之前,都调用一次`srand`,而由于计算机运行很快,所以每用time得到的时间都是一样的(`time`的时间精度较低,只有`55ms`)。这样相当于使用同一个种子产生随机序列,所以产生的随机数总是相同的。把`srand`放在循环外看看:
```C
srand( (unsigned)time( NULL ) );
for(int i=0;i<100000;i++)
printf("%d\n",rand());
}
```
问题解决了:)
既然生成的是 `0`-`RAND_MAX`之间均匀分布的随机整数(我们姑且把以上方法生成的是理想的随机数吧),那么要生成`0`-`x`之间(包括`0`不包括`x`)的随机数就把`rand()`改成 `rand()/(double)RAND_MAX*x` ,要生成`x`-`y`之间(包括`x`不包括`y`)的就是 `rand()/(double)RAND_MAX*(y-x)+x` 了。但是如果要生0-10之间的整数很多人会这么写:
```C
#include<stdio.h>
#include<stdlib.h>
#include<time.h>
void main()
{
srand((int)time(0));
for(int x=0;x<10;x++)
printf("%d\n",(rand()%10));
}
```
那么生成`x`到`y`之间(包括x不包括y)的随机数就是 `rand()%(y-x)+x`
???懂一点概率的就知道这样写,会使得到的数列分布不均的,但是大家确实都喜欢这么写……因为在x的值比较小,RAND_MAX相对较大,而生成的数列有不算太大,又是用来解决精确度要求不高的问题(如一些游戏目标,传说游戏中踩地雷式的遇敌就是用`rand()`实现的. 当主角在地图上走的时候动不动冒出三两小兵挑衅兼找死.它的实现方式是设主角所立位置为0,主角每走一步,变量加1,当变量==随机取得的数时,小兵出现),这样写足够了……
## 下面再讨论生成m个小于n的不重复随机数的算法
更确切的说是生成n以内的m个不重复的随机整数(`m<=n`)。通俗点讲就是不放回随机抽样……当`m=n`的时候,这种算法会被称为洗牌算法,当然,当`m<n`的时候,有时候习惯上也称其为洗牌算法……
本人认为算法结构很重要,但语句结构也很重要,所以我喜欢一个算法给出多个语句结构……
最容易想到的傻瓜算法,逐个产生这些随机数,每产生一个,都跟前面的随机数比较,如果重复,就重新产生:
**算法一(1)**
```C
srand((unsigned)time(NULL));
for(j = 0; j < m; j++) {
loop:a[j] = rand() % n;
for(i=0;i<j;i++){if(a[i]==a[j])goto loop;
}
```
很早的时候学编程都喜欢用goto语句,因为过去算法是用流程图表示的,用goto可以直接转译,而且循环相关理论发展不完善,仅仅是作为条件分支的一个补充,如果条件分支箭头向上就是一个循环,而且goto可以实现过去循环所不能实现的结构,形成很巧妙的循环交叉。所以过去一般认为有两种结构,顺序和分支,循环是分支的特殊情况。但是break和continue语句使这些结构实现成为可能,后来发现goto的使用会造成维护和调试的许多麻烦,所以循环逐渐代替了goto,使程序更有层次。现在编程不建议用goto了,如果用goto还会被认为编程修养不好……
言归正题,把它的goto去掉:
**算法一(2)**
```C
srand((unsigned)time(NULL));
int j=0;
while (j<m)
{
a[j] = rand() % n ;
for(i=0;i<j;i++){if(a[i]==a[j]) break; }
if(i<j)continue;
j++
}
```
但是后来都建议用for循环,这样可以使循环条件跟紧凑:
**算法一(3)**
```C
srand((unsigned)time(NULL));
for(j=0;j<m;j++)
{
a[j]=rand()%n;
for(i=0;i<j;i++)
{
if(a[i]==a[j]){i=-1;a[j]=rand()%n;}
}
}
```
但这是个很笨的方法,且比较次数呈线性增长,越往后次数越多……另一个想法是生成一个就把它从中去掉,就可以实现不重复了:
**算法二(1)**
```C
for (i=0;i<n;i++)
a[i]=i+1;
for(i=0;i<m;i++)
{
j=rand()%(n-i);
b[i]=a[j];
for (k=j;k<n-i-2;k++)
a[k]=a[k+1];
}
```
b[]是生成的随机数列。
这样做也太麻烦了,生成的直接做个标记不就行了?
**算法三(1-1)**
```C
i=rand()%m;
a[i]=1;b[0]=i;
for(j=1;j<m;j++)
{
for (i=rand()%m;a[i]==1;i=rand()%m);
b[j]=i;a[i]=1;
}
```
写紧凑一些吧,直接:
**算法三(1-2)**
```C
for(j=0;j<m;j++){
for(i=rand()%m;a[i]==1;i=rand()%m);
b[j]=i;a[i]=1;
}
```
但是我看到了更简洁的:
```C
int n=某值;
int a[n]={0};
for (i=1;i<=n;i++)
{
while(a[x=rand()%n]);
a[x]=i;
}
```
这个无循环体的while保证a[m]是初始化的0状态。这生成了n个,我们只取m个,这太浪费了,结合一下:
```C
int n=某值,m=某值;
int a[n]={0},b[m]
for (i=1;i<=n;i++)
{
while(a[x=rand()%n]);
b[i]=x;a[x]=1;
}
```
但是总叫人不舒服,对这种算法谁有更好的写法呢?
(传说当n较大时,算法一和算法三有使程序陷入死循环的风险)
这种算法越到后面,遇到已使用过的元素的可能性越高,重复次数就越多,但是当m较小时还是很好的:)
这都不太让人满意么?看看下面这个:
**算法四(1)**
```C
for (i=0;i<n;i++)
a[i]=i+1;
for (i=n-1;i>0;i--)
{
w=rand()%(i+1);
t=a[i];
a[i]=a[w];
a[w]=t;
}
```
这个算法很不错,有人会怀疑其随机性,但个人认为是没问题的,首先第二行按顺序用0到n填满整个数组;第三行,是随机产生n-1个1到i(包括1也包括i)的数组下标,把这个下标的元素值跟下标为i的元素值交换,一直进行到下标为1的元素。因此它只需要遍历一次就能产生全部的随机数。
但这样会生成n个小于n的随机数,也就是说这其实是个洗牌算法,但是我们只要m个,再加两句:
```C
for(i=0;i<m;i++)
b[i]=a[i];
```
b[]是生成的随机数列
如果m和n很接近的话或者相等是最好,但可能不是……那改一下:
**算法四(2)**
```C
for (i=0;i<n;i++)
a[i]=i+1;
for (i=0;i<m;i++)
{
w=rand()%n;
t=a[i];
a[i]=a[w];
a[w]=t;
}
```
但是条件反过来了,如果m远小于n还行,如果接近,其随机性就存在问题了(为什么?因为总体在抽样过程中是减小的),再改一下:
**算法四(3)**
```C
for (i=0;i<n;i++)
a[i]=i+1;
for (i=0;i<m;i++)
{
w=rand()%(n-i)+i;
t=a[i];
a[i]=a[w];
a[w]=t;
}
```
这样可以万无一失了……
**【2011年11月25日0点34分重新编辑添加】**
上面的算法想到的都是在a[0]到a[m-1]中存的自然是我们想要的随机数……当然也可以反过来取……其实算法四(1)快要成功了……
**算法四(4)**
```C
for (i=0;i<n;i++)
a[i]=i+1;
for (i=n-1;i>n-m-1;i--)
{
w=rand()%(i+1);
t=a[i];
a[i]=a[w];
a[w]=t;
}
```
坑爹呀!这跟算法四(1)好像基本一样嘛!!!(好吧,我承认其实我是直接复制过来的……)但是有一点不太一样……呃……好吧,其实这点不一样也没有什么不一样的……因为算法四(1)生成的是n个,这个生成的是m个,自然在循环条件上有点小差异了……但是……还是有一点不一样啊……那就是……这m个随机数生成在a数组的后面……从a[n-1]和a[n-m]就是我们想要的随机数……如果你非要取出来,可能是这样:
```C
for(i=0;i<m;i++)
b[i]=a[n-i-1];
```
这就是所谓一念之差啊……
*之前这篇博文因为一念之差,算法四(1)少了个"+1",结果,导致生成的随机数有问题:生成的随机数的频数分布矩阵的对角线全是0。但是这个问题却一直未被人发现,直到两年后,楼下有位热心的新浪网友发现了这个问题,而这个问题也直到博文发表三年后的今天才被更正过来……悲剧啊……我希望这个“+1”不会误导太多的网友……但是对于那些没有考究过本文就转发并且还不标明转发出处的那些网友,本人就无能为力了……*
**【2011年11月25日0点34分重新编辑添加】**