你有被這樣拜託過嗎?
最困擾的是,沒有人定義那個「分得剛剛好」到底是什麼。
一問之下,通常會得到這類回答:
全部都帶著「盡量」。
很可惜,只有「盡量」的話,沒辦法直接寫成程式。
沒辦法翻成 if,也沒辦法翻成 for。所以實作的人才會頭痛。
這篇文章要寫的,就是把那個「分得剛剛好」定義出來並解決的故事。
題材是公司內的 Shuffle Lunch(讓平常不一起工作的同事一起吃午餐的制度),把 80 人分成 3 到 4 人一組。
不過,本質上和排班表、值班表是一樣的。這次沒有用求解器或函式庫,只靠 Google Apps Script 寫出來。
把 80 人每個月分成 3 到 4 人的小組。
如果隨機分配,上個月跟這個月重複到同一對人,大約每個月會出現 5 對。
也就是說,「明明是 Shuffle Lunch,結果又跟上個月一樣呢」這種情況,每個月大約會有 5 對。
這 5 對是怎麼算的(可跳過)80 人分成 4 人一組,共 20 組,所以同桌的配對數是 C(4,2)×20 = 120 對。
80 人能形成的總配對數是 C(80,2) = 3160 對。
特定兩人同組的機率是 120/3160 = 約 3.8%。
上個月的 120 對,這個月再次同桌的期望值是 120 × 3.8% = 約 4.6 對。
於是我把「分得剛剛好」真的定義出來後,結果變成這樣:
上個月重複的配對0 對(連續 12 個月)6 個月內曾見過的人再次同席0 對(連續 12 個月)計算時間約 2 秒小規模下與嚴格最優解一致140/140 case使用的求解器/函式庫沒有
原本隨機分配每個月大概會有 5 對重複,但把演算法做好之後,可以做到 0 對。
接下來會說,我把這個「剛剛好」翻譯成了什麼。
順帶一提,就算是隨機分配,也會出現偏差;值班表或排班表也一樣,常常會偏得超出「只是剛好」的直覺範圍。
把營運端提出的需求列出來,大概會像這樣:
首先做的,是把這份清單分成兩類。
(A) 違反就會讓營運壞掉的。
(B) 違反了營運仍然能運作,但價值會下降的。
例如,如果把申請不參加的人排進去,那就是 bug,所以「申請不參加的人絕對不能排進去」屬於A。
相反地,就算有一組人跟上個月一樣,午餐還是能辦,所以「盡量避開最近才一起吃過的人」屬於B。
補充一下,(A) 稱為硬性約束,(B) 稱為軟性約束。
而且最重要的是:
軟性約束之間,可能彼此衝突。
把上個月一起吃過的兩個人拆開,結果又可能連帶讓同一事業部的兩個人坐在一起。
顧了這邊,就顧不到那邊。
所以我一開始就放棄了「全部都滿足」這種設計。這類問題很常見。(題外話:營運端大概也直覺知道不可能完美解掉,所以才會說「分得剛剛好」吧。)

