热知识(一):乱谈随机数
minermouse · · 科技·工程
又有一个不知道啥时候更下一篇的系列开更了!(悲)
(小蒟蒻第一次写专栏,写的不好的地方请指出来)
(感觉这个放休闲娱乐也许更好?)
Part -1. 前言
随机数,这个我们编程中经常用到的工具,你真的了解它的原理吗?
当然知道。
Part 0. 伪随机数
你可能觉得你平时根本用不到随机数,但实际上,我们平时写程序时,很多时候都需要随机数,比如:
- 对拍,用随机数据测试程序的正确性
- 生成随机数据集,评估算法性能
- 实现随机化算法或者用随机化技巧(随机乱搞)
- 写模拟退火
- ...
不过,你真的了解计算机是如何生成随机数的吗?随机数看起来是"随机的",但实际上计算机作为确定性系统,要产生真正的随机性并不容易。一般来说,在OI中,我们使用的其实是'伪'随机数,由确定的算法生成,只是"看起来"随机罢了。
::::info[Note: PRNG]{open} 伪随机数生成器 (Pseudo-Random Number Generator,PRNG) 就像一个数学函数,通过固定的数学公式更新:给它一个输入(种子),它就会输出一个看起来随机的数/序列。关键是:相同的种子产生完全相同的数/序列。
特性:
- 确定性:相同种子->相同初始状态->完全相同的序列
- 周期性:有限状态->序列最终会重复(周期长度对于一个伪随机数生成器的质量是重要指标)
- 伪随机性:看起来随机但不是真随机 ::::
Part 1. 经典C语言伪随机数
你用过rand()吗?
用过?好,那你知道在调用rand()前必须先调用一个srand()吗?
知道?那你知道为什么吗?
知道! 不知道?那让我来解释一下吧:
::::info[Why?] 如果你看过上面那个折叠框里的内容,你就会知道什么是伪随机数生成器。
实际上,rand()就是一个伪随机数生成器,srand()就是用来给rand()设定种子的函数。
通常,rand()内部实现为一个线性同余生成器(LCG,或线性同余发生器)。
::::success[什么是线性同余生成器?]
百度百科上的定义。
简单来说,线性同余生成器就是一个公式,根据种子和上一个值进行递推,来算出下一个值。
递推公式:
由这个公式,还衍生出了最小标准LCG(首次出现于1988年Park与Miller的论文中),也就是对应C++标准库中的minstd_rand(较新,后面还会讲到它)。
::::
Part 2. <random>
在了解rand()和srand()后,你可能觉得它们简单易用,但它有以下几个缺点:
::::error[rand()的几宗罪]
-
rand()大多数实现生成随机数生成质量低下。 -
rand()大多数实现周期较短 。 -
rand()生成的数据大小依赖于RAND_MAX(本人电脑使用MinGW-GCC 11.5.0 64-bit Debug,其中的RAND_MAX大小为32767),而为了生成比RAND_MAX大的数,需要额外处理(比如做:int bad_big_rand(){ return rand()+(rand()<<15)+(rand()<<30);//丑陋的拼接 }这种丑陋的拼接)。
-
如果直接用:
int res=rand() % n; //poor cout<<res; //Print the random number.会导致数据生成不均匀。 :::warning[为什么这样生成的数据不均匀?] 假设n为10000(生成0-9999的随机数),
RAND_MAX为32767。 32768除以RAND_MAX是3余2768,所以0-2768的数平均每10000次会出现4次,而2769-9999的数平均每10000次只会出现3次!
设
那么,
整整相差了约33.3%! :::
- 想在多线程环境下用
rand()?请看下面这个例子:#include<bits/stdc++.h> //多线程下使用rand()并输出结果 void thread_func(int id){ for(int i=0;i<5;i++){ int r=rand(); //获取随机数 std::cout<<"Thread "<<id<<": "<<r<<'\n'; //输出线程id和随机数 } } int main(){ srand(time(0)); //设置种子 std::vector<std::thread>threads; for(int i=0;i<4;i++) threads.emplace_back(thread_func,i); //创建4个线程 for(auto&t:threads) t.join(); //等待所有线程结束... return EXIT_SUCCESS; }我电脑上的输出:
Thread Thread Thread 3: 41 Thread 3: 18467 Thread 3: 6334 Thread 3: 26500 Thread 3: 19169 Thread 2: 41 Thread 2: 18467 Thread 2: 6334 Thread 2: 26500 Thread 2: 19169 0: 41 Thread 0: 18467 Thread 0: 6334 Thread 0: 26500 Thread 0: 19169 1: 41 Thread 1: 18467 Thread 1: 6334 Thread 1: 26500 Thread 1: 19169这4个线程的输出居然是同一段的打乱的序列(41,18467,6334,26500,19169)!这违背了随机数的初衷。
你能一眼看出这段程序的问题吗?(提示:rand()不是线程安全的。)
(其实cout在多线程下也会出现打印顺序混乱。)
没错,在多线程环境下使用rand(),会导致数据竞争。
:::info[Why?]
这种现象正是多线程环境下使用rand()函数导致数据竞争的典型表现。不同线程输出了完全相同(只是打乱)的随机数序列,这正是rand()在设计上的(另一个)缺陷。
问题在于rand()函数内部维护着一个全局状态(通常是一个静态变量)。每次调用rand(),它都会根据这个状态计算下一个随机数并更新该状态。
当多个线程同时调用 rand()时,就会发生以下情况:
-
竞争全局状态 :多个线程会不加控制地同时读写这个唯一的全局状态变量。
-
序列化执行 :虽然由于竞争,线程的执行顺序是不确定的,但最终每个线程都从同一个起点(相同的全局状态)开始获取随机数,并且它们的调用会交替地修改这个状态。
-
结果混乱与重复 :数据竞争导致每个线程都只能得到完整随机序列的一小部分、错位的一部分,甚至在这里,多个线程恰好输出了完全相同的序列片段。这完全破坏了随机数的预期行为。 ::: ::::
正因rand()有如此多的问题,C++11为我们带来了一个全新的库——<random>,一个更灵活、更强大、线程安全、更科学、周期更长、质量更高 、更美妙、更鲁棒、更复杂、更看不懂的随机数生成库,解决了rand()的所有痛点。
Part 2.1. <random>,Part 2
<random>库采用了模块化设计,将随机数生成分为三个独立的部分:
-
随机数引擎->产生伪随机数的"算法引擎"
-
随机数分布->将均匀分布映射到特定分布
-
随机数设备->获取真随机种子
::::info[一些<random>里常用的随机数引擎对比]
| 引擎 | 周期长度 | 速度 | 质量 | 典型场景 | 评价 |
|---|---|---|---|---|---|
mt19937() |
通用推荐 | 黄金标准,质量极高 | |||
mt19937_64() | mt_19937() |
|||||
minstd_rand() (还记得它吗?) |
简单快速 | 快,但质量一般 | |||
ranlux24() |
约 |
高质量模拟 | 极高质量,但慢 | ||
ranlux48() | 约ranlux24(),但状态空间更大 |
|||||
knuth_b() |
兼容性(或其他什么不知道的东西) | 经典算法,中庸之道 |
PS:原理放在最后 ::::
Part 2.2 <random>,Part 3
如何使用<random>?
话不多说,直接上代码:
::::info[一个掷骰子的示例]{open}
//掷骰子
#include<bits/stdc++.h>
int main(){
//1.创建随机设备(获取真随机种子)
std::random_device rd; //后面还要考!
//2.选择随机数引擎(推荐mt19937)
std::mt19937 gen(rd());
//3.选择分布(这里生成1-6的均匀整数)
//分布有很多种,这里就不展开来讲了
//可以不选,但不选的话它的输出不均匀
std::uniform_int_distribution<int>dis(1,6);
//4.生成随机数
for(int i=0;i<10;i++)
std::cout<<dis(gen)<<" ";
return EXIT_SUCCESS;
}
::::
Part 2.3 <random>,Part 4
一个<random>扯出4小节???
如果<random>库不能解决rand()的诸多“罪状”,那我们为什么要用它呢?
正因它能解决rand()的各种问题,所以才被应用。
::::info[<random>如何解决rand()的各种“罪”?]
罪状1-2: 质量低下,周期短。
<random>的解决方法:
std::mt_19937 gen();
//mt_19937的周期极长
罪状3:数据大小限制(RAND_MAX的限制)。
<random>的解决方法:
//不用拼接了!
int good_big_random(std::mt19937 gen){
//直接生成64位随机数
std::uniform_int_distribution<long long>dis(0,LLONG_MAX);
return dis(gen);
}
罪状4:分布不均匀
<random>的解决方法:
std::uniform_int_distribution<T>dis;
//T means Type
罪状5:线程不安全
<random>的解决方法:
#include<bits/stdc++.h>
//每个线程有自己的引擎和分布,安全无忧
void thread_func_better_rand(int id){ //线程安全!!!
//thread_local保证每个线程创建自己的引擎实例
thread_local std::mt19937 gen(std::random_device{}()); //稍后在Part 3里会提到这个std::random_device
thread_local std::uniform_int_distribution<int>dis(1,RAND_MAX);
for(int i=0;i<5;i++)
std::cout<<"Thread "<<id<<":"<<dis(gen)<<'\n';
}
int main(){ //同上,只改了个函数名
srand(time(0));
std::vector<std::thread>threads;
for(int i=0;i<4;i++)
threads.emplace_back(thread_func_better_rand,i);
for(auto&t:threads)
t.join();
return EXIT_SUCCESS;
}
输出:
Thread Thread 0:2:20794
Thread 2:21562
Thread 2:4304
Thread 2:27644
Thread 2:24975
4456
Thread 0:14788
Thread 0:13527
Thread 0:14606
Thread 0:6180
Thread 3:20929
Thread 3:24298
Thread 3:5696
Thread 3:20076
Thread 3:16806
Thread 1:24286
Thread 1:11866
Thread 1:32394
Thread 1:4095
Thread 1:24948
得到的数据质量大幅提升! ::::
总结:rand()+srand() VS. <random> |
特性 | rand()+srand() |
<random> 库 |
|---|---|---|---|
| 随机性质量 | 一般(通常为LCG) | 高(如梅森旋转等) | |
| 周期长度 | 一般较短(实现定义) | 极长(如 |
|
| 范围控制 | 依赖 RAND_MAX,需手动处理 |
无硬性限制 | |
| 分布均匀性 | 取模操作导致偏差 | 严格均匀,无偏差 | |
| 线程安全 | 不安全(全局状态) | 安全(可线程局部实例) | |
| 分布类型 | 仅均匀分布(且不完美) | 丰富多样(正态、泊松等) | |
| 可预测性 | 种子固定则序列固定 | 种子固定则序列固定 | |
| 使用推荐(C++) | 不推荐使用 | 推荐使用 |
::::info[那为啥在OI里,rand()这么常见?]{open}
因为OI对随机数质量要求不大,只要方便就行。
而rand()很简单直接,符合OI的需求。
::::
Part 3. 真随机数
不管<random>中的随机数引擎再怎么强大,它也还是PRNG,是个确定性算法,对于那些对随机性要求极高的地方(如密码学、安全密钥生成、(或许还有)彩票系统等),就得用真正的随机性了——这就要用到真随机数生成器(TRNG) 了。
::::info[Note:TRNG]{open} 真随机数生成器(或真随机数发生器)(True Random Number Generator,TRNG)是一种用于生成随机数的设备,其输出的随机数是基于物理随机现象或过程产生的。与伪随机数生成器不同,真随机数生成器可以做到真正的随机。 ::::
但计算机系统是个确定性系统,该怎么实现真随机数生成器呢?
为了解决这个问题,人们想出了各种各样、五花八门的方法,其中包括(仅仅是一小部分!):
- 量子噪声:如光子的偏振、放射性衰变的时间间隔。
- 热噪声:电子元件中的热运动
- 硬件噪声:键盘敲击间隔、鼠标移动轨迹、磁盘读写延迟(也就是熵池)。
- 大气噪声:通过无线电接收的大气干扰信号。
柏林噪声:Minecraft用来生成地形的自然噪声生成算法,本质是伪随机数生成器
于是操作系统也提供了一系列接口来生成真随机数:
Linux/Unix:访问特殊文件/dev/random(阻塞型,当熵池中的熵不足时,会停止生成(被阻塞),直到收集到足够的熵)或者/dev/urandom(非阻塞型,当熵池中的熵不足时,会换用密码学安全的伪随机数生成器(CSPRNG,可简单理解为既不可预测又抗逆推的伪随机数生成器)继续生成)。
Windows:提供了CryptGenRandom API(或较新的BCryptGenRandom)来生成密码学安全的随机数。
MacOS:这个其实我也不太清楚(本人用的是Windows),有知道的人麻烦发一下评论区,我到时候更新下(挖坑ing...)
实际上硬件也有随机数生成指令,比如x86/x86-64硬件架构下提供了RDRAND和RDSEED两个指令。
好了,现在回到正题。
各种操作系统是提供了各种不同的随机数生成接口,但要保持跨平台的开发者咋办呢?你这么多不同的接口,那跨平台应用咋弄?
于是之前提到的<random>库又开始发力了。
Part 3.1. <random>, Part 5
没错,又是<random>。
其实之前的代码示例提到过了这章要讲的东西(并挖好了坑),不过没讲。
没错,就是那个std::random_device。(为啥没讲?因为还没到时间啊。)
为了能让可怜的应用们跨平台,C++又往<random>里塞了个叫做std::random_device的函数,用来生成非确定性的随机数,换句话说就是实现真随机数生成器。
为了实现真随机数生成器,random_device在理想情况下,会访问操作系统的真随机源,然后返回一个真随机数。
但这只是理想情况 啊啊啊,万一不理想呢?
然后就得看运气了...运气好,它会给用一个(较)高质量的伪随机数生成器代替真随机源;不好的话,那可能只有rand()的生成质量了。一般来书,这种情况在早期Windows下的旧版MinGW编译器中比较常见。
::::info[那如何判断呢?]{open}
使用name.entropy()来判断,若返回0.0,那你的编译器中的random_device很有可能是使用了伪随机数生成器(name替换为你的random_device的名字)。
::::
Part 3.2. random_device
(又是一小节)
那它有什么应用场景呢?(否则我们干嘛/凭什么用它?)
它的应用场景...考虑性能的话基本上只能用来生成种子。
为什么呢?那这又得扯到熵池了(别问我为什么之前没讲)。
::::info[Note:熵池]{open} 熵池是计算机系统中用于收集和存储随机性(熵)的软件或硬件机制,其核心目标是生成高质量的随机数,尤其在对安全性要求极高的场景(如密码学)中至关重要。
-
熵的来源与收集:计算机本身是确定性的,无法直接产生真随机数。但系统的运行环境中充满了不可预测的物理噪声,例如Linux内核会主动从多种来源收集这些噪声,例如硬件设备的中断时间间隔、键盘按键和鼠标移动的时序、磁盘I/O的完成时间等。
-
熵的估算:并非所有噪声都“同样随机”。为了量化池中数据的随机性,内核会进行熵估算。这个估算值(单位是比特)代表池中数据的不确定性程度。熵值越大,表示数据的随机性越好,产生的随机数质量越高。 ::::
然后为啥呢?
仔细观察熵池介绍中的熵的来源与收集。这里能解释。
还是没看懂?那就看下面吧。
::::info[Why?]{open} 继续观察熵池介绍中的熵的来源与收集,会发现它是依赖于系统的运行环境中不可预测的物理噪声,而万一这些物理噪声被我们“挥霍”完了呢?那不就没得用了?
其实就是:
- 首先,持续大量地使用
random_device去消耗熵池里的熵,可能会导致奇奇怪怪的性能减慢。 - 其次,这玩意会访问物理熵源(如硬件噪声)(或执行密码学操作),在速度上远慢于纯算法的伪随机数引擎(如
mt19937)。频繁调用可能导致性能瓶颈。
所以总结一下,就是性能问题。
因此,它一般是给伪随机数生成器如mt19937播种的,而不是直接使用。
::::
Part 4. 一些总结
好了,现在讲完了,是总结时间了。
随机数总结:
| 概念 | 伪随机数 (PRNG) | 真随机数 (TRNG) |
|---|---|---|
| 本质 | 确定性算法生成 | 物理现象测量 |
| 可预测性 | 可预测(知道种子) | 理论上不可预测 |
| 周期性 | 有周期 | 无周期 |
| 速度 | 快 | 慢 |
| 典型应用 | 模拟、游戏、测试 | 密码学、安全密钥 |
- 从
rand()到<random>
rand() 的主要问题:
- 质量低下(通常为简单LCG)
- 周期短(依赖实现)
- 范围受限(
RAND_MAX) - 分布不均(
rand() % n有偏差) - 线程不安全(全局状态竞争)
<random> 的优势:
- 模块化设计(引擎+分布+设备)
- 高质量算法(如
mt19937) - 丰富分布类型
- 线程安全(每个线程独立实例)
- 无范围限制
-
随机数生成方案选择
- 通用编程:
std::mt19937+分布类 - 密码学安全:系统真随机源(
/dev/urandom等) - 可重现测试:固定种子的伪随机引擎
- 多线程:每个线程独立的引擎实例
- 高质量模拟:
std::ranlux24/48 - OI用途:随便吧。
- 通用编程:
-
关键原则
- 弃用
rand():现代C++中不再推荐使用 - 避免取模偏差:使用
uniform_int_distribution - 种子管理:用
random_device或混合熵源 - 对象复用:引擎和分布对象应在循环外创建
- 理解需求:根据不同场景选择合适方案
- 弃用
最终建议:
- 学习时理解原理,实践中使用现代库
- 在安全敏感场景务必使用真随机源
- 根据实际需求而非习惯选择随机数方案
- 随机数是工具,正确理解才能正确使用
Part 4.1. 附:所有提到的<random>引擎的原理
::::info[<random>库各种伪随机数生成器引擎的原理]
:::success[minstd_rand、minstd_rand0]
其实和rand()一个原理。
历史背景:
线性同余生成器(LCG)是最古老、最经典的伪随机数生成算法,最早由D.H. Lehmer在1951年提出。而minstd_rand0和minstd_rand对应的参数分别来自两个重要的历史版本:
-
minstd_rand0:使用参数a=16807, c=0, m=2^{31}-1 ,这被称为"最小标准随机数生成器",由Lewis、Goodman和Miller在1969年提出,在20世纪70-80年代被广泛使用。 -
minstd_rand:使用改进参数a=48271, c=0, m=2^{31}-1 ,由Park和Miller在1988年提出,修正了原算法在某些统计测试中的弱点,被称为"最小标准随机数生成器的改进版"。
算法类型:
- 线性同余生成器 (LCG)
核心公式:
-
X_{n+1}=(a\times X_n + c)\mod m
参数配置:
-
minstd_rand0:
a=16807,c=0,m=2^{31}-1 -
minstd_rand:a=
48271,c=0,m=2^{31}-1
特点:
- 极快的生成速度
- 质量一般,可能存在可预测性
- 周期:
2^{31}-2 (约21亿,不会输出0) - 内存占用极低(仅存储一个32位整数)
应用场景:需要快速生成但质量要求不高的场合
趣闻:由于LCG算法简单,在20世纪80年代的计算机上,很多程序员会手写实现这个算法,而不是调用系统函数!(一对比,感觉我啥也不是...) :::
:::success[mt19937和mt19937_64]
历史背景:
梅森旋转算法由松本真和西村拓士在1997年提出,是对传统LCG算法的重大突破。这个名字中的"19937"来自梅森素数
设计故事:
松本和西村发现传统随机数生成器在高维空间中会呈现明显的规律性(点会聚集在超平面上),而梅森旋转算法通过复杂的位运算和巨大的状态数组,成功解决了这个问题,使得随机点在高达623维的空间中都能均匀分布。
算法类型:梅森旋转算法 (Mersenne Twister)
核心原理:
32位版: 维护一个624个元素的内部状态数组(19937位),通过复杂的位运算进行状态转移,每次生成32位随机数。
64位版: 状态数组312个元素,周期相同但每次生成64位。
核心实现:
目前没想出来怎么实现...翻C++源码,但看不懂。限于篇幅,不贴出来了。
但Python下找到一篇(来自Cryptography Wiki:
def _int32(x):
return int(0xFFFFFFFF & x)
class MT19937:
# 初始化
def __init__(self, seed):
self.mt = [0] * 624
self.mt[0] = seed
self.mti = 0
for i in range(1, 624):
self.mt[i] = _int32(1812433253 * (self.mt[i - 1] ^ self.mt[i - 1] >> 30) + i)
# 提取伪随机数
def extract_number(self):
if self.mti == 0:
self.twist()
y = self.mt[self.mti]
y = y ^ y >> 11
y = y ^ y << 7 & 2636928640
y = y ^ y << 15 & 4022730752
y = y ^ y >> 18
self.mti = (self.mti + 1) % 624
return _int32(y)
# 旋转状态
def twist(self):
for i in range(0, 624):
y = _int32((self.mt[i] & 0x80000000) + (self.mt[(i + 1) % 624] & 0x7fffffff))
self.mt[i] = (y >> 1) ^ self.mt[(i + 397) % 624]
if y % 2 != 0:
self.mt[i] = self.mt[i] ^ 0x9908b0df
特点:
- 质量:统计质量极高
- 周期:
2^{19937}−1 (天文数字,用到宇宙毁灭都用不完) - 速度:相对较快
- 内存占用:不算小,但值得(其实要说它大,也没多大)
趣闻(也不算吧...):这个算法被广泛用于各种重要软件中,包括Python、Ruby、Redis等,成为了现代编程语言的随机数标准。
:::
:::success[ranlux24和ranlux48]
历史背景:
ranlux算法由Martin Lüscher和Fred James在1994年提出,名字来源于"LUXury RANdom numbers"。设计目标是解决当时随机数生成器在统计物理和蒙特卡洛模拟中暴露的质量问题。
设计哲学:
Lüscher发现,许多随机数生成器虽然通过了基本的统计测试,但在长时间运行的科学模拟中会产生系统性偏差。ranlux通过"奢侈地"丢弃大部分生成的随机数,确保输出的随机数具有极高的统计质量。(这就是为什么它慢)
算法类型:减法进位算法+丢弃块适配器
工作流程:
(每日翻不到+看不懂源码)
- 基础引擎:使用减法进位算法生成原始序列
- 丢弃策略:每生成N个值,只保留最后的K个
- 其中ranlux24每223个保留23个(丢弃200个),而ranlux48每389个保留11个(丢弃378个)
特点:
- 质量:极高的统计质量
- 速度:相对较慢,因为丢弃了大量计算
- 奢侈等级:ranlux24为3级,ranlux48为4级(最高质量)
趣闻:ranlux系列有0-4五个质量等级,等级越高丢弃的随机数越多,质量也越高,体现了"用时间换空间质量"的设计理念。
:::
:::success[knuth_b]
历史背景:
这个算法来自Donald Knuth(高德纳)的经典著作《计算机程序设计艺术》第二卷。Knuth是计算机科学领域的泰斗,他的著作被誉为"计算机圣经"。
设计故事:
Knuth在书中系统地分析了各种随机数生成算法,并提出了这个基于线性同余生成器加洗牌表的改进方案。他意识到单纯的LCG在统计特性上存在缺陷,通过引入洗牌缓冲区,可以打乱输出序列的顺序,改善随机性质量。
算法类型:线性同余生成器+洗牌表适配器
工作原理:
- 基础引擎:使用minstd_rand0生成原始序列
- 洗牌缓冲区:维护256个元素的缓冲区
- 洗牌过程:先从缓冲区中随机选择一个元素输出,然后用基础引擎的新值替换缓冲区中的一个位置
特点:
- 质量:改善了基础LCG的统计特性,比普通LCG好
- 速度:中等速度
- 历史意义:经典算法,向后兼容
- 内存占用:较大(256个元素的缓冲区)
趣闻:Knuth的《计算机程序设计艺术》至今仍未完成,但他对随机数生成的研究已经影响了整个计算机科学领域数十年。 :::
(其实还有一种方法生成伪随机数:Xorshift,本质是对一个数进行一大堆异或和左右移,但生成的质量一般不太好,当然可以对返回出的结果进行干扰,让质量上升,但这玩意怎么搞...说真的我也不知道) ::::
Part 4.2. AI使用说明
- 感谢Deepseek V3.2对部分内容进行润色。(已检查过)
- 至于为什么用呢?还不是因为我感觉我文笔太差了...(但润色后还是感觉很差...)
有的地方不是AI生成的,但如果你觉得我用了AI,那可能是我AI文章看多了,写作风格也像AI了(无语...)。
如还是有错误,请在评论区提出,我看到了会及时修正!
背锅人:minermouse