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

數小錢錢的種花兔發表於2020-07-31

揹包問題總結分析

揹包問題是個很經典的動態規劃問題,本部落格對揹包問題及其常見變種的解法和思路進行總結分析

01揹包

問題介紹

有 N 件物品和一個容量是 V 的揹包。每件物品只能使用一次。

第 i 件物品的體積是 v[i],價值是 w[i]。

求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。

基本思路

定義int[][] dpdp[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]);
    }
}

相關文章