伪随机与真随机:在线随机数生成器如何工作

包括 Pick4Us 在内,大多数在线随机数生成器使用的是伪随机数生成器(PRNG):一个固定的算法,从一个看不见的“种子”出发,算出一串看起来随机、也能通过统计检验的数字。密码学安全的生成器在此基础上保证不可预测,硬件随机数生成器则直接测量物理噪声。做游戏、课堂点名和日常决定,一个好的伪随机算法完全够用;生成密码或进行受法规约束的抽签,就不够了。

三类随机数生成器

类型随机性从哪里来常见用途
伪随机数生成器(PRNG)对内部状态反复套用同一个公式,从种子开始计算游戏、模拟、日常抽选(如 Math.random())
密码学安全伪随机数生成器(CSPRNG)同样是算法,但设计成看到再多输出也推算不出下一个,并以操作系统收集的熵作为种子密码、密钥、安全令牌(如 crypto.getRandomValues())
硬件随机数生成器(“真随机”)测量电子噪声等物理过程为 CSPRNG 提供种子、专用设备

用大白话讲种子和周期

CSPRNG 正是为堵上这个缺口而设计的:即使手里有大量输出,猜下一位也不会比瞎猜更准。硬件随机数生成器测量的是物理过程,但原始噪声采集得慢,还要经过处理才能变得均匀,所以操作系统通常把它混入 CSPRNG 作为种子材料,而不是每次请求都直接读取。

Math.random() 与 crypto.getRandomValues()

浏览器里的 JavaScript 自带两个随机数来源。

Math.random() 返回一个大于等于 0、小于 1 的小数,分布大致均匀。ECMAScript 规范有意不规定具体算法,交给各个引擎自行选择;MDN 也说明,种子同样由引擎选定,用户无法指定或重置。

实际上,主流引擎在 2015 至 2016 年间都换成了 xorshift128+。Chrome 和 Node.js 使用的 V8 引擎从 4.9.41 版起采用这一算法,随 Chrome 49 发布,取代了此前在多项统计检验中表现不佳的 MWC1616;Firefox 的 SpiderMonkey 和 Safari 的 JavaScriptCore 也在同一时期做了同样的更换。引擎今后仍可能调整这些细节,规范本身也不保证任何特定算法。

crypto.getRandomValues() 会用密码学强度的随机值填满一个整数数组。MDN 解释说,出于性能考虑,浏览器并不是每次都去读取硬件随机源,而是使用以足够熵作为种子的密码学安全伪随机算法。单次调用最多填充 65,536 字节。它比 Math.random() 慢一些,但在人能感知的尺度上没有区别。

把随机字节换算到某个范围时,有个常见陷阱:直接取余数会带来小偏差。一个字节有 256 种取值,而 256 = 6 × 42 + 4,所以用“字节 % 6”时,256 个取值中各有 43 个会得到 0、1、2、3,却只各有 42 个会得到 4 和 5。严谨的代码会丢弃多出来的那几个值并重新抽取。

什么时候用 Math.random() 没问题,什么时候不行

适合使用 Math.random() 的场景:

不适合的场景:

  • 密码、PIN 码、验证码、API 密钥、会话令牌或重置密码的令牌。请使用 crypto.getRandomValues() 或密码管理器。
  • 受法规约束的抽签,例如官方有奖抽奖、入学或住房摇号。这类活动往往对抽取方式、见证人和记录有法律规定,请按当地规则办理。浏览器里的小工具不是经过审计的抽签系统。
  • 任何有人能通过预测结果而获利的场合。

Pick4Us 的所有工具都使用 Math.random(),这适合快速的日常抽选,网站也无法预设结果。它没有使用 crypto.getRandomValues(),也不宣称“真随机”。

如何抽出不重复的数字

如果允许重复,重复出现得比直觉要早得多。从 1–100 中抽 10 个数,至少出现一次重复的概率约为 37%。算法是把每个新数字都避开已抽数字的概率连乘起来:100/100 × 99/100 × … × 91/100 ≈ 0.628,再用 1 减去它。这和著名的“生日问题”是同一个道理。

在 Pick4Us 随机数生成工具里:

从一万亿个数里抽 5 个不重复的数,总不能先把一万亿个数全部列出来吧?这个工具用的是 Floyd 抽样算法,由 Jon Bentley 与 Bob Floyd 于 1987 年在《Communications of the ACM》的“Programming Pearls”专栏中发表。要从 n 个数里抽 k 个,它只需要走 k 步:每一步在 0 到某个上限之间随机挑一个数,上限从 n − k 开始,每步加 1;如果挑中的数已经被选过,就改选当前的上限本身。这样,每一种 k 个数的组合被选中的机会都相同,计算量只取决于 k,与范围大小无关。最后工具还会把选出的数字打乱,让顺序也是随机的。

如果要抽的是名字而不是数字,可以用转盘选择的“连续抽签”模式,它会在下一次旋转前移除上一次抽中的选项,效果相同。转盘还支持 1–100 的整数权重,每个选项被选中的概率等于它的权重除以转盘上剩余选项的总权重。

包含两端的范围与“差一”陷阱

Pick4Us 的范围包含两端:最小值设为 1、最大值设为 6,1 和 6 都可能出现。可能的取值个数是“最大值 − 最小值 + 1”,所以 1–100 有 100 个数,而 0–100 有 101 个。

如果自己写代码,常用的包含两端的写法(也是 MDN 上的 getRandomIntInclusive 示例)是 Math.floor(Math.random() * (max − min + 1)) + min。常见错误有两个:

自己动手检验是否公平

只看输出无法“证明”一个生成器是随机的,但足以发现明显的问题。

  1. 多收集一些结果。先关闭“不重复”(否则同一次生成里每个值最多只出现一次),每次最多生成 500 个数字,复制结果粘贴到表格里,重复几次。
  2. 算出期望次数。每个值的期望出现次数 = 抽取总次数 ÷ 可能取值的个数。
  3. 与正常波动比较。对于公平的生成器,某个值出现次数的标准差是 √(抽取次数 × p × (1 − p)),其中 p = 1 ÷ 可能取值的个数。落在期望值上下约两个标准差之内都属正常;取值较多时,有一两个超出范围同样正常。
抽取次数范围每个值的期望次数正常范围(约 ±2 个标准差)
601–610约 4–16
6001–6100约 82–118
1,0001–10100约 81–119
1,0001–2500约 468–532

如果想用一个数字来概括,可以计算卡方统计量:对每个值算出(实际次数 − 期望次数)² ÷ 期望次数,再全部加起来。有 6 个可能取值时,公平的生成器得到大于 11.07 的结果只有 5% 的机会;有 10 个取值时,这个 5% 门槛是 16.92。一次超过门槛并不能说明问题,因为大约每 20 次检验就有 1 次会碰巧超过;反复超过才值得深究。

小样本最容易误导人:掷 60 次骰子,某一面只出现 5 次或者出现 15 次都很平常。我们的抛硬币概率文章也展示了,在完全公平的序列里,长长的连续同面有多常见。

实用总结:在线随机数生成器大多是伪随机的。做游戏、课堂和日常抽选,Math.random() 已经足够;密码、令牌和受监管的抽签则需要密码学安全的来源和规范的流程。需要避免重复时打开“不重复”或使用号码池,记住范围包含两端,判断是否公平要看几百次抽取的统计,而不是几次的手感。

试试文中提到的工具

随机数生成 · 转盘选择

相关文章

← 返回博客列表