## 背包問題 --- ### 背包問題是啥 背包問題是**動態規劃 (Dynamic Programming, DP)** 最經典的問題之一。它的核心思想是通過將問題拆解成子問題,慢慢一步一步解完他們。 ![image](https://hackmd.io/_uploads/HJvJ3Iggeg.png) #### 背包問題在幹嘛? - 有一個容量為 `W` 的背包。 - 有 `n` 件物品,每件物品有兩個屬性: - **重量 (`weight[i]`)** - **價值 (`value[i]`)** (這裡會把`weight`和`value`當成陣列分別存每個物品的重量和價值) - **目標**:讓放進背包的物品總重量不超過 `W`,且總價值最大化。 - **限制**:每個物品最多只能選擇一次(這就是所謂的「01 背包問題」),每個背包問題都會有些不一樣的限制,這裡先用最常見也最基礎的01背包做講解 --- ### 二維 DP 背包問題 (01 背包) ![image](https://hackmd.io/_uploads/ry4H28lele.png) #### 狀態定義 我們用一個**二維陣列 `dp[i][j]`** 來表示: - `dp[i][j]`:表示考慮前 `i` 件物品,且背包容量為 `j` 時,所能達到的最大價值。 換句話說: - 如果我們只考慮前 `i` 件物品,並且背包容量為 `j`,那麼可以裝進背包的最大價值是多少。 --- #### 狀態轉移方程式 ![favorite-facts](https://hackmd.io/_uploads/ryAch8lllg.gif) ```cpp= dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]); ``` 這個公式分成兩部分: 1. **不選擇第 `i` 件物品**: - 如果不選擇第 `i` 件物品,那麼最大價值等於「只考慮前 `i-1` 件物品,且剩餘背包容量為 `j` 時的最大價值」。 - 對應的狀態是 `dp[i-1][j]`。 2. **選擇第 `i` 件物品**: - 如果選擇第 `i` 件物品,那麼最大價值等於「只考慮前 `i-1` 件物品,且背包剩餘容量為 `j - weight[i]` 時的最大價值」,再加上第 `i` 件物品的價值 `value[i]`。 - 對應的狀態是 `dp[i-1][j-weight[i]] + value[i]`。 3. **取兩者的較大值**: - 我們肯定是希望最後的價值是最大的,所以取 `max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])`。 --- #### 初始條件 1. **背包容量為 0 時** (`j = 0`): - 沒東西放,你當然一點價值都沒有(嗯? 所以 `dp[i][0] = 0`。 2. **沒有物品可選時** (`i = 0`): - 無論背包容量多大,你沒東西拿,還是一樣一點價值都沒有(嗯? 所以 `dp[0][j] = 0`。 --- ## 每次進入 `dp[i][j]` 判斷時他到底會怎麼走: #### 假設當前 `i = 3`,`j = 5`,物品 `3` 的重量為 `3`,價值為 `4`,我們檢查 `dp[3][5]`。 #### 1. **檢查能否選擇物品 `3`**: - 條件:`j >= weight[3]`。 - 如果 `j = 5` 且 `weight[3] = 3`,滿足條件,可以選擇物品。 #### 2. **計算不選擇物品 `3` 的情況**: - 最大價值為「只考慮前 `2` 件物品,且容量為 `5` 時的最大價值」。 - 對應:`dp[2][5]`。 #### 3. **計算選擇物品 `3` 的情況**: - 假設選擇物品 `3`,需要考慮剩餘容量 `j - weight[3] = 5 - 3 = 2`。 - 最大價值為「前 `2` 件物品在容量 `2` 時的最大價值」加上物品 `3` 的價值 `4`。 - 對應:`dp[2][2] + value[3]`。 #### 4. **取兩者的最大值**: - 比較 `dp[2][5]` 和 `dp[2][2] + value[3]`,取較大值作為 `dp[3][5]`。 --- ## 全過程的逐步演示: 假設有以下物品: | 物品編號 | 重量 (`weight`) | 價值 (`value`) | |----------|----------------|----------------| | 1 | 2 | 3 | | 2 | 1 | 2 | | 3 | 3 | 4 | | 4 | 2 | 2 | 背包容量 `W = 5`。 初始 `dp` 陣列為: ``` dp[i][j] = 0 (所有 i 和 j 都初始化為 0) ``` --- ### 第 1 件物品 (`weight[1] = 2`, `value[1] = 3`): - 只更新容量 `j >= 2` 的情況,公式為: ```cpp dp[1][j] = max(dp[0][j], dp[0][j-2] + 3); ``` | 容量 (`j`) | 更新公式 | 結果 | |------------|--------------------------|------| | `j = 0` | 無法放入,`dp[1][0] = 0` | 0 | | `j = 1` | 無法放入,`dp[1][1] = 0` | 0 | | `j = 2` | `max(0, 0 + 3)` | 3 | | `j = 3` | `max(0, 0 + 3)` | 3 | | `j = 4` | `max(0, 0 + 3)` | 3 | | `j = 5` | `max(0, 0 + 3)` | 3 | --- ### 第 2 件物品 (`weight[2] = 1`, `value[2] = 2`): - 只更新容量 `j >= 1` 的情況,公式為: ```cpp dp[2][j] = max(dp[1][j], dp[1][j-1] + 2); ``` | 容量 (`j`) | 更新公式 | 結果 | |------------|--------------------------|------| | `j = 0` | 無法放入,`dp[2][0] = 0` | 0 | | `j = 1` | `max(0, 0 + 2)` | 2 | | `j = 2` | `max(3, 0 + 2)` | 3 | | `j = 3` | `max(3, 3 + 2)` | 5 | | `j = 4` | `max(3, 3 + 2)` | 5 | | `j = 5` | `max(3, 3 + 2)` | 5 | --- ### 第 3 件物品 (`weight[3] = 3`, `value[3] = 4`): - 只更新容量 `j >= 3` 的情況,公式為: ```cpp dp[3][j] = max(dp[2][j], dp[2][j-3] + 4); ``` | 容量 (`j`) | 更新公式 | 結果 | |------------|--------------------------|------| | `j = 0` | 無法放入,`dp[3][0] = 0` | 0 | | `j = 1` | 無法放入,`dp[3][1] = 2` | 2 | | `j = 2` | 無法放入,`dp[3][2] = 3` | 3 | | `j = 3` | `max(5, 0 + 4)` | 5 | | `j = 4` | `max(5, 2 + 4)` | 6 | | `j = 5` | `max(5, 3 + 4)` | 7 | --- ### 最終結果: 當考慮所有物品且背包容量為 `5` 時,`dp[4][5]` 的最大價值為 `7`。 --- ## 所以?: 1. **每次進入 `dp[i][j]` 時,我們會檢查是否能選擇當前物品**,並分別計算選擇與不選擇的情況。 2. **狀態轉移公式的核心是取兩者的最大值**,這保證了我們逐步構建最優解。 3. **通過動態規劃,我們有效地解決了重疊子問題,並利用最優子結構性質找到整體最優解**。 #### 實現範例(完整程式碼) 假設有以下物品: - 物品 1:重量 2,價值 3 - 物品 2:重量 1,價值 2 - 物品 3:重量 3,價值 4 - 物品 4:重量 2,價值 2 背包容量為 `W = 5`。 ```cpp name=knapsack_2d.cpp #include <bits/stdc++.h> using namespace std; int main(){ int W = 5; vector<int> weight = {2, 1, 3, 2}; vector<int> value = {3, 2, 4, 2}; int n = weight.size(); vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0)); for (int i = 1; i <= n; i++){ for (int j = 0; j <= W; j++){ if (j >= weight[i-1]){ dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i-1]] + value[i-1]); } else{ dp[i][j] = dp[i-1][j]; } } } cout << "最大價值: " << dp[n][W] << '\n'; } ``` --- ### 為什麼這樣做可以解決背包問題? 1. **問題拆解**: - 這樣解可以將原問題拆解成「是否選擇當前物品」的兩種情況,這樣可以逐步縮小問題規模,並用子問題的解構建出原問題的解。 2. **最優子結構**: - 每個子問題的最優解可以用來構建更大的問題的最優解。 - 例如,`dp[i][j]` 的最優解是由 `dp[i-1][j]` 和 `dp[i-1][j-weight[i]] + value[i]` 的最優解組成的。 3. **那又為何使用dp?**: - 在計算過程中,許多子問題會被重複計算。 - 這很顯然就是dp的適用情況了,用陣列把結果存下來避免過多非必要運算 --- ### 壓縮到一維 DP #### 核心邏輯 仔細思考會發現,背包用二維陣列存其實很浪費空間,陣列裡的`i`其實不太必要,我們可以嘗試刪掉後改成一維陣列,可以有效減少空間浪費。 #### 狀態定義 - **`dp[j]`**:表示背包容量為 `j` 時的最大價值。 #### 狀態轉移方程式 轉移方程式與二維 DP 相同: ```cpp= dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ``` #### 小重點:遍歷順序 - 必須從右向左更新 `dp[j]`。 - **為什麼?** - 如果從左向右更新,會導致當前面的結果影響到後面的計算,然後開始一步錯步步錯,跟我的人生一樣拿到WA --- #### 一維 DP 實現範例 ```cpp name=knapsack_1d.cpp #include <bits/stdc++.h> using namespace std; int main(){ int W = 5; vector<int> weight = {2, 1, 3, 2}; vector<int> value = {3, 2, 4, 2}; int n = weight.size(); vector<int> dp(W + 1, 0); for (int i = 0; i < n; i++){ for (int j = W; j >= weight[i]; j--){ dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); } } cout << "最大價值: " << dp[W] << '\n'; } ``` --- ### 完全背包問題 (無限次選擇物品) #### 問題描述 每種物品可以被選擇無限次,但總重量不能超過背包容量 `W`。 --- #### 狀態轉移方程式 與 01 背包的公式類似,但是每種物品可以被多次選擇,因此需要改變遍歷順序: ```cpp= dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ``` #### 小重點:遍歷順序 - 必須從左向右更新 `dp[j]`,以確保每個物品可以被重複使用。 - **為什麼?** - 如果從右向左遍歷,會錯過重複選擇同一物品的機會。 --- #### 完全背包實現範例 ```cpp name=knapsack_unbounded.cpp #include <bits/stdc++.h> using namespace std; int main(){ int W = 5; vector<int> weight = {2, 1, 3, 2}; vector<int> value = {3, 2, 4, 2}; int n = weight.size(); vector<int> dp(W + 1, 0); for (int i = 0; i < n; i++){ for (int j = weight[i]; j <= W; j++){ dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); } } cout << "最大價值: " << dp[W] << '\n'; } ``` --- ### 小結 1. **二維 DP** 是理解背包問題的基礎方法,有助於清晰地看到每層的狀態轉移。 2. **一維 DP** 是對空間的優化,適合處理大規模問題。 3. **完全背包** 是背包問題的重要變形,通過改變遍歷順序來實現無限次選擇。