[控场AI]
· 10 分钟阅读· 5,173 字

用crypto.getRandomValues打造公平转盘:拒绝采样与卡方检验

用crypto.getRandomValues打造公平转盘:拒绝采样与卡方检验

用crypto随机源、拒绝采样和卡方检验打造可验证公平的转盘抽奖。

文章从随机数源头剖析转盘抽奖不公平的技术成因,指出Math.random()非密码学安全且浮点返回掩盖了随机性分配,应改用crypto.getRandomValues()。核心问题是取模偏差:随机范围非n整数倍时,低序号条目概率偏高,实测百万次字节取模卡方p值几乎为零。修复方案是拒绝采样,丢弃不均匀尾部后重试,同时用Fisher-Yates替代有偏的sort随机洗牌。加权抽取在总权重上调用无偏randomInt即可保证概率精确。文章强调应先定赢家再做动画,使公平性独立于渲染,最后给出浏览器内卡方检验代码,提醒p<0.01约1%误报率,且检验只能发现偏差不能证明无偏。

转盘抽奖总被质疑“黑幕”,但多数不公平源于代码细节而非故意。本文从随机数源头讲起,剖析Math.random()的隐患、取模偏差的成因,给出拒绝采样与加权抽取的实现,并教你用卡方检验自证公平。所有代码为纯浏览器JavaScript,无依赖。

转盘公平性实现示意图

为什么Math.random()不适合公平抽奖

Math.random()在生成彩带特效时没问题,但涉及可能被人较真的结果时,它并不可靠。规范不要求特定算法或质量,各引擎使用快速PRNG(如V8的xorshift128+),并非密码学安全,输出足够多后可恢复内部状态并预测后续值。它返回浮点数,开发者常写Math.floor(Math.random() * n),这掩盖了随机性如何在条目间分配。

浏览器已内置更好的源:crypto.getRandomValues()。它用操作系统密码学安全生成器填充类型化数组,现代浏览器和Node(globalThis.crypto)均可用。但好随机源只是第一步,下一步才是多数实现悄悄出错的地方。

密码学安全伪随机数生成器(CSPRNG) 与普通 PRNG 的关键区别在于是否可被高效预测。普通 PRNG(如 xorshift128+)状态空间较小,算法公开,攻击者观测到足够输出后可反推种子并预测未来值;而 CSPRNG 通常以内核熵池为种子,并设计为即使知晓算法也难以从输出推断内部状态。crypto.getRandomValues() 在规范层面要求实现使用系统级密码学安全源,但需要明确:它保证的是不可预测性,而非统计意义上的绝对均匀——均匀性仍需通过拒绝采样等后处理保证。此外,它受每调用 65536 字节的上限约束,大块随机数据需分多次填充。

取模偏差:藏在x % n里的bug

拿到随机整数和10个条目,直觉做法是randomInteger % entries.length。只要随机范围不是n的整数倍,就会产生偏差。以随机字节(0-255,共256个值)和10个条目为例:256 = 25×10 + 6,0-249均分(各25次),余数250-255映射到条目0-5,这六个条目各获得26/256≈10.16%的概率,其余四个为25/256≈9.77%。前六个名字获得约4%的相对优势。

作者实测一百万次字节取模,条目0-5落在约101,000次,条目6-9约97,500次,卡方检验p值几乎为零。32位整数偏差虽小(六个条目多出约4.3亿分之一的机会),但仍非零,而修复成本极低。

卡方拟合优度检验 用于判断观测频数与理论频数是否一致:统计量 χ² = Σ(Oᵢ−Eᵢ)²/Eᵢ,在零假设(各条目等概率)下近似服从自由度 k−1 的卡方分布。自由度等于可自由变动的类别数,因为总数固定后最后一个类别的计数由其余类别决定。文中用 Wilson-Hilferty 变换把 χ² 近似为正态分布以估算 p 值,这是浏览器内免依赖查表的常用技巧;严谨场景应改用正则化不完全伽马函数。需要注意,卡方检验对系统性偏差敏感,但对样本量有要求:每类期望频数通常应不小于 5,否则近似失效,转盘场景下旋转次数应远大于条目数。

修复方案:拒绝采样

思路是找到随机范围内n的最大倍数,若随机数落在该上限之上则丢弃重抽。这样保留的每个值映射到完全相同数量的结果。

const RANGE = 2 ** 32;
function randomInt(n) {
  if (!Number.isInteger(n) || n < 1 || n > RANGE) throw new RangeError("n must be an integer from 1 to 2^32");
  const limit = RANGE - (RANGE % n);
  const buf = new Uint32Array(1);
  let x;
  do {
    crypto.getRandomValues(buf);
    x = buf[0];
  } while (x >= limit);
  return x % n;
}

被拒绝的尾部小于n,相对于42.9亿个值极小,实际转盘几乎不会重试超过一次。抽取赢家只需一行:const winnerIndex = randomInt(entries.length);。

同样的辅助函数还能修复另一个经典bug:entries.sort(() => Math.random() - 0.5)并非均匀洗牌,结果依赖排序算法且有偏。应使用Fisher-Yates配合randomInt:

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

