C 语言的随机数来自 <stdlib.h> 中的两个函数:rand() 生成下一个值,srand() 设置起点。它们并不是真正的随机数,而是根据种子确定性地计算出来的伪随机序列。这对密码学来说是个限制,对测试来说却是个优点。
rand() 与 RAND_MAX
rand() 返回一个介于 0 和 RAND_MAX 之间(含两端)的 int。RAND_MAX 是一个宏,保证至少为 32767;在 Linux 和 macOS 上它是 2147483647。
运行两次。两次的数字完全相同,而这并不是 bug。
用 srand 设置种子
不调用 srand 时,序列的行为就像你调用了 srand(1)。种子相同,序列就相同,每次运行都一样。要让每次运行得到不同的数字,就用一个会变化的值作为种子,通常是当前时间:
<time.h> 中的 time(NULL) 返回从 1970 年开始到现在的秒数,所以每次运行都会得到不同的种子。转换为 unsigned int 是为了消除 time_t 窄化转换的警告。
关于种子有三条规则,每一条都常被弄错:
只设置一次种子,放在 main 的开头。每次调用 rand() 之前都调用 srand 是经典的反模式:在一个不到一秒就执行完的循环里,time(NULL) 每次迭代都返回同一个值,于是你一再用同一个数重设种子,rand() 每次都返回同一个首值。输出就是一列完全相同的“随机”数。
不要为了“改善”随机性而重设种子。 生成器的质量来自其内部状态的不断推进;重置这个状态等于把整个序列丢掉。
time(NULL) 的精度是一秒。 在同一秒内启动的两个程序会得到相同的序列。对游戏来说没问题,但对任何需要独立性的场景就不对了。
指定范围内的随机数
标准写法使用取余运算符:
rand() % n /* 0 to n-1 */
rand() % n + min /* min to min+n-1 */
要得到 min 到 max(含两端)的值,可能取值的个数是 max - min + 1:
+ 1 正是差一错误的藏身之处。rand() % 6 得到 0 到 5,所以掷骰子是 rand() % 6 + 1。为了“包含 6”而写成 rand() % 7 + 1,你就得到了一个七面骰子。
关于取模偏差的实话
除非 n 能整除 RAND_MAX + 1,否则 rand() % n 并不是完全均匀的。
用小数字来想一想。如果 RAND_MAX 是 9,也就是 rand() 返回 0 到 9 这十个等概率的值,那么 rand() % 3 会把 0、3、6、9 映射到 0;把 1、4、7 映射到 1;把 2、5、8 映射到 2。结果 0 在十种情况中占四种,结果 1 和 2 各占三种。0 出现的可能性高出 33%。
真实的 RAND_MAX 也有同样的偏差,只是小得多:多出来的值是前 (RAND_MAX + 1) % n 个结果,每个大约在 21 亿次中多一次机会。对掷骰子、洗牌或模拟来说,这根本无法测量,用 % 就好,不必纠结。
当它确实重要时,比如统计工作或任何与安全相关的场景,应该丢弃多出来的值,而不是把它们折叠进来:
这个循环丢掉会造成偏差的那一小段值,然后重新抽取。它很快就会结束,因为被拒绝的部分只占整体的极小比例。
对于真正对安全敏感的随机数,无论多小心,rand() 都是错误的工具:在 macOS 和 BSD 上使用 arc4random_buf,在 Linux 上使用 getrandom(),在 Windows 上使用 BCryptGenRandom。
随机浮点数
除以 RAND_MAX 得到 [0.0, 1.0] 中的值,再进行缩放:
(double) rand() 中的强制转换必不可少。没有它,rand() / RAND_MAX 就是整数除法,几乎总是得到 0,只有二十亿分之一的机会碰到最大值时得到 1。这个 bug 看起来就像“我的随机浮点数全是零”。原因请参阅类型转换。
可复现的序列
固定的种子每次运行都会得到完全相同的序列,这正是测试、调试,或者带有可分享关卡代码的游戏所需要的:
种子 42 每次使用都会生成相同的五个数,无论是在这次运行中,还是在同一个库上的任何其他运行中。正因为可以复现,模拟程序应该允许选择种子:平时用时钟作为种子运行,复现 bug 时传入固定种子。
有一点要注意:给定种子对应的序列是不可移植的。不同的 C 库使用不同的生成器,所以 glibc 上的种子 42 和 Windows 上的种子 42 会给出不同的数字。在同一台机器上可以复现,跨机器则不行。
掷骰子游戏
把所有内容组合起来:只设置一次种子,一个处理范围的辅助函数,再用一个数组统计结果:
直方图应该在 7 处达到峰值,并向 2 和 12 逐渐减少:凑出 7 有六种方式,凑出 2 或 12 各只有一种。如果这里的生成器给出的是平坦分布,那它就是坏的。
两个相关页面:标准库介绍了 <stdlib.h> 的其余部分,数学函数介绍了 <math.h>,一旦随机值要参与真正的计算,你就会用到它。
常见问题
C 语言中怎么生成随机数?
包含 <stdlib.h>,在 main 开头用 srand((unsigned) time(NULL)) 设置一次种子(需要 <time.h>),然后每要一个值就调用一次 rand()。rand() 返回一个介于 0 和 RAND_MAX 之间(含两端)的 int。
C 语言中怎么得到两个值之间的随机数?
使用 rand() % (max - min + 1) + min。掷一个 1 到 6 的骰子就是 rand() % 6 + 1。% n 把结果映射到 0..n-1,加上 min 则平移这个区间。只要确保个数包含了两个端点就行,+ 1 就是为此而加的。
为什么我的 C 程序每次打印的随机数都一样?
因为你从没调用过 srand。没有种子时,rand() 的行为就像种子是 1,所以每次运行都会生成完全相同的序列。在程序开始时调用一次 srand((unsigned) time(NULL)),只调用一次,而不是每次调用 rand() 之前都调用,那样只会更糟。
生成随机数时的取模偏差是什么?
只有当 n 能整除 RAND_MAX + 1 时,rand() % n 才是完全均匀的。否则在整个范围内,最前面几个值会多出现一次,使它们的概率略微偏高。RAND_MAX 为 2147483647 且 n 很小时,这种偏差远低于游戏或模拟能察觉的程度,但在密码学或统计学中,请使用拒绝循环或合适的随机数生成器。