三个人的名单,均不均匀能直接算出来
打乱名单决定发言顺序有两种写法:每次从剩下的位置里挑一个,或给排序塞一个随机比较器。后者短,却从一开始就不在抽签——三四个元素时路径少得能一条条走完,分布是算出来的,不是统计出来的。
线上文本随机打乱是前一种,模板第 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。
图 1|3 个元素时随机比较器 6 个排列的概率,由 8 条比较路径全部穷举而得,非抽样;虚线是穷举 6 条抽样路径得到的 Fisher-Yates 16.67%。
图 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,负数照样放行:
| 次数栏填 | 实际轮数 | 随机数 | 统计区显示 |
|---|---|---|---|
| 0 | 1 | 5 | 打乱次数=1 |
| 留空 | 1 | 5 | 打乱次数=1 |
| 2.9 | 2 | 10 | 打乱次数=2 |
| -5 | 0 | 0 | 打乱次数=-5 |
| 1000 | 1000 | 9000 | 打乱次数=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 次只是复核。