Math.random 够不够"随机"?转盘公平性、加权抽奖与防作弊

· 约 6 分钟 🎯 做个决定

抽签、转盘、抽奖背后都是同一个问题:“随机”到底是什么。日常我们说”随机”指”我没法预测”,但计算机里”随机”细分成至少四个层次——每个层次决定了你的工具适合用什么场景。这篇用决策转盘做切入,把 Math.random、加权抽奖、动画与公平性、防作弊都讲清楚。

四种”随机”

类型例子可预测性速度
真随机(TRNG)放射性衰变、热噪声物理上不可预测极慢,专用硬件
操作系统熵池/dev/urandom、Windows CryptoAPI实际不可预测快,OS 内核服务
CSPRNGcrypto.getRandomValues密码学安全30M ops/s
PRNGMath.random算法可推测200M ops/s

Math.random 是 PRNG——给定算法和种子,结果完全可复现。V8(Chrome / Node)用 xorshift128+,Safari 用 WebKit 的 Mersenne Twister 变体,Firefox 用 xorshift128+。所有现代浏览器周期都 ≥ 2^128,分布均匀性早就过统计学检验。

对决策转盘、班会抽签、谁请奶茶——PRNG 完全够用。担心”不够随”是想多了。真正会出问题的是下面几点。

加权抽奖:累计区间法

权重 5:3:2 不是把扇区切成 5:3:2 就完事——还要保证随机数落到对应区间。累计区间法是标准做法:

权重: A=5, B=3, C=2
累计: [5, 8, 10]   总和 = 10

r = Math.random() * 10
r ∈ [0, 5)   → A
r ∈ [5, 8)   → B
r ∈ [8, 10)  → C

代码 6 行:

function weightedPick(items, weights) {
  const total = weights.reduce((s, w) => s + w, 0);
  let r = Math.random() * total;
  for (let i = 0; i < items.length; i++) {
    r -= weights[i];
    if (r < 0) return items[i];
  }
}

别用这些错误写法

  • Array(weight).fill(name) 展开成大数组——权重 100 万时数组炸内存
  • ❌ 对每项分别判 Math.random() < weight/total——会出现全不中或多个中
  • ❌ 浮点累加 360°——0.1 + 0.2 ≠ 0.3 的经典 bug,最后一段可能少 0.1°

权重多到上万选项时,把累计区间存成有序数组,二分查找把抽奖从 O(n) 降到 O(log n)。再多用 Vose’s Alias Method——预处理后 O(1) 抽样,是大型抽奖系统的标配。

动画结果:先定还是后定

转盘动画有两种实现哲学:

方案 A:动画先转,停下来看结果

const finalAngle = startAngle + 1080 + Math.random() * 360;
animateTo(finalAngle);
const result = whichSectorAt(finalAngle);

问题:(1) 指针可能正好压在两扇区交界——视觉争议;(2) 浮点 + 动画缓动函数累计误差,“扇区面积比例”和”实际中签概率”不严格相等;(3) 加权场景实现复杂——要让最终角度”恰好”按权重分布。

方案 B:结果先定,反推角度

const result = weightedPick(items, weights);   // 先抽出来
const sectorMid = sectorMidAngle(result);       // 该扇区中线
const offset = (Math.random() - 0.5) * 0.7 * sectorWidth;  // ±70% 随机偏移
const finalAngle = startAngle + 1080 + sectorMid + offset;
animateTo(finalAngle);

优势:(1) 结果由严格的加权抽样决定,扇区面积只是视觉演绎;(2) 永远不会压线;(3) 加权和动画解耦,加权算法换成什么都不影响动画。

所有正经抽奖动画都是方案 B——直播间宝箱、游戏抽卡、链上抽奖动画都是”结果先定,动画后演”。本工具用的也是方案 B。

视觉公平 ≠ 概率公平

人类大脑对随机分布有强烈偏见,这点决定了”看起来公平”和”实际公平”经常打架:

  • 真随机会聚集——丢硬币 100 次出现”连续 7 次正面”是正常的(概率 ~50%),但人会怀疑被作弊
  • 真随机会缺失——10 次抽奖某选项一次没出现是正常的,但用户会投诉”算法不准”
  • Spotify 案例——早期纯随机播放被骂”老放同一首”,后来改成反聚集调度(避免最近放过的、分散同艺人)才平息

