不久前我想做一個經典的「貼上姓名清單、得到 N 個平均分組」的工具——這種工具每位老師和活動主辦人都曾經需要過。我以為它基本上就是 Fisher-Yates 洗牌再加上迴圈裡的 Array.slice,也許一個小時就能搞定。
後來我認真思考了大家說「隨機幫我的班級分組」時真正想要什麼。他們要的不是隨機,而是「隨機,但不要把那兩個打架的孩子放在同一組」或是「把實習生平均分配到每張桌」或是「三位團隊負責人必須落在不同的小組」。一旦加入單一限制條件,真正的隨機就變成敵人,而非特色。所以我最後上線的工具在分組邏輯裡根本沒有 Math.random()。它是一個確定性、懂規則的放置演算法,只是包裝成「聰明」的洗牌。
解析貼上的清單比看起來麻煩得多
在任何分組動作之前,原始的 textarea 必須先變成一份乾淨且不重複的姓名清單。人們可能從 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 人的班級還可以,5000 人的清單我就不會這樣做了。
清單無法平均分配時會發生什麼
有 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.