前段时间我想做一个经典的“粘贴名单、得到 N 个均匀分组”的工具——那种老师和活动组织者早晚都会需要的工具。我本以为它基本上就是 Fisher-Yates 洗牌加 Array.slice 的循环,大概一个小时就能搞定。
后来我真正思考了大家说“随机分组班级”时想要什么。他们想要的不是真正的随机,而是“随机分组,但不要把打架的两个孩子分到同一组”或“把实习生均匀分布到每张桌子上”或“三个团队负责人必须分到不同组”。一旦加入一条约束,真正的随机就成了敌人,而不是特性。所以我最终发布的工具在分组逻辑里根本没有 Math.random()。它是一个确定性的、知晓规则的放置算法,伪装成“智能”洗牌。
解析粘贴的名单比看起来要麻烦得多
在分组前,原始文本区必须变成干净的唯一姓名列表。人们可能从 Excel、Google 文档或群聊中粘贴,因此分隔符可能是换行符、逗号、全角中文逗号、分号或纯空白:
function parseInput() {
const tokens = rawInput.value
.split(/[\n,,;\s]+/g)
.map((s) => s.trim())
.filter(Boolean);
// Remove duplicates within the same input session
const uniqueTokens = [...new Set(tokens)];
for (const name of uniqueTokens) {
if (peopleIndex.has(name)) continue; // Skip if already exists
const id = newId();
peopleIndex.set(name, id);
peopleList.push({ id, name, tags: new Set() });
}
}
Enter fullscreen mode Exit fullscreen mode
这里做了两次去重:`new Set(tokens)` 在当前粘贴中折叠重复项,而 `peopleIndex`(一个 `name -> id` 映射)防止你重新添加之前粘贴过的某人,这样可以增量补充名单而不会重复。显而易见的陷阱:去重基于精确字符串匹配。两个恰好都叫“John”的人会被静默合并为一个条目。没有单独的身份字段来区分他们——对这个工具而言,名字就是身份。
没有洗牌——这是一个带评分的放置循环
算法没有随机化顺序再切片,而是按“受约束程度”对人员排序,然后贪心地先放置最受约束的人:
const peopleOrder = [...peopleList].sort((a, b) => {
// 1. Priority tags first
const pa = Array.from(a.tags).filter((id) => priorityTagIds.has(id)).length;
const pb = Array.from(b.tags).filter((id) => priorityTagIds.has(id)).length;
if (pb !== pa) return pb - pa;
// 2. People with more rules (hard + soft) should be placed first
const ta = totalRuleDegree(a.id);
const tb = totalRuleDegree(b.id);
if (tb !== ta) return tb - ta;
// 3. Rare tags first (harder to place later)
const ra = a.tags.size === 0 ? Infinity : [...a.tags].reduce((s, id) => s + (tagFreq.get(id) || 0), 0);
const rb = b.tags.size === 0 ? Infinity : [...b.tags].reduce((s, id) => s + (tagFreq.get(id) || 0), 0);
return ra - rb;
});
Enter fullscreen mode Exit fullscreen mode
直觉与你打包行李时相同:先放那些笨拙、形状奇特的物品,还能有空间协商;最后让容易放的东西填补空隙。带有优先规则标签、或被三条“必须分开”规则绑住的人,会比没有任何约束的人先被安排。
对每个人,`pickBestGroupFor` 会对每个组打分并保留最佳组——而它给一个假设放置打分的方式是我真正觉得聪明(或有点疯狂,取决于你的心情)的部分:它实际先把人放进去,真实地重新计算每条规则的违规情况,读取损害,再撤销:
function deltaScoreIfPlace(pid, gid) {
const g = groups.find((x) => x.id === gid);
const snapshot = takeLightSnapshot();
g.members.push(pid); // temporarily place for testing
const vlist = computeViolationsInternal();
const hardViolations = vlist.filter((v) => v.type === "HARD");
const softViolations = vlist.filter((v) => v.type === "SOFT");
const score = hardViolations.length > 0 ? -Infinity : -softViolations.length * 10;
applyLightSnapshot(snapshot); // restore before trying the next group
return score;
}
Enter fullscreen mode Exit fullscreen mode
它没有增量的“这个具体放置是否会造成冲突”检查——只是突变真实状态,重新运行整个规则集的完整违规扫描,读取计数,然后用快照/恢复对回滚。对于 30 人的班级来说没问题。我不会把 5,000 人的名单粘贴进去。
名单无法整除时会发生什么
当有 `n` 个人和 `k` 个组时,基础容量是 `Math.floor(n / k)`,余数是 `n % k` 个人。这个余数如何处理实际上取决于一个复选框,而且和我最初连线时的预期不同:
const baseCap = Math.floor(n / k);
const remainder = n % k;
const capacities = groups.map((_, i) =>
allowPlusMinusOne.value ? baseCap : baseCap + (i < remainder ? 1 : 0),
);
Enter fullscreen mode Exit fullscreen mode
当“允许 ±1”关闭时,余数会被确定性地分配——前 `remainder` 个组正好多一个座位,其余组得到 `baseCap`,这是硬上限。但打开“允许 ±1”后,`capacities` 数组被计算后实际上被忽略:放置循环内部的容量检查会切换为对每个组使用平坦的 `avgCap + 1`,而不是读取 `capacities[gIndex]`:
if (!allowPlusMinusOne.value) {
if (g.members.length >= capacities[gIndex]) continue;
} else {
if (g.members.length >= avgCap + 1) continue;
}
Enter fullscreen mode Exit fullscreen mode
所以在 ±1 模式下,没有计划好的“谁得到额外座位”的分配——它只是当一个人的轮次在排序顺序中到来时,尚未达到 `avgCap + 1` 的组。通常由于排序的进给方式,结果会接近均匀,但这是循环顺序的副作用,而不是设计好的分布。
值得了解的限制
- 同名冲突是真实存在的:粘贴两个“Alex”,第二个会静默合并到第一个人的标签集,而不是成为第二个人。
- 硬违规的“自动修复”不是优化器——它按顺序尝试将此人移到每个组,然后按顺序尝试与每个人交换,并在清除所有硬违规的第一个组合处停止。它不寻找最佳修复,只是寻找一个可行的。
- 整个工具在客户端运行(没有任何服务器往返),这对隐私很好,但意味着上述违规重扫方法受限于浏览器单线程,而不是服务器。
- 软规则是真的软:如果无法实现零违规,算法会愉快地接受打破三条“倾向分开”规则的放置,且除非你之后读取违规列表,否则不会告诉你它选择了牺牲哪些软规则组合。
我最终把它清理成了一个小免费工具,如果你想跳过自己构建约束评分循环: Smart Grouping Tool。无需注册,完全在浏览器中运行。
其他语言版本
- 智能分組工具 — 繁體中文
- 智能分组工具 — 简体中文
- Smart Grouping Tool — English
- スマートグループ化ツール — 日本語
- 무료 스마트 분조 도구 — 한국어
- Outil de Groupement Intelligent — Français
- Инструмент умного группирования — Русский
- Intelligentes Gruppierungs-Tool — Deutsch
- Alat Pengelompokan Cerdas — Bahasa Indonesia
- Herramienta de Agrupación Inteligente — Español
- Công cụ Nhóm Thông minh — Tiếng Việt
- เครื่องมือจัดกลุ่มอัจฉริยะ — ไทย
- Narzędzie Inteligentnego Grupowania — Polski
- Akıllı Gruplama Aracı — Türkçe
- Strumento di Raggruppamento Intelligente — Italiano
- Ferramenta de Agrupamento Inteligente — Português
- Slimme Groeperingstool — Nederlands
- Інструмент Розумного Групування — Українська
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.