mulberry32:十行代码换来可复现的演示点集

举报
岚枫 发表于 2026/11/06 08:23:28 2026/11/06
【摘要】 演示页和测试都需要「看起来随机、但每次一样」的点集。`Math.random()` 不能指定种子,换一次刷新就换一批点,出了问题也无法复现。mulberry32 只用十行、一个 32 位状态,就能做到同 seed 同序列。这篇拆开它的每一步,并说明它能用在哪、不能用在哪儿。

几何演示页需要一个点集:随机撒点、三簇、近共线,用户点「重新生成」就换一批。测试又需要另一件事:同一批点每次都能重新跑出来,断言失败时能定位到具体输入。

这两件事的共同点是「随机」,冲突点是「可复现」。Math.random() 只满足前半句:它没有种子参数,浏览器也不会告诉你这一次用的是什么种子。刷新一次,点集就变了;昨天失败的那个算例,今天再也找不回来。

解决办法不是找一个更「随机」的算法,而是找一个伪随机数生成器(PRNG):给定种子,输出一条确定的序列;种子相同,序列永远相同。mulberry32 是这类生成器里实现最短的一档。

十行实现

function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

调用 mulberry32(777) 得到一个函数,每次调用返回一个 [0, 1) 之间的数。没有依赖,没有全局状态,两个生成器互不影响。

逐行拆开

种子:把数字收进 32 位无符号整数。

let a = seed >>> 0;

>>> 0 是 JavaScript 里把数字转成 uint32 的常用写法:先转成 32 位整数,再按无符号解释。结果是:

  • -1 >>> 0 等于 4294967295;
  • 4294967296 >>> 0 等于 0;
  • 1.9 >>> 0 等于 1;
  • NaN >>> 0 和 Infinity >>> 0 都等于 0。

这四条在本机 Node v24 上逐条跑过,输出就是上面这些。

所以种子不是「任意数字」,而是「模 2³² 之后的值」。两个只差 2³² 的种子会得到同一条序列:本机拿种子 1 和 4294967297 各生成一个函数,前 50 个输出逐个相同。

推进:每次加一个奇数。

a = (a + 0x6D2B79F5) >>> 0;

0x6D2B79F5 是 1831565813,一个奇数(本机验证:确实是奇数,且它与 2³² 的最大公约数是 1)。每次加一个奇数再截回 32 位,状态会遍历整个 2³² 空间,不会提前回到起点。这条是靠同余算出来的,不是逐步数出来的:模 2³² 的加法要绕满一整圈,当且仅当增量与模数互质,奇数就满足这个条件;43 亿步没有真的走完,只在小一号的模数上按同样的走法验证过周期等于模数本身。这也是它周期上界的来源:状态只有 32 位,最多 2³² 个数,走完必然重复。

这里说的是状态周期。状态周期是 2³²,不代表输出序列的最小周期也是 2³²:输出经过混淆和截断,是多对一的映射,不同的内部状态可能产生同一个输出。要谈输出周期,得实测整条序列,不能从状态周期直接推。

混淆:两次乘加。

let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);

三件事:

  • t ^ (t >>> 15) 把高位和低位混合,让输入的一个比特影响输出的多个比特;
  • Math.imul(x, y) 做 32 位整数乘法,结果自动截断,不会因为 JavaScript 的浮点乘法丢掉低位;
  • t | 1 强制最低位为 1,保证乘数是奇数,避免乘法把信息「乘没」。

第二句里的 t ^= t + ... 再混一次,进一步打散。这几句都只做 32 位整数运算,不参与状态推进,所以不影响状态周期。

输出:除以 2³²。

return ((t ^ (t >>> 14)) >>> 0) / 4294967296;

先右移、异或,把高位的随机性带到低位,再除以 2³²。除数是 2 的幂,浮点除法是精确的;分子是一个 32 位整数,最大 4294967295,所以结果必然落在 [0, 1),不会等于 1。这一段在本机 Node v24.9.0 上用种子 777 跑了 300 万个输出来验证:全部在区间内,最小约 5.9e-8、最大约 0.99999922,且每个值乘回 2³² 都能还原成除法之前的那个整数,没有一次出现舍入误差。

同一个种子,同一条序列

以种子 777 为例,前五个输出是(显示保留 9 位小数):

0.686378718
0.034451418
0.192387330
0.123002950
0.632967042

这五个数不是「大概一样」,而是逐位相同:只要 JavaScript 引擎的 Math.imul 和位运算符合规范,换浏览器、换机器、换 Node 版本,序列都不变。原因是整个计算只用 32 位整数加法和乘法、异或、移位,以及一次除以 2³²;32 位整数运算由规范精确定义,唯一一次除法除数是 2 的幂,也没有舍入。上面那五个数在本机 Node v24 上逐位复跑过,完全一致;换 Node 版本、换机器那部分是按规范推定的,浏览器端的 V8、SpiderMonkey、JavaScriptCore 本文没有逐一实测,能确认的只是它们都得实现同一套位运算语义。

对比一下 Math.random():它没有种子,内部状态由引擎管理,V8、SpiderMonkey、JavaScriptCore 的实现各不相同,规范也不要求可复现。用它生成点集,等于把「这次测试的输入」交给运气。