Fisher-Yates 洗牌 的正确性依赖于每一步从剩余未处理元素中均匀抽取一个与当前位置交换,共进行 n−1 次,从而产生 n! 种等可能排列之一。使用 sort(() => Math.random() - 0.5) 的问题在于:比较函数返回随机值会违反排序算法对比较关系一致性的假设,不同引擎的排序实现(如 V8 对短数组用插入排序、长数组用 TimSort)会产生不同的偏差模式,且无法保证每种排列概率相同。配合无偏的 randomInt(i + 1) 时,Fisher-Yates 是严格均匀的,这也是它成为洗牌标准算法的原因。

加权切片:让概率可见

奖品转盘常需不等概率,比如“再接再厉”应是“免费咖啡”的三倍。给每个条目整数权重,在总权重上抽一个数并遍历列表:

function pickWeighted(entries) {
  const total = entries.reduce((sum, e) => sum + e.weight, 0);
  let r = randomInt(total);
  for (let i = 0; i < entries.length; i++) {
    r -= entries[i].weight;
    if (r < 0) return i;
  }
}
pickWeighted([{ label: "Free coffee", weight: 1 }, { label: "Try again", weight: 3 }]); // “Try again”约75%

因为randomInt无偏,每个条目的概率恰好是weight / total。绘制切片时按相同比例呈现,视觉大小与真实概率一致。

先定赢家,再做动画

反直觉但关键:旋转动画不决定赢家。点击瞬间就用randomInt选出赢家,动画只是计算如何停在该切片上。这样公平性不依赖渲染,点击时机、设备性能或后台标签页都无法改变结果,且便于测试——不绘制任何东西即可调用百万次。

将选定切片转为最终旋转角度(角度以顶部为起点顺时针计,指针在顶部):

const randomFloat = () => randomInt(RANGE) / RANGE;
function targetRotation(slice, currentRotation, extraTurns = 6) {
  const margin = (slice.end - slice.start) * 0.1;
  const landAt = slice.start + margin + randomFloat() * (slice.end - slice.start - 2 * margin);
  const mod = (a, m) => ((a % m) + m) % m;
  const offset = mod(-(landAt + currentRotation), 360);
  return currentRotation + extraTurns * 360 + offset;
}

用easeOutCubic曲线从当前角度动画到目标角度,转盘先快后慢自然停下。视觉特效(如钉子咔嗒声、回弹)只要最终落在预设目标,都不影响概率。

easeOutCubic 是缓动函数族中的一种,形式为 f(t) = 1 − (1 − t)³,t 从 0 到 1 表示动画进度。其导数为 3(1 − t)²,在起点处速度最大、终点处速度趋近于零,因此转盘呈现“先快后慢、自然停下”的观感。缓动函数只影响角度随时间的变化曲线,不影响最终目标角度,这正是“先定赢家”设计的前提:动画的插值细节、时长、是否被后台标签页节流,都不会改变已确定的获胜索引,公平性因此与渲染完全解耦。

自证公平:浏览器里的卡方检验

“相信我,它是随机的”缺乏说服力。卡方拟合优度检验可判断观测计数是否与等概率一致。对k个条目和N次旋转,期望E = N/k,统计量χ² = Σ(观测−E)²/E,自由度k−1。浏览器快速检查可用Wilson-Hilferty近似:

function erf(x) {
  const sign = Math.sign(x); x = Math.abs(x);
  const t = 1 / (1 + 0.3275911 * x);
  const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
  return sign * y;
}
function chiSquareTest(counts) {
  const n = counts.reduce((a, b) => a + b, 0);
  const expected = n / counts.length;
  const chi2 = counts.reduce((s, o) => s + (o - expected) ** 2 / expected, 0);
  const df = counts.length - 1;
  const z = (Math.cbrt(chi2 / df) - (1 - 2 / (9 * df))) / Math.sqrt(2 / (9 * df));
  const p = 0.5 * (1 - erf(z / Math.SQRT2));
  return { chi2, df, p };
}

对5个条目跑10万次,一次运行得到计数[20044, 19882, 19929, 20156, 19989],χ²=2.27,p=0.69,各条目偏离期望值约0.8%,完全正常。对比字节取模版本,χ²≈340,p≈0。

p值解读:生成器公平时p在0到1均匀分布,常用阈值p<0.01视为可疑,意味着公平转盘约1%的运行会误报。若偶尔一次低p可重跑,持续偏低则有问题。需注意卡方检验能发现偏差但不能证明无偏,它是健全性检查而非认证,受监管抽奖需独立审计;Wilson-Hilferty是近似,严谨p值应使用正则化不完全伽马函数。

要点回顾

  • 涉及可能争论的结果,用crypto.getRandomValues()而非Math.random()。
  • 永远不要单独使用random % n,用拒绝采样消除取模偏差。
  • 洗牌用Fisher-Yates,不用sort(() => Math.random() - 0.5)。
  • 先选出赢家,再动画让转盘停在其上。
  • 用卡方检验自测,并预期公平转盘在p<0.01时约1%概率“失败”。

这些代码运行在浏览器中,可打开开发者工具直接阅读。公平不是口号,而是可验证的工程实践。

#公平转盘#crypto.getRandomValues#拒绝采样#取模偏差#卡方检验#随机数生成#Fisher-Yates洗牌#加权随机抽取
分享:

相关推荐