背包 DP(0/1、完全背包、Coin Change)
範圍 — 完整的背包問題家族:0/1 vs 完全背包(unbounded)vs 有界背包(bounded)vs 分組背包(group)、子集合和(subset-sum)的化簡、為什麼 0/1 的內層迴圈要倒序,以及區分組合與排列的迴圈順序規則。 另見:dp.md — 一頁式的背包模板與其餘 DP 模式;knapsack_01_zh.md — 0/1 背包的中文詳解 — 只講 0/1 情況的中文逐步解說;combinatorics_math_patterns.md — 不用 DP 的計數方法。
LeetCode 題目清單
總覽
關鍵性質
- 複雜度:時間
O(n * W);壓成一維後空間O(W)—n個物品、容量/目標為W。 - 核心想法:每個物品都是一個拿 / 不拿的決定(一旦某個物品帶著一份選項菜單,就變成 從多個選項中最多挑一個的決定),而區分各種變形的那個 DP 維度是 容量軸 — 也就是內層迴圈讀到的值,是否已經包含了當前這個物品。
- 使用時機:有一組固定的物品、每個帶有成本,有一個硬性的容量/目標, 然後要對子集合問「最大值 / 可行性 / 有幾種方法」。
一張決定一切的表
| 變形 | 重複使用 | 外層迴圈 | 內層迴圈 | LC |
|---|---|---|---|---|
| 0/1 | 每個物品最多 1 次 | 物品 | 容量,倒序 | 416, 494, 1049, 474 |
| 完全背包 — 組合 | 無限次,順序不重要 | 物品 | amount,正序 | 518 |
| 完全背包 — 排列 | 無限次,順序很重要 | amount | 物品 | 377 |
| 完全背包 — 最小 / 最大 | 無限次,順序無關 | 皆可 | 皆可 | 322, 279, 1449 |
| 有界背包 | 每個物品最多 k 次 |
物品(二進位拆分成多份 0/1 副本) | 容量,倒序 | 2585, 1774 |
| 分組背包 | 每個物品最多用 1 個選項 | 物品 | 容量在外層且倒序;選項在內層 | 4040, 1155, 2218 |
參考資料
- dp.md — 精簡版背包模板與其餘 DP 模式家族
- knapsack_01_zh.md — 0/1 背包中文詳解:state 定義、倒序 trace、LC 494/416 解題流程
- Knapsack problem — Wikipedia
題型分類
| 分類 | 它回答什麼問題 | 答案型別 | LC |
|---|---|---|---|
| 子集合可行性 | 有某個子集合剛好湊到這個和嗎? | boolean | 416, 1049, 2915 |
| 子集合計數 | 有幾個子集合能湊到? | int(方法數) | 494, 518 |
| 容量上限下的最大價值 | 塞得進容量的最大價值是多少? | int(max) | 經典 0/1、474、879 |
| 湊到目標的最少物品數 | 湊出這個金額最少要幾枚硬幣 / 幾個平方數? | int(min)或 -1 | 322, 279 |
| 有序 vs 無序計數 | 1+2 和 2+1 算同一種嗎? |
決定迴圈的巢狀順序 | 518 vs 377 |
| 每個物品只能用一種形態 | 每個物品可以用好幾種形態之一 — 該用哪一種? | int(最小成本) | 4040, 1155, 2218 |
模板與演算法
迴圈順序:組合 vs 排列
🔑 關鍵洞見:在完全背包類的題目(例如 Coin Change)中,巢狀迴圈的順序決定了你數的是組合還是排列。
這裡兩種順序都是正確的 DP,只是在回答不同的問題 —— 那是「相加」才有的特權。當 items 是被 「接起來」而不是「相加」時(LC 139 Word Break),item 放外層就不是換一個問題,而單純是錯的: dp_loop_order.md。
🎯 終極速查表:什麼時候用哪個模式
| 當題目說…… | 該用的模式 | 迴圈順序 | 方向 | DP 轉移式 | 範例 LC |
|---|---|---|---|---|---|
| 「數方法數」+ 順序不重要 | 組合 | 物品 → 目標 | 正序 | dp[i] += dp[i-item] |
518 |
| 「數方法數」+ 順序重要 | 排列 | 目標 → 物品 | 正序 | dp[i] += dp[i-item] |
377 |
| 「每個物品只能用一次」+ 求最大/最小 | 0/1 背包 | 物品 → 容量 | 倒序 | dp[w] = max(dp[w], ...) |
416 |
| 「物品可無限使用」+ 求最大/最小 | 完全背包 | 物品 → 容量 | 正序 | dp[i] = min(dp[i], ...) |
322 |
⚡ 快速辨識:
- 看到「different sequences」或「different orderings」→ 排列(目標在外層)
- 看到「number of combinations」或「unique ways」→ 組合(物品在外層)
- 看到「each element at most once」→ 0/1 背包(倒序)
- 看到「minimum coins」或「fewest items」→ 完全背包(正序)
📊 視覺總覽:四個核心模式
┌─────────────────────────────────────────────────────────────────────────┐
│ DP KNAPSACK PATTERN MATRIX │
└─────────────────────────────────────────────────────────────────────────┘
COUNT WAYS FIND MIN/MAX
┌──────────────────┬──────────────────────────┐
│ │ │
ORDER MATTERS? │ PERMUTATIONS │ Not typically used │
(Yes) │ LC 377 │ (Use Permutations │
│ Target→Item │ for counting) │
│ Forward │ │
├──────────────────┼──────────────────────────┤
│ │ │
ORDER DOESN'T │ COMBINATIONS │ UNBOUNDED KNAPSACK │
MATTER │ LC 518 │ LC 322 │
(No) │ Item→Target │ Item→Capacity │
│ Forward │ Forward │
├──────────────────┼──────────────────────────┤
│ │ │
USE EACH ONCE │ Not typical │ 0/1 KNAPSACK │
(Constraint) │ (Can adapt │ LC 416 │
│ 0/1 pattern) │ Item→Capacity │
│ │ BACKWARD ⚠️ │
└──────────────────┴──────────────────────────┘
Legend:
Item→Target = Outer loop: items, Inner loop: target
Target→Item = Outer loop: target, Inner loop: items
Forward = Inner loop: i to target (allows reuse)
BACKWARD ⚠️ = Inner loop: target to i (prevents reuse)
🎯 決策流程:
Start
│
├─ Question asks "count ways"?
│ │
│ ├─ YES → Order matters?
│ │ ├─ YES → Permutations (Target→Item) [LC 377]
│ │ └─ NO → Combinations (Item→Target) [LC 518]
│ │
│ └─ NO → Question asks "min/max"?
│ │
│ ├─ Each item once?
│ │ ├─ YES → 0/1 Knapsack (BACKWARD) [LC 416]
│ │ └─ NO → Unbounded (FORWARD) [LC 322]
│ │
│ └─ Unknown → Check problem constraints
📋 主模式表:依題型分類的 DP 轉移式
| 模式類型 | 迴圈順序 | DP 轉移式 | 它數的是什麼 | 心智模型 | 範例 | 結果 |
|---|---|---|---|---|---|---|
| 組合 (順序不重要) |
物品 → 目標for item in items:for i in range(item, target+1): |
dp[i] += dp[i - item] |
不重複的集合 [1,2] = [2,1] |
「先處理完物品 1 的所有用法,再處理物品 2 的所有用法」 強制產生標準順序 |
LC 518 coins=[1,2] amount=3 |
2 種 {1,1,1} {1,2} |
| 排列 (順序重要) |
目標 → 物品for i in range(1, target+1):for item in items: |
dp[i] += dp[i - item] |
不同的排列順序 [1,2] ≠ [2,1] |
「對每個目標值,試著讓每個物品當『最後一個』」 允許任意順序 |
LC 377 nums=[1,2] target=3 |
3 種 {1,1,1} {1,2} {2,1} |
| 0/1 背包 (每個只用一次) |
物品 → 容量 (倒序) for item in items:for w in range(W, weight-1, -1): |
dp[w] = max(dp[w],dp[w-weight[i]] + value[i]) |
有限制條件下的最大/最小值 每個物品最多用 1 次 |
「必須倒序迭代,才不會在同一輪裡把同一個物品用了兩次」 | LC 416 Partition Subset |
True/False 或最大價值 |
| 完全背包 (可無限使用) |
物品 → 容量 (正序) for item in items:for w in range(weight, W+1): |
dp[w] = max(dp[w],dp[w-weight[i]] + value[i]) |
無次數限制下的最大/最小值 每個物品可無限使用 |
「正序迭代 — 同一輪裡就能用到已更新的值」 | LC 322 Coin Change (最少硬幣數) |
最小數量 或 -1 |
💻 各模式的程式碼模板
// java
// IDEA: the four knapsack loop orders side by side — each differs only in nesting/direction
// time = O(n * W), space = O(W)
// ============================================
// PATTERN 1: COMBINATIONS (Item → Target)
// ============================================
// LC 518: Coin Change II
public int countCombinations(int target, int[] items) {
int[] dp = new int[target + 1];
dp[0] = 1; // Base: one way to make 0
// OUTER: Items/Coins
for (int item : items) {
// INNER: Target/Amount (forward)
for (int i = item; i <= target; i++) {
dp[i] += dp[i - item]; // ← Same transition
}
}
return dp[target];
}
// ============================================
// PATTERN 2: PERMUTATIONS (Target → Item)
// ============================================
// LC 377: Combination Sum IV
public int countPermutations(int target, int[] items) {
int[] dp = new int[target + 1];
dp[0] = 1; // Base: one way to make 0
// OUTER: Target/Amount
for (int i = 1; i <= target; i++) {
// INNER: Items/Coins
for (int item : items) {
if (i >= item) {
dp[i] += dp[i - item]; // ← Same transition
}
}
}
return dp[target];
}
// ============================================
// PATTERN 3: 0/1 KNAPSACK (Item → Capacity BACKWARDS)
// ============================================
// LC 416: Partition Equal Subset Sum
public boolean canPartition(int[] nums, int target) {
boolean[] dp = new boolean[target + 1];
dp[0] = true; // Base: can make 0
// OUTER: Items
for (int num : nums) {
// INNER: Capacity (BACKWARDS to prevent reuse)
for (int w = target; w >= num; w--) {
dp[w] = dp[w] || dp[w - num]; // ← Different transition (OR)
}
}
return dp[target];
}
// ============================================
// PATTERN 4: UNBOUNDED KNAPSACK (Item → Capacity FORWARDS)
// ============================================
// LC 322: Coin Change (minimum coins)
public int minCoins(int target, int[] coins) {
int[] dp = new int[target + 1];
Arrays.fill(dp, target + 1); // Infinity
dp[0] = 0; // Base: 0 coins for 0 amount
// OUTER: Items/Coins
for (int coin : coins) {
// INNER: Target (FORWARDS allows reuse)
for (int i = coin; i <= target; i++) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1); // ← Different transition (MIN)
}
}
return dp[target] > target ? -1 : dp[target];
}
🔑 重點觀察:
-
DP 轉移式相同(
dp[i] += dp[i - item]) 的有:- 組合(物品 → 目標)
- 排列(目標 → 物品)
- 唯一差別:迴圈順序!
-
DP 轉移式不同的有:
- 0/1 背包:
dp[w] = dp[w] || dp[w - num](布林 OR 或取 MAX) - 完全背包:
dp[i] = min(dp[i], dp[i - coin] + 1)(取 MIN/MAX)
- 0/1 背包:
-
背包的方向很關鍵:
- 倒序 → 阻止重複使用(0/1)
- 正序 → 允許重複使用(完全背包)
🎯 模式選擇決策樹
Question: What does the problem ask for?
├─ "Count number of ways/combinations to reach target"
│ ├─ Order matters? (e.g., [1,2] ≠ [2,1])
│ │ ├─ YES → Use PERMUTATIONS pattern (Target → Item)
│ │ │ Example: LC 377 Combination Sum IV
│ │ └─ NO → Use COMBINATIONS pattern (Item → Target)
│ │ Example: LC 518 Coin Change II
│ │
│ └─ Can reuse items?
│ ├─ YES → Unbounded, iterate forwards
│ └─ NO → 0/1 Knapsack, iterate backwards
│
└─ "Find minimum/maximum value"
├─ Can reuse items?
│ ├─ YES → Unbounded Knapsack (forwards)
│ │ Example: LC 322 Coin Change (min coins)
│ └─ NO → 0/1 Knapsack (backwards)
│ Example: LC 416 Partition Equal Subset Sum
│
└─ Always use (Item → Capacity) order
深入探討:0/1 背包與子集合和模式 🎒
這個模式非常基礎,而且經常以各種偽裝的形式出現。Last Stone Weight II 就是一個很好的例子,示範怎麼看出一題其實骨子裡是子集合和問題。
什麼時候用這個模式
看到下列訊號就用 0/1 背包 / 子集合和:
| 訊號 | 代表什麼 | 範例 |
|---|---|---|
| 「Partition」或「split into two groups」 | 把物品分成幾個子集合 | LC 1049 (Last Stone Weight II) |
| 「Maximize/minimize the difference」 | 找出最佳的分割方式 | LC 1049, 494 |
| 「Can you achieve sum X?」 | 檢查某個特定和是否湊得出來 | LC 416 (Equal Subset Partition) |
| 「Each item used at most once」 | 0/1 限制(不是無限次) | 以上皆是 |
| 「Minimize difference between groups」 | 分成兩組且要盡量平衡 | LC 1049 |
關鍵辨識:看到「partition」或「divide into two groups」→ 想到 0/1 背包。
核心想法:數學上的轉換 🧮
問題:把陣列分成兩組,並讓兩組的差最小。
Given: stones = [2, 7, 4, 1, 8, 1]
Total sum = 23
Goal: Split into two groups with min |sum1 - sum2|
Mathematical insight:
Let sum1 = S (sum of group 1)
Then sum2 = total - S (sum of group 2)
Difference = |sum1 - sum2| = |S - (total - S)| = |2S - total|
To minimize this: Maximize S such that S ≤ total/2
Result = total - 2*S (where S is the largest achievable sum ≤ total/2)
為什麼這樣成立:
- 找出不超過
total / 2的最大子集合和 - 這就給出了最平衡的分割方式
- 剩下那一組的和是
total - S - 兩者的差 =
(total - S) - S = total - 2*S
模式:兩種變形
變形 1:布林 DP(這個和湊得出來嗎?)
// java
// LC 1049 - Last Stone Weight II
// IDEA: variant 1 — boolean subset sum; can we reach exactly `sum`?
// time = O(n * total), space = O(total)
public int lastStoneWeightII(int[] stones) {
int total = 0;
for (int stone : stones) {
total += stone;
}
int target = total / 2;
// dp[j] = can we achieve sum j?
boolean[] dp = new boolean[target + 1];
dp[0] = true; // Base: always can make sum 0 (choose nothing)
// For each stone
for (int stone : stones) {
// Iterate BACKWARDS to prevent using same stone twice
for (int j = target; j >= stone; j--) {
dp[j] = dp[j] || dp[j - stone]; // Can achieve j if:
// (already could) OR (could make j-stone and add this stone)
}
}
// Find largest achievable sum ≤ target
for (int j = target; j >= 0; j--) {
if (dp[j]) {
return total - 2 * j;
}
}
return 0;
}
變形 2:整數 DP(可達成的最大值)
// java
// LC 1049 - Last Stone Weight II
// IDEA: variant 2 — maximise the achievable subset sum <= total/2, answer = total - 2*best
// time = O(n * total), space = O(total)
public int lastStoneWeightII(int[] stones) {
int total = 0;
for (int stone : stones) {
total += stone;
}
int target = total / 2;
// dp[j] = maximum sum we can achieve ≤ j
int[] dp = new int[target + 1];
dp[0] = 0; // Base: can make sum 0
// For each stone
for (int stone : stones) {
// Iterate BACKWARDS to prevent reuse
for (int j = target; j >= stone; j--) {
// Either skip this stone (dp[j])
// Or include it and add to best we could do with j-stone (dp[j-stone] + stone)
dp[j] = Math.max(dp[j], dp[j - stone] + stone);
}
}
return total - 2 * dp[target];
}
為什麼要倒序迭代?(最關鍵的細節)
❌ WRONG: Forward iteration (causes reuse)
for (int j = stone; j <= target; j++) {
dp[j] = dp[j] || dp[j - stone];
}
Problem: When we update dp[j], we're using the NEW value of dp[j-stone]
which might have already been updated by the same stone in this iteration.
This allows using the same stone multiple times!
Example with stone=3, target=9:
j=3: dp[3] = dp[0] = true ✓
j=6: dp[6] = dp[3] = true ✓ BUT dp[3] was just updated by the same stone!
j=9: dp[9] = dp[6] = true ✓ Again, using same stone multiple times!
✅ CORRECT: Backward iteration (prevents reuse)
for (int j = target; j >= stone; j--) {
dp[j] = dp[j] || dp[j - stone];
}
Reason: We process from right to left, so dp[j-stone] is always from the PREVIOUS iteration
(before this stone was considered). So we use each stone only once.
Example with stone=3, target=9:
j=9: dp[9] = dp[6] (old value from previous stone) ✓
j=6: dp[6] = dp[3] (old value from previous stone) ✓
j=3: dp[3] = dp[0] (old value from previous stone) ✓
完整範例:Last Stone Weight II
stones = [2, 7, 4, 1, 8, 1]
total = 23
target = 23 / 2 = 11
Initial: dp = [T, F, F, F, F, F, F, F, F, F, F, F]
After stone 2:
dp[2] = T (can make sum 2)
dp = [T, F, T, F, F, F, F, F, F, F, F, F]
After stone 7:
dp[9] = T (can make 2+7)
dp[7] = T
dp[2] = T (unchanged)
dp = [T, F, T, F, F, F, F, T, F, T, F, F]
After stone 4:
dp[11] = T (can make 7+4)
dp[9] = T (unchanged)
dp[6] = T (can make 2+4)
dp[4] = T
dp = [T, F, T, F, T, F, T, T, F, T, F, T]
... continue for remaining stones ...
Final: Find largest j ≤ 11 where dp[j] = T
Result = 23 - 2 * j
相似的 LeetCode 題目 📚
| 題目 | 目標 | 轉換方式 | 複雜度 |
|---|---|---|---|
| LC 1049: Last Stone II | 最後一顆石頭的最小重量 | 分成兩組,讓差最小 | O(n × sum/2) |
| LC 416: Partition Equal Subset | 能否分成兩組和相等? | 能否湊出 sum = total/2? | O(n × sum/2) |
| LC 494: Target Sum | 數出湊到 target 的方法數 | 視為:正號組和為 sum1、負號組和為 sum2,解 sum1 - sum2 = target | O(n × sum) |
| LC 879: Profitable Schemes | 數出合法的獲利方案數 | 對 (人數, 利潤) 做 DP | O(n × k × p) |
轉換範例:
LC 416 (Partition Equal Subset):
Question: Can we partition into two equal subsets?
Answer: Can we achieve sum = total/2?
DP: boolean[] dp where dp[j] = can we make sum j?
Return: dp[total/2]
LC 494 (Target Sum):
Question: Assign +/- to reach target T
Transformation: Let sum1 = sum of items with +
Let sum2 = sum of items with -
sum1 - sum2 = T
sum1 + sum2 = total (all items)
Solving: sum1 = (total + T) / 2
Feasibility first (both are required before the DP runs):
abs(T) > total -> 0 ways: even all-plus or all-minus cannot reach T
(total + T) is odd -> 0 ways: sum1 would not be an integer
So: This is 0/1 knapsack! Find count of subsets with sum = (total + T) / 2
DP: int[] dp where dp[j] = count of ways to make sum j
Return: dp[(total + T) / 2]
// java
// LC 494 - Target Sum
// IDEA: reduce "assign +/-" to "count subsets summing to (total + T) / 2", then 0/1 knapsack
// time = O(n * target), space = O(target)
public int findTargetSumWays(int[] nums, int target) {
int total = 0;
for (int x : nums) total += x;
// NOTE !!! guard before the division — otherwise `sub` is negative or non-integral
if (Math.abs(target) > total || ((total + target) % 2) != 0) return 0;
int sub = (total + target) / 2;
int[] dp = new int[sub + 1];
dp[0] = 1; // one way to make 0: pick nothing
for (int num : nums) {
for (int j = sub; j >= num; j--) { // backward -> each num used at most once
dp[j] += dp[j - num];
}
}
return dp[sub];
}
常見陷阱 ⚠️
-
正序迭代而不是倒序
- 會允許同一個物品被重複使用多次
- 0/1 背包一定要倒序迭代
-
DP 轉移式寫錯
- 布林版:
dp[j] = dp[j] || dp[j - weight] - 整數和版:
dp[j] = Math.max(dp[j], dp[j - weight] + weight) - 計數方法數版:
dp[j] += dp[j - weight] - 千萬別搞混!
- 布林版:
-
沒認出「partition」這個模式
- 「兩組之間的差」→ 想到分割
- 「分成兩隊」→ 想到分割
- 「切分陣列」→ 想到分割
-
總和造成整數溢位
- 總和很大時要小心溢位
- 必要時考慮改用 long
⚡ 速查:迴圈順序 → 題型
| 外層迴圈 | 內層迴圈 | 模式名稱 | 使用時機 | 題目 |
|---|---|---|---|---|
| 物品/硬幣 | 目標/金額 | 組合 | 數不重複的集合(順序不重要) | LC 518 |
| 目標/金額 | 物品/硬幣 | 排列 | 數序列(順序重要) | LC 377 |
| 物品(倒序) | 容量 | 0/1 背包 | 每個物品只用一次,求最大/最小 | LC 416, 494 |
| 物品(正序) | 容量 | 完全背包 | 物品可無限使用,求最大/最小 | LC 322 |
快速比較表
| 面向 | 組合 (LC 518) | 排列 (LC 377) |
|---|---|---|
| 迴圈順序 | 硬幣 → 金額 | 金額 → 硬幣 |
| 順序重要嗎? | ❌ 不重要:[1,2] = [2,1] | ✅ 重要:[1,2] ≠ [2,1] |
| 題目類型 | Coin Change II | Combination Sum IV |
| 外層迴圈 | for (int coin : coins) |
for (int i = 1; i <= target; i++) |
| 內層迴圈 | for (int i = coin; i <= amount; i++) |
for (int num : nums) |
| 範例 | amount=3, coins=[1,2] → 2 種 | target=3, nums=[1,2] → 3 種 |
模式 1:組合(外層:硬幣,內層:金額)
// java
// IDEA: coins outer, amount inner -> each coin is offered once, so sets are counted
// time = O(n * amount), space = O(amount)
// LC 518: Coin Change II - Count combinations
// Example: [1,2] and [2,1] are the SAME combination
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1; // Base case: 1 way to make amount 0
// OUTER LOOP: Iterate through each coin
// This ensures we process all uses of one coin before moving to the next,
// which prevents duplicate combinations like [1,2] and [2,1].
for (int coin : coins) {
// INNER LOOP: Update dp table for all amounts reachable by this coin
for (int i = coin; i <= amount; i++) {
// Number of ways to make amount 'i' is:
// (Current ways) + (Ways to make 'i - coin')
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
為什麼這樣成立:
- 一次只處理一種硬幣(例如先處理所有的 1,再處理所有的 2,接著所有的 5)
- 當你開始用硬幣
2時,硬幣1的所有計算都已經做完了 - 不可能在
2之後再放一個1,因此強制產生非遞減的順序 - 結果:只會數到組合(順序不重要)
範例追蹤:coins = [1,2], amount = 3
After coin 1: dp = [1, 1, 1, 1] // {}, {1}, {1,1}, {1,1,1}
After coin 2: dp = [1, 1, 2, 2] // + {2}, {1,2}
Result: 2 combinations → {1,1,1}, {1,2}
模式 2:排列(外層:金額,內層:硬幣)
// java
// IDEA: amount outer, coins inner -> every coin is retried at every amount, so orderings count
// time = O(n * target), space = O(target)
// LC 377: Combination Sum IV - Count permutations
// Example: [1,2] and [2,1] are DIFFERENT permutations
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
// OUTER LOOP: Iterate through each amount
// For each amount, try all coins to see which was "last added"
for (int i = 1; i <= target; i++) {
// INNER LOOP: Try each coin for current amount
for (int num : nums) {
if (i >= num) {
dp[i] += dp[i - num];
}
}
}
return dp[target];
}
為什麼這樣數的是排列:
- 對每個金額問:「我加進去的最後一枚硬幣是哪一枚?」
- 每一枚硬幣在每一步都可以當那個「最後一枚」
- 結果:排列(順序重要)
範例追蹤:nums = [1,2], target = 3
dp[1]: Use 1 → [1] (1 way)
dp[2]: Use 1 → [1,1], Use 2 → [2] (2 ways)
dp[3]: From dp[2] add 1 → [1,1,1], [2,1]
From dp[1] add 2 → [1,2]
Result: 3 permutations → {1,1,1}, {1,2}, {2,1}
比較表
| 迴圈順序 | 結果型態 | 題目範例 | 使用情境 |
|---|---|---|---|
| 外層:硬幣 內層:金額 |
組合 (順序不重要) |
LC 518 Coin Change II | 數不重複的硬幣組合 |
| 外層:金額 內層:硬幣 |
排列 (順序重要) |
LC 377 Combination Sum IV | 數不同的排列順序 |
🔥 並排程式碼比較
LC 518: Coin Change II(組合)
// java
// LC 518 - Coin Change II
// IDEA: combinations — coins outer
// time = O(n * amount), space = O(amount)
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1; // Base: 1 way to make 0
// CRITICAL: Coin outer loop = COMBINATIONS
for (int coin : coins) { // ← Process coins one by one
for (int i = coin; i <= amount; i++) { // ← Update all amounts for this coin
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
// Example: amount=3, coins=[1,2]
// Result: 2 combinations
// {1,1,1}, {1,2} (Note: [1,2] and [2,1] counted as same)
LC 377: Combination Sum IV(排列)
// java
// LC 377 - Combination Sum IV
// IDEA: permutations — amount outer
// time = O(n * target), space = O(target)
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1; // Base: 1 way to make 0
// CRITICAL: Amount outer loop = PERMUTATIONS
for (int i = 1; i <= target; i++) { // ← Process each amount
for (int num : nums) { // ← Try every number for this amount
if (i >= num) {
dp[i] += dp[i - num];
}
}
}
return dp[target];
}
// Example: target=3, nums=[1,2]
// Result: 3 permutations
// {1,1,1}, {1,2}, {2,1} (Note: [1,2] and [2,1] are different)
🔍 詳細追蹤比較:為什麼迴圈順序有差
範例:nums/coins = [1, 2]、target/amount = 3
LC 518(組合 — 硬幣在外層):
Initialize: dp = [1, 0, 0, 0]
Process coin 1:
i=1: dp[1] += dp[0] = 1 → [1, 1, 0, 0] // ways: {1}
i=2: dp[2] += dp[1] = 1 → [1, 1, 1, 0] // ways: {1,1}
i=3: dp[3] += dp[2] = 1 → [1, 1, 1, 1] // ways: {1,1,1}
Process coin 2:
i=2: dp[2] += dp[0] = 1+1=2 → [1, 1, 2, 1] // ways: {1,1}, {2}
i=3: dp[3] += dp[1] = 1+1=2 → [1, 1, 2, 2] // ways: {1,1,1}, {1,2}
// Note: Can't get {2,1} because
// all coin-1 uses are done before coin-2
Final: dp[3] = 2 ✅ Only {1,1,1} and {1,2}
LC 377(排列 — 金額在外層):
Initialize: dp = [1, 0, 0, 0]
i=1 (building sum 1):
Try 1: dp[1] += dp[0] = 1 → [1, 1, 0, 0] // ways: {1}
Try 2: skip (2 > 1)
i=2 (building sum 2):
Try 1: dp[2] += dp[1] = 1 → [1, 1, 1, 0] // {1} + 1 = {1,1}
Try 2: dp[2] += dp[0] = 1+1=2 → [1, 1, 2, 0] // {} + 2 = {2}
i=3 (building sum 3):
Try 1: dp[3] += dp[2] = 2 → [1, 1, 2, 2] // {1,1} + 1 = {1,1,1}
// {2} + 1 = {2,1} ✅
Try 2: dp[3] += dp[1] = 2+1=3 → [1, 1, 2, 3] // {1} + 2 = {1,2} ✅
Final: dp[3] = 3 ✅ All three: {1,1,1}, {1,2}, {2,1}
關鍵洞見:
- LC 518(硬幣在外層):處理完硬幣 1 之後就再也不回頭。這強制產生一個標準順序(所有的 1 都排在所有的 2 前面),因此不會同時數到 {1,2} 和 {2,1}。
- LC 377(金額在外層):對每個和問「加進去的最後一個數字是誰?」每個數字都可以當「最後一個」,所以 {1,2} 和 {2,1} 都會被算到。
什麼時候用哪一種
**用組合(硬幣 → 金額)**的時機:
- 題目問「有幾種方法」但不考慮順序
- [1,2,5] 和 [2,1,5] 應該只算一次
- 關鍵字:「combinations」、「unique sets」
**用排列(金額 → 硬幣)**的時機:
- 題目問的是不同的序列/順序
- [1,2] 和 [2,1] 應該分開計算
- 關鍵字:「permutations」、「different orderings」、「sequences」
完整 Java 範例:LC 518 Coin Change II
// java
// LC 518 - Coin Change II
// IDEA: count the ways to form each amount; coins outer keeps `{1,2}` and `{2,1}` as one
// time = O(n * amount), space = O(amount)
public int change(int amount, int[] coins) {
// dp[i] = total number of combinations that make up amount i
int[] dp = new int[amount + 1];
// Base case: There is exactly 1 way to make 0 amount (empty set)
dp[0] = 1;
// CRITICAL: Coin outer loop = COMBINATIONS
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
測試案例:
Input: amount = 5, coins = [1,2,5]
Output: 4
Combinations: {5}, {2,2,1}, {2,1,1,1}, {1,1,1,1,1}
Input: amount = 3, coins = [2]
Output: 0
Explanation: Cannot make 3 with only coins of 2
📚 題目對照
| 題目 | LC # | 迴圈順序 | 它數的是什麼 | 檔案位置 |
|---|---|---|---|---|
| Coin Change II | 518 | 硬幣 → 金額 | 組合(順序不重要) | leetcode_java/.../CoinChange2.java |
| Combination Sum IV | 377 | 金額 → 硬幣 | 排列(順序重要) | leetcode_java/.../CombinationSumIV.java |
💡 記憶小技巧:
- 「Coin first」= Combinations(兩個都是 C 開頭)
- 「Amount first」= Arrangements/Permutations(兩個都是 A 開頭)
📝 最終總結:完整模式比較
| 面向 | LC 518: Coin Change II (組合) |
LC 377: Combination Sum IV (排列) |
|---|---|---|
| 它數的是什麼 | 不重複的集合(順序不重要) | 不同的序列(順序重要) |
| 範例 | [1,2] = [2,1](相同) | [1,2] ≠ [2,1](不同) |
| 外層迴圈 | for (int coin : coins) |
for (int i = 1; i <= target; i++) |
| 內層迴圈 | for (int i = coin; i <= amount; i++) |
for (int num : nums) |
| DP 轉移式 | dp[i] += dp[i - coin] |
dp[i] += dp[i - num] |
| base case | dp[0] = 1 |
dp[0] = 1 |
| nums=[1,2], target=3 時的結果 |
2 種組合: {1,1,1}, {1,2} |
3 種排列: {1,1,1}, {1,2}, {2,1} |
| 為什麼成立 | 先把硬幣 1 完全處理完再處理硬幣 2,強制產生標準順序 → 不會出現 {2,1} | 對每個和都讓每個數字當「最後一個」→ 允許所有順序 |
| 檔案位置 | CoinChange2.java |
CombinationSumIV.java |
🔥 唯一的差別:
// java
// IDEA: the two nestings printed together — the only difference is which loop is outer
// time = O(n * amount), space = O(amount)
// LC 518: Combinations
for (int coin : coins) // ← ITEM OUTER
for (int i = coin; i <= amount; i++)
// LC 377: Permutations
for (int i = 1; i <= target; i++) // ← TARGET OUTER
for (int num : nums)
兩者用的是完全相同的轉移式:dp[i] += dp[i - item]
為什麼守衛條件是 if (i - coin >= 0) 而不是 if (i == coin)
🔑 問題:為什麼要用 if (i >= coin) 而不是 if (i == coin)?
這是理解動態規劃如何在既有子問題答案上疊加的基礎概念。
簡短的答案
i == coin只檢查單一一枚硬幣是否剛好等於金額i >= coin檢查的是這枚硬幣能不能搭配之前算出的某個和一起湊到這個金額
i - coin >= 0 背後的邏輯
計算 dp[i] 時,我們不是只在找一枚剛好等於 i 的硬幣,而是在找一枚硬幣 coin,使得 i 減掉它之後剩下的餘額是我們已經知道怎麼解的。
i:現在想湊出的總金額coin:剛拿起來的那枚硬幣的面額i - coin:「餘額」,也就是還剩下要湊的金額
只要 i - coin >= 0,這枚硬幣就放得下,而餘額是一個我們已經算過的子問題 — 因為表格是從 0 一路填到 amount。== 0 這個情況並沒有被特判:它讀的是 dp[0],而 base case 早就把它設好了。這正是守衛條件用 >= 而不是 > 的原因。
DP 會回頭去看 dp[i - coin],直接重用那個答案!
一個具體的例子
假設 coins = [2],我們要算 dp[4](怎麼湊出 4 分錢)。
- 試硬幣
coin = 2 i - coin是4 - 2 = 2- 因為
2 > 0,我們不會停下來,而是去看dp[2] dp[2] = 1已經算過了(用一枚 2 分硬幣湊出 2 分)- 所以
dp[4] = dp[2] + 1 = 2
如果只用 if (i - coin == 0):
- 我們永遠只會發現
dp[2] = 1 - 算到
dp[4]時,條件4 - 2 == 0會是 false - 就會錯誤地下結論說 4 分錢湊不出來!
三種情況
檢查 i - coin 時:
i - coin 的結果 |
意義 | 動作 |
|---|---|---|
負數(< 0) |
這枚硬幣對這個金額來說太大了 | 跳過這枚硬幣 |
零(== 0) |
這一枚硬幣剛好等於金額 | dp[i] = 1 |
正數(> 0) |
這枚硬幣放得下,還要再看「餘額」 | dp[i] = dp[remainder] + 1 |
後兩列其實是同一行程式碼 — dp[i] = dp[i - coin] + 1 — 因為 dp[0] 已經被
初始化成 0。這就是為什麼一個守衛條件 i - coin >= 0 就同時涵蓋了兩者。
💡 關鍵洞見
if (i >= coin) 這個條件同時涵蓋了「硬幣剛好對上金額」以及「硬幣只是更大拼圖的其中一塊」這兩種情況。
完整範例與追蹤
輸入:coins = [1,2,5], amount = 11
設定:
- DP 陣列:
int[12](索引 0 到 11) - 初始化:
dp[0] = 0,其餘全設為12(我們的「無窮大」)
逐步追蹤:
金額 1 到 4:
i=1時:只有硬幣1放得下(1 >= 1)。dp[1] = dp[0] + 1 = 1i=2時:- 硬幣
1:dp[2] = dp[1] + 1 = 2 - 硬幣
2:dp[2] = dp[0] + 1 = 1(勝出:最小值是 1)
- 硬幣
i=3時:- 硬幣
1:dp[3] = dp[2] + 1 = 2 - 硬幣
2:dp[3] = dp[1] + 1 = 2 dp[3] = 2(例如2+1或1+1+1)
- 硬幣
i=4時:- 硬幣
1:dp[4] = dp[3] + 1 = 3 - 硬幣
2:dp[4] = dp[2] + 1 = 2 dp[4] = 2(例如2+2)
- 硬幣
金額 5(第一次大跳躍):
- 硬幣
1:dp[5] = dp[4] + 1 = 3 - 硬幣
2:dp[5] = dp[3] + 1 = 3 - 硬幣
5:dp[5] = dp[0] + 1 = 1 - 結果:
dp[5] = 1(剛好對上)
金額 10:
- 硬幣
1:dp[10] = dp[9] + 1 = 4 - 硬幣
2:dp[10] = dp[8] + 1 = 4 - 硬幣
5:dp[10] = dp[5] + 1 = 2 - 結果:
dp[10] = 2(代表5+5)
最終目標:金額 11:
-
試硬幣
1:- 餘額:
11 - 1 = 10 - 查
dp[10]:是2 - 計算:
dp[11] = dp[10] + 1 = 3
- 餘額:
-
試硬幣
2:- 餘額:
11 - 2 = 9 - 查
dp[9]:是3(例如5+2+2) - 計算:
dp[11] = dp[9] + 1 = 4
- 餘額:
-
試硬幣
5:- 餘額:
11 - 5 = 6 - 查
dp[6]:是2(例如5+1) - 計算:
dp[11] = dp[6] + 1 = 3
- 餘額:
最後比較:dp[11] = min(3, 4, 3) = 3
為什麼餘額 i - coin > 0 這件事行得通
計算 11 的時候,演算法完全不需要「重新解」怎麼湊出 10 或 6,只要查表就好:
- 「喔,我知道湊出 10 的最佳方式是 2 枚硬幣(
5+5)」 - 「再加上我手上這枚 1,就用 3 枚硬幣湊出了 11(
5+5+1)」
總結表(精簡版)
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| dp[i] | 0 | 1 | 1 | 2 | 2 | 1 | 2 | 2 | 3 | 3 | 2 | 3 |
DP 的程式碼模式
// java
// LC 322 - Coin Change
// IDEA: min coins per amount; order is irrelevant so either nesting works
// time = O(n * amount), space = O(amount)
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
// dp[i] = min coins to make amount i
int[] dp = new int[amount + 1];
// Initialize with "Infinity" (amount + 1 is safe)
Arrays.fill(dp, amount + 1);
// Base case: 0 coins needed for 0 amount
dp[0] = 0;
// Iterate through every amount from 1 to amount
for (int i = 1; i <= amount; i++) {
// For each amount, try every coin
for (int coin : coins) {
// CRITICAL CONDITION: Check if coin fits
if (i >= coin) {
// DP equation: Min of (current value) OR
// (1 coin + coins needed for remainder)
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// If value is still "Infinity", we couldn't reach it
return dp[amount] > amount ? -1 : dp[amount];
}
參考:詳細實作見 leetcode_java/src/main/java/LeetCodeJava/DynamicProgramming/CoinChange.java:356-408。
Deep Dive: Difference-Keyed Knapsack — LC 956 🏗️ Priority 4 of 5 — High value — a gap here costs you rounds
When to Use a Difference Key
Every problem above indexes the table by a total — dp[capacity], dp[amount], dp[sum].
Some problems instead split the items into two groups and only care how far apart the groups
are. Then the total is irrelevant and the right key is the difference:
dp[capacity] = best value -> "how much have I packed?"
dp[difference] = best height -> "how unbalanced are the two piles?"
Recognise it by the shape of the answer:
- “split into two subsets with equal sum, maximise that sum” (LC 956 — the two steel supports)
- “minimise
|sum(A) - sum(B)|” (LC 1049 Last Stone Weight II, LC 2035 Partition Array Into Two Arrays) - “make two piles equal, items may be left out”
LC 416 / LC 1049 are the same family solved with a total. With small sums you can key on
sumand readdp[total/2]at the end. LC 956 cannot: it wants the height of the balanced pair, and rods can be discarded — so the value you carry and the value you key by are different things. That is exactly what the difference key buys.
The State
dp[d] = the tallest achievable TALLER side, over all ways to split some subset
of the rods so far into two piles differing by exactly d
dp[0] = 0 (two empty piles, difference 0, height 0)
answer = dp[0] after all rods -- difference 0 means equal, and we kept the max height
Each rod has three choices, not two, and this is where it differs from 0/1 knapsack:
skip it -> (d, taller) unchanged
put on TALLER -> (d + x, taller + x) the gap widens
put on SHORTER -> (|d - x|, max(taller, shorter+x)) the gap closes, and may flip sides
where shorter = taller - d
The |d - x| and the max(...) together handle the case where the short pile overtakes the tall
one — the two piles swap roles and the difference reflects back off zero.
// java
// LC 956 - Tallest Billboard
// IDEA: key the table by the DIFFERENCE between the two supports, store the taller
// support's height. Answer = dp[0], the best height at difference zero.
// time = O(n * sum), space = O(sum)
public int tallestBillboard(int[] rods) {
int sum = 0;
for (int r : rods) sum += r;
int[] dp = new int[sum + 1];
Arrays.fill(dp, -1); // NOTE !!! -1 = unreachable; 0 is a real, reachable height
dp[0] = 0;
for (int x : rods) {
int[] prev = dp.clone(); // "skip x" is prev carried forward, already in dp
for (int d = 0; d <= sum; d++) {
if (prev[d] < 0) continue;
int taller = prev[d], shorter = taller - d;
// 1) x joins the taller pile -> gap widens by x
if (d + x <= sum) dp[d + x] = Math.max(dp[d + x], taller + x);
// 2) x joins the shorter pile -> gap becomes |d - x|, piles may swap
int nd = Math.abs(d - x);
dp[nd] = Math.max(dp[nd], Math.max(taller, shorter + x));
}
}
return dp[0];
}
# python
# LC 956 - Tallest Billboard
# IDEA: dict from difference -> tallest "taller side"; only reachable differences are stored
# time = O(n * sum), space = O(sum)
def tallestBillboard(rods):
dp = {0: 0} # difference -> height of the taller support
for x in rods:
prev = dict(dp) # snapshot: each rod is used at most once
for d, taller in prev.items():
shorter = taller - d
nd, nt = d + x, taller + x # x on the taller side
dp[nd] = max(dp.get(nd, 0), nt)
nd2 = abs(d - x) # x on the shorter side
dp[nd2] = max(dp.get(nd2, 0), max(taller, shorter + x))
return dp[0]
Trace — rods = [1, 2, 3, 6]
start {0: 0}
after 1 {0: 0, 1: 1}
after 2 {0: 0, 1: 2, 2: 2, 3: 3}
after 3 {0: 3, 1: 3, 2: 4, 3: 3, 4: 5, 5: 5, 6: 6}
after 6 {0: 6, 1: 6, 2: 7, 3: 6, ...} -> dp[0] = 6, supports {6} and {1,2,3}
dp[0] jumps from 0 to 3 when the third rod balances {3} against {1,2}, and to 6 when the
6 balances the whole rest.
Common Pitfalls — difference key ⚠️
- Initialising the array to
0instead of-1. Height0at differencedis a legal state only ford = 0; a zero-filled array claims every difference is reachable for free and the answer comes out too large. - Mutating
dpwhile iterating it (Python) or forgetting theclone()(Java). Without the snapshot a rod can be placed on both piles in one pass. - Storing the shorter side instead of the taller. Either convention works, but the transition
formulas are different — pick one and derive
shorter = taller - dfrom it consistently. d + xoverflowing the array. Cap the loop atsum; a difference larger than the total is meaningless.
Similar LeetCode Problems — two-pile splits 📚
| Problem | Key |
|---|---|
| LC 1049 Last Stone Weight II | minimise the difference — the same table, read the smallest reachable d |
| LC 416 Partition Equal Subset Sum | feasibility only — a boolean dp[sum] is enough |
| LC 2035 Partition Array Into Two Arrays | n <= 30, so meet-in-the-middle beats a difference table |
| LC 494 Target Sum | signs instead of piles — algebra turns it back into a subset-sum on the total |
深入:分組 0/1 背包 — LC 4040 🎁 Priority 4 of 5 — High value — a gap here costs you rounds
什麼時候一個物品變成一個「組」
在單純的 0/1 背包裡,一個物品就是一組 (weight, value),決定只有拿 / 不拿。
在分組背包(group knapsack)裡,每個物品帶來的是一份互斥選項的菜單,
決定變成要挑哪一個,或都不挑:
0/1 item i -> (w, v) take it or don't
group item i -> {(w, c), (w', c'), ...} take AT MOST ONE of these
只要一個物品可以用好幾種形態之一,就是這個題型:
| 題目 | 這裡的「組」是…… | 每組的規則 |
|---|---|---|
| LC 4040 Minimum Operations to Form Subset Sum I | 一個 x 以及它能被變成的每一個值,操作次數就是成本 |
≤ 1 |
| LC 1155 Number of Dice Rolls With Target Sum | 一顆骰子與它的點數 1..f |
恰好 1 |
| LC 2218 Maximum Value of K Coins From Piles | 一堆硬幣與它的每一個前綴(拿 0..k 枚) |
≤ 1 |
| LC 2585 Number of Ways to Earn Points | 某一種題型用 0, 1, … count 次 |
≤ 1 |
| LC 474 Ones and Zeroes | — (反例) | 就是普通 0/1,只是容量是二維,沒有菜單 |
有界背包其實就是分組背包。「最多可以用
k次」就是那個組{(w, v), (2w, 2v), …, (kw, kv)};本文開頭那張表裡的二進位拆分, 只是把同一件事寫得更快而已。
狀態 — 同一條軸,換了裡面的選擇
容量軸完全沒變。變的只有在它裡面發生的事:
0/1 dp[s] = best( dp[s], dp[s - w] + v ) ONE candidate
group dp[s] = best( dp[s], best over the item's options ) |group| candidates
LC 4040 問的是最小成本,所以表格的初始值是 INF,而不是 0/False:
dp[s] = fewest operations to make SOME subset of the elements seen so far sum to exactly s
dp[0] = 0, everything else INF
answer = dp[sum], or -1 if it never left INF
唯一的規則:每組最多挑一個選項 ⚠️
在單純的 0/1 裡,是倒序迭代擋住了同一個物品被重複使用 — 但它擋不住同一組裡兩個 不同選項同時被選走。有兩種迴圈順序能落實分組規則;而第三種、也就是最像 0/1 模板的那種, 是錯的:
✅ snapshot read prev, write into a copy every option competes against the state BEFORE the item
✅ capacity OUTER for s in W..0: dp[s-w] is a smaller index, not yet touched by this item
for (w, c) in group:
❌ options OUTER for (w, c) in group: option B reads a dp[] that option A has already updated
for s in W..w:
而且錯得並不隱晦。nums = [5], sum = 3:5 這一組是 {(2, 1), (1, 2)}
(5 -> 2,以及 5 -> 2 -> 1)。答案應該是 -1 — 一個元素不可能同時當子集合裡的兩個成員 —
但選項在外層的迴圈會很開心地湊出 2 + 1 = 3,然後回報 3。
# python
# The group knapsack skeleton -- both correct orders, minimisation flavour
# time = O(n * W * |group|), space = O(W)
def group_knapsack(groups, W):
INF = float('inf')
dp = [INF] * (W + 1)
dp[0] = 0
for options in groups: # options = [(weight, cost), ...]
# form A -- snapshot: dp is read-only for the whole group
new_dp = dp[:]
for w, c in options:
for s in range(w, W + 1):
if dp[s - w] != INF:
new_dp[s] = min(new_dp[s], dp[s - w] + c)
dp = new_dp
# form B -- in place, capacity OUTER and backward (no copy needed)
# for s in range(W, -1, -1):
# for w, c in options:
# if s >= w and dp[s - w] != INF:
# dp[s] = min(dp[s], dp[s - w] + c)
return dp[W]
建出 LC 4040 的那一組:只有兩條純鏈
這題有一半的工夫在建模:決定菜單裡放什麼。元素 x 可以被乘 2、也可以被除 2,
但它所有的乘法都必須排在所有的除法之前 — 這條規則把菜單收斂成兩條直鏈:
x, 2x, 4x, ... k doublings, cost k
x, x//2, x//4, ... k halvings, cost k
混著做永遠不划算:先 k 次乘 2、再 j 次除 2,落點是 x * 2^(k-j) — 乘 2 不會丟掉低位元,
所以那些除法剛好把它還原回來 — 而純鏈本來就能到那裡,成本是 |k - j| 而不是 k + j。
只有先除再乘才可能到達真正新的值(5 -> 2 -> 4,任何純鏈都到不了),
而那正是題目禁止的。
x = 10, sum = 13 -> (10, 0), (5, 1), (2, 2), (1, 3) 20 is already over sum
x = 2, sum = 13 -> (2, 0), (4, 1), (8, 2), (1, 1)
兩條鏈一旦不再有用就要剪掉 — 乘 2 的鏈超過 sum 之後(它只會越來越大),
除 2 的鏈到 0 為止(它再也幫不上任何正的和)。
# python
# LC 4040 - Minimum Operations to Form Subset Sum I
# IDEA: group 0/1 knapsack -- each x is a menu of (value, ops); take at most one entry per x
# time = O(n * sum * log(max(x, sum))), space = O(sum)
def minOperations(nums, sum):
INF = float('inf')
dp = [INF] * (sum + 1) # dp[s] = min ops for a subset summing to s
dp[0] = 0
for x in nums:
options = []
if x <= sum:
options.append((x, 0)) # NOTE !!! keep x untouched -- the zero-cost option
value, op = x, 0
while value <= sum: # x, 2x, 4x, ...
if op > 0:
options.append((value, op))
value *= 2
op += 1
value, op = x, 0
while value > 0: # x//2, x//4, ...
value, op = value // 2, op + 1
if value == 0:
break
if value <= sum:
options.append((value, op))
new_dp = dp[:] # NOTE !!! snapshot => x is used at most once,
for value, cost in options: # in at most one of its forms
for s in range(value, sum + 1):
if dp[s - value] != INF:
new_dp[s] = min(new_dp[s], dp[s - value] + cost)
dp = new_dp
return -1 if dp[sum] == INF else dp[sum]
// java
// LC 4040 - Minimum Operations to Form Subset Sum I
// IDEA: same group knapsack, written in place with the capacity loop OUTER and backward
// time = O(n * sum * log(max(x, sum))), space = O(sum)
public int minOperations(int[] nums, int sum) {
final int INF = Integer.MAX_VALUE / 2;
int[] dp = new int[sum + 1];
Arrays.fill(dp, INF);
dp[0] = 0;
for (int x : nums) {
List<int[]> options = new ArrayList<>();
for (long v = x, op = 0; v <= sum; v *= 2, op++) // x, 2x, 4x, ... (op = 0 keeps x)
options.add(new int[]{(int) v, (int) op});
for (long v = x, op = 0; v > 0; ) { // x/2, x/4, ...
v /= 2;
op++;
if (v == 0) break;
if (v <= sum) options.add(new int[]{(int) v, (int) op});
}
for (int s = sum; s >= 0; s--) { // NOTE !!! capacity OUTER, backward
for (int[] o : options) { // options INNER
int value = o[0], cost = o[1];
if (s >= value && dp[s - value] != INF)
dp[s] = Math.min(dp[s], dp[s - value] + cost);
}
}
}
return dp[sum] >= INF ? -1 : dp[sum];
}
Trace — nums = [10, 2]、sum = 13
groups 10 -> (10,0) (5,1) (2,2) (1,3)
2 -> (2,0) (4,1) (8,2) (1,1)
start {0:0}
after 10 {0:0, 1:3, 2:2, 5:1, 10:0}
after 2 {0:0, 1:1, 2:0, 3:3, 4:1, 5:1, 6:2, 7:1, 8:2, 9:2, 10:0, 11:1, 12:0, 13:3}
^
dp[13] = dp[5] + 2 = 1 + 2 -> 10->5 and 2->4->8
注意 dp[1] 從 3 進步到 1:第一組只能靠 10 -> 5 -> 2 -> 1 湊到 1,
第二組用 2 -> 1 就到了。每一格記的是到那個和的最省做法,而不是所有做法的集合。
常見陷阱 — 分組背包 ⚠️
- 選項放外層又原地更新
dp。 這是 0/1 的肌肉記憶,而它會無聲地把同一個元素的兩種形態 混用 — 見上面nums = [5], sum = 3的例子。 - 漏掉成本為零的那個選項。 少了
(x, 0),元素就只能被變形後才能用, 於是nums = [4], sum = 4會回傳-1。 - 去枚舉混合鏈。 在這題只是白做工 — 但「可以剪掉」這個論證是這一題操作順序規則的性質。 如果先除再乘是被允許的,那兩條純鏈就是不完整的,所以要重用這個捷徑前先回去讀規則。
- 沒有替兩條鏈設界。 乘 2 的鏈一旦超過
sum就再也回不來,而除到0的值對正的和沒有貢獻; 否則兩者都是無窮/空轉的迴圈。 - 讀
dp[sum]卻沒檢查INF,那會回傳一個巨大的哨符值而不是-1。在 Java 裡也要把INF設成MAX_VALUE / 2,這樣dp[...] + cost才不會溢位。
類似的 LeetCode 題目 — 分組與菜單 📚
| 題目 | 關鍵 |
|---|---|
| LC 1155 Number of Dice Rolls With Target Sum | 每顆骰子恰好一個點數 — 所以沒有「跳過」,而 dp 是計數:dp[i][t] += dp[i-1][t-f] |
| LC 2218 Maximum Value of K Coins From Piles | 組 = 一堆硬幣的各個前綴;先對每堆做前綴和,之後就是這個模板配 max |
| LC 2585 Number of Ways to Earn Points | 組 = 「這種題型用 j 次」,j = 0..count — 把有界背包當分組背包讀 |
| LC 1449 Form Largest Integer With Digits That Add up to Target | 一份無限次的菜單被所有位數共用 — 它不是分組背包;比較一下迴圈順序 |
| LC 474 Ones and Zeroes | 就是普通 0/1,但容量是 (zeros, ones) 一對 — 物品的軸還能怎麼長大的另一種方式 |
| LC 4040 Minimum Operations to Form Subset Sum I | 菜單是推導出來的、不是題目給的 — 大部分工夫在證明哪些選項可達、而且最省 |
模式選擇策略
Does one item offer SEVERAL mutually exclusive options?
│
├─ YES ──► Group Knapsack [4040, 1155, 2218, 2585]
│ for group in groups:
│ for w in range(W, -1, -1): # CAPACITY outer, backward
│ for (weight, cost) in group: # options inner
│ └─ or snapshot dp and let every option read the pre-group state
│ └─ options-outer + in-place dp is the classic bug: two forms of one item
│
└─ NO ──► Is each item reusable?
│
├─ NO ──► 0/1 Knapsack
│ for item in items:
│ for w in range(W, weight-1, -1): # BACKWARD
│ └─ asks "can we hit the sum?" -> boolean dp
│ └─ asks "how many ways?" -> dp[j] += dp[j-w]
│ └─ asks "best value?" -> dp[j] = max(dp[j], dp[j-w]+v)
│
└─ YES ──► Does order matter?
│
├─ NO (combinations, {1,2} == {2,1}) ──► items outer, amount inner [518]
├─ YES (permutations, {1,2} != {2,1}) ──► amount outer, items inner [377]
└─ Min/max only (order irrelevant) ──► either nesting [322, 279]
總結
| 每一列只記住一件事的話 | ……就記這個 |
|---|---|
| 0/1 vs 完全背包 | 內層迴圈的方向:倒序阻止重複使用,正序允許重複使用 |
| 組合 vs 排列 | 迴圈的巢狀順序:物品在外層數的是集合,金額在外層數的是序列 |
| 分割類題目 | 「切成相等的兩半」⇒ 對 total / 2 做子集合和 |
| LC 494 Target Sum | sum1 = (total + T) / 2,但要先檢查 abs(T) <= total 且 (total + T) 為偶數 |
| 守衛條件 | 是 i - coin >= 0,不是 > 0 — == 0 的情況讀到的是已初始化的 dp[0] |
| 有界背包 | 把每個物品二進位拆分成 1, 2, 4, … 份副本,再跑一般的 0/1 |
| 分組背包 | 把容量迴圈放在選項迴圈外面 — 選項在外層會拿到同一個物品的兩種形態 |
| LC 4040 | 那一組就是兩條純 x2 / x//2 鏈,成本 = 鏈長;是「先乘後除」讓混合做法變得多餘 |