在整理過程中,有一項讓人卡住的需求。
就是「4 人小組盡量多」。
盡量。怎麼看都像是軟性約束。
如果把它做成懲罰項,就會變成每多一個 3 人小組扣幾分之類的設計。
但其實不需要。
「最大化 4 人小組」換句話說,就是「最小化小組數」。
問題來了。
78 人要分成 3 到 4 人一組,想要「4 人小組盡量多」。
這條件需要最佳化嗎?
其實不需要。
78 人只能是「4 人×18 + 3 人×2」。
用公式表達就是這樣:
G = \left\lceil \frac{N}{4} \right\rceil, \qquad n_4 = N - 3G, \qquad n_3 = 4G - N
$n_4$ 是 4 人小組的數量,$n_3$ 是 3 人小組的數量。
N=80 時,G=20。剛好整除,所以是 4 人×20,3 人小組為 0。
如果有 2 人申請不參加,變成 N=78,G 仍然是 20,組合就變成 4 人×18 + 3 人×2。
這兩種情況都不可能有其他構成。根本沒有選擇空間對吧。
看起來像「盡量」,但其實是唯一決定的需求,意外地不少。
找到這種東西就賺到了,因為整個搜尋空間直接少掉一塊!
再往下講一個做法上的重點。
既然知道是硬性約束,原本可以用超大的懲罰值,讓它實際上被避開——
但我沒有這樣做。
我直接讓系統不產生違反規則的分法。
const G = Math.ceil(N / 4);
const numFour = N - 3 * G;
const numThree = 4 * G - N;
const sizes = [];
for (let i = 0; i < numFour; i++) sizes.push(4);
for (let i = 0; i < numThree; i++) sizes.push(3);
// 後面只會照這個 sizes 來切分
搜尋演算法只會產生這種大小組合。
之後會提到的「一步操作」也只限制在不改變組別大小的操作。
這樣一來,硬性約束的違反在原理上就不會發生。
檢查流程也不用,重試也不用。
約束與其用懲罰,不如直接讓它不可能發生,會更好👌
到這裡,硬性約束已經用結構解決了。那剩下的軟性約束怎麼辦?
做法很簡單。每發生一次不理想的事,就加一次分。
最後選總分最低的分法,就這麼簡單。
這個分數一般叫做「懲罰值」,簡單說就是 不喜歡程度的分數。
我把懲罰值定義如下:
事件懲罰值上個月同席的配對再次見面3 × 6 = 18同一事業部的配對每多 1 組8懲罰值本身沒有意義。重點是和其他項目相比時的比例。
「上個月的配對(18) > 事業部重複(8)」。
這個不等式,直接就是優先順序。需求訪談時說的「這個最優先」「再來是這個」,在這裡就變成數字了。
實際營運中如果被說「這個月要更重視混合不同事業部的人」,只要把 8 調高就行。
順帶一提,18 和 8 可以約分。改成 9 和 4,最後選出來的分法不會變。真正有作用的是比例。
就是對每個小組 $g$ 算分,再把全部加總。
\begin{aligned}
P = \sum_{g} \Bigl[\;
& 3 \sum_{\{a,b\} \subset g} \mathrm{PairScore}(a,b) && \text{最近同席程度} \\
+\; & 8 \sum_{d} \binom{k_d}{2} \;\Bigr] && \text{同一事業部的配對數}
\end{aligned}
沒有什麼新東西,只是把前面的表格直接寫成公式而已。
式子裡的 3 是倍率。上個月同席是 6 分,所以乘 3 會變成 18。(為什麼是 6,後面會解釋。)
我也想過。
但後來放棄了。
每個月只有一次,拿來學習的資料一年只會增加 12 筆。
而且權重能讓人手動決定,本身也有營運上的價值。
因為當有人問「為什麼這兩個人同組」的時候,可以說得出理由。
對公司內部的機制來說,可解釋性有時比精準度更重要。
再介紹一個計數上的巧思。
我沒有用「同一事業部有 2 個人就扣 8 分」,而是改成「同一事業部的每一組配對扣 8 分」。
這樣一來:
人越聚越多,新增 1 人的代價就越大。只要改變計數方式,就能表現出「2 人還算可以,3 人以上就要盡量避免」這種營運直覺。

「盡量避開最近的人」裡面的 最近,也需要翻譯。
如果直接做成很直覺的實作,大概會像這樣:
if (先月同じグループだった) penalty += 18;
這樣雖然能動,但兩個月以前的資訊會完全消失。
所以我改成替過去每一次見面加上衰減係數再累加。
// decay(m) = max(0, 7 − m) m 是幾個月前(上個月=1)
function decay_(monthsAgo) {
return Math.max(0, 7 - monthsAgo);
}
上個月是 6 分。半年前是 1 分。超過 7 個月以前就是 0 分。
把這些加總後,就是 PairScore(a, b)。
「3 個月前只有同席 1 次(4 分)」比「上個月同席(6 分)」來得沒那麼痛。
越近的過去權重越重,越遠的過去就不算。
不要用門檻直接一刀切,而是讓它連續地作用,這點很重要。值班表裡「不要同一個人連著出現」也可以直接用這種形式。

