背包問題:動態規劃的 Hello World#
背包問題是動態規劃領域最經典的問題之一,也是學習 DP 的必經之路。掌握背包問題的解題思路,對於理解動態規劃的核心思想至關重要。
0-1 背包問題#
問題描述#
給你一個可放總重量為 W 的背包和 N 個物品,每個物品有重量 w[i] 和價值 v[i]。現在讓你用這個背包裝物品,問最多能裝的價值是多少?
輸入:W = 5, N = 3
w = [3, 2, 1], v = [5, 2, 3]
輸出:8
解釋:選擇 i=0 和 i=2 這兩件物品。
總重量 4 < W,最大價值 = 5 + 3 = 8為什麼叫 0-1 背包? 每次只能做兩種選擇:放入當前物品(1)或不放入(0)。 如果每個物品可以放入多個,就變成了完全背包問題。
判斷是否為 DP 問題#
- 重叠子問題:各種排列組合間存在重複計算
- 無後效性:選定物品後,重量與價值確定,不受後續影響
- 最優子結構:後續計算可由前面狀態推導
狀態轉移方程#
狀態定義:DP[tn][rw] 表示考慮前 tn 件物品,背包容量為 rw 時的最大價值
狀態參數:
tn:已走訪的物品數量rw:背包剩餘容量
決策:對於每件物品,選擇「放入」或「不放入」
$$ DP(tn, rw) = \begin{cases} 0, & tn \leq 0 \ 0, & rw \leq 0 \ DP(tn-1, rw), & rw < w[tn] \ \max(DP(tn-1, rw), DP(tn-1, rw-w[tn]) + v[tn]), & rw \geq w[tn] \end{cases} $$
程式碼實現#
0-1 背包 Java 實現
int dp(int[] w, int[] v, int N, int W) {
// 創建備忘錄
int[][] dp = new int[N + 1][W + 1];
// 初始化狀態
for (int i = 0; i < N + 1; i++) dp[i][0] = 0;
for (int j = 0; j < W + 1; j++) dp[0][j] = 0;
for (int tn = 1; tn < N + 1; tn++) {
for (int rw = 1; rw < W + 1; rw++) {
if (rw < w[tn]) {
// 容量不足,不放入
dp[tn][rw] = dp[tn - 1][rw];
} else {
// 決策:放入 vs 不放入
dp[tn][rw] = Math.max(
dp[tn - 1][rw], // 不放入
dp[tn - 1][rw - w[tn]] + v[tn] // 放入
);
}
}
}
return dp[N][W];
}備忘錄填充示意#
rw=0 rw=1 rw=2 rw=3 rw=4 rw=5
tn=0 0 0 0 0 0 0
tn=1 0 0 0 5 5 5
tn=2 0 0 2 5 5 7
tn=3 0 3 3 5 8 8
↑
答案:8完全背包問題#
問題描述#
與 0-1 背包類似,但每種物品可以選擇任意多個。
輸入:W = 5, N = 3
w = [3, 2, 1], v = [5, 2, 3]
輸出:15
解釋:選擇 i=2 物品 5 次,總價值 = 5 × 3 = 15關鍵區別#
| 問題類型 | 決策 | 狀態轉移 |
|---|---|---|
| 0-1 背包 | 放或不放 | DP[tn-1][rw-w[tn]] |
| 完全背包 | 放幾個 | DP[tn][rw-w[tn]] |
核心差異
- 0-1 背包:從上一個物品的狀態轉移(
tn-1)- 完全背包:從當前物品的更小容量狀態轉移(
tn)
狀態轉移方程#
$$ DP(tn, rw) = \begin{cases} 0, & tn \leq 0 \ 0, & rw \leq 0 \ DP(tn-1, rw), & rw < w[tn] \ \max(DP(tn-1, rw), DP(tn, rw-w[tn]) + v[tn]), & rw \geq w[tn] \end{cases} $$
時間複雜度優化#
樸素解法:三重迴圈 O(kv^2)
- 走訪物品 × 走訪容量 × 走訪數量
優化解法:雙重迴圈 O(kv)
- 將「放幾個」的迴圈隱含在狀態轉移中
完全背包優化版 Java 實現
int bag(int[] w, int[] v, int N, int W) {
int[][] dp = new int[N + 1][W + 1];
// 初始化
for (int i = 0; i < N + 1; i++) dp[i][0] = 0;
for (int j = 0; j < W + 1; j++) dp[0][j] = 0;
for (int tn = 1; tn < N + 1; tn++) {
for (int rw = 1; rw < W + 1; rw++) {
dp[tn][rw] = dp[tn - 1][rw];
// 關鍵:使用 dp[tn][rw-w[tn]] 而非 dp[tn-1][rw-w[tn]]
if (w[tn] <= rw) {
dp[tn][rw] = Math.max(
dp[tn][rw],
dp[tn][rw - w[tn]] + v[tn]
);
}
}
}
return dp[N][W];
}空間複雜度優化#
由於 DP[tn][rw] 只依賴於 DP[tn-1][rw] 和 DP[tn][...],可以使用滾動陣列:
計算第 1 個物品時:用第 0 行做 tn-1 的快取,第 1 行做 tn 的快取
計算第 2 個物品時:用第 1 行做 tn-1 的快取,第 0 行做 tn 的快取
...以此類推空間優化版實現
int bag(int[] w, int[] v, int N, int W) {
int[][] dp = new int[2][W + 1]; // 只需兩行
for (int tn = 1; tn < N + 1; tn++) {
for (int rw = 1; rw < W + 1; rw++) {
int ctn = tn % 2; // 當前行
int ptn = 1 - ctn; // 上一行
dp[ctn][rw] = dp[ptn][rw];
if (w[tn] <= rw) {
dp[ctn][rw] = Math.max(
dp[ctn][rw],
dp[ctn][rw - w[tn]] + v[tn]
);
}
}
}
return dp[N % 2][W];
}背包問題的變種#
問題轉化示例:粉碎石頭#
問題:有一堆石頭,每次選兩塊粉碎。若 x ≤ y,結果為 y-x。求最後剩下的最小重量。
轉化思路:
- 將所有石頭分成兩組
- 兩組之差就是最終結果
- 要使差最小,一組應接近總和的 1/2
轉化為背包:
- 背包容量 = 總重量 / 2
- 物品重量 = 石頭重量
- 物品價值 = 石頭重量(價值等於重量)
- 求能裝入的最大價值
背包問題的本質 背包問題的核心是「選擇」——在有限的資源(容量)下,如何選擇物品以達到最優目標(最大價值)。 很多看似不相關的問題,都可以轉化為背包問題。
背包問題總結#
| 問題類型 | 物品數量 | 關鍵狀態轉移 | 時間複雜度 |
|---|---|---|---|
| 0-1 背包 | 每種 1 個 | DP[tn-1][rw-w[tn]] | O(NW) |
| 完全背包 | 每種無限 | DP[tn][rw-w[tn]] | O(NW) |
| 多重背包 | 每種 k 個 | 需要額外處理 | O(NWK) |
面試提醒 在真正的演算法面試中,很少會直接考察標準背包問題。 面試官更傾向於考察背包問題的變種,需要你識別出背包問題的特徵,並進行轉化。