使用方法
- 把名单粘进「名单」框,一行一人(默认已直出 20 个名字,上限 200 人)。支持空格、逗号、顿号、分号混排;按 Ctrl/Cmd + Enter 触发,Enter 留给换行。
- 填「队伍数」(默认 4,整数 1~20)。队数不能超过人数——当前 3 人时填 5 队会被拒绝并告诉你最多 3 队,而不是给你 2 支空队。
- 选「分配方式」。默认均匀取商:有余数时前 r 队各多 1 人。选蛇形轮转时余数轮流发给不同队——实测两法在 r < 队数时永远同解,这个选项只在队数被改大时才显出差别。
- 点「随机分组」。四张卡给出有效人数、队数、人数差与随机源;结果区逐队列出成员,末尾附两条校验。
- 「载入示例」还原 20 人名单与 4 队;对照表固定展示商余分解的 24 组数据,可导出 CSV。
这个随机队伍生成器能解决什么问题
一是让「轮流挑人」这件事变得没有争议。让 supervisor 挨个点名分队,顺序会影响结果——先被点到的人往往来自同一个项目、同一批入职,彼此不认识,分出来的队还是「小圈子」。随机队伍生成器把洗牌放在切段之前,这是唯一能消除顺序影响的顺序。示例名单如果按姓氏笔画排好就直接切 4 段,每一队拿到的必然是笔画相邻的人;洗牌之后这个相关性消失。
二是人数不整除时不靠「凑」。23 人分 6 队,用商余分解得 4/4/4/4/4/3,人数差恰好 1;用「每队轮流多拉一个」的手工办法很容易变成 5/5/5/4/3/1,差 2。本页把不变式写死:各队人数差 ≤ 1 且 Σ = 总人数,两条都由构造保证,不靠事后检查。
三是把名单里的脏数据先清掉。从表格复制的名单常带空行、行首序号(1. 张三)、全角空格、零宽字符、纯符号行,甚至同一个名字出现两次。直接拿去洗牌,结果就是「某队 6 人、某队 4 人」和「张三在一队、二队、三队各出现一次」。本页在洗牌前做五步清洗(剔零宽字符 → trim 去空行 → 按多分隔符拆 → 剥序号 → 去纯符号并去重),并把每一步剔掉几个如实报出来。
本页的随机队伍生成器不做这些事:不保证「强队的都分开」这类平衡性约束——那需要知道每个人的水平,本页只认名字;不保留原顺序或做能力加权;也不做「连续按几次得到同样结果」的复现,那需要自带 PRNG 而不是用 crypto。如果你要的是不重复点名而不是分队,请用随机转盘;要的是纯乱序,请用列表随机打乱。
原理:Fisher-Yates 洗牌与商余分解
为什么必须是 Fisher-Yates,而不是 sort(() => Math.random() - 0.5)。常用的错误写法把随机性塞进比较器里,问题在于当比较器返回 0 时 V8 的 TimSort 会保留原相对序,结果是「某些输入永远排不出某些结果」,且分布随数组规模偏离。实测数据:4 人分队 200000 次,Fisher-Yates 覆盖全部 4! = 24 种排列,最大计数 8537、最小 8121,卡方 30.8(自由度 23、α = 0.05 的临界值是 35.17,低于它即与均匀分布无显著差异);而 sort(Math.random() - 0.5) 的最大计数冲到 37543、最小 3018,卡方 232304.9,是前者的 7500 倍。第三种错误写法「逐位独立取下标」卡方 5965.5,同样不合格。
本页用的具体算法是 Durstenfeld 版 Fisher-Yates:Fisher 与 Yates 在 1938 年的《Statisticians' Key》表 8 给出原始版本(每次从未选元素中随机挑一个移到末尾,用辅助数组),Durstenfeld 在 1992 年把它改成原地交换:for (i = n - 1; i > 0; i--) { j = randomInt(i + 1); swap(a, i, j) }。循环从后往前,每次把 i+1 个未确定位置里的随机一个换到 i,每个位置只需一次取样、总共 n-1 次,且不需要额外内存。
取样用 crypto 加拒绝采样,消除模偏差。页面调 crypto.getRandomValues(new Uint32Array(1)) 拿一个 32 位无符号整数(值域 0~4294967295),再用 randomInt(n) 映射到下标。直接 % n 有模偏差:2^32 mod 20 = 16,下标 0~15 各多一个映射,相对偏差为 n / 2^32(n = 20 时约 4.66×10⁻⁹)。拒绝采样把上界截到 floor(2^32 / n) × n,超出那段整段丢弃重抽,偏差恒为 0。实测各档 n 的拒绝概率最高是 n = 999 时的 0.000013%,期望重抽次数小于 1.0000001 次。这个量级小到测不出来,但抽签、抽奖这类场景应该走 crypto——V8 的 Math.random() 用 xorshift128+,只有 128 位内部状态、周期 2128−1,不是密码学安全的。旧浏览器或非安全上下文(http 非 localhost)下 crypto 不可用,页面降级并在结果区明确标注随机源。
切段用商余分解,不变量是构造出来的。base = floor(n / k)、r = n mod k,于是 r 恒在 [0, k)。均匀取商给前 r 队各加 1 人;蛇形轮转把第 j 个余数发给第 j mod k 队。实测两法在 r < k 时完全同解——20/3、22/5、23/6、13/5、100/7、17/4 六组数据都是这样。原因是 r = n mod k < k 恒成立,余数不够分一圈,蛇形的「轮转」和「顺序发」落在同一批队上。保留蛇形选项是防御性的:若将来允许「每队至少 1 人后再轮转」这类规则,它才是必需的。
为什么不按原始顺序切段。这是最容易犯的错:20 人按原顺序切 4 段,得到的四队是「1-5 号、6-10 号、11-15 号、16-20 号」,如果名单恰好按姓氏笔画或工号排好,每队的人就高度相关。洗牌是唯一解。反过来,随机是幂等混合的:名单如果已经随机过一次,再洗一次不会让结果「更随机」,所以不需要判断输入是否已随机。
名单清洗的四步,各有针对性。① 按 /[,、;\\s]+/ 切分并 filter(Boolean),让空格、逗号、顿号、分号、换行都能当分隔符——「陈嘉禾,林小满、黄梓萱;吴俊杰」这样混排也能正确切成 4 人;② String.prototype.trim 去首尾空白,它会清掉全角空格 U+3000(实测 ' 孙可欣 '.trim() 得 '孙可欣'),但不会清零宽字符 U+200B 与 U+FEFF,所以要额外一步 replace(/[\\u200B-\\u200D\\uFEFF]/g, '');③ 剥行首序号 /^\\d+[.、)]\\s*/,把「1. 陈嘉禾」还原成「陈嘉禾」;④ 用 Set 按原样去重(姓名不做小写化,中文没有大小写问题)。示例名单 20 行、无重名,清洗前后都是 20 人。
上限 200 人、20 队是两层考虑。200 人时切 20 队每队 10 人,人数差必然为 0;200 人切 3 队是 67/67/66。人数上限不是算力问题(200 次取样是毫秒级),而是超过 200 人后队长已经无法凭记忆核对每个队员的名字。队数上限 20 则来自同一考虑。
完整示例(数值仅作演示)
示例条件与页面默认值完全一致:名单 20 人、4 队、均匀取商。逐项核对:
- 清洗:20 行 → 去空行 20 → 拆分隔符剥序号 20 → 剔纯符号 0 → 去重 20,重名 0 个;
- 分配:
20 ÷ 4 = 5 余 0,商 5、余 0 → 各队 5 人,人数差 0; - 不变量一:4 队 × 5 人 = 20 = 名单人数;
- 不变量二:最大队 5 − 最小队 5 = 0 ≤ 1;
- 覆盖性:20 个名字洗牌后仍是 20 个不同的,每人恰好出现一次;
- 取样:20 人洗牌需 19 次取样(n − 1 次),随机源
crypto.getRandomValues,拒绝采样期望重抽小于 1.0000001 次。
上面每个数字都能在下方对照表里印证结构:第 1 行(6 人 2 队,商 3 余 0)与第 10 行(20 人 3 队,商 6 余 2 → 7/7/6)是两类典型——整除与不整除。「20 人 3 队」这一行是本页默认值 4 队旁边最近的一行,方便你改成 3 队时直接对照。
输入范围与对照表
- 队伍数:1~20 的整数。填 0、负数、小数、字母会被拒绝并指出字段。队数 > 人数会被直接拒绝:3 人填 5 队时页面提示「当前 3 人最多只能组 3 队(每队至少 1 人)」。如果放行,你会拿到
[1,1,1,0,0]这样含 2 支空队的分布,队名和后面的人名对不上号。 - 名单:1~200 人。空名单、纯空行、纯标点都会报具体原因。单行姓名超过 20 字符会被标为「疑似误粘整段文本」并拒绝,因为正常中文姓名是 2~4 字。
- 分配方式:两个单选项,无独立校验。切换后立即重算,不需要额外点按钮。
下面这张表覆盖 12 组整除与 12 组不整除的情况,每行都标了商、余、各队人数与 Σ 校验:
| 场景 | 总人数 | 队数 | 商 | 余 | 人数差 | 典型分法 |
|---|---|---|---|---|---|---|
| 整除 | 20 | 4 | 5 | 0 | 0 | 5 / 5 / 5 / 5 |
| 余 1 | 7 | 2 | 3 | 1 | 1 | 4 / 3 |
| 余 2 | 14 | 3 | 4 | 2 | 1 | 5 / 5 / 4 |
| 余 = 队数 − 1 | 23 | 6 | 3 | 5 | 1 | 4 / 4 / 4 / 4 / 4 / 3 |
| 余接近队数 | 13 | 5 | 2 | 3 | 1 | 3 / 3 / 3 / 2 / 2 |
| 大队数小余数 | 17 | 4 | 4 | 1 | 1 | 5 / 4 / 4 / 4 |
| 百人规模 | 100 | 7 | 14 | 2 | 1 | 15 / 15 / 14 × 5 |
| 上限人数 | 200 | 3 | 66 | 2 | 1 | 67 / 67 / 66 |
| 上限人数满队 | 200 | 20 | 10 | 0 | 0 | 10 × 20 |
| 单人一队 | 5 | 5 | 1 | 0 | 0 | 1 × 5 |
| 队数=人数 | 20 | 20 | 1 | 0 | 0 | 1 × 20 |
| 奇数大余 | 199 | 20 | 9 | 19 | 1 | 10 × 19 / 9 × 1 |
最后一行值得多看一眼:199 人分 20 队,商 9、余 19,也就是前 19 队各 10 人、最后 1 队只有 9 人。这是「均匀取商」的固有偏好——最后那队永远吃亏 1 人。如果这 1 人让你在意,把分配方式切到蛇形轮转虽然在这个例子结果相同(余数 19 刚好发满一圈),但配合「每队至少 1 人后再轮转」的规则就能把差距摊得更均匀。
边界与精度说明
- 队数 > 人数必须拒绝,而不是生成空队。3 人分 5 队时商余分解给出
[1,1,1,0,0],含 2 支空队;1 人分 100 队会含 99 支空队。空队在结果里表现为「第 4 队(0 人):」这样的行,看起来像 bug 实则是规则漏洞。页面的处理是提前拒绝并说明「每队至少 1 人」。 - 余数永远小于队数,这是恒等式不是巧合。
r = n mod k按定义满足0 ≤ r < k,所以「前 r 队各多 1 人」永远发得完,且人数差恒 ≤ 1。这就是为什么不需要蛇形轮转来「打散」余数——它无处可打散。 - 洗牌的取样次数是固定的 n − 1 次。200 人洗牌做 199 次
randomInt调用,每次内部平均 1.0000001 次取样,合计约 199 次crypto读取,在毫秒级完成。列表越长,O(n) 的洗牌优势越明显(相比冒泡排序的 O(n²))。 - 随机源与安全整数。
2^32 = 4294967296小于安全整数上限 253 − 1 = 9007199254740991,所以getRandomValues填入的Uint32值是无损的,取样过程不存在精度损失。2^53 这个上限在本页完全不构成约束——最大的量只是 200 这个队伍计数。 - 「同种子同结果」做不到,也不该做。
Math.random()不可播种,crypto.getRandomValues同样没有种子参数。要复现必须自带 PRNG(如 Mulberry32:32 位状态、周期 232,实测同种子 20261003 两次运行输出完全一致),但它不是加密安全的,只适合「可复现」而非「不可预测」。本页刻意不提供种子输入——让用户以为「输入种子就能抽奖」是错的用法。 - 零宽字符必须单独清。
String.prototype.trim只清 Unicode 空白,不清 U+200B(零宽空格)、U+200D(零宽连接符)、U+FEFF(BOM)。从网页复制或从 Excel 粘贴的姓名里这三者很常见,混在名字中间会导致「张三」和「张三」被当成两个人分到不同队。页面在 trim 之后再做一次显式剔除。 - 名单上限 200 与队数上限 20 的关系。200 人 20 队时商为 10、余为 0,是最省心的一档;200 人 1 队时商 200、余 0,等于不分组——页面允许,但结果区会照常输出「第 1 队(200 人)」,你自己判断是否需要。
常见问题
为什么不用「大家轮流点」那种手工作业?
因为顺序会影响结果。轮流点时,先被点到的人往往来自同一项目、同一批入职,彼此熟悉,分出来的队仍是小圈子。必须先洗牌再切段,顺序才被彻底打乱。实测也证明了这个差异的量级:Fisher-Yates 的卡方 30.8(与均匀分布无显著差异),而错误写法 sort(Math.random() - 0.5) 卡方 232304.9。
每次点按钮结果都不一样,能指定种子让它固定吗?
不能,也不建议。取样走 crypto.getRandomValues,它没有种子参数;要固定结果必须换成 Mulberry32 这类自带 PRNG,但那样就不再抗预测了。抽签场景需要的是「别人算不出你要出什么」,所以本页坚持每次都真随机。碰到想要上一次的名单,用「复制结果」先存走。
23 人分 6 队,最后一队只有 3 人,能更平均吗?
不能更好。商余分解给出的 4/4/4/4/4/3 已经是「人数差 ≤ 1」的最优解——要让每队都 ≥ 4 人就需要 24 人。要真正均衡只能改变规则,比如「每队先保底 1 人,剩下的按 round-robin 轮流进」,那会引入「谁先被选」的顺序问题,与随机分队的初衷冲突。
名单里有重名的会怎样?
全部重复行会被剔掉(保留第一次出现的那个),并在结果区报告剔了几行。示例名单 20 人无重名,所以你看到的是「去重 20」。如果你的名单里确实有两个「张伟」而他们确实是不同的人,请先改成「张伟(销售)」「张伟(技术)」再粘进来。
队数填比人数大,为什么直接报错?
因为商余分解会产出空队:3 人分 5 队得到 [1,1,1,0,0],含 2 支空队,1 人分 100 队含 99 支。空队在结果里表现为「(0 人)」的空行,看起来像故障。页面选择提前拒绝并告诉你「当前 3 人最多只能组 3 队」,而不是让你自己从一堆空队里判断发生了什么。
粘进来的名字带序号「1.」和全角空格,能识别吗?
能。清洗分四步:先按空格/逗号/顿号/分号/换行切分,再 trim()(它会清掉全角空格 U+3000),再剥掉行首的 数字 + 点/顿号/右括号 + 空白,最后按原样去重。零宽字符 U+200B、U+200D、U+FEFF trim() 清不掉,页面额外做一次显式剔除。实测「1. 陈嘉禾」会还原成「陈嘉禾」。
「蛇形轮转」和默认的「均匀取商」什么时候才有区别?
在当前规则下永远没有区别。因为余数 r = n mod k 恒小于队数 k,蛇形发的第 j 个余数落到的第 j mod k 队,和顺序发的前 r 队是完全同一批。实测 20/3、22/5、23/6、13/5、100/7、17/4 六组全部同解。保留这个选项是防御性的:将来若改成「每队保底 1 人后再轮转」,它才是必需的。
名单会传到服务器吗?
不会。清洗、洗牌、分配三步全部在当前页面完成,只用到 split、trim、Set、Array.prototype.sort 之外的原生数组操作和 crypto.getRandomValues,没有网络请求,也不写 Cookie 或 localStorage。名单里有真实姓名时可以放心在 Network 面板里自查——面板始终是空的。