杂记 › 生活随笔 › 用 sort(() => Math.random() - 0.5) 打乱名单,抽签就已经不公平了

用 sort(() => Math.random() - 0.5) 打乱名单,抽签就已经不公平了

三个人的名单,均不均匀能直接算出来

打乱名单决定发言顺序有两种写法:每次从剩下的位置里挑一个,或给排序塞一个随机比较器。后者短,却从一开始就不在抽签——三四个元素时路径少得能一条条走完,分布是算出来的,不是统计出来的。

线上文本随机打乱是前一种,模板第 80-88 行的循环体:

for (var i = arr.length - 1; i > 0; i--) {
  var j = Math.floor(Math.random() * (i + 1));
  var temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
}

第 81 行让 i 从 n-1 递减到 1,第 82 行让 j 均匀取自 0 到 i 这 (i+1) 档,第 83-85 行三行全交换、无 i !== j 守卫,每轮消耗随机数 n-1 个。一条抽样序列对应 n × (n-1) × …… × 2 = n! 条等概率路径,每条 1/n!,少到能枚举(有多陡见排列与组合)。枚举得:n=3 的 6 条路径落在 6 个排列上、各 16.67%,n=4 的 24 条落在 24 个上、各 4.17%,最大/最小 1.0000。枚举靠往第 82 行喂强制值,共 647 个、断言失败 0 次。

随机比较器错在比较顺序不由抽签决定

反面教材是 arr.sort(() => Math.random() - 0.5)。模板里 .sort( 出现 0 次,可它在范文里最常见。排序不是抽签机器:引擎自己决定先比哪两个。V8 对短数组走 TimSort 的二分插入分支,插入第 k 个元素时比几次取决于前面已给出的答案,同样 3 个元素比较次数会是 2、3 或 4 次。

枚举它只需一步抽象:比较器忽略实参、只回答小于或大于,第 c 次比较是一个二分支点,路径概率 = 2^(-c),同深度叶子通分即得每个排列的概率。n=3 共 8 条比较路径、通分母 2^4 = 16,n=4 共 34 条、树深 3/5/6、通分母 2^6 = 64。

0 1 2 37.50% 2 1 0 31.25% 1 0 2 12.50% 0 2 1 6.25% 1 2 0 6.25% 2 0 1 6.25%

图 1|3 个元素时随机比较器 6 个排列的概率,由 8 条比较路径全部穷举而得,非抽样;虚线是穷举 6 条抽样路径得到的 Fisher-Yates 16.67%。

18.75% 1 个 17.19% 1 个 6.25% 3 个 4.69% 3 个 3.13% 4 个 1.56% 12 个

图 2|同一写法在 4 个元素上分成 6 档、合计 24 个排列,由 34 条比较路径全部穷举而得,非抽样;虚线是枚举 24 条抽样路径的 4.17%。

3 个元素时原序 0 1 2 独占 37.50%,是均匀值的 2.2500 倍,最冷三档各 6.25%,最大/最小 6.0000 倍;4 个元素时 18.75% 对 1.56%,差 12.0000 倍。n=5 的 172 条是 48.0000 倍,n=6 的 1034 条是 192.0000 倍,而均匀值只有 1/720 = 0.14%。

三条边界:这四个长度上穷举到的可达排列是 6/6、24/24、120/120、720/720,概率为 0 的有 0 个,所以「某些顺序永远抽不出来」讲不了,只能说不均;数字是 V8 的 Array.prototype.sort 实测枚举,Chrome、Edge、Node 同源,Firefox 与 Safari 不同;跑很多遍只能佐证,那 100000 次用的是 mulberry32、种子 20260213,n=4 给出 1.0696 对 12.8487,与穷举吻合。权重不同时得换加权抽签。

粘进输入框后,谁被钉在原位更要命

第 42 行提示写着「将文本中所有字符随机打乱顺序」,可第 24 行的「保留空格和换行」默认就 checked,第 113 行判的是 input[i] === ' ' || input[i] === '\n' || input[i] === '\t':锚点恰好 U+0020、U+000A、U+0009 三个码位,先被单独存下,只洗其余字符再按原下标粘回。

输入  ab[SP]cd[LF]ef[TAB]gh[SP]ij
输出  ac[SP]dg[LF]bh[TAB]ji[SP]ef

默认档消耗 9 个随机数,洗的是那 10 个码元;不勾选就改走第 138 行 input.split(""),4 处锚点被改写 3 处。第 113 行还不含 \r:输入 aa[SP]bb[CR][LF]cc[SP]dd,勾着保留,回车仍从下标 5 搬到 11,LF 还在下标 6,行尾就此打散;U+00A0、U+3000 也参与打乱。行模式是另一套:第 152 行只按 LF 切,三行含空行是 3 行、2 个随机数,勾「打乱前去除空行」后第 161 行显示过滤过的 总行数=2;它只读第 149 行那个控件,隐藏的 ts-times 改成 50 毫无影响。

「打乱次数」这一栏,兜底规则和想的不一样

第 105 与第 149 行都是 parseInt(…, 10) || 1,只接住 0、空与 NaN,负数照样放行:

次数栏填实际轮数随机数统计区显示
015打乱次数=1
留空15打乱次数=1
2.9210打乱次数=2
-500打乱次数=-5
100010009000打乱次数=1000

随机数一列按 t*(n-1) 计,前四行输入 6 个码元、末行 10 个。-5 最别扭:第 119 行 t < times 直接为假,0 轮、0 个随机数、输出即输入,界面仍显示「打乱次数=-5」。另一头也没人管:第 28/35 行的 max="100" 只是 HTML 属性,JS 里 0 处范围校验。

没有种子入口,复现这件事就做不到

seed、crypto、getRandomValues、localStorage 在模板里命中 0 行,唯一随机源是第 82 行的 Math.random()。同输入同参数,再点一次必然换结果:第 49 行的按钮只是再跑 times 轮,不是换种子。所以「密码生成:打乱字符增加随机性」不成立——它只排列用户给的字符、不引入新熵,Math.random 是 xorshift128+,状态 128 位、输出 53 位,不抗预测。

Q:为什么点了两次,结果一样或不一样?能复现吗?

可动的只剩一个时必然一样:单字符 0 个随机数,六个全同字符照消耗 5 个而输出不变,三个空格全是锚点;n=2 也只有 1/2 的概率改变顺序。想复现也不行,0 个种子参数。

Q:勾上「保留空格和换行」保住了什么?

空格、换行、制表符的原位,代价是被洗的字符变少;回车 U+000D 不在名单里照样被搬走,那句「所有字符」不准确。

Q:均匀性是跑很多次验证的吗?

不是,两边都是穷举:一边枚举第 82 行的全部抽样序列,一边枚举全部比较分支,100000 次只是复核。

📎 相关文章

加仑有两种、马力有两种、字节有两套进位:换算前先问是哪一种 2026-10-21 2 月 29 日出生的人,平年哪天满 18 周岁?两种周年口径差一天 2026-10-21 全马 3:30 要跑多快?九段递进配速表、前后半差和那段触底的配速 2026-10-21 同一份成绩单能算出五个 GPA:加权平均、四种平均与目标绩点的反推 2026-10-21 每月 3000 放 20 年差在哪:复利终值、单利口径与 72 法则的真实误差 2026-10-21