結構上能決定的部分到這裡結束。剩下的交給「搜尋」。
把 80 人分成 4 人×20 組的方法有 7.3×10⁷² 種。不可能暴力搜尋。
所以我們只做這件事:
從現在的分法中挑 2 個人交換看看。如果分數下降,就採用。
一直重複,直到沒有能下降的交換。就這樣。
實際執行起來像這樣↓

初始狀態是「和上個月完全相同的分組」。因為所有配對都帶著重見懲罰,所以一開始是 428 分。
接著一步一步,挑最有效的交換往下走。
428 → 288 → 184 → 107 → 55 → 37 → 19 → 16。7 步就降到底了。
可以看出,是依序選了最有用的步驟(−140、−104、−77、−52、……,最後是 −3)。

我做的事情其實只是「往比現在更好的方向,一步一步前進」。這種方法叫做局部搜尋,或者山登法(hill climbing)。
我準備的「一步操作」只有兩種:
這兩種都不會破壞組別大小,所以硬性約束會一路自動維持。
// 1 對 1 交換: ga[i] ⇔ gb[j]
const delta = groupPenalty_(newA, ...) + groupPenalty_(newB, ...)
- pen[a] - pen[b]; // ← 只重算受影響的兩組
if (delta < bestDelta) { bestDelta = delta; bestOp = {type:'swap', a, b, i, j}; }
順帶一提,80 人剛好時所有小組都是 4 人,所以「移動」這個操作根本不會用到。
等到有 2 人不參加、變成 78 人時,才會出現 2 個 3 人組,那時它才會派上用場。GIF 裡面出現兩次的「移動」就是這個。
沒錯。

山登法會停在局部最佳解。只要下到一個谷底,就不會知道旁邊其實還有更深的谷。
所以我改成,從不同起點爬 30 次(多起點搜尋)。
從隨機的初始配置出發,往下爬到底,在 30 次裡選最低的那個谷底。
老實說,這樣也不能保證大域最佳。
所以我實際去量,看看會差多少。
一開始提到的結果,就是這樣測出來的。
① 小規模時和暴力枚舉比對(N=6~12)
人數少的話可以列舉所有分法。針對 N=6~12,每個 case 測 20 次,和嚴格最優解比對後,140 個 case 全部一致。
至少在小規模下,山登法沒有漏掉最優解。(N=12 時,所有分法共有 5,775 種。)
② 連續模擬 12 個月
用 80 人名單跑 12 個月,統計每個月「曾與上個月同席的配對」有幾對再次重逢。
結果 12 個月全部都是 0 對。即使擴大到「6 個月內曾同席的配對」,也都是 0 對。
隨機分配時每個月大約會有 5 對重複,這裡變成 0,所以 PairScore 的效果如預期發揮了。

當被要求「幫我分得剛剛好」時,我做了 5 件事。
我覺得最有趣的是第 2 點。
看起來像「盡量」的東西,實際上卻是沒有選擇空間的約束。也就是說,把本來不用搜尋的東西,直接從搜尋裡排除掉了。
一提到最佳化,常會讓人想到把聰明的搜尋演算法或求解器搬出來。
但真正有效的,反而是在搜尋之前先把問題縮小。
「分得剛剛好」本身沒辦法直接變成程式。
但它可以翻成約束、權重比例,以及計數方式。
這篇文章把「幫我分得剛剛好」這種模糊需求,翻譯成了約束、權重與計數方式。
Sapeet 很重視像這樣深入理解 AI 與演算法原理,並且一邊動手實作、一邊連結到產品與業務的做法。
我們也舉辦交流活動「Open Sapeet!」,讓大家更了解 Sapeet 的技術與開發氛圍。
2026 年 9 月 11 日(五)19:30 起,Sapeet 的工程師等員工將登台,分享 AI、產品開發,以及未來的工作方式。也可能會聊到和這篇文章類似的「如何把『剛剛好』翻譯成程式」這種話題。
演講之後,也準備了輕食和飲料,讓大家能輕鬆交流。
如果你想看看新創公司的開發現場,或想認識 Sapeet 的氛圍,歡迎來玩。
活動詳情與報名請見👇️
https://connpass.com/event/398374/

原文出處:https://qiita.com/KYoshiyama/items/50f50f39fd0da34e3dce