做转盘要不要降低连续重复率取决于场景:

场景推荐原因
决策(今天吃什么)真随机连续抽到火锅就吃火锅,简单
轮班(谁洗碗)真随机 + “上次抽中本次权重 -1”体感公平,避免一周三次同一人
抽奖(中奖名单)真随机 + 公开算法不能改算法,但要公开透明
游戏伪随机(暴击)保底机制玩家心理预期,连续不暴击改为下次必暴击

抽奖防作弊:可审计三件套

本工具是单机娱乐场景,不需要防作弊。但如果你做正经抽奖(公司年会、几千元礼物、社群活动),公平抽奖必须满足三件

  1. 可复现——公开种子(seed),任何人用同种子能复算出同一结果
  2. 不可预测——种子在抽奖前不可知;最常见做法是用未来某个区块哈希做种子
  3. 可审计——公开候选名单、权重、算法源码、最终结果,第三方能从头算一遍

典型方案对照

方案可复现不可预测可审计适合
浏览器 Math.random单机娱乐
服务端 CSPRNG + 公开 seed⚠️ 信任服务端⚠️中等抽奖
公示规则 + 公证处大型彩票
链上 VRF(Chainlink)Web3 抽奖
未来区块哈希加密社群抽奖

链上 randomness 是黄金标准——所有数据上链不可改、可全网验证。本工具不参与,只负责”我和朋友决定吃什么”这层场景。

CSPRNG vs Math.random:什么时候必须升级

Math.random 够用的场景:动画、游戏 AI、洗牌玩单机斗地主、决策抽签、转盘抽奖(娱乐)。

必须用 crypto.getRandomValues 的场景

const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const random32 = buf[0];   // 0 ~ 2^32-1
  • UUID v4(标准要求 122 bit 密码学随机)
  • 任何 token、session ID、密码重置链接
  • 加密 key、IV、nonce
  • 抽奖中的”种子”(防止用户反推下次结果)

速度对比:Math.random 约 200M ops/s,crypto.getRandomValues 约 30M ops/s——前者快 6-7 倍。但加密相关场景永远不能省这点性能。

洗牌别用 sort 黑魔法

经常看到这种洗牌写法:

// ❌ 错误!分布不均匀
arr.sort(() => Math.random() - 0.5);

为什么错:JS 的 sort 比较函数必须满足传递性(a<b 且 b<c → a<c),但 Math.random() - 0.5 完全随机,违反传递性。V8 / Firefox / Safari 三家排序实现不同(TimSort / merge sort),统计上前面位置的元素出现概率被低估或高估。Mike Bostock 写过著名的可视化对比

Fisher-Yates 才对

function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

O(n) 复杂度,证明严格均匀分布。本工具不做洗牌(决策转盘是抽样,不是排列),但同一族算法。

隐私与心理

本工具完全本地运行——选项、权重、历史记录全在你浏览器的 localStorage不发任何网络请求。这反过来也意味着:抽奖结果只对自己可信——朋友看你转盘,他没法验证你点 GO 之前有没有调过权重。所以本工具适合”我自己用”或”在场所有人都看着屏幕”的场景,不适合远程抽奖。

要做”在场所有人都信”的抽奖:(1) 用本工具做演示动画;(2) 但真实结果用扔骰子、抓阄、提前公布的 seed 等所有人能验证的方式产生。技术解决不了信任问题,仪式感和透明度才能。

心法

  • PRNG 对生活决策完全够用——别在”够不够随”上焦虑
  • 加权抽奖用累计区间法——别用数组展开或浮点累加
  • 动画结果先定后演——避免压线和精度争议
  • 真公平要可审计——公开种子、算法、结果三件套
  • 抽奖防作弊只在金额或情感重要时才必要——日常娱乐不必上链

打开 做个决定,输入”今天吃什么”的几个选项,按 GO 看一次——你看到的是 6 行 weighted pick + 反推角度动画的合奏,背后的随机性思考其实比大多数人想的复杂得多。

❓ 常见问题

Math.random 真的够"随机"吗?

