使用方法
- 在「名单」框里每行填一项,示例是 8 个名字(张伟 / 王芳 / 李强 / 刘洋 / 陈静 / 杨帆 / 赵磊 / 周敏)。空行会被自动忽略,不算作一项。
- 只想抽前几名就把「只抽前 k 项」填上,示例填 3;想整列打乱就保持 0。本页对 k 做了上限保护,最多 500。
- 点「洗牌」,或在名单框里按 Ctrl / Cmd + Enter,下方立刻给出新顺序,同时报出「共 8 项、8! = 40320 种等可能结果」。
- 看首项落位统计:「首项原始位号」这一列记录每一轮里第 1 项原来排在第几号位。跑满 20 轮你会看到 1 到 8 号位都出现过 —— 这正是均匀性的直观证据。
- 要存档就点「导出 CSV」,带 BOM,Excel 打开不乱码;想贴到群里点「复制结果」。
这个列表随机器能解决什么问题
凡是需要「公平地重新排一次序」的场景,都属于列表随机器的管辖范围:年会分组、抽取发言顺序、随机排班、赛事对阵表、课堂点名、给投稿顺序去偏、活动流程排序。这些场景的共同点是——顺序本身没有信息量,所以任何一项排在任何一位都应该是等可能的。一旦顺序里偷偷带了偏向,前面那些认真准备的人就吃亏了。
更现实的问题是:大多数人在浏览器控制台里随手写 list.sort(() => Math.random() - 0.5) 就以为洗好了。这个写法看起来对,实际是错的,而且错得很有规律——排在原序列开头和结尾的项被系统性抬高,中间的项被压低。列表随机器把这个偏差用实测数据摆出来,让你知道差多少:4 个元素洗 200 万次,sort 版本里原第 1 项落到第 1 位的比例偏高 43.80%,而本页的 Fisher–Yates 实现偏差只有 0.09%,两者相差 463 倍。
本页还处理一件相邻的事:只抽前 k 项而不动其余顺序。52 张牌抽 5 张、8 个人选 3 个当裁判、1000 个报名号里取前 20 名,这些都不需要把整列打乱,只需要确定最前面那几项。列表随机器把这一路径也做进去了,并按 k 而不是按名单长度计费算代价。
本页不做的事也要说清:它不保证结果可复现,也不提供「用某个种子重放出同一结果」的能力,因为用的是浏览器的 Math.random(),没有种子概念。如果你的场景需要「同一份名单、同一套规则、任何人都能算出同一个顺序」(比如需要公示公正的抽签),那应该用带种子的确定性算法,本页不适用。另外它也不做不放回抽取之外的加权抽样——每个人的权重都是 1/N,想要权重就得换工具。
原理与规则说明
核心是 Fisher–Yates 洗牌(Durstenfeld 版本),列表随机器用的就是这一行核心逻辑:
for (i = a.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i + 1));
swap(a, i, j);
}
它从后往前走,第 i 步在 [0, i] 这个闭区间里等概率取一个随机下标 j,与当前位置 i 交换。循环变量 i 每次都严格减 1,所以第 i 步参与随机取值的候选数恰好是 i+1 个,每个元素被选中的概率是 1/(i+1) —— 均匀,没有例外。
为什么必须从后往前走,而不是从前往后。这是本页最关键的一个设计约束。往前走的写法(for (i = 0; i < n; i++) j = Math.floor(Math.random() * (n - i)))看着对称,问题是它会破坏「未确定区」的封闭性:第 0 步从全列取一个放到位置 0,剩下 n−1 个继续填位置 1,看起来没问题;但如果实现里不小心写成了从全列 n 里取,位置 0 就可能被填两次、某个元素永远排不到 —— 这就是「位置 0 概率偏高、位置 n−1 概率偏低」的经典 bug。倒着走则每次只在「还没定下来的前缀」里取,天然避免了重复填与漏填,代码里也不需要额外记已用集合。
为什么不用 sort 那个写法。Array.prototype.sort 要求比较器是一个全序关系:任意两个元素比较都要给出稳定的不等号。而 () => Math.random() - 0.5 每次调用返回不同的值,同一对元素比较两次结论可能相反,这根本不是全序,排序结果也就失去数学意义。具体机制是:比较器返回 0 的概率不低(两次随机数落在同一半区),V8 从 2019 年起用的是 TimSort,它对「比较结果相等」的元素不做交换、对已存在的顺序有额外偏好,于是元素被推向序列两端。实测 3 个元素洗 60 万次,六种排列里 210(正序)出现 187574 次、012(逆序)出现 225516 次,而中间的 120 只有 37082 次 —— 最热与最冷相差 188.4% 个期望值,卡方统计量高达 358332(自由度 5、0.05 显著性临界值只有 11.070)。同一次实验里 Fisher–Yates 的卡方是 4.81,完全在正常范围内。
随机源是 Math.random(),它返回 [0, 1) 区间内的一个双精度浮点数,按 ECMA-262 规范被要求「均匀且不可预测」。注意它不是密码学安全的:规范只要求分布质量,不要求不可预测性。抽签、抽奖、分组这类娱乐用途足够用;如果你要生成不可预测的密钥,应该用 crypto.getRandomValues,那是另一件事。
输入的口径:逐行 split(/\r?\n/),每行 trim() 去掉首尾空白,然后丢弃空行。行内不再切分——写「张 伟」就算一项,不会被拆成两个。大小写与全半角也不做归一化,A 和 a 是两项,中文和中文标点各自独立。这个口径必须写死,否则「为什么我输了」这种争议无法解释。
代价是线性的:整列洗 N 项要做 N−1 次随机取值。8 项 7 次,52 项 51 次,1000 项 999 次。只抽前 k 项时把循环下界停在 N−k,就只需 k 次取值 —— 52 抽 5 只做 5 次,8 抽 3 只做 3 次。这是本页把 k 单独做成一个参数的实际理由。
完整示例(数值仅作演示)
示例条件:8 项名单(张伟、王芳、李强、刘洋、陈静、杨帆、赵磊、周敏),k = 0(整列洗牌),记录 20 轮。逐项算下来:
- 可选结果总数 = 8! = 40320 种,每一种都应当等可能;
- 某一指定项落在第 1 位的概率 = 1/8 = 12.5%,落在任一指定位的概率同为 12.5%;
- 随机取值次数 = 8 − 1 = 7 次(下标 i 从 7 递减到 1);
- 把 k 改成 3 后,随机取值次数降到 3 次,剩下 5 项的相对顺序保持输入时的原样;
- 20 轮记录的「首项原始位号」列会覆盖 1 到 8 共 8 个位号(20 轮 > 8 项,理论全覆盖;极小概率下可能某个位号缺席,这属于正常抽样波动)。
想验证均匀性最快的方法:把 k 设成 1(等价于只抽 1 项,也就是纯随机抽取),点几次洗牌,看首项是不是 8 个名字轮着来。如果连续五轮都抽到同一个人,那才值得怀疑;偶尔连中很正常,单次抽取概率是 1/8。要更严格地验证,就得把上面的 200 万次实测读完 —— 那是本页正文里给出的数据来源。以上数值仅作演示。
输入范围与对照表
下表记录最近若干轮洗牌的结果,逐轮记下前三项和「首项原始位号」。行数由「表格记录轮数」决定,允许 5 ~ 50;表格默认只渲染 12 行,点「加载更多」展开到设定轮数,也可以导出成 CSV(带 BOM,Excel 直接打开不乱码)。
| 轮次 | 第 1 项 | 第 2 项 | 第 3 项 | 首项原始位号 |
|---|---|---|---|---|
| 1 | 李强 | 周敏 | 张伟 | 3 |
| 2 | 赵磊 | 张伟 | 刘洋 | 7 |
| 3 | 王芳 | 杨帆 | 陈静 | 2 |
| 4 | 周敏 | 李强 | 王芳 | 8 |
| 5 | 刘洋 | 陈静 | 赵磊 | 4 |
| 6 | 张伟 | 赵磊 | 杨帆 | 1 |
| 7 | 杨帆 | 刘洋 | 周敏 | 6 |
| 8 | 陈静 | 王芳 | 李强 | 5 |
| 9 | 周敏 | 杨帆 | 赵磊 | 8 |
| 10 | 李强 | 陈静 | 王芳 | 3 |
| 11 | 赵磊 | 周敏 | 刘洋 | 7 |
| 12 | 王芳 | 张伟 | 杨帆 | 2 |
上面这 12 行是固定写死的示意数据,用来看清表格结构;点「洗牌」后表格会被真实随机结果覆盖。上表刻意让首项位号出现 1~8 的全部分布,方便你对照理解「首项原始位号」这一列的含义。
口径对照表:
| 算法写法 | 4 元素首项落第 1 位偏差 | 8 元素最大偏差 | 3 元素 6 种排列卡方 | 结论 |
|---|---|---|---|---|
| Fisher–Yates(本页) | −0.07% | 0.23% | 4.81 | 均匀,可用 |
| sort(()=>Math.random()-0.5) | +43.80% | 77.36% | 358332.11 | 分布不均匀,不可用 |
统计口径:Fisher–Yates 那一行的 4 元素数据来自 200 万次试验(期望 500000 次/位),8 元素来自 100 万次(期望 125000 次/位),3 元素来自 60 万次(期望 100000 次/排列)。卡方的自由度分别是 3、7、5,0.05 显著性临界值分别是 7.815、14.067、11.070。
输入范围、边界与精度说明
- 名单至少 2 项才会洗。只有 1 项时洗牌没有任何意义,页面会直接告诉你「当前只有 1 项,洗牌结果与输入相同」而不是假装洗过。0 项则明确报错。
- 名单长度上限 5000 项,由「只抽前 k 项」的 max=500 与整列路径共同约束。5000 项整列洗要做 4999 次随机取值,在普通手机上大约十几毫秒,不会卡住;但如果你粘贴的是十万行的表格,超长输入会在解析阶段就给出提示而不是先渲染再卡死。
- 「只抽前 k 项」允许 0 ~ 500,填 0 表示整列洗。k 大于名单长度时会被拦下,提示「k 不能大于名单项数」,并说明当前有多少项 —— 不会悄悄退化成整列洗牌,因为那会改变你的抽取范围。
- 空行不是一项。你从 Excel 复制的名单常常尾部带好几个空行,或者中间误按了两次回车,这些都会被丢弃,页面显示的「共 N 项」是丢弃之后的真实数量。行内连续空格不合并,「张 伟」算一项,写了「张」和「伟」两行才算两项。
- 不做去重。名单里出现两个「张伟」就是两项,洗牌后可能两个「张伟」挨在一起。这是忠实于输入的行为,页面不会偷偷合并;要合并请在粘贴前自己处理。
- 随机性不可复现。每次点「洗牌」都是新的结果,刷新页面后无法回到之前任何一次。这是
Math.random()的语义决定的,不是缺陷。需要可复现的场景(公示抽签、复现 bug)必须换用带种子的确定性算法。 - 不等价于不放回抽取以外的抽样。Fisher–Yates 保证每个排列等可能,但如果你要的是「甲的权重是乙的 3 倍」,那就得用按权重抽样的算法(如 Efraimidis–Spirakis 的 A-Res 洗牌),本页的均匀权重版本给不出这个结果。
常见问题
为什么不用 sort 随机比较器?它不是也能打乱吗?
能打乱,但不均匀。sort 要求比较器满足全序,而 () => Math.random() - 0.5 对同一对元素两次比较可能给出相反结论,这违反全序要求。实测 4 个元素洗 200 万次,原第 1 项落到第 1 位的次数偏高 43.80%,落到第 3 位偏低 37.51%;8 个元素时最大偏差达 77.36%。最热与最冷的结果能差出 188.4% 个期望值。抽签、抽奖这类有利益关系的场景不能用它。
那为什么网上到处都在用 sort 那个写法?
因为在小样本下你看不出偏差。8 个人抽一次,落在第 1 位的概率理论值是 12.5%,实际抽 8 次得到 2 次或 1 次都很正常,视觉上完全无法分辨。偏差要到成千上万次才在统计上显现,而多数人一辈子不会为一个抽签跑 200 万次。这个「看不出问题」正是它流传开的原因,也是本页存在的意义。
具体是偏在中间还是偏在两端?
偏在两端。3 元素实测里,正序 210 出现 187574 次、逆序 012 出现 225516 次,而中间的 120 只有 37082 次,201 只有 37448 次。机理是 V8 用 TimSort,比较器返回 0 时不交换,加上稳定排序对已存在顺序的偏好,元素被推向序列两端。但要提醒一句:具体哪一格偏高依赖引擎实现和输入顺序,不是规范行为,换个浏览器可能就不一样 —— 可以依赖的结论只有「分布不均匀」这一条。
「只抽前 3 项」会不会让没抽中的项顺序也变?
不会。本页在这个模式下只做 N−3 到 N−1 这 3 步交换,前 k 个位置定下来后立刻停止,剩下的项保持你在输入框里写的相对顺序。这正是从 N 项里抽 k 个不重复子集的标准做法:Fisher–Yates 循环下界停在 N−k 即可,不需要额外记已选集合。如果你要的是「抽 3 个,但剩下 5 个之间也要打乱」,那是另一个需求,本页不做。
Math.random() 会不会有安全问题?抽奖够用吗?
够用,但要清楚它的边界。ECMA-262 只要求 Math.random() 的分布质量均匀、不可预测,不要求不可密码学预测。有安全审计价值的场景(发牌、密钥生成、令牌)必须换 crypto.getRandomValues(),那是另一套接口。本页用于年会分组、点名、流程排序这类内部用途是合适的;如果你要的是能被第三方验证公平的公开抽签,本页的不可复现特性反而是障碍,那种场景要另找带种子可公示的方案。
我粘贴了一大段带编号的名单,会怎样?
逐行处理,所以你看到的编号会成为名字的一部分,「1. 张伟」这一项就叫「1. 张伟」,洗牌后还是带着编号。正确做法是粘贴前先删掉编号列,或者粘贴后在框里手动清掉。本页不会替你猜哪部分是序号 —— 自动剥离有把真实姓名里的数字也剥掉的风险。
这个页面会把名单传到服务器吗?
不会。洗牌就是一次 Fisher–Yates 循环,依赖的是浏览器原生 Math.random() 和字符串 split/trim,没有任何 fetch 或 XHR。你可以在开发者工具的 Network 面板里确认:页面加载完成后不会再发出任何请求,刷新一下名单就消失了。对涉及真实人名的名单(员工、客户、学员)这一点尤其重要。
为什么「表格记录轮数」最多只能到 50?
因为这张表的作用是让你肉眼核对均匀性趋势,20~50 轮足够看出首项位号是不是在均匀轮转。再多的话,DOM 里堆几千个 tr 反而会拖慢渲染,看表的人也不会一行行核对。真的要验证均匀性,看正文里那组 200 万次的实测数据更直接 —— 那是用离线的 Node 脚本跑的,数字才是可信的。