列表随机器

把一份名单打乱成真正等概率的随机顺序,用的是 Fisher–Yates 洗牌而不是 sort(()=>Math.random()-0.5)。同屏给出洗完的结果、首项落位统计和逐轮记录表,可只抽取前 k 项而不动其余顺序。

轮次第 1 项第 2 项第 3 项首项原始位号

提示:在名单框里按 Ctrl / Cmd + Enter 触发洗牌(Enter 留给换行);数字框里按裸 Enter 触发。

使用方法

  1. 在「名单」框里每行填一项,示例是 8 个名字(张伟 / 王芳 / 李强 / 刘洋 / 陈静 / 杨帆 / 赵磊 / 周敏)。空行会被自动忽略,不算作一项。
  2. 只想抽前几名就把「只抽前 k 项」填上,示例填 3;想整列打乱就保持 0。本页对 k 做了上限保护,最多 500。
  3. 点「洗牌」,或在名单框里按 Ctrl / Cmd + Enter,下方立刻给出新顺序,同时报出「共 8 项、8! = 40320 种等可能结果」。
  4. 看首项落位统计:「首项原始位号」这一列记录每一轮里第 1 项原来排在第几号位。跑满 20 轮你会看到 1 到 8 号位都出现过 —— 这正是均匀性的直观证据。
  5. 要存档就点「导出 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 轮。逐项算下来:

想验证均匀性最快的方法:把 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。

输入范围、边界与精度说明

常见问题

为什么不用 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 脚本跑的,数字才是可信的。

相关工具