对决策转盘、轮班、谁去倒垃圾这类用途完全够用。Math.random 是伪随机数生成器(PRNG)——给定种子可复现,但分布均匀、周期足够长(V8 用的 xorshift128+ 周期约 2^128)。不够用只在三个场景:(1) 加密用途(密钥、token);(2) 高额博彩(百万元抽奖、链上随机);(3) 多人对抗(玩家可能反推种子下一次结果)。一般生活决策、班会抽签、几人抽签谁请奶茶都可以放心用。

加权抽奖怎么做到"5:3:2 真的是 5:3:2"?

累计区间法——把权重累加:A=5、B=8(5+3)、C=10(5+3+2),然后 Math.random() × 10 落到哪段就抽中谁。不要做的事:(1) 把每个选项分别 Math.random() < weight/total 判一遍——会出现"全部不中"或"多个中"的 bug;(2) 把权重用 Array(weight).fill(name) 展开成大数组——权重 100000 时数组爆掉;(3) 直接对扇区角度做几何采样——浮点累计误差可能让最后一段少算 0.1°。本工具用累计区间 + 二分查找,权重 1 万亿也是 O(log n)。

转盘动画结束时指针正好停在两扇区交界,怎么算?

永远不会停在交界——本工具用"结果先定 + 反推角度":(1) 先按权重抽出本轮中签项;(2) 计算该扇区中线对准指针的目标角度;(3) 在该扇区内加 ±宽度 70% 的随机偏移,让指针不每次都正中;(4) 转盘动画从当前角度旋转到目标角度。结果不是动画停下来才决定的,所以不存在"刚好压线"的争议。这种"先定后演"的做法是所有正经抽奖动画的标准——直播间、游戏内开宝箱都是这么做。

想"看起来很随"和"真的很随"为什么经常冲突?

人类对随机有偏见——真随机序列经常出现"连续 5 次同一个"或"某选项 10 次没出现",会被怀疑作弊。Spotify 早期纯随机播放被吐槽"老放同一首歌",后来改成调度算法(避免最近放过的、分散同艺人)才平息。做转盘要不要降低连续重复率?取决于你的用户:(1) 决策类(吃啥)—— 用真随机就好,连续抽到火锅就吃火锅;(2) 轮班类(谁洗碗)—— 加"上次抽中的本次权重 -1"逻辑,体感更公平;(3) 抽奖类(中奖名单)—— 用真随机,但要展示 seed 和算法透明度。

怎么验证一个抽奖是真公平的?

公平抽奖三件套:(1) 可复现 —— 公开种子(seed),任何人用同一种子能复算出同一结果;(2) 不可预测 —— 种子在抽奖前不可知(最常见做法:抽奖前用未来某个区块哈希做种子);(3) 抽奖后可审计 —— 公开候选名单 + 权重 + 算法源码 + 最终结果,第三方能从头算一遍。链上抽奖用区块哈希做种子是黄金标准;线下公证抽奖(彩票)用机械搅奖球是物理熵源。本工具是单机娱乐用途——种子来自浏览器 PRNG,不公开也不审计,不适合做高额抽奖。

crypto.getRandomValues 和 Math.random 差在哪?什么时候必须用前者?

Math.random 是 PRNG(伪随机),种子可被反推;crypto.getRandomValues 是 CSPRNG(密码学安全伪随机),种子来自 OS 熵池(鼠标移动、磁盘 IO 等真随机源)+ 不可预测的内部状态。速度差:Math.random 约 200M ops/s,crypto.getRandomValues 约 30M ops/s——前者快 6-7 倍。必须用 CSPRNG 的场景:生成 UUID v4、密码、token、加密 key、抽奖防作弊。Math.random 够用:动画、游戏 AI、决策抽签、洗牌一副扑克给自己玩。

为什么不要用 Math.floor(Math.random() * n) 来洗牌?

因为这不是洗牌,是替换抽样——洗牌正确做法是 Fisher-Yates:从最后一个位置开始,每次和前面 0..i 中随机一个交换。array.sort(() => Math.random() - 0.5) 也是错的——比较函数不传递(a < b 且 b < c 不蕴含 a < c),V8 / Firefox / Safari 实现不同结果分布也不同,统计上前面位置的元素被低估或高估。Fisher-Yates 才是均匀分布、O(n) 复杂度的标准答案。本工具不洗牌,但加权抽奖底层用类似思路保证均匀。

🎯 打开 做个决定 彩色转盘 · 权重调节 · 多主题保存 · 历史记录