位元遮罩 DP(狀態壓縮)
範圍 — 狀態是「一個用整數編碼的子集合」的 DP:遮罩運算、子遮罩列舉、TSP 與指派問題的模板,以及 n <= 20 這條規模上限。 另見:dp.md — 精簡版的位元遮罩模板,以及它在各 DP 模式中的定位;bit_manipulation.md — 純粹的位元技巧,不談 DP。
LeetCode 題目清單
概觀
關鍵性質
- 複雜度:標準的子集合 DP 是
O(2^n * n)時間、O(2^n)空間 — 所以n <= 20是實務上的 天花板(2^20≈ 1e6)。子遮罩列舉的成本是O(3^n)。 - 核心想法:DP 的狀態是一個集合,而
n個元素的集合就只是一個n位元的整數, 所以整張 memo 表就是一個以該整數為索引的一維陣列。 - 什麼時候用:題目要記錄「我已經用過/走過哪些元素」、
n很小,而且暴力解會是一個排列搜尋。
參考資料
- dp.md — 一個螢幕就講完的位元遮罩模板
- bit_manipulation.md — 單獨看的位元技巧
模板與演算法
狀態壓縮模式
什麼時候該用位元遮罩 DP:
- 狀態空間很小(≤ 20 個元素)
- 需要記錄哪些元素被選了/走過了
- 排列/組合類的問題
- 旅行推銷員問題的各種變形
常見的位元遮罩運算:
# python
# IDEA: the bit vocabulary every bitmask DP is written in
# Check if i-th bit is set
if mask & (1 << i):
pass
# Set i-th bit
new_mask = mask | (1 << i)
# Unset i-th bit
new_mask = mask & ~(1 << i)
# Iterate through all submasks
submask = mask
while submask:
# Process submask
submask = (submask - 1) & mask
Java 的位元遮罩運算:
// java
// IDEA: the same bit vocabulary in Java
// Check if i-th bit is set
if ((mask & (1 << i)) != 0) {
// i-th item is included
}
// Set i-th bit
int newMask = mask | (1 << i);
// Unset i-th bit
int newMask = mask & ~(1 << i);
// Toggle i-th bit
int newMask = mask ^ (1 << i);
// Count number of set bits
int count = Integer.bitCount(mask);
// Get lowest set bit
int lowestBit = mask & (-mask);
// Iterate through all subsets
for (int mask = 0; mask < (1 << n); mask++) {
// Process mask
}
// Iterate through all submasks of mask
for (int submask = mask; submask > 0; submask = (submask - 1) & mask) {
// Process submask
}
模式 1:走遍所有節點(TSP 變形)
題型:找出恰好走過所有節點一次的最短路徑
狀態定義:dp[mask][i] = 走完 mask 中所有節點、且停在節點 i 的最小成本
轉移:對每個還沒走過的節點 j,試著從目前的節點 i 走過去
時間複雜度:O(2^n × n²) 空間複雜度:O(2^n × n)
範例:LC 847 - Shortest Path Visiting All Nodes
// java
// LC 847 - Shortest Path Visiting All Nodes
// IDEA: BFS over (mask, node); dp[mask][node] = fewest edges to stand on `node`
// having visited exactly the set `mask`
// time = O(2^n * n^2), space = O(2^n * n)
public int shortestPathLength(int[][] graph) {
int n = graph.length;
int[][] dp = new int[1 << n][n];
// NOTE !!! every state must start at +inf, not 0 — otherwise
// `dp[nextMask][next] > dist + 1` is false for unvisited states and the BFS never expands
for (int[] row : dp) {
Arrays.fill(row, Integer.MAX_VALUE);
}
Queue<int[]> queue = new LinkedList<>();
// Initialize: start from any node
for (int i = 0; i < n; i++) {
dp[1 << i][i] = 0;
queue.offer(new int[]{1 << i, i});
}
int target = (1 << n) - 1;
while (!queue.isEmpty()) {
int[] curr = queue.poll();
int mask = curr[0], node = curr[1];
int dist = dp[mask][node];
if (mask == target) {
return dist;
}
for (int next : graph[node]) {
int nextMask = mask | (1 << next);
if (dp[nextMask][next] > dist + 1) {
dp[nextMask][next] = dist + 1;
queue.offer(new int[]{nextMask, next});
}
}
}
return -1;
}
模式 2:指派問題
題型:把 n 件工作指派給 n 個工人,讓總成本最小/最大
狀態定義:dp[w][mask] = 在恰好 w 個工人接下 mask 這些工作時的最小完工時間
轉移:讓工人 w 接下 mask 的某個子遮罩;剩下的交給工人 1..w-1
時間複雜度:O(k × 3^n) — 對所有遮罩跑子遮罩迴圈是 3^n,每個工人各跑一次 空間複雜度:O(k × 2^n)
工人數必須是狀態的一部分。 只用
dp[mask]去最小化max(dp[mask ^ sub], sum(sub)), 等於是把mask拆成不限數量的組別,那算出來的是「工人愛用幾個就用幾個時的最小完工時間」, 而不是「恰好用k個」。
範例:LC 1723 - Find Minimum Time to Finish All Jobs
// java
// LC 1723 - Find Minimum Time to Finish All Jobs
// IDEA: dp[w][mask] = min makespan after w workers have taken exactly the jobs in mask
// time = O(k * 3^n), space = O(k * 2^n)
public int minimumTimeRequired(int[] jobs, int k) {
int n = jobs.length, full = (1 << n) - 1;
// Precompute sum for each subset (lowest-set-bit recurrence, O(2^n))
int[] subsetSum = new int[1 << n];
for (int mask = 1; mask <= full; mask++) {
int lowBit = mask & -mask;
subsetSum[mask] = subsetSum[mask ^ lowBit] + jobs[Integer.numberOfTrailingZeros(lowBit)];
}
int[][] dp = new int[k + 1][1 << n];
for (int[] row : dp) {
Arrays.fill(row, Integer.MAX_VALUE);
}
dp[0][0] = 0; // 0 workers can only cover the empty job set
for (int w = 1; w <= k; w++) {
for (int mask = 0; mask <= full; mask++) {
// NOTE !!! `sub` is worker w's share; `mask ^ sub` goes to workers 1..w-1
for (int sub = mask; ; sub = (sub - 1) & mask) {
int prev = dp[w - 1][mask ^ sub];
if (prev != Integer.MAX_VALUE) {
dp[w][mask] = Math.min(dp[w][mask], Math.max(prev, subsetSum[sub]));
}
if (sub == 0) break; // must run sub == 0 too (worker w idles), then stop
}
}
}
return dp[k][full];
}
模式 3:帶限制的子集合選取
題型:選出滿足特定限制的子集合
狀態定義:dp[mask] = 達成 mask 所代表狀態的方法數/最小成本
轉移:對每個元素,依照目前的遮罩決定要不要納入
時間複雜度:O(2^n × n),或用子遮罩列舉時是 O(3^n) 空間複雜度:O(2^n)
範例:LC 691 - Stickers to Spell Word
// java
// LC 691 - Stickers to Spell Word
// IDEA: dp[mask] = fewest stickers to cover the letters in `mask`
// time = O(2^n * stickers * n), space = O(2^n)
public int minStickers(String[] stickers, String target) {
int n = target.length();
int[] dp = new int[1 << n];
Arrays.fill(dp, -1);
dp[0] = 0;
for (int mask = 0; mask < (1 << n); mask++) {
if (dp[mask] == -1) continue;
for (String sticker : stickers) {
int newMask = mask;
int[] counts = new int[26];
for (char c : sticker.toCharArray()) {
counts[c - 'a']++;
}
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) == 0) {
char c = target.charAt(i);
if (counts[c - 'a'] > 0) {
counts[c - 'a']--;
newMask |= (1 << i);
}
}
}
if (dp[newMask] == -1 || dp[newMask] > dp[mask] + 1) {
dp[newMask] = dp[mask] + 1;
}
}
}
return dp[(1 << n) - 1];
}
模式 4:分割成 K 個子集合
題型:把 n 個元素在限制下分成 k 組
狀態定義:dp[mask] = mask 裡的元素能不能剛好切成幾個完整的組
轉移:試著從目前的狀態湊出一個完整的組
時間複雜度:O(2^n × n) 空間複雜度:O(2^n)
範例:LC 698 - Partition to K Equal Sum Subsets
// java
// LC 698 - Partition to K Equal Sum Subsets
// IDEA: fill one bucket at a time; dp[mask] tracks the running remainder
// time = O(2^n * n), space = O(2^n)
public boolean canPartitionKSubsets(int[] nums, int k) {
int sum = 0;
for (int num : nums) sum += num;
if (sum % k != 0) return false;
int target = sum / k;
int n = nums.length;
boolean[] dp = new boolean[1 << n];
int[] total = new int[1 << n];
dp[0] = true;
for (int mask = 0; mask < (1 << n); mask++) {
if (!dp[mask]) continue;
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) != 0) continue;
int newMask = mask | (1 << i);
if (total[mask] % target + nums[i] <= target) {
dp[newMask] = true;
total[newMask] = total[mask] + nums[i];
}
}
}
return dp[(1 << n) - 1];
}
Pattern 5: Set Cover — the mask is the GOAL, not the items Priority 5 of 5 — Must know — expect it in almost every loop
Problem Type: pick the fewest items so that the union of what they cover is everything
State Definition: dp[cover] = the cheapest team whose skills union to exactly cover
Transition: for each person p, dp[cover | skills(p)] <- dp[cover] + {p}
Time Complexity: O(people × 2^m) Space Complexity: O(2^m) states (plus whatever you store per state to rebuild the answer)
The mask indexes the SKILLS, not the PEOPLE. This is the whole trick and it is the one thing that goes wrong in the room. LC 1125 has up to 60 people and at most 16 skills —
2^60is impossible and2^16is nothing. Whenevernlooks far too big for bitmask DP, check whether the requirement is the small side.
Example: LC 1125 - Smallest Sufficient Team
// java
// LC 1125 - Smallest Sufficient Team
// IDEA: dp[cover] = smallest team covering that skill set. People are up to 60, so the
// team itself is stored as a 64-bit person mask and rebuilt by scanning its bits.
// time = O(people * 2^m), space = O(2^m)
public int[] smallestSufficientTeam(String[] reqSkills, List<List<String>> people) {
int m = reqSkills.length, full = (1 << m) - 1;
Map<String, Integer> skillId = new HashMap<>();
for (int i = 0; i < m; i++) skillId.put(reqSkills[i], i);
long[] team = new long[1 << m]; // team[cover] = bitmask of chosen people
int[] size = new int[1 << m];
Arrays.fill(size, Integer.MAX_VALUE);
size[0] = 0; // the empty team covers nothing, for free
for (int p = 0; p < people.size(); p++) {
int pm = 0;
for (String s : people.get(p)) {
Integer id = skillId.get(s); // people may list skills nobody asked for
if (id != null) pm |= 1 << id;
}
if (pm == 0) continue;
for (int cover = 0; cover <= full; cover++) {
if (size[cover] == Integer.MAX_VALUE) continue;
int next = cover | pm;
/** NOTE !!! `next >= cover` always, and when pm is already inside `cover` we get
* next == cover and the relaxation below is a no-op — so a person can never be
* added twice even though we write forward into the same array. */
if (size[next] > size[cover] + 1) {
size[next] = size[cover] + 1;
team[next] = team[cover] | (1L << p);
}
}
}
long chosen = team[full];
int[] ans = new int[size[full]];
int k = 0;
for (int p = 0; p < people.size(); p++) {
if ((chosen >> p & 1) == 1) ans[k++] = p;
}
return ans;
}
# python
# LC 1125 - Smallest Sufficient Team
# IDEA: dict from skill-cover mask -> the smallest team reaching it. Only reachable
# covers are ever stored, which in practice is far fewer than 2^m.
# time = O(people * 2^m), space = O(2^m * m)
def smallestSufficientTeam(req_skills, people):
skill_id = {s: i for i, s in enumerate(req_skills)}
full = (1 << len(req_skills)) - 1
dp = {0: []} # cover -> list of person indices
for p, skills in enumerate(people):
pm = 0
for s in skills:
if s in skill_id: # ignore skills nobody required
pm |= 1 << skill_id[s]
if pm == 0:
continue
# NOTE !!! iterate a SNAPSHOT -- otherwise person p can be re-used within one pass
for cover, crew in list(dp.items()):
nxt = cover | pm
if nxt == cover:
continue
if nxt not in dp or len(dp[nxt]) > len(crew) + 1:
dp[nxt] = crew + [p]
return dp[full]
Similar problems: LC 691 Stickers to Spell Word (same shape, but a sticker may be used more
than once, so relax dp[cover] from every cover repeatedly — BFS or an ascending sweep),
LC 1434 Number of Ways to Wear Different Hats, LC 2305 Fair Distribution of Cookies.
Pattern 6: Row-by-Row Profile DP — the mask is ONE ROW Priority 4 of 5 — High value — a gap here costs you rounds
Problem Type: fill a grid subject to constraints between a cell and its neighbours, where the
grid is narrow (cols <= ~12) but may be arbitrarily tall
State Definition: dp[i][mask] = best value for rows 0..i when row i is exactly mask
Transition: for each pair (prev, cur) of legal row layouts, check the cross-row rule
Time Complexity: O(rows × 4^cols) naive, O(rows × 3^cols) if you enumerate submasks Space Complexity: O(2^cols) — only the previous row is needed
Which dimension goes in the mask. Always the short one. LC 1349 is
m <= 8rows byn <= 8columns, but the same problem with 10,000 rows and 8 columns is identical work — the exponent is on the width alone. Transpose first if the grid is tall and thin the other way.
The constraints split cleanly into two independent checks, and keeping them separate is what makes this writable under pressure:
within a row : no two students side by side -> mask & (mask << 1) == 0
no student on a broken seat -> mask & broken[i] == 0
across rows : no upper-left neighbour -> cur & (prev << 1) == 0
no upper-right neighbour -> cur & (prev >> 1) == 0
(directly above is ALLOWED — cheating needs a diagonal)
Example: LC 1349 - Maximum Students Taking Exam
// java
// LC 1349 - Maximum Students Taking Exam
// IDEA: dp[mask] = most students seated so far with the current row laid out as `mask`.
// Row validity and cross-row validity are two separate bit tests.
// time = O(m * 4^n), space = O(2^n)
public int maxStudents(char[][] seats) {
int m = seats.length, n = seats[0].length, full = 1 << n;
int[] broken = new int[m];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (seats[i][j] == '#') broken[i] |= 1 << j;
}
}
int[] prev = new int[full];
Arrays.fill(prev, -1);
prev[0] = 0; // before row 0: only the empty layout exists
for (int i = 0; i < m; i++) {
int[] cur = new int[full];
Arrays.fill(cur, -1);
for (int mask = 0; mask < full; mask++) {
if ((mask & broken[i]) != 0) continue; // sits on a broken seat
if ((mask & (mask << 1)) != 0) continue; // two students side by side
for (int p = 0; p < full; p++) {
if (prev[p] == -1) continue; // unreachable previous layout
if ((mask & (p << 1)) != 0) continue; // upper-left neighbour
if ((mask & (p >> 1)) != 0) continue; // upper-right neighbour
cur[mask] = Math.max(cur[mask], prev[p] + Integer.bitCount(mask));
}
}
prev = cur;
}
int best = 0;
for (int v : prev) best = Math.max(best, v);
return best;
}
# python
# LC 1349 - Maximum Students Taking Exam
# IDEA: same two-tier validity test; -1 marks an unreachable layout so it can never
# be relaxed from (a plain 0 default would invent seatings that do not exist)
# time = O(m * 4^n), space = O(2^n)
def maxStudents(seats):
m, n = len(seats), len(seats[0])
full = 1 << n
broken = [0] * m
for i in range(m):
for j in range(n):
if seats[i][j] == '#':
broken[i] |= 1 << j
prev = [-1] * full
prev[0] = 0
for i in range(m):
cur = [-1] * full
for mask in range(full):
if mask & broken[i] or mask & (mask << 1):
continue
best = max((prev[p] for p in range(full)
if prev[p] >= 0 and not (mask & (p << 1)) and not (mask & (p >> 1))),
default=-1)
if best >= 0:
cur[mask] = best + bin(mask).count('1')
prev = cur
return max(prev)
Similar problems: LC 1659 Maximize Grid Happiness (profile DP with three states per cell, so base 3 instead of base 2), LC 1655 Distribute Repeating Integers, and the classic domino/tromino tiling family, where the mask describes which cells of the next row are already covered.
Bitmask DP Common Patterns Summary
| Pattern | State Definition | Transition | Example Problems |
|---|---|---|---|
| Visit All Nodes | dp[mask][i] = cost to visit mask, end at i |
Try next unvisited node | LC 847, LC 943 |
| Assignment | dp[mask] = cost to assign tasks in mask |
Assign next task to worker | LC 1723, LC 1986 |
| Subset Selection | dp[mask] = ways/cost for subset mask |
Include/exclude next item | LC 691, LC 1434 |
| Partition | dp[mask] = can partition mask into groups |
Form complete groups | LC 698, LC 1681 |
| Set Cover | dp[cover] = cheapest set of items reaching that cover |
Union in one more item | LC 1125, LC 691 |
| Profile DP | dp[i][mask] = state at row i with column mask |
Process row by row | LC 1349, tiling problems |
進階技巧
1. 預先算好子集合的性質:
// java
// IDEA: subset-sum precompute via the lowest set bit
// time = O(2^n), space = O(2^n)
// Precompute sum for all subsets - O(2^n × n)
int[] subsetSum = new int[1 << n];
for (int mask = 0; mask < (1 << n); mask++) {
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) != 0) {
subsetSum[mask] += arr[i];
}
}
}
2. 子遮罩列舉 — O(3^n):
// java
// IDEA: submask enumeration — the `(sub - 1) & mask` idiom
// time = O(3^n), space = O(1)
// For each mask, iterate through all its submasks
for (int mask = 0; mask < (1 << n); mask++) {
for (int submask = mask; submask > 0; submask = (submask - 1) & mask) {
// dp[mask] can be computed from dp[submask] and dp[mask ^ submask]
dp[mask] = Math.min(dp[mask], dp[submask] + dp[mask ^ submask]);
}
}
3. SOS(Sum Over Subsets)DP — O(2^n × n):
// java
// IDEA: SOS (sum over subsets) DP — n passes instead of 3^n
// time = O(2^n * n), space = O(2^n)
// For each mask, sum values of all its submasks
int[] dp = new int[1 << n];
// ... initialize dp ...
for (int i = 0; i < n; i++) {
for (int mask = 0; mask < (1 << n); mask++) {
if ((mask & (1 << i)) != 0) {
dp[mask] += dp[mask ^ (1 << i)];
}
}
}
複雜度分析
| 技巧 | 時間複雜度 | 空間複雜度 | 適用情境 |
|---|---|---|---|
| 基本位元遮罩 | O(2^n × n) | O(2^n) | 走遍全部、指派 |
| 子遮罩列舉 | O(3^n) | O(2^n) | 分割、子集合和 |
| SOS DP | O(2^n × n) | O(2^n) | 子集合求和 |
| 輪廓線 DP | O(2^m × n) | O(2^m) | 網格鋪磚(m = 寬度) |
可行規模的界線:
- n ≤ 15:非常安全,約 32K 個狀態
- n ≤ 20:可行,約 1M 個狀態
- n ≤ 24:很吃緊,約 16M 個狀態(小心 TLE)
- n > 24:通常已經超出位元遮罩 DP 能處理的範圍
面試提示
-
認出狀態壓縮:
- 關鍵字:「走遍全部」、「指派」、「分成 k 組」
- 限制條件:n ≤ 20
- 需要記錄子集合/走過的元素
-
選對狀態:
- TSP 型:
dp[mask][last_node] - 指派型:
dp[mask](隱含地派給第 k 個工人) - 分割型:
dp[mask]搭配取模檢查
- TSP 型:
-
最佳化:
- 預先算好子集合的性質
- 最短路徑類的問題改用 BFS
- 子集合求和的查詢考慮用 SOS DP
-
常見錯誤:
- 忘了初始化
dp[0] - 子遮罩迭代寫錯:要用
(submask - 1) & mask - 用某個位元前沒先檢查它是不是 1
1 << n讓 int 溢位(n ≥ 31 要用1L << n)
- 忘了初始化
總結
| 步驟 | 要寫什麼 |
|---|---|
| 1. 檢查規模 | n <= 20 嗎?不是的話,位元遮罩 DP 就是選錯工具了。 |
| 2. 狀態 | dp[mask](組別/工人數會影響答案時,再加第二個維度) |
| 3. 迭代順序 | mask 由小到大 — mask 的每個子遮罩數值上都比它小,所以一定已經算好了 |
| 4. 轉移 | 不是「加一個元素」(O(2^n * n)),就是「切出一個子遮罩」(O(3^n)) |
| 5. 答案 | dp[(1 << n) - 1] — 也就是全集 |
真正會發生的三個 bug
- 遞迴式是用
</>來鬆弛,卻讓表格停在預設的 0 — 要先把它初始化成±infinity。 - 空的子遮罩其實是合法選擇時,卻寫成
for (sub = mask; sub > 0; sub = (sub-1) & mask)— 這樣會整個跳過它。 n >= 31時1 << n在int上溢位 — 要用1L << n。