揹包問題總結分析
揹包問題是個很經典的動態規劃問題,本部落格對揹包問題及其常見變種的解法和思路進行總結分析
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的總組合數。
例題:單詞拆分,組合總和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();
List<Integer> vList = new LinkedList<>();
List<Integer> wList = new LinkedList<>();
int[] dp = new int[V + 1];
for (int i = 0; i < N; ++i) {
int v = reader.nextInt();
int w = reader.nextInt();
int s = reader.nextInt();
for (int k = 1; k <= s; k *= 2) {
vList.add(k * v);
wList.add(k * w);
s -= k;
}
if (s > 0) {
vList.add(s * v);
wList.add(s * w);
}
}
for (int i = 0; i < vList.size(); ++i) {
int v = vList.get(i);
int w = wList.get(i);
for (int j = V; j >= v; --j) {
dp[j] = Math.max(dp[j], dp[j - v] + w);
}
}
System.out.println(dp[V]);
}
}
混合揹包問題
描述:物品一共有三類,第一類物品只能用一次(01揹包),第二類物品能用無限次(完全揹包),第三類物品最多用s次(多重揹包)
思路
將01揹包、完全揹包、二進位制優化的多重揹包三個演算法都結合起來,遍歷到每個物品的時候做一個判斷即可。
- 遍歷每一行輸入,即每一類物品;
- 如果是物品只能選一次,按照01揹包方法,更新dp陣列(計算每一個容量下,選或不選的最大價值);
- 如果物品可以選無數次,則按照完全揹包方法,更新dp陣列;
- 如果給定 s ,則將s按二進位制分解為log(s)份,也按照01揹包來計算。
具體的題目描述可參考混合揹包問題,程式碼如下:
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=0;i<N;++i){
int v = reader.nextInt();
int w = reader.nextInt();
int s = reader.nextInt();
if(s == -1){// 01揹包
dp_01(dp, V, v, w);
}else if(s == 0){ // 完全揹包
for(int j=v;j<=V;++j){
dp[j] = Math.max(dp[j],dp[j-v]+w);
}
}else{ // 多重揹包
for(int k=1;k<=s;s-=k,k*=2){
dp_01(dp, V, k*v, k*w);
}
if(s>0) dp_01(dp, V, s*v, s*w);
}
}
System.out.println(dp[V]);
}
private static void dp_01(int[] dp, int V, int v, int w){
for(int j=V;j>=v;--j){
dp[j] = Math.max(dp[j],dp[j-v]+w);
}
}
}
二維費用揹包問題
每個物品有兩個屬性:體積和重量。在01揹包的基礎上,多加入了一個維度“重量”,即費用從一維擴充套件到二維。
思路
將dp陣列設定為二維陣列,分別代表體積和重量兩個維度,跟01揹包相比多了一層迴圈。程式碼如下:
import java.util.Scanner;
public class Main{
public static void main(String[] args){
Scanner reader = new Scanner(System.in);
int N = reader.nextInt();//物品數量
int V = reader.nextInt();//體積上限
int M = reader.nextInt();//重量上限
int[][] dp = new int[V+1][M+1];
for(int i=0;i<N;++i){
int v = reader.nextInt();//物品體積
int m = reader.nextInt();//物品重量
int w = reader.nextInt();//物品價值
for(int j=V;j>=v;--j){
for(int k=M;k>=m;--k){
dp[j][k] = Math.max(dp[j][k],dp[j-v][k-m]+w);
}
}
}
System.out.println(dp[V][M]);
}
}
分組揹包問題
輸入物品有 N 個組,每一組中只能選擇一個物品。
思路
依然是在01揹包的基礎上做改動。每次選擇時,假設組內有S個物品,則有S+1種決策,遍歷這些決策,選取價值最大的即可。程式碼如下所示:
import java.util.Scanner;
public class Main{
public static void main(String[] args){
Scanner reader = new Scanner(System.in);
int N = reader.nextInt();
int V = reader.nextInt();
int[] dp=new int[V+1];
for(int i=0;i<N;++i){
int s = reader.nextInt();
int[] v = new int[s];
int[] w = new int[s];
for(int k=0;k<s;++k){
v[k] = reader.nextInt();
w[k] = reader.nextInt();
}
for(int j=V;j>0;--j){
for(int k=0;k<s;++k){
if(j>=v[k])
dp[j] = Math.max(dp[j],dp[j-v[k]]+w[k]);
}
}
}
System.out.println(dp[V]);
}
}