背包 DP(0/1、完全背包、Coin Change)

動態規劃與遞迴優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 完整的背包問題家族: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

參考資料

辨識

限制條件的線索依照 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
# 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
// 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];
}

同一個迴圈能回答三種問題;只有轉移式會變:

text
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:

text
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
# 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
# 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
# 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
// 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:

text
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
# 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
// 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:

text
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
# 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
// 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]。

text
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
# 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
// 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]:

text
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 取快照,或是把容量迴圈放在選項迴圈外面並倒序跑。

text
✅ 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 的成本就能到那裡 — 所以菜單就只有兩條純鏈,各自在不再有用時剪掉:

text
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
# 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
// 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:

text
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
# 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
// 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
}
text
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
# 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
// 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。

模式選擇策略

text
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 鏈,成本 = 鏈長;是「先乘後除」讓混合做法變得多餘