背包 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 情況的繁體中文逐步解說;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 定義、倒序迴圈的追蹤,以及 LC 494 / 416 的完整解題流程
- Knapsack problem — Wikipedia
辨識
限制條件的線索依照 complexity_cheatsheet.md 裡的尺規:這裡的單一物品模板都是 O(n * W),所以 n * W 大約在 10⁷ 以內就放得下;分組模板(Template 6)還要對每組的選項做迴圈,是 O(n * W * |group|),預算要把這個因子算進去。
| 題目敘述裡的訊號 | 限制條件線索 | 該用 | 不適用的情況 |
|---|---|---|---|
| 「分成兩個總和相等的子集」、「有沒有子集合剛好湊到 X」 | n ≤ 200、值 ≤ 100 → 總和 ≤ 2·10⁴ | 模板 1 0/1 子集合和 | 物品可以重複使用 → 模板 4 / 模板 2 |
「替每個數字加上 + 或 -」、「分成兩堆,讓差值最小」 |
總和 ≤ 1000 | 代數轉換之後用 模板 1(LC 494、LC 1049) | 物品可以不用,而且要的是平衡時的高度 → 模板 5 |
| 「湊出金額的組合數」、硬幣無限多 | amount ≤ 5000 | 模板 2 物品在外層 | 「順序不同的序列視為不同組合」 → 模板 3 |
「可能的組合數」,但 (1, 2) 和 (2, 1) 都要算 |
target ≤ 1000 | 模板 3 金額在外層 | 要的是列出所有組合本身,不是個數 → backtrack.md(LC 39) |
| 「最少幾枚硬幣 / 最少幾個完全平方數加起來等於 n」 | amount ≤ 10⁴ | 模板 4 | 問的是方法數 → 模板 2 |
| 「兩個不相交、總和相等的子集,讓總和最大」,物品可以丟掉 | n ≤ 20、總和 ≤ 5000 | 模板 5 差值鍵 | n ≤ 30 而值很大 → 折半搜尋(LC 2035) |
| 「每個元素可以用好幾種形態之一」、「一顆骰子 / 一堆硬幣 / 一種題型」 | 總和 ≤ 10³–10⁴ | 模板 6 分組背包 | 一份可重複使用的菜單被所有位置共用 → 模板 4(LC 1449) |
| 「字串能不能切成字典裡的單字」 | n ≤ 300 | 不是求和:物品是被串接起來的 | 物品在外層在這裡就是錯的 → dp_loop_order.md |
題型分類
| 分類 | 它回答什麼問題 | 答案型別 | 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 |
模板與演算法
模板比較表
| 模板 | 題目說…… | 迴圈順序 | 內層方向 | 轉移式 |
|---|---|---|---|---|
| 1:0/1 子集合和 | 「每個物品最多用一次」 | 物品 → 容量 | 倒序 | dp[s] = dp[s] or dp[s - w] |
| 2:完全背包,組合 | 「有幾種方法」,順序不重要 | 物品 → 金額 | 正序 | dp[a] += dp[a - coin] |
| 3:完全背包,排列 | 「有幾種方法」,順序很重要 | 金額 → 物品 | 正序 | dp[t] += dp[t - num] |
| 4:完全背包,最少個數 | 「最少幾個物品」,數量無限 | 皆可 | 正序 | dp[a] = min(dp[a], dp[a - coin] + 1) |
| 5:差值鍵 | 兩堆,只在乎差距 | 物品 → 差值 | 快照 | 每個物品三種選擇 |
| 6:分組 | 每個物品有一份形態菜單 | 物品 → 容量在外層 → 選項 | 倒序或快照 | 菜單裡取最好的 |
模板 1:0/1 背包(子集合和) — LC 416 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「分成兩個總和相等的子集」、「有沒有子集合剛好湊到 X」、
「分成兩組」、「每個元素最多用一次」。n ≤ 200 且總和 ≤ 2·10⁴ →
預期就是一張 O(n * sum) 的表。
關鍵 — 處理完第 i 個物品後,dp[s] 為真,若且唯若前 i 個物品中有某個子集合的和恰為 s。
內層迴圈倒序跑,所以 dp[s - num] 仍是這個物品之前的值:每一輪每個物品只被提供一次,絕不會兩次。
- 狀態:
dp[s]= 目前看過的物品中,有沒有子集合的和恰為s - 轉移式:
dp[s] = dp[s] or dp[s - num]— 不拿num,或在s - num上面再拿它 - 初始值:
dp[0] = True— 空子集合 - 順序:物品在外層,
s從target往下到num - 答案:
dp[target],其中target = total // 2
# python
# LC 416 - Partition Equal Subset Sum
# IDEA: 0/1 knapsack on sums -- can a subset reach total / 2? inner loop backward = each num once
# time = O(n * target), space = O(target)
def canPartition(nums):
total = sum(nums)
if total % 2 == 1: # an odd total cannot split into two equal halves
return False
target = total // 2
dp = [False] * (target + 1) # dp[s] = some subset of the nums so far sums to s
dp[0] = True # the empty subset
for num in nums:
for s in range(target, num - 1, -1): # BACKWARD: dp[s - num] is still the old row
if dp[s - num]:
dp[s] = True
return dp[target]
// java
// LC 416 - Partition Equal Subset Sum
// IDEA: 0/1 knapsack on sums -- can a subset reach total / 2? inner loop backward = each num once
// time = O(n * target), space = O(target)
public boolean canPartition(int[] nums) {
int total = 0;
for (int num : nums) {
total += num;
}
if (total % 2 == 1) { // an odd total cannot split into two equal halves
return false;
}
int target = total / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true; // the empty subset
for (int num : nums) {
for (int s = target; s >= num; s--) { // BACKWARD: dp[s - num] is still the old row
if (dp[s - num]) {
dp[s] = true;
}
}
}
return dp[target];
}
同一個迴圈能回答三種問題;只有轉移式會變:
can we reach s? dp[s] = dp[s] or dp[s - w] base dp[0] = True
how many subsets? dp[s] += dp[s - w] base dp[0] = 1 (LC 494)
best value within s? dp[s] = max(dp[s], dp[s - w] + v) base dp[*] = 0 (classic knapsack)
複雜度
- 時間 O(n · target):巢狀的
for num×for s in range(target, …),每一對(物品、和)只碰一次。 - 空間 O(target):只有一列
dp = [False] * (target + 1),原地滾動 — 這只有在內層迴圈倒序時才合法。
試跑 — 為什麼要倒序,只有一個物品 3、target = 9:
forward s=3: dp[3] = dp[0] = T
s=6: dp[6] = dp[3] = T <- dp[3] was set by THIS item a moment ago: 3 used twice
s=9: dp[9] = dp[6] = T <- and three times
backward s=9: dp[9] = dp[6] (old, F)
s=6: dp[6] = dp[3] (old, F)
s=3: dp[3] = dp[0] = T <- the only sum one 3 can make
陷阱
- 內層迴圈正序會變成完全背包:
nums = [3]、target = 6會因為把那唯一的3用了兩次而回報True。 - 總和為奇數:
[1, 2, 3, 5]總和 11;11 // 2 = 5湊得到(2 + 3),所以漏掉奇偶檢查會對一個不可能的分割回傳True。 - 轉移式搞混:可行性用
or、計數用+=、價值用max(…, dp[s - w] + v)。在計數表上用or能編譯但答案是錯的:每個計數都會變成 0 或 1。(在布林表上用max剛好等於or,無害,只是不清楚。) - 總和很大:在 Java 裡轉移式若是把值加總,值 × n 可能超出
int範圍;改用long。
不適用時 — 物品可以重複使用 → 模板 4 或 模板 2(正序迴圈);物品可以丟掉而且要的是平衡時的高度 → 模板 5; 一個物品有好幾種形態 → 模板 6。
練習 — LC 416 ⭐ · LC 1049 (最小化差距:找 ≤ total // 2 的最大可達 s) ·
LC 494 (數子集合:代數轉換後用 +=) · LC 474 (兩種容量,一張二維的列) ·
LC 879 (計數,再加一條封頂在 minProfit 的利潤軸)
那一列從哪裡來 — 遞迴與二維表
倒序的一維迴圈是一段推導的終點,面試官可能問其中任何一步。最先寫出來的遞迴依序決定每個物品 — 拿或不拿。沒有記憶化,它就是選/不選的回溯,O(2^n);以 (i, remaining) 為鍵記憶化,它就是這個模板的 top-down 版本:
# python
# LC 416 - Partition Equal Subset Sum (top-down)
# IDEA: can(i, r) = can some subset of nums[i:] sum to r? take nums[i] or skip it; memoise on (i, r)
# time = O(n * target), space = O(n * target)
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
target = total // 2
memo = {}
def can(i, remaining):
if remaining == 0:
return True
if i == len(nums) or remaining < 0:
return False
key = (i, remaining)
if key in memo:
return memo[key]
result = can(i + 1, remaining - nums[i]) or can(i + 1, remaining) # take, or skip
memo[key] = result
return result
return can(0, target)
由下而上,同一組狀態排成一張表,每個物品一列:
# python
# IDEA: dp[i][w] = best value using the first i items within capacity w
# time = O(n * W), space = O(n * W)
def knapsack_01_2d(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
weight, value = weights[i - 1], values[i - 1]
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w] # skip item i
if weight <= w:
dp[i][w] = max(dp[i][w], dp[i - 1][w - weight] + value) # take item i
return dp[n][capacity]
第 i 列只讀第 i - 1 列的 w 與 w - weight 兩格。只留一列、讓 w 倒序跑,讀到 dp[w - weight] 時它還是第 i - 1 列的值:這就是 Template 1。
迴圈順序:組合 vs 排列
在完全背包裡,兩層迴圈的順序決定你數的是組合還是排列。轉移式是同一行
dp[i] += dp[i - item];只有巢狀順序換了位置。模板 2 和
模板 3 就是這兩半。
這裡兩種順序都是正確的 DP,只是回答不同的問題 — 這是「求和」才有的特性。當物品是被串接而不是相加時(LC 139 Word Break),物品在外層的順序就不是在回答另一個問題,而是直接錯了:dp_loop_order.md。
| 面向 | LC 518: Coin Change II(組合) | LC 377: Combination Sum IV(排列) |
|---|---|---|
| 數的是什麼 | 不重複的集合(順序不重要) | 不同的序列(順序重要) |
| 例子 | [1,2] = [2,1](相同) | [1,2] ≠ [2,1](不同) |
| 外層迴圈 | for coin in coins |
for t in range(1, target + 1) |
| 內層迴圈 | for a in range(coin, amount + 1) |
for num in nums |
| DP 轉移式 | dp[a] += dp[a - coin] |
dp[t] += dp[t - num] |
| 初始值 | 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 被提供之前 → 只有非遞減序列 | 對每個和,每個數字都被試著當最後一個 → 每一種順序 |
記憶口訣:Coin(硬幣)在外層數的是 Combinations(組合);Amount(金額)在外層數的是 Arrangements(排列)。
模板 2:完全背包計數,組合 — LC 518 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「湊出該金額的組合數」、「可以假設每種硬幣有無限多枚」,而且 {1,2} / {2,1} 算同一個答案。
amount ≤ 5000、硬幣 ≤ 300 → O(n * amount)。
關鍵 — 硬幣在外層,所以當硬幣 c 被提供時,前面的每種硬幣都已經處理完了:一個和只能以非遞減的硬幣順序被湊出來,每個多重集合只算一次。內層迴圈正序,所以 dp[a - coin] 可能已經包含這枚硬幣 — 這就是「無限供應」。
# python
# LC 518 - Coin Change II
# IDEA: coins OUTER (each multiset built in one canonical order), amount inner FORWARD (reuse)
# time = O(n * amount), space = O(amount)
def change(amount, coins):
dp = [0] * (amount + 1) # dp[a] = combinations of the coins so far that sum to a
dp[0] = 1 # one way to make 0: take nothing
for coin in coins:
for a in range(coin, amount + 1): # forward: dp[a - coin] may already use this coin
dp[a] += dp[a - coin]
return dp[amount]
// java
// LC 518 - Coin Change II
// IDEA: coins OUTER (each multiset built in one canonical order), amount inner FORWARD (reuse)
// time = O(n * amount), space = O(amount)
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1; // one way to make 0: take nothing
for (int coin : coins) {
for (int a = coin; a <= amount; a++) { // forward: dp[a - coin] may already use this coin
dp[a] += dp[a - coin];
}
}
return dp[amount];
}
複雜度
- 時間 O(n · amount):
for coin×for a in range(coin, amount + 1),每次一個加法。 - 空間 O(amount):只有一列
dp。
試跑 — coins = [1, 2]、amount = 3:
start dp = [1, 0, 0, 0]
coin 1 a=1..3 dp = [1, 1, 1, 1] {1} {1,1} {1,1,1}
coin 2 a=2 dp[2] += dp[0] -> 2 {1,1} {2}
a=3 dp[3] += dp[1] -> 2 {1,1,1} {1,2} -- no {2,1}: coin 1 is finished
answer dp[3] = 2
陷阱
- 把兩層迴圈對調,會無聲地變成在解 LC 377:
coins = [1, 2]、amount = 3會回傳 3。 dp[0] = 0:每個計數都停在 0。空組合就是湊出 0 的那一種方法。- 內層迴圈倒序會變成 0/1:
amount = 4、coins = [2]回傳 0 而不是 1。
不適用時 — 題目把 (1, 2) 和 (2, 1) 分開算 →
模板 3;每個物品最多一次 →
用 += 的 模板 1。
練習 — LC 518 · LC 1641 (母音字串 = 可重複的組合) · LC 2585
(每種題型最多用 count 次:有界背包,當成分組來讀)
模板 3:完全背包計數,排列 — LC 377 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「加起來等於 target 的可能組合數」,並附註順序不同的序列視為不同組合;「每次從一組步數中選一種,爬上去有幾種方法」。
target ≤ 1000 → O(n * target)。
關鍵 — 金額在外層,所以 dp[t] 問的是最後一個放的是哪個數字?,在每個和上都試遍每個數字:dp[t] = Σ dp[t - num]。兩個只差在順序的序列,在某一步結尾的數字會不同,所以兩者都會被算到。
# python
# LC 377 - Combination Sum IV
# IDEA: amount OUTER, numbers inner -- dp[t] sums over the number placed LAST, so orders differ
# time = O(n * target), space = O(target)
def combinationSum4(nums, target):
dp = [0] * (target + 1) # dp[t] = ordered sequences summing to t
dp[0] = 1 # the empty sequence
for t in range(1, target + 1):
for num in nums: # try every number as the last one
if t - num >= 0:
dp[t] += dp[t - num]
return dp[target]
// java
// LC 377 - Combination Sum IV
// IDEA: amount OUTER, numbers inner -- dp[t] sums over the number placed LAST, so orders differ
// time = O(n * target), space = O(target)
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1; // the empty sequence
for (int t = 1; t <= target; t++) {
for (int num : nums) { // try every number as the last one
if (t - num >= 0) {
dp[t] += dp[t - num];
}
}
}
return dp[target];
}
複雜度
- 時間 O(target · n):
for t×for num,每次一個加法。 - 空間 O(target):只有一列
dp。
試跑 — nums = [1, 2]、target = 3:
t=1 last 1: dp[0]=1 dp[1] = 1 {1}
t=2 last 1: dp[1]=1, last 2: dp[0]=1 dp[2] = 2 {1,1} {2}
t=3 last 1: dp[2]=2, last 2: dp[1]=1 dp[3] = 3 {1,1,1} {2,1} {1,2}
陷阱
- 物品在外層會得到組合數:
nums = [1, 2, 3]、target = 4回傳 4,而不是 7。 - 中間值溢位:在評測的測資上,某些小於
target的dp[t]會超過 32 位元。題目保證答案放得下,而每一步都是加法,所以 Java 會繞回的int最後仍會落在正確的值;在 C++ 裡有號整數溢位是未定義行為 — 改用unsigned。 - 負數(進階題)會讓計數變成無限多:
[1, -1]會永遠迴圈下去。這個模板假設數字都是正的。
不適用時 — 順序不重要 → 模板 2; 要的是序列本身 → backtrack.md。
練習 — LC 377 · LC 70 ⭐ (nums = {1, 2}) · LC 2466 (步數 zero 和 one,對一個區間加總)
模板 4:完全背包最少個數 — LC 322 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「最少需要幾枚硬幣」、「最少幾個完全平方數」,每種數量無限,湊不出來就回傳 -1。
amount ≤ 10⁴、硬幣 ≤ 12 → O(n * amount)。
貪婪法(先拿最大的硬幣)是那個誘人的錯誤答案。
關鍵 — dp[a] 是恰好湊出 a 的最少硬幣數,對每個比目前小的 a 都已經算好。最後一枚硬幣是某個 coin ≤ a,剩下的就是已經解好的 dp[a - coin];對所有選擇取最小值。最小值與順序無關,所以兩種巢狀順序都行。
# python
# LC 322 - Coin Change
# IDEA: dp[a] = min over the last coin of dp[a - coin] + 1; INF = amount + 1 marks "unreachable"
# time = O(amount * n), space = O(amount)
def coinChange(coins, amount):
INF = amount + 1 # more coins than any real answer can use
dp = [INF] * (amount + 1) # dp[a] = fewest coins summing to exactly a
dp[0] = 0 # zero coins make 0
for a in range(1, amount + 1):
for coin in coins:
if a - coin >= 0: # the coin fits; dp[a - coin] is already final
dp[a] = min(dp[a], dp[a - coin] + 1)
return -1 if dp[amount] == INF else dp[amount]
// java
// LC 322 - Coin Change
// IDEA: dp[a] = min over the last coin of dp[a - coin] + 1; INF = amount + 1 marks "unreachable"
// time = O(amount * n), space = O(amount)
public int coinChange(int[] coins, int amount) {
int INF = amount + 1; // more coins than any real answer can use
int[] dp = new int[amount + 1];
Arrays.fill(dp, INF);
dp[0] = 0; // zero coins make 0
for (int a = 1; a <= amount; a++) {
for (int coin : coins) {
if (a - coin >= 0) { // the coin fits; dp[a - coin] is already final
dp[a] = Math.min(dp[a], dp[a - coin] + 1);
}
}
}
return dp[amount] == INF ? -1 : dp[amount];
}
複雜度
- 時間 O(amount · n):
for a in range(1, amount + 1)×for coin in coins。 - 空間 O(amount):只有一列
dp。
試跑 — coins = [1, 2, 5]、amount = 11。每個 dp[a] 讀的都是它左邊已經定案的格子:
| a | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| dp[a] | 0 | 1 | 1 | 2 | 2 | 1 | 2 | 2 | 3 | 3 | 2 | 3 |
dp[11] = min(dp[10] + 1, dp[9] + 1, dp[6] + 1) = min(3, 4, 3) = 3 → 5 + 5 + 1。
為什麼守衛條件是 a - coin >= 0 而不是 a == coin:
a - coin |
意義 | 那一行程式做的事 |
|---|---|---|
負數(< 0) |
這枚硬幣對這個金額太大 | 被守衛條件跳過 |
零(== 0) |
這一枚硬幣就是整個金額 | dp[0] + 1 = 1 — dp[0] 已經初始化 |
正數(> 0) |
硬幣放得下;剩下的是已解好的子問題 | dp[a - coin] + 1 |
陷阱
if a == coin只會找到一枚硬幣的答案:coins = [2]、amount = 4回傳 -1 而不是 2,因為dp[4]從來沒讀到dp[2]。- 回傳
dp[amount]卻沒檢查INF:coins = [2]、amount = 3會回傳 4。 - 貪婪法:
coins = [1, 3, 4]、amount = 6— 先拿最大的會拿4 + 1 + 1= 3 枚;DP 找到3 + 3= 2 枚。 - 在 Java 用
Integer.MAX_VALUE當 INF:dp[a - coin] + 1溢位成負數,在每次min都會贏。amount + 1是安全的。
不適用時 — 問的是方法數 → 模板 2;
每枚硬幣只能用一次 → 用 min 加上倒序迴圈的 模板 1。
練習 — LC 322 ⭐ · LC 279 (硬幣就是 ≤ n 的完全平方數) · LC 1449 (先最大化位數,再組出最大的字串)
模板 5:以差值為鍵的背包 — LC 956 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「分成兩個不相交、總和相等的子集,回傳可能的最大總和」,而且物品可以不用;更一般地說,就是兩堆東西、只在乎它們之間的差距。
n ≤ 20 且總和 ≤ 5000 → O(n * sum)。
關鍵 — 用兩堆的差值當表格的鍵,而不是用總和:dp[d] 是把目前為止的部分物品放進兩堆、讓兩堆恰好相差 d 的所有做法中,較高那堆的最大高度。每個物品有三種選擇 — 不用、放高的那堆、放矮的那堆 — 答案是 dp[0]。
dp[capacity] = best value -> "how much have I packed?" (Templates 1-4)
dp[difference] = best height -> "how unbalanced are the two piles?"
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
|d - x| 加上 max(...),一起處理了矮的那堆超過高的那堆的情況:兩堆互換角色,差值從零反彈回來。
# 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 placed at most once
for d, taller in prev.items():
shorter = taller - d
dp[d + x] = max(dp.get(d + x, 0), taller + x) # x on the taller side
nd = abs(d - x) # x on the shorter side
dp[nd] = max(dp.get(nd, 0), max(taller, shorter + x))
return dp[0] # difference 0 = equal supports
// 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); // -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];
int shorter = taller - d;
if (d + x <= sum) { // x joins the taller pile
dp[d + x] = Math.max(dp[d + x], taller + x);
}
int nd = Math.abs(d - x); // x joins the shorter pile
dp[nd] = Math.max(dp[nd], Math.max(taller, shorter + x));
}
}
return dp[0];
}
複雜度
- 時間 O(n · sum):
for x in rods×prev裡最多sum + 1個差值。 - 空間 O(sum):每個可達的差值一筆,再加上
prev快照。
試跑 — 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}
第三根鋼條讓 {3} 與 {1,2} 平衡時,dp[0] 從 0 跳到 3;6 與其餘全部平衡時,再跳到 6。
陷阱
- 把陣列初始化成
0而不是-1:全填零的陣列等於宣稱每個差值都能免費達到;於是rods = [1, 2]回傳 1 而不是 0。 - 一邊迭代
dp一邊修改它(Python),或忘了clone()(Java):沒有快照的話,一根鋼條在同一輪裡會被放到兩堆上。 - 存矮的那邊而不是高的那邊:兩種慣例都行,但轉移式不同 — 從你選定的那一種推出
shorter = taller - d。 - 在 Java 裡
d + x超出陣列:上限設在sum;比總和還大的差值沒有意義。
不適用時 — 沒有物品可以丟掉、而且只問可不可行 → 用總和當鍵就夠了, 模板 1(LC 416、LC 1049);n ≤ 30 且值可達 10⁷ → 折半搜尋(LC 2035),差值表太寬了。
練習 — LC 956 · LC 1049 (同一個想法,用總和當鍵來讀) · LC 2035 (折半搜尋)
模板 6:分組背包 — LC 4040 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 一個物品可以用好幾種形態之一,或完全不用:一顆骰子與它的點數、一堆硬幣與它的前綴、一個元素與它能被變成的每一個值。
容量仍然是一個 ≤ 10³–10⁴ 的和 → O(n * W * |group|)。
| 題目 | 這裡的「組」是…… | 每組的規則 |
|---|---|---|
| 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 |
關鍵 — 容量軸完全沒變;變的只有裡面的選擇:
dp[s] = best(dp[s], 該物品所有選項中 dp[s - w] + c 的最佳值)。同一個物品的每個選項,都必須讀到該物品之前的狀態,這樣最多只會取一種形態 — 做法是對 dp 取快照,或是把容量迴圈放在選項迴圈外面並倒序跑。
✅ 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:
LC 4040 的菜單:元素 x 可以被乘 2、也可以被除 2,但所有乘法都排在所有除法之前。先 k 次乘 2、再 j 次除 2 的混合做法落點是 x * 2^(k-j) — 純鏈以 |k - j| 而不是 k + j 的成本就能到那裡 — 所以菜單就只有兩條純鏈,各自在不再有用時剪掉:
x, 2x, 4x, ... k doublings, cost k (stop past sum: it only grows)
x, x//2, x//4, ... k halvings, cost k (stop at 0: it never helps a positive sum)
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)
# python
# LC 4040 - Minimum Operations to Form Subset Sum I
# IDEA: group knapsack -- each x is a menu of (value, ops); a snapshot of dp lets at most one
# entry per x be taken
# 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 = [] # the menu for x: (value, ops)
value, op = x, 0
while value <= sum: # x itself (0 ops), 2x, 4x, ...
options.append((value, op))
value *= 2
op += 1
value, op = x, 0
while value > 0: # x//2, x//4, ...
value //= 2
op += 1
if 0 < value <= sum:
options.append((value, op))
new_dp = dp[:] # snapshot: every option reads the pre-x state
for value, cost in options:
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; // + cost cannot overflow
int[] dp = new int[sum + 1];
Arrays.fill(dp, INF);
dp[0] = 0;
for (int x : nums) {
List<int[]> options = new ArrayList<>();
long value = x;
int op = 0;
while (value <= sum) { // x itself (0 ops), 2x, 4x, ...
options.add(new int[]{(int) value, op});
value *= 2;
op++;
}
value = x;
op = 0;
while (value > 0) { // x/2, x/4, ...
value /= 2;
op++;
if (value > 0 && value <= sum) {
options.add(new int[]{(int) value, op});
}
}
for (int s = sum; s >= 0; s--) { // capacity OUTER, backward
for (int[] option : options) { // options INNER: one form per x
int v = option[0];
int cost = option[1];
if (s >= v && dp[s - v] != INF) {
dp[s] = Math.min(dp[s], dp[s - v] + cost);
}
}
}
}
return dp[sum] >= INF ? -1 : dp[sum];
}
複雜度
- 時間 O(n · sum · |menu|):
for x×for s× 各個選項,而每條鏈因為是加倍或減半,只有O(log(max(x, sum)))個項目。 - 空間 O(sum):
dp加上一份快照new_dp(Java 寫法不需要)。
試跑 — nums = [10, 2]、sum = 13:
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:菜單是{(2, 1), (1, 2)},那種迴圈會用一個元素湊出2 + 1 = 3,回報 3 而不是 -1。 - 漏掉成本為零的那個選項:少了
(x, 0),nums = [4]、sum = 4會回傳 -1。 - 去枚舉混合鏈在這一題只是白做工;如果先除再乘是被允許的(
5 -> 2 -> 4),那兩條純鏈就是不完整的。重用這個捷徑前先回去讀規則。 - 沒有替鏈設界:乘 2 的鏈一旦超過
sum就再也回不來,而除到0永遠幫不上忙 — 不剪的話兩者都是無窮迴圈。 - 讀
dp[sum]卻沒檢查INF會回傳哨符值而不是 -1;在 Java 裡把INF設成MAX_VALUE / 2。
不適用時 — 一份無限次的菜單被所有位置共用(LC 1449) →
模板 4,不是分組;容量是一對 (zeros, ones),但每個物品只有一種形態(LC 474) → 用二維列的普通
模板 1。有界背包就是分組背包 — 「最多 k 次」就是那個組 {(w, v), (2w, 2v), …, (kw, kv)};開頭那張表裡的二進位拆分只是寫得更快的版本。
練習 — LC 4040 (菜單是推導出來的,不是題目給的) · LC 1155 (恰好一個點數:不能跳過,用 += 計數) · LC 2585 (題型用 0..count 次) · LC 2218 (先對每堆做前綴和,再取 max)
LC 範例
Last Stone Weight II — LC 1049
關鍵 — 互撞石頭等於把石頭分成兩堆再相減:
|S - (total - S)| = total - 2S。要讓它最小,就是找最大的可達子集合和S ≤ total // 2,這是一個可行性問題。
歸約到:模板 1 — 同一列布林值,只是從 total // 2 往下讀,而不只讀 total // 2 那一格。
# python
# LC 1049 - Last Stone Weight II
# IDEA: 0/1 subset sum up to total // 2; the answer is total - 2 * (largest reachable sum)
# time = O(n * total), space = O(total)
def lastStoneWeightII(stones):
total = sum(stones)
half = total // 2
dp = [False] * (half + 1) # dp[s] = some subset of the stones sums to s
dp[0] = True
for stone in stones:
for s in range(half, stone - 1, -1): # backward: each stone once
if dp[s - stone]:
dp[s] = True
for s in range(half, -1, -1): # the most balanced split
if dp[s]:
return total - 2 * s
// java
// LC 1049 - Last Stone Weight II
// IDEA: 0/1 subset sum up to total / 2; the answer is total - 2 * (largest reachable sum)
// time = O(n * total), space = O(total)
public int lastStoneWeightII(int[] stones) {
int total = 0;
for (int stone : stones) {
total += stone;
}
int half = total / 2;
boolean[] dp = new boolean[half + 1];
dp[0] = true;
for (int stone : stones) {
for (int s = half; s >= stone; s--) { // backward: each stone once
if (dp[s - stone]) {
dp[s] = true;
}
}
}
for (int s = half; s >= 0; s--) { // the most balanced split
if (dp[s]) {
return total - 2 * s;
}
}
return total; // unreachable: dp[0] is always true
}
stones = [2, 7, 4, 1, 8, 1], total = 23, half = 11
after 2 reachable {0, 2}
after 7 reachable {0, 2, 7, 9}
after 4 reachable {0, 2, 4, 6, 7, 9, 11}
... 11 stays reachable -> answer = 23 - 2 * 11 = 1
Target Sum — LC 494
關鍵 — 設
P為被加上+的數字總和。那麼P - (total - P) = T,所以P = (total + T) / 2:數正負號的指派方式,就是數和為P的子集合。要先做兩個檢查 —abs(T) > total以及total + T為奇數,都代表 0 種方法。
歸約到:模板 1 — 改用計數轉移式 dp[s] += dp[s - num],以及 dp[0] = 1。
# python
# LC 494 - Target Sum
# IDEA: reduce "assign +/-" to "count subsets summing to (total + T) / 2", then 0/1 knapsack
# time = O(n * P), space = O(P)
def findTargetSumWays(nums, target):
total = sum(nums)
if abs(target) > total or (total + target) % 2 == 1: # P negative or not an integer
return 0
plus = (total + target) // 2
dp = [0] * (plus + 1) # dp[s] = subsets of the nums so far summing to s
dp[0] = 1 # the empty subset
for num in nums:
for s in range(plus, num - 1, -1): # backward: each num once
dp[s] += dp[s - num]
return dp[plus]
// java
// LC 494 - Target Sum
// IDEA: reduce "assign +/-" to "count subsets summing to (total + T) / 2", then 0/1 knapsack
// time = O(n * P), space = O(P)
public int findTargetSumWays(int[] nums, int target) {
int total = 0;
for (int num : nums) {
total += num;
}
if (Math.abs(target) > total || (total + target) % 2 != 0) { // P negative or not an integer
return 0;
}
int plus = (total + target) / 2;
int[] dp = new int[plus + 1];
dp[0] = 1; // the empty subset
for (int num : nums) {
for (int s = plus; s >= num; s--) { // backward: each num once
dp[s] += dp[s - num];
}
}
return dp[plus];
}
nums 裡的 0 不用特別處理:range(plus, -1, -1) 包含 s = 0,所以 dp[0] 會翻倍 — [0, 0, 1]、T = 1 得到 4。
模式選擇策略
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]
練習路徑
| 階段 | 題目(由易到難) | 它多教了什麼 |
|---|---|---|
| 核心 ⭐ | LC 322 again · LC 416 again | 最少個數那一列,以及倒序的 0/1 那一列,各一次 |
| 練習 | LC 279 again · LC 518 ok · LC 377 again · LC 1049 again · LC 494 ok · LC 474 again | 計數時的迴圈順序、分割的代數轉換、二維容量 |
| 延伸 | LC 1155 未評定 · LC 956 未評定 · LC 879 未評定 · LC 1449 未評定 · LC 4040 未評定 | 分組菜單、差值鍵、封頂的利潤軸、重建答案 |
總結
| 每一列只記住一件事的話 | ……就記這個 |
|---|---|
| 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 鏈,成本 = 鏈長;是「先乘後除」讓混合做法變得多餘 |