点集是怎么用它的

演示页的点集构造分两类:

  • 吃种子的:random、cluster、line,对应开头说的随机撒点、三簇、近共线。它们调用 rnd() 决定坐标;
  • 不吃种子的:grid、circle。前者按行列铺点,后者按角度铺点,都是确定性构造,换种子结果不变。circle 还加了一个 sin 扰动,让半径在 0.36 附近轻微起伏,所以它不是严格共圆。

这两条分工来自演示页自己的构造代码,本文没有把那套代码搬到别处重跑:本机只复算了下面 random 的坐标和 grid 的取整,cluster、line、circle 的具体点列没有逐条核对,属于「按代码读出来的事实」而不是「实测出来的数字」。

random 的坐标范围是 [0.06, 0.94],留了 6% 的边距,避免点正好贴在边界上;换算式是 (0.06 + rnd() * 0.88) * 1000,先把 0 到 1 的输出放大到 0..1000,最后再统一四舍五入到两位小数。这个先后顺序是有区别的:如果先在 0 到 1 之间取到两位再放大,得到的是 660.00、90.00 这样的整数坐标,和下面这组数对不上。在 0..1000 的矩形里、种子 777、取 3 个点,本机 Node v24 复跑得到:

(664.01, 90.32)
(229.30, 168.24)
(617.01, 396.31)

这三个数可以直接写进测试用例,作为「种子 777 的前三个点」。如果哪天改动让它们变了,说明随机流被改动了——可能是生成器实现变了,也可能是调用顺序变了(比如先多调了一次 rnd())。这种变化不一定是 bug,但一定是需要知道的变更。

还有一个构造上的细节:grid 按 cols × rows 铺点,rows = Math.ceil(n / cols) 是向上取整,所以请求 24 个点可能得到 25 个(例如 5 列时,Math.ceil(24 / 5) 是 5,5×5=25,这一步在本机复算过)。这不是随机数的问题,是构造方式决定的;如果测试要断言「点数等于请求值」,grid 不能直接用。

seed 0 的坑

makePoints({ seed, type, count }) 里写的是:

const rnd = mulberry32(seed || 20261017);

seed || 20261017 把 0 当成了「没有传种子」,于是 seed = 0 会悄悄变成默认种子 20261017。如果 0 是一个合法种子,这里应该用 ??:

const rnd = mulberry32(seed ?? 20261017);

界面上的种子输入也有类似问题:Number(value) || 1 会把 0 变成 1。对演示来说无所谓,但对「种子是测试用例的一部分」的场景,这种隐式替换会让复现失败——你输入 0,跑出来的是另一个种子。这三行判断在本机 Node v24 上都验过:0 || 20261017 给出 20261017,Number("0") || 1 给出 1,换成 0 ?? 20261017 才拿到 0。

它能用在哪,不能用在哪儿

能用:

  • 演示页的随机点集,刷新后同 seed 同图;
  • 测试的固定算例,失败时打印 seed 就能复现;
  • 需要「看起来随机」的占位数据,比如布局、颜色、初始位置。

不能用:

  • 令牌、密码、会话 ID、抽奖。mulberry32 不是密码学安全的:状态只有 32 位,输出可以被反推,种子空间也可以暴力枚举;
  • 需要长周期、高维均匀性的模拟。2³² 约 43 亿次调用,演示和常规测试远远用不完;但做蒙特卡洛数值求积(用随机采样估算面积、体积这类量)或大规模采样时,周期和相关性都需要更认真的评估;
  • 需要「不可预测」的场合。它的全部安全性来自种子保密,而种子一旦泄露,整条序列都可重建。

均匀性方面,它输出的是 32 位整数除以 2³²,取值分布接近均匀;但这是「一维、长序列」的性质,不等于任意维度、任意短片段都均匀。对撒点、非对抗性的洗牌(比如打乱展示顺序)、随机初始位置这些用途,够用;对需要严格统计性质的场景,应该换更成熟的生成器并做检验。

在测试里怎么用

一条简单的规则:测试里不出现没有种子的随机。

具体做法:

  1. 固定一组种子,覆盖不同分布(例如 1、777、20261017),每个种子对应一组断言;
  2. 用例失败时,把种子和生成的点集一起打印出来,或者导出成 JSON;
  3. 不要用时间戳、Math.random() 生成种子;要让「这次跑」和「上次跑」可比;
  4. 生成器的实现变更要当成一次显式的版本变更:老种子还能复现老序列,说明兼容;不能复现,就在变更记录里写清楚。

生成器本身只有十行,真正的成本在「决定哪些东西应该可复现」。点集、初始布局、采样顺序,这些一旦进入测试,就应该由种子控制;颜色、动画相位这类不影响断言的,可以继续用 Math.random()。

小结

mulberry32 不是最强的 PRNG,但它在「短、可读、可复现、够用」这四个维度上很均衡。它的价值不在于随机性有多好,而在于它把「随机」变成了一个可以写进用例的输入。

演示页刷新后能看到同一批点,测试失败后能找回同一个算例,靠的是这十行里固定的整数运算:同一个种子进去,同一条序列出来。

(完)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。