1. 程式人生 > >【資料結構與演算法】揹包問題總結梳理

【資料結構與演算法】揹包問題總結梳理

# 揹包問題總結分析 揹包問題是個很經典的動態規劃問題,本部落格對揹包問題及其常見變種的解法和思路進行總結分析 ## 01揹包 #### 問題介紹 有 N 件物品和一個容量是 V 的揹包。每件物品只能使用一次。 第 i 件物品的體積是 v[i],價值是 w[i]。 求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。 #### 基本思路 定義`int[][] dp`,`dp[i][j]` 表示當容量為j時,對於前i個物品而言的最優放置策略(即最大價值)。對於物品 i 而言,只有放與不放,這兩種選擇。因此可以得到 *狀態轉移方程*: - 放物品 i :`dp[i][j] = dp[i - 1][j - v[i]] + w[i]`; - 不放物品 i :`dp[i][j] = dp[i - 1][j]`。 **直觀方法:** ``` // v和w陣列長度都是 N + 1,v[0]和w[0]都是0 private static void backpack1(int N, int V, int[] v, int[] w) { int[][] dp = new int[N + 1][V + 1]; for (int i = 1; i <= N; ++i) { for (int j = 1; j <= V; ++j) { dp[i][j] = dp[i - 1][j]; if (j >= v[i]) { dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - v[i]] + w[i]); } } } System.out.println(dp[N][V]); } ``` 這種方法空間不是最優的。觀察程式碼發現,dp[i]只跟dp[i-1]有關,所以可以將二維降成一維。 **優化方法:** ``` private static void backpack2(int N, int V, int[] v, int[] w) { int[] dp = new int[V + 1]; for (int i = 1; i <= N; ++i) { for (int j = V; j >= v[i]; --j) { dp[j] = Math.max(dp[j], dp[j - v[i]] + w[i]); } } System.out.println(dp[V]); } ``` **注意:** 內層迴圈不能順序列舉。`dp[j - v[i]] `實際上相當於 `dp[i - 1][j - v[i]] `,而不是`dp[i][j - v[i]] `,如果順序列舉, dp[i] 的 j - v[i] 的位置已經被計算過,覆蓋了。所以應該通過倒序列舉來規避這個問題。 **兩個要點:** - 若 dp[] 全部初始化為0,計算結果的 dp[V] 就是答案; - 若 dp[0] 初始化為0,其它元素全部初始化為負無窮,則最後遍歷dp[]得到最大值為答案。 **解釋如下:** dp[V] 一定是最大值。同樣遍歷了所有物品情況下,容量 V 大於 V - X ,最後得到的價值 dp[V] 必然大於 dp[V - X]。 dp陣列初始化值全為 0 ,則允許dp[V]從任何一個初始項轉化而來,並不一定是 dp[0]。最終結果如果從 dp[k] 轉化而來,說明有 k 體積的空餘。但是,如果我們更改一下dp陣列初始化的情況: 將 dp[0][0] 取0 ,dp[0][1] ~ dp[0][V]全部取負無窮,同樣計算,得到的結果 dp[N][1] ~ dp[N][V] 中最後一位數不一定是最大值。迴圈求MAX,可排除掉從“負無窮”初始值轉化而來的結果。假設得到的結果 dp[N][Y] ,則該值為體積總和恰好等於 Y 的最大價值。 ## 完全揹包 #### 問題介紹 與01揹包的區別:所有物品可以無限件使用。其它都一樣。 #### 基本思路 跟01揹包一樣,一定需要一個`for (int i = 1; i <= N; ++i)`外層迴圈,列舉每個物品。內部迴圈相較於01揹包需要發生呢個變化。需要列舉 v[i]~V 容量下,放置 1~k 個物品i,最大價值的情況,並記錄進 dp 陣列。因此直觀思路是再套兩層迴圈,如下所示。 ``` for (int j = V; j >= v[i]; --j) { for(int k=1;k*v[i]<=j;++k){ dp[j] = Math.max(dp[j], dp[j - k * v[i]] + w[i]); } } ``` 實際上, k 的那一層迴圈是可以省略的。如下所示 **完全揹包解法:** ``` private static void completeBackpack(int N, int V, int[] v, int[] w) { int[] dp = new int[V + 1]; for (int i = 1; i <= N; ++i) { for (int j = v[i]; j <= V; ++j) { dp[j] = Math.max(dp[j], dp[j - v[i]] + w[i]); } } System.out.println(dp[V]); } ``` 如上述程式碼所示,內層遍歷 j 採用正向列舉即可節省一層迴圈。前文提到過,在01揹包裡,這樣列舉是錯誤的,因為dp[i][] 會把 dp[i-1][] 覆蓋掉。但在本問題中可以巧妙利用其“覆蓋”的特性,縮減時間複雜度。覆蓋的過程,實際上就是原有的 dp 值加一個 w[i] 。對於每一個 dp[j] 而言,需要考慮是在 dp[j - v[i]] 加一個物品 i 的價值,還是不加物品 i 繼續沿用 dp[j] 。`for (int j = v[i]; j <= V; ++j)`這樣迴圈,最多可以加 (V - v[i] + 1)次物品,由於物品 i 體積大於等於 1,所以物品 i 的新增次數不可能超過 (V - v[i])/ 1 次,所以一定會遇到最優的情況。 ## 涉及順序的完全揹包問題 即放入揹包中的物品,順序不同的序列被視為不同的組合,求滿足target的總組合數。 例題:[單詞拆分](https://leetcode-cn.com/problems/word-break/description/),[組合總和IV](https://leetcode-cn.com/problems/combination-sum-iv/) #### 思路 將前面完全揹包問題解決方案中兩層迴圈倒過來即可解決該問題,即把對容量的遍歷放在外層,物品的迴圈放在內層。前文的迴圈方式相當於去除了重複的組合。 *換種思路來理解*:假設物品1~ n,對於每一個容量K而言(K<=target),要從前一步抵達K的位置,有1~ n種可能。假設某物品體積為v,對於容量(K-v)而言也同樣是遍歷過n個物品,所以應該在內層迴圈遍歷n個物品,這樣一定枚舉了所有排列情況。 示例程式碼如下: ``` class Solution { public int combinationSum4(int[] nums, int target) { int[] dp = new int[target+1]; dp[0] = 1; for(int j=1;j<=target;++j){ for(int item : nums){ if(j>=item) dp[j] += dp[j-item]; } } return dp[target]; } } ``` ## 多重揹包 #### 問題介紹 在完全揹包基礎上,對每個物品限定數量。 #### 普通解法 ``` import java.util.Scanner; public class Main{ public static void main(String[] args) throws Exception{ Scanner reader = new Scanner(System.in); int N = reader.nextInt(); int V = reader.nextInt(); int[] dp = new int[V + 1]; for(int i=1;i<=N;++i){ int v = reader.nextInt(); int w = reader.nextInt(); int s = reader.nextInt(); for(int j=V;j>=v;--j){ for(int k=1;k<=s&&k*v<=j;++k){ dp[j] = Math.max(dp[j],dp[j-k*v]+k*w); } } } System.out.println(dp[V]); } } ``` #### 二進位制優化方法 實際上,當s非常大時,將物品劃分為s個物品,轉化為01揹包問題來計算,這樣時間複雜度非常巨大。有一個技巧,可以簡化該問題:對於任意一個數S,分成數量不同的若干個數,這些數選或不選可以拼成小於S的任意一個數。 如何劃分這個S便是問題的關鍵。試想,對於一個數 7 它的二進位制形式是 111 ,每一位上取 1 或者取 0 正好可以描述“選物品”或者“不選物品”兩個行為,因此可以想到將 7 劃分為 1 + 2 + 4。對於二進位制位全為 1 的數,可以使用上述方法進行劃分。如果不是這樣的數,譬如說10,該如何劃分呢? 實際上可以劃分為 1 + 2 + 4 + 3。要證明此猜想,只需要證明7~10之間的數一定能通過1、2、4、3這四個數選或不選來得到即可。由於 1、2、4 一定能得到5、6、7,因此 +3 一定能得到 8、9、10,所以得證。 二進位制優化方法的程式碼如下所示: ``` import java.util.Scanner; import java.util.LinkedList; import java.util.List; public class Main{ public static void main(String[] args) throws Exception { Scanner reader = new Scanner(System.in); int N = reader.nextInt(); int V = reader.nextInt();