1. 程式人生 > >揹包九講

揹包九講

0-1揹包問題

有n個重量和價值分別為 wi,viwi,vi 的物品。從這些物品中挑選出總重量不超過 WW 的物品, 求所有挑選方案中價值總和的最大值。

樣例:

n=4 (w,v)=(2,3),(1,2),(3,4),(2,2) W=5n=4 (w,v)=(2,3),(1,2),(3,4),(2,2) W=5

n個物品,每種物品只有兩種選擇,放進揹包,或者不放進揹包。n個物品對應的,最大的所有可能的總數為 2n2n 種不同的放法。 最樸素的,我們可以列舉所有可能的放法,找到最大值。

Rec(n,W)Rec(n,W) 表示剩餘容量為WW 的揹包,還有1~n1~n 這nn 個物品可以選擇, 所能取得的最大的揹包價值。 還剩ii 個物品可以選, 揹包容量剩餘jj 的時候,可以分為兩種情況: 1. 第ii 個物品不選,只考慮前i−1i−1 個物品。則 Rec(i,j)=Rec(i−1,j)Rec(i,j)=Rec(i−1,j) ,此時揹包容量j不變 2. 第ii 個物品被選擇, 接下來要考慮的就是, 如何在前i−1i−1 個物品中選擇物品放入容量為j−w[i]j−w[i] 的揹包, 則 Rec(i,j)=Rec(i−1,j−w[i])+v[i]Rec(i,j)=Rec(i−1,j−w[i])+v[i]

總和兩種可能,取較大值。可以給出遞推式:

Rec(i,j)=max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])Rec(i,j)=max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])

還需要考慮一些細節: 1. 終止條件, i=0i=0 , 無物品可選 Rec(0,j)=0Rec(0,j)=0 。 0個物品可以選擇,放入容量為j的揹包, 得到的最大價值只能為0 2. j < w[i],揹包剩餘容量不足以放下第i個物品,Rec(i,j)=Rec(i−1,j)Rec(i,j)=Rec(i−1,j)

綜上,得到遞推式:

Rec(i,j)=⎧⎩⎨0Rec(i−1,j)max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])i=0j<w[i]otherRec(i,j)={0i=0Rec(i−1,j)j<w[i]max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])other

遞迴方法:

#include <iostream>
#define MAXN 10000
using namespace std;

int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int Rec(int i, int j) {
    int res;
    if (i == 0) {
        // 終止條件, 無物品可選 Rec(0, j) = 0
        // 0個物品可以選擇,放入容量為j的揹包, 得到的最大價值只能為0
        res = 0;
    }
    else if (j < w[i]) {
        // 揹包剩餘容量不足以放下第i個物品
        res = Rec(i-1, j);
    }
    else {
        // 抉擇,第i個物品選或者不選,都試一下,取較大值
        res = max(Rec(i-1, j), Rec(i-1, j-w[i]) + v[i]);
    }
    return res;
}

int main() {
    cout << Rec(n, W) << endl;
    return 0;
}

大概畫個遞迴過程:

                                        Rec(4, 5)
                               /                        \
                      Rec(3, 3)                             Rec(3, 5)
                    /        \                            /            \
            Rec(2, 0)     Rec(2, 3)              Rec(2, 2)             Rec(2, 5)  
            /             /     \                /     \                /       \
      Rec(1, 0)     Rec(1, 2)  Rec(1, 3)   Rec(1, 1)    Rec(1, 2)   Rec(1, 4)    Rec(1, 5)
       /          /    \         /    \         /       /    \      /      \      /     \
   (0,0)     (0,0)   (0,2)   (0,1)    (0,3)  (0,1)  (0,0)   (0,2) (0,2)   (0,4) (0,3)  (0,5)

從上圖可以看出,我們枚舉了所有可能的情況,即對於每個物品i,物品i選還是不選,我們都考慮了一遍。遞迴的層數是n+1層,最後一層是判定終止。

再來重申一下 Rec(i,j)Rec(i,j) 的含義,表示有編號為1~ ii 的這前 ii 個物品可以選擇,放入容量為jj 的揹包,所能達到的最大價值。終止條件是i=0i=0 ,時,無物品可選,Rec(0,j)=0Rec(0,j)=0 ,題目要求的是 Rec(n,W)Rec(n,W) 。 在求解Rec(n,W)Rec(n,W) 的過程中,有些情況可能會被重複計算。比如上圖Rec(1,2)Rec(1,2) 在第四行計算了2次。這種重複計算是隨著nn 的增大,指數級增長的,所以n,Wn,W 較大時,問題就是不可解的。時間和空間複雜度太高 ,為O(2n)O(2n) 。

記憶化搜尋

我們可以利用遞迴求解過程中的重複計算。如果Rec(i,j)Rec(i,j) 已經計算過,則記錄下來,下次需要的時候直接拿來用即可。

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int dp[MAXN][MAXN]; //記錄搜尋過的結果
int W = 5, n = 4;

int Rec(int i, int j) {
    //Rec(i, j)計算過,直接拿來用
    if (dp[i][j] != -1) return dp[i][j];

    int res;
    if (i == 0) {
        res = 0;
    }
    else if (j < w[i]) {
        res = Rec(i-1, j);
    }
    else {
        res = max(Rec(i-1, j), Rec(i-1, j-w[i]) + v[i]);
    }
    return dp[i][j] = res; //記錄
}

int main() {
    memset(dp, -1, sizeof(dp));
    cout << Rec(n, W) << endl;
    return 0;
}

對於任意的i,j,Rec(i,j)i,j,Rec(i,j) 只會計算一次,所以複雜度為O(nW)O(nW)

動態規劃

上面的遞迴過程,是把大問題分解成小問題,最後由小問題的解合併成大問題的解。 動態規劃的方法,就是先把小問題的解計算好,存在表裡,等計算大問題的時候,需要用到小問題的解,就過來查一下表。 由遞推式:

Rec(i,j)=⎧⎩⎨0Rec(i−1,j)max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])(i=0)(j<w[i])(other)Rec(i,j)={0(i=0)Rec(i−1,j)(j<w[i])max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])(other)

轉化為動態規劃的寫法:

dp[i][j]=⎧⎩⎨0dp[i−1][j]max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])(i=0)(j<w[i])(other)dp[i][j]={0(i=0)dp[i−1][j](j<w[i])max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])(other)

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) { //i從1開始,因為i=0的值已經確定為0
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
  cout << solve(n, W) << endl;
  return 0;
}

dp 優化空間複雜度

輸出一下上面的二維陣列 dp[][]dp[][] :

0 0 0 0 0 0 0 0 3 3 3 3 0 2 3 5 5 5 0 2 3 5 6 7 0 2 3 5 6 7

我們是填dpdp 表的順序是從上到下,從左到右,從遞迴式可以看出,dp[i][j]dp[i][j] 是由 dp[i−1][j]dp[i−1][j] 和 dp[i−1][j−w[i]]dp[i−1][j−w[i]] 的值推來的。填寫第ii 行的值,只依賴於上一行,第i−1i−1 行的值。下次填寫第i+1i+1 行的時候,只會用到第ii 行的值,第i−1i−1 行的值以後都不會再用到了。 所以我們可以從這裡入手優化空間複雜度。沒有必要存下整個二維陣列,我們只需要存2行,然後不斷更新這2行就可以了。

實際上,dp[i][j]dp[i][j] 的值只依賴於第i−1i−1 行的 dp[i−1][0...j]dp[i−1][0...j] 這前 j+1j+1 個元素, 與dp[i−1][j+1...W]dp[i−1][j+1...W] 的值無關。 所以,我們可以只存1行,就能完成整個dpdp 過程。用dp[0...W]dp[0...W] 儲存當前行,更新dp[0...W]dp[0...W] 的時候,我們按照 j=W...0j=W...0 的遞減順序計算dp[j]dp[j] ,這樣可以保證計算dp[j]dp[j] 時用到的dp[j]和dp[j−w[i]]dp[j]和dp[j−w[i]] 的值和原本的二維陣列中的第i−1i−1 行的值是相等的。更新完dp[j]dp[j] 的值後,對dp[0...j−1]dp[0...j−1] 的值不會產生影響。

虛擬碼:

for i = 1 to n
    for j = W to w[i]
        dp[j] = max(dp[j], dp[j-w[i]] + v[i])

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 10000
using namespace std;

int dp[MAXN];
int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) { // i從1開始,遞增
        for (int j = W; j >= 0; j--) { // j按遞減順序填表
            if (j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
            }
        }
    }
    return dp[W];
}

int main() {
    cout << solve(n, W) << endl;
    return 0;
}

初始化的細節問題

我們看到的求解最優解的揹包問題中,事實和桑有兩種不太相同的問法。 1. 要求”揹包恰好裝滿“ 時的最優解 2. 不要求揹包一定要被裝滿時的最優解

我們上面所討論的就是第2種, 不要求揹包一定要被裝滿時的最優解。 一種區別這兩種問法的實現方法是在初始化的時候有所不不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了 dp[0]dp[0] 為0, 其他dp[1...W]均設為−∞dp[1...W]均設為−∞ ,這樣就可以保證最終得到 dp[W]dp[W] 是一種恰好裝滿揹包的最優解 如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將dp[0...W]dp[0...W] 全部設為0。

這是為什麼呢?可以這樣理解:初始化的dpdp 陣列事實上就是在沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量為0的揹包可以在什麼也不裝的狀態下被 “恰好裝滿” ,此時揹包價值為0。其他容量的揹包均沒有合法的解,屬於未定義的狀態,所以都應該被賦值為 −∞−∞ 。當前的合法解,一定是從之前的合法狀態推得的

如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解 “什麼也不裝”,這個解的價值為0,所以初始化時狀態的值也就全部為0了。

完全揹包問題

有nn 種重量和價值分別為wi,viwi,vi 的物品。從這些物品中挑選總重量不超過WW 的物品,求出挑選物品價值總和的最大值。在這裡,每種物品可以挑選任意多件。

在0-1揹包問題中,每種物品只有不選和選兩種可能。在完全揹包問題中,每個物品可以選0, 1, … ⌊W/wi⌋⌊W/wi⌋ 個。 令dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。令dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。 那麼遞推關係為:

dp[i][j]={0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋i=01≤i≤ndp[i][j]={0i=0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋1≤i≤n

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            for (int k = 0; k <= j/w[i]; k++) {
                dp[i][j] = max(dp[i][j], dp[i-1][j-k*w[i]] + k*v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

最壞情況下複雜度為 O(nW2)O(nW2) , 不夠好。這裡面還是有很多多餘的重複計算。第三層迴圈裡,對於每種物品,都計算 ⌊j/wi⌋⌊j/wi⌋ 次,這是不必要的。動態規劃就是要利用已經計算過的小規模的問題的解,來求解更大規模的問題的解。讓我們來探尋一下,還有什麼我們沒有利用的重複計算。

再重申一下,dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。 再來看一下上面的遞推公式:

dp[i][j]={0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋i=01≤i≤ndp[i][j]={0i=0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋1≤i≤n

這裡的k可分為兩種情況,k=0和k≠0,也就是第i種物品不選,或者至少選1個。這裡的k可分為兩種情況,k=0和k≠0,也就是第i種物品不選,或者至少選1個。 * k=0時,即不選擇第i種物品,dp[i][j]=dp[k=0時,即不選擇第i種物品,dp[i][j]=dp[ i−1i−1 ][j],][j], * k≠0時,即至少選一個第i種物品,dp[i][j]=dp[k≠0時,即至少選一個第i種物品,dp[i][j]=dp[ ii ][j−w[i]]+v[i]][j−w[i]]+v[i]

注意上面紅色標識的 i−1i−1 和 ii 。 k=0時,比較容易理解,k≠0時,先強行往揹包裡塞一個第i種物品,然後把問題轉化成更小規模的問題。k=0時,比較容易理解,k≠0時,先強行往揹包裡塞一個第i種物品,然後把問題轉化成更小規模的問題。 i和j都是按遞增順序迴圈的,所以求解dp[i][j]時,dp[i][j−w[i]]的值是已經求過了的,可以直接拿來用。i和j都是按遞增順序迴圈的,所以求解dp[i][j]時,dp[i][j−w[i]]的值是已經求過了的,可以直接拿來用。

綜上,可以得出遞推公式:

dp[i][j]=max(dp[i−1][j],dp[i][j−w[i]]+v[i])(1)dp[i][j]=max(dp[i−1][j],dp[i][j−w[i]]+v[i])(1)

更正式地推導一下:

dp[i][j]=max(dp[i−1][j−k×w[i]]+k×v[i]),0≤k≤⌊j/wi⌋ =max(dp[i−1][j],max(dp[i−1][j−k×w[i]]+k×v[i])),1≤k≤⌊j/wi⌋dp[i][j]=max(dp[i−1][j−k×w[i]]+k×v[i]),0≤k≤⌊j/wi⌋ =max(dp[i−1][j],max(dp[i−1][j−k×w[i]]+k×v[i])),1≤k≤⌊j/wi⌋ =max(dp[i−1][j],=max(dp[i−1][j], max(dp[i−1][(j−w[i])−(k−1)×w[i]]+(k−1)×v[i])max(dp[i−1][(j−w[i])−(k−1)×w[i]]+(k−1)×v[i]) +v[i]),+v[i]), 0≤(k−1)≤⌊j/wi⌋−10≤(k−1)≤⌊j/wi⌋−1 =max(dp[i−1][j],=max(dp[i−1][j], dp[i][j−w[i]]dp[i][j−w[i]] +v[i])+v[i])

剛好和上面的(1)式一樣。

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {// 不能塞的時候也不能硬塞,要注意一下
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

類似0-1揹包問題, 完全揹包問題也可以在空間上進行優化。 我們發現dp[i][j]的值只依賴於第i行和第i−1行的值,可以只用一個一維陣列來存所有需要的值我們發現dp[i][j]的值只依賴於第i行和第i−1行的值,可以只用一個一維陣列來存所有需要的值

虛擬碼:

for i = 1 to n
    for j = 0 to W
        dp[j] = max(dp[j], dp[j-w[i]] + v[i])

我們可以發現,完全揹包的虛擬碼和0-1揹包的虛擬碼非常像,只是第二層迴圈j的順序不同,0-1揹包是逆序迴圈,完全揹包是正序迴圈。0-1揹包問題為何逆序,之前已經講得非常清楚了。完全揹包問題第二層的j正序迴圈,因為dp[i][j]dp[i][j] 需要用到dp[i][j−w[i]]dp[i][j−w[i]] , 只用一維陣列儲存的話,按j正序迴圈,就能保證計算dp[j]dp[j] 時,舊的dp[j]dp[j] 的值就等於dp[i−1][j]dp[i−1][j] 的值,dp[j−w[i]]dp[j−w[i]] 已經計算過,且對應的就是二維陣列中dp[i][j−w[i]]dp[i][j−w[i]] 的值。

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
            }
        }
    }
    return dp[W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

多重揹包問題

有nn 種物品和一個容量為WW 的揹包。第ii 種物品有mimi 個,每件重量為wiwi , 價值為vivi ,求從這nn 種物品中挑選重量總和不超過WW 的物品的最大價值。

轉化成0-1揹包問題

多重揹包問題,最簡單的解法,就是轉化成0-1揹包問題。第ii 個物品有 mimi 個, 等價於有mimi 個相同的物品。但直接拆分成 mimi 件物品並不是最好的方法。我們可以利用二進位制來拆分。例如 m1=13=20+21+22+6m1=13=20+21+22+6 ,我們將第一種物品共13件,拆分成 20,21,22,620,21,22,6 這四件, 13以內的任何數字都可以通過這四種數字組合而成。

下面給出一個二進位制拆分的多重揹包模板:

const int N = 100, W = 100000;
int cost[N], weight[N], number[N];
int dp[W + 1];

int knapsack(int n, int w)
{
    for (int i = 0; i < n; ++i)
    {
        int num = min(number[i], w / weight[i]);
        for (int k = 1; num > 0; k*=2)
        {
            if (k > num) k = num;
            num -= k;
            for (int j = w; j >= weight[i] * k; --j)
                dp[j] = max(dp[j], dp[j - weight[i] * k] + cost[i] * k);
        }
    }
    return  dp[w];
}

時間複雜度為 O(nlogM×W)O(nlogM×W) , 實際應用已經足夠好了。

單調佇列優化

多重揹包問題的遞迴式為:

dp[i][j]={0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤m[i]i=01≤k≤ndp[i][j]={0i=0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤m[i]1≤k≤n

根據遞推式,很容易能寫出三重迴圈版本的多重揹包程式碼。但這明顯不是我們想要的。是否有方法能像完全揹包問題一樣,能有O(nW)O(nW) 的解法呢?

是的,有。 通過巧妙的構造,我們就可以用單調佇列來優化多重揹包問題, 使得複雜度降為 O(nW)O(nW)

多重揹包 單調佇列模板:

0-1揹包問題

有n個重量和價值分別為 wi,viwi,vi 的物品。從這些物品中挑選出總重量不超過 WW 的物品, 求所有挑選方案中價值總和的最大值。

樣例:

n=4 (w,v)=(2,3),(1,2),(3,4),(2,2) W=5n=4 (w,v)=(2,3),(1,2),(3,4),(2,2) W=5

n個物品,每種物品只有兩種選擇,放進揹包,或者不放進揹包。n個物品對應的,最大的所有可能的總數為 2n2n 種不同的放法。 最樸素的,我們可以列舉所有可能的放法,找到最大值。

Rec(n,W)Rec(n,W) 表示剩餘容量為WW 的揹包,還有1~n1~n 這nn 個物品可以選擇, 所能取得的最大的揹包價值。 還剩ii 個物品可以選, 揹包容量剩餘jj 的時候,可以分為兩種情況: 1. 第ii 個物品不選,只考慮前i−1i−1 個物品。則 Rec(i,j)=Rec(i−1,j)Rec(i,j)=Rec(i−1,j) ,此時揹包容量j不變 2. 第ii 個物品被選擇, 接下來要考慮的就是, 如何在前i−1i−1 個物品中選擇物品放入容量為j−w[i]j−w[i] 的揹包, 則 Rec(i,j)=Rec(i−1,j−w[i])+v[i]Rec(i,j)=Rec(i−1,j−w[i])+v[i]

總和兩種可能,取較大值。可以給出遞推式:

Rec(i,j)=max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])Rec(i,j)=max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])

還需要考慮一些細節: 1. 終止條件, i=0i=0 , 無物品可選 Rec(0,j)=0Rec(0,j)=0 。 0個物品可以選擇,放入容量為j的揹包, 得到的最大價值只能為0 2. j < w[i],揹包剩餘容量不足以放下第i個物品,Rec(i,j)=Rec(i−1,j)Rec(i,j)=Rec(i−1,j)

綜上,得到遞推式:

Rec(i,j)=⎧⎩⎨0Rec(i−1,j)max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])i=0j<w[i]otherRec(i,j)={0i=0Rec(i−1,j)j<w[i]max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])other

遞迴方法:

#include <iostream>
#define MAXN 10000
using namespace std;

int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int Rec(int i, int j) {
    int res;
    if (i == 0) {
        // 終止條件, 無物品可選 Rec(0, j) = 0
        // 0個物品可以選擇,放入容量為j的揹包, 得到的最大價值只能為0
        res = 0;
    }
    else if (j < w[i]) {
        // 揹包剩餘容量不足以放下第i個物品
        res = Rec(i-1, j);
    }
    else {
        // 抉擇,第i個物品選或者不選,都試一下,取較大值
        res = max(Rec(i-1, j), Rec(i-1, j-w[i]) + v[i]);
    }
    return res;
}

int main() {
    cout << Rec(n, W) << endl;
    return 0;
}

大概畫個遞迴過程:

                                        Rec(4, 5)
                               /                        \
                      Rec(3, 3)                             Rec(3, 5)
                    /        \                            /            \
            Rec(2, 0)     Rec(2, 3)              Rec(2, 2)             Rec(2, 5)  
            /             /     \                /     \                /       \
      Rec(1, 0)     Rec(1, 2)  Rec(1, 3)   Rec(1, 1)    Rec(1, 2)   Rec(1, 4)    Rec(1, 5)
       /          /    \         /    \         /       /    \      /      \      /     \
   (0,0)     (0,0)   (0,2)   (0,1)    (0,3)  (0,1)  (0,0)   (0,2) (0,2)   (0,4) (0,3)  (0,5)

從上圖可以看出,我們枚舉了所有可能的情況,即對於每個物品i,物品i選還是不選,我們都考慮了一遍。遞迴的層數是n+1層,最後一層是判定終止。

再來重申一下 Rec(i,j)Rec(i,j) 的含義,表示有編號為1~ ii 的這前 ii 個物品可以選擇,放入容量為jj 的揹包,所能達到的最大價值。終止條件是i=0i=0 ,時,無物品可選,Rec(0,j)=0Rec(0,j)=0 ,題目要求的是 Rec(n,W)Rec(n,W) 。 在求解Rec(n,W)Rec(n,W) 的過程中,有些情況可能會被重複計算。比如上圖Rec(1,2)Rec(1,2) 在第四行計算了2次。這種重複計算是隨著nn 的增大,指數級增長的,所以n,Wn,W 較大時,問題就是不可解的。時間和空間複雜度太高 ,為O(2n)O(2n) 。

記憶化搜尋

我們可以利用遞迴求解過程中的重複計算。如果Rec(i,j)Rec(i,j) 已經計算過,則記錄下來,下次需要的時候直接拿來用即可。

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int dp[MAXN][MAXN]; //記錄搜尋過的結果
int W = 5, n = 4;

int Rec(int i, int j) {
    //Rec(i, j)計算過,直接拿來用
    if (dp[i][j] != -1) return dp[i][j];

    int res;
    if (i == 0) {
        res = 0;
    }
    else if (j < w[i]) {
        res = Rec(i-1, j);
    }
    else {
        res = max(Rec(i-1, j), Rec(i-1, j-w[i]) + v[i]);
    }
    return dp[i][j] = res; //記錄
}

int main() {
    memset(dp, -1, sizeof(dp));
    cout << Rec(n, W) << endl;
    return 0;
}

對於任意的i,j,Rec(i,j)i,j,Rec(i,j) 只會計算一次,所以複雜度為O(nW)O(nW)

動態規劃

上面的遞迴過程,是把大問題分解成小問題,最後由小問題的解合併成大問題的解。 動態規劃的方法,就是先把小問題的解計算好,存在表裡,等計算大問題的時候,需要用到小問題的解,就過來查一下表。 由遞推式:

Rec(i,j)=⎧⎩⎨0Rec(i−1,j)max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])(i=0)(j<w[i])(other)Rec(i,j)={0(i=0)Rec(i−1,j)(j<w[i])max(Rec(i−1,j),Rec(i−1,j−w[i])+v[i])(other)

轉化為動態規劃的寫法:

dp[i][j]=⎧⎩⎨0dp[i−1][j]max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])(i=0)(j<w[i])(other)dp[i][j]={0(i=0)dp[i−1][j](j<w[i])max(dp[i−1][j],dp[i−1][j−w[i]]+v[i])(other)

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) { //i從1開始,因為i=0的值已經確定為0
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
  cout << solve(n, W) << endl;
  return 0;
}

dp 優化空間複雜度

輸出一下上面的二維陣列 dp[][]dp[][] :

0 0 0 0 0 0 0 0 3 3 3 3 0 2 3 5 5 5 0 2 3 5 6 7 0 2 3 5 6 7

我們是填dpdp 表的順序是從上到下,從左到右,從遞迴式可以看出,dp[i][j]dp[i][j] 是由 dp[i−1][j]dp[i−1][j] 和 dp[i−1][j−w[i]]dp[i−1][j−w[i]] 的值推來的。填寫第ii 行的值,只依賴於上一行,第i−1i−1 行的值。下次填寫第i+1i+1 行的時候,只會用到第ii 行的值,第i−1i−1 行的值以後都不會再用到了。 所以我們可以從這裡入手優化空間複雜度。沒有必要存下整個二維陣列,我們只需要存2行,然後不斷更新這2行就可以了。

實際上,dp[i][j]dp[i][j] 的值只依賴於第i−1i−1 行的 dp[i−1][0...j]dp[i−1][0...j] 這前 j+1j+1 個元素, 與dp[i−1][j+1...W]dp[i−1][j+1...W] 的值無關。 所以,我們可以只存1行,就能完成整個dpdp 過程。用dp[0...W]dp[0...W] 儲存當前行,更新dp[0...W]dp[0...W] 的時候,我們按照 j=W...0j=W...0 的遞減順序計算dp[j]dp[j] ,這樣可以保證計算dp[j]dp[j] 時用到的dp[j]和dp[j−w[i]]dp[j]和dp[j−w[i]] 的值和原本的二維陣列中的第i−1i−1 行的值是相等的。更新完dp[j]dp[j] 的值後,對dp[0...j−1]dp[0...j−1] 的值不會產生影響。

虛擬碼:

for i = 1 to n
    for j = W to w[i]
        dp[j] = max(dp[j], dp[j-w[i]] + v[i])

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 10000
using namespace std;

int dp[MAXN];
int w[MAXN] = {0, 2, 1, 3, 2};
int v[MAXN] = {0, 3, 2, 4, 2};
int W = 5, n = 4;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) { // i從1開始,遞增
        for (int j = W; j >= 0; j--) { // j按遞減順序填表
            if (j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
            }
        }
    }
    return dp[W];
}

int main() {
    cout << solve(n, W) << endl;
    return 0;
}

初始化的細節問題

我們看到的求解最優解的揹包問題中,事實和桑有兩種不太相同的問法。 1. 要求”揹包恰好裝滿“ 時的最優解 2. 不要求揹包一定要被裝滿時的最優解

我們上面所討論的就是第2種, 不要求揹包一定要被裝滿時的最優解。 一種區別這兩種問法的實現方法是在初始化的時候有所不不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了 dp[0]dp[0] 為0, 其他dp[1...W]均設為−∞dp[1...W]均設為−∞ ,這樣就可以保證最終得到 dp[W]dp[W] 是一種恰好裝滿揹包的最優解 如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將dp[0...W]dp[0...W] 全部設為0。

這是為什麼呢?可以這樣理解:初始化的dpdp 陣列事實上就是在沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量為0的揹包可以在什麼也不裝的狀態下被 “恰好裝滿” ,此時揹包價值為0。其他容量的揹包均沒有合法的解,屬於未定義的狀態,所以都應該被賦值為 −∞−∞ 。當前的合法解,一定是從之前的合法狀態推得的

如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解 “什麼也不裝”,這個解的價值為0,所以初始化時狀態的值也就全部為0了。

完全揹包問題

有nn 種重量和價值分別為wi,viwi,vi 的物品。從這些物品中挑選總重量不超過WW 的物品,求出挑選物品價值總和的最大值。在這裡,每種物品可以挑選任意多件。

在0-1揹包問題中,每種物品只有不選和選兩種可能。在完全揹包問題中,每個物品可以選0, 1, … ⌊W/wi⌋⌊W/wi⌋ 個。 令dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。令dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。 那麼遞推關係為:

dp[i][j]={0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋i=01≤i≤ndp[i][j]={0i=0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋1≤i≤n

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            for (int k = 0; k <= j/w[i]; k++) {
                dp[i][j] = max(dp[i][j], dp[i-1][j-k*w[i]] + k*v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

最壞情況下複雜度為 O(nW2)O(nW2) , 不夠好。這裡面還是有很多多餘的重複計算。第三層迴圈裡,對於每種物品,都計算 ⌊j/wi⌋⌊j/wi⌋ 次,這是不必要的。動態規劃就是要利用已經計算過的小規模的問題的解,來求解更大規模的問題的解。讓我們來探尋一下,還有什麼我們沒有利用的重複計算。

再重申一下,dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。dp[i][j]:=從前i種物品中挑選總重量不超過j的物品的最大總價值。 再來看一下上面的遞推公式:

dp[i][j]={0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋i=01≤i≤ndp[i][j]={0i=0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤⌊j/wi⌋1≤i≤n

這裡的k可分為兩種情況,k=0和k≠0,也就是第i種物品不選,或者至少選1個。這裡的k可分為兩種情況,k=0和k≠0,也就是第i種物品不選,或者至少選1個。 * k=0時,即不選擇第i種物品,dp[i][j]=dp[k=0時,即不選擇第i種物品,dp[i][j]=dp[ i−1i−1 ][j],][j], * k≠0時,即至少選一個第i種物品,dp[i][j]=dp[k≠0時,即至少選一個第i種物品,dp[i][j]=dp[ ii ][j−w[i]]+v[i]][j−w[i]]+v[i]

注意上面紅色標識的 i−1i−1 和 ii 。 k=0時,比較容易理解,k≠0時,先強行往揹包裡塞一個第i種物品,然後把問題轉化成更小規模的問題。k=0時,比較容易理解,k≠0時,先強行往揹包裡塞一個第i種物品,然後把問題轉化成更小規模的問題。 i和j都是按遞增順序迴圈的,所以求解dp[i][j]時,dp[i][j−w[i]]的值是已經求過了的,可以直接拿來用。i和j都是按遞增順序迴圈的,所以求解dp[i][j]時,dp[i][j−w[i]]的值是已經求過了的,可以直接拿來用。

綜上,可以得出遞推公式:

dp[i][j]=max(dp[i−1][j],dp[i][j−w[i]]+v[i])(1)dp[i][j]=max(dp[i−1][j],dp[i][j−w[i]]+v[i])(1)

更正式地推導一下:

dp[i][j]=max(dp[i−1][j−k×w[i]]+k×v[i]),0≤k≤⌊j/wi⌋ =max(dp[i−1][j],max(dp[i−1][j−k×w[i]]+k×v[i])),1≤k≤⌊j/wi⌋dp[i][j]=max(dp[i−1][j−k×w[i]]+k×v[i]),0≤k≤⌊j/wi⌋ =max(dp[i−1][j],max(dp[i−1][j−k×w[i]]+k×v[i])),1≤k≤⌊j/wi⌋ =max(dp[i−1][j],=max(dp[i−1][j], max(dp[i−1][(j−w[i])−(k−1)×w[i]]+(k−1)×v[i])max(dp[i−1][(j−w[i])−(k−1)×w[i]]+(k−1)×v[i]) +v[i]),+v[i]), 0≤(k−1)≤⌊j/wi⌋−10≤(k−1)≤⌊j/wi⌋−1 =max(dp[i−1][j],=max(dp[i−1][j], dp[i][j−w[i]]dp[i][j−w[i]] +v[i])+v[i])

剛好和上面的(1)式一樣。

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN][MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {// 不能塞的時候也不能硬塞,要注意一下
                dp[i][j] = dp[i-1][j];
            }
            else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i]);
            }
        }
    }
    return dp[n][W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

類似0-1揹包問題, 完全揹包問題也可以在空間上進行優化。 我們發現dp[i][j]的值只依賴於第i行和第i−1行的值,可以只用一個一維陣列來存所有需要的值我們發現dp[i][j]的值只依賴於第i行和第i−1行的值,可以只用一個一維陣列來存所有需要的值

虛擬碼:

for i = 1 to n
    for j = 0 to W
        dp[j] = max(dp[j], dp[j-w[i]] + v[i])

我們可以發現,完全揹包的虛擬碼和0-1揹包的虛擬碼非常像,只是第二層迴圈j的順序不同,0-1揹包是逆序迴圈,完全揹包是正序迴圈。0-1揹包問題為何逆序,之前已經講得非常清楚了。完全揹包問題第二層的j正序迴圈,因為dp[i][j]dp[i][j] 需要用到dp[i][j−w[i]]dp[i][j−w[i]] , 只用一維陣列儲存的話,按j正序迴圈,就能保證計算dp[j]dp[j] 時,舊的dp[j]dp[j] 的值就等於dp[i−1][j]dp[i−1][j] 的值,dp[j−w[i]]dp[j−w[i]] 已經計算過,且對應的就是二維陣列中dp[i][j−w[i]]dp[i][j−w[i]] 的值。

程式碼:

#include <iostream>
#include <cstring>
#define MAXN 1000
using namespace std;

int dp[MAXN];
int w[MAXN] = {0, 3, 4, 2};
int v[MAXN] = {0, 4, 5, 3};
int W = 7, n = 3;

int solve(int n, int W) {
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= W; j++) {
            if (j < w[i]) {
                dp[j] = dp[j];
            }
            else {
                dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
            }
        }
    }
    return dp[W];
}

int main() {
    cout << solve(n, W) << endl; // 10
    return 0;
}

多重揹包問題

有nn 種物品和一個容量為WW 的揹包。第ii 種物品有mimi 個,每件重量為wiwi , 價值為vivi ,求從這nn 種物品中挑選重量總和不超過WW 的物品的最大價值。

轉化成0-1揹包問題

多重揹包問題,最簡單的解法,就是轉化成0-1揹包問題。第ii 個物品有 mimi 個, 等價於有mimi 個相同的物品。但直接拆分成 mimi 件物品並不是最好的方法。我們可以利用二進位制來拆分。例如 m1=13=20+21+22+6m1=13=20+21+22+6 ,我們將第一種物品共13件,拆分成 20,21,22,620,21,22,6 這四件, 13以內的任何數字都可以通過這四種數字組合而成。

下面給出一個二進位制拆分的多重揹包模板:

const int N = 100, W = 100000;
int cost[N], weight[N], number[N];
int dp[W + 1];

int knapsack(int n, int w)
{
    for (int i = 0; i < n; ++i)
    {
        int num = min(number[i], w / weight[i]);
        for (int k = 1; num > 0; k*=2)
        {
            if (k > num) k = num;
            num -= k;
            for (int j = w; j >= weight[i] * k; --j)
                dp[j] = max(dp[j], dp[j - weight[i] * k] + cost[i] * k);
        }
    }
    return  dp[w];
}

時間複雜度為 O(nlogM×W)O(nlogM×W) , 實際應用已經足夠好了。

單調佇列優化

多重揹包問題的遞迴式為:

dp[i][j]={0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤m[i]i=01≤k≤ndp[i][j]={0i=0max(dp[i−1][j−k×w[i]]+k×v[i])0≤k≤m[i]1≤k≤n

根據遞推式,很容易能寫出三重迴圈版本的多重揹包程式碼。但這明顯不是我們想要的。是否有方法能像完全揹包問題一樣,能有O(nW)O(nW) 的解法呢?

是的,有。 通過巧妙的構造,我們就可以用單調佇列來優化多重揹包問題, 使得複雜度降為 O(nW)O(nW)

多重揹包 單調佇列模板:

#include <iostream>
#include <deque>
#include <algorithm>

using namespace std;

struct Pack
{
    int sum, cost;
    Pack(int s, int c) : sum (s), cost(c) {}
};

const int Maxv = 1001;
deque <Pack> Q;
int N, V, F[Maxv];

int main()
{
    cin >> N >> V;
    for (int i = 1, p, w, c; i <= N; i ++)
    {
        cin >> p >> w >> c; p = min(p, V / w);
        for (int j = 0; j < w; j ++)
        {
            Q.clear();
            for (int k = 0; k <= (V - j) / w; k ++)
            {
                int y = F[k * w + j] - k * c;
                while (Q.size() && Q.back().cost <= y) Q.pop_back();
                Q.push_back(Pack(k, y));
                if (Q.front().sum < k - c) Q.pop_front();
                F[k * w + j] = Q.front().cost + k * c;
            }
        }
    }
    cout << F[V] << endl;
    return 0;

揹包問題是一個經典的動態規劃模型。它既簡單形象容易理解,又在某種程度上能夠揭示動態規劃的本質,故不少教材都把它作為動態規劃部分的第一道例題,我也將它放在我的寫作計劃的第一部分。

讀本文最重要的是思考。因為我的語言和寫作方式向來不以易於理解為長,思路也偶有跳躍的地方,後面更有需要大量思考才能理解的比較抽象的內容。更重要的是:不大量思考,絕對不可能學好動態規劃這一資訊學奧賽中最精緻的部分。

目錄

第一講 01揹包問題

這是最基本的揹包問題,每個物品最多隻能放一次。

第二講 完全揹包問題

第二個基本的揹包問題模型,每種物品可以放無限多次。

第三講 多重揹包問題

每種物品有一個固定的次數上限。

第四講 混合三種揹包問題

將前面三種簡單的問題疊加成較複雜的問題。

第五講 二維費用的揹包問題

一個簡單的常見擴充套件。

第六講 分組的揹包問題

一種題目型別,也是一個有用的模型。後兩節的基礎。

第七講 有依賴的揹包問題

另一種給物品的選取加上限制的方法。

第八講 泛化物品

我自己關於揹包問題的思考成果,有一點抽象。

第九講 揹包問題問法的變化

試圖觸類旁通、舉一反三。

附:USACO中的揹包問題

給出 USACO Training 上可供練習的揹包問題列表,及簡單的解答。

 P01: 01揹包問題

題目

有N件物品和一個容量為V的揹包。第i件物品的費用是c[i],價值是w[i]。求解將哪些物品裝入揹包可使價值總和最大。

基本思路

這是最基礎的揹包問題,特點是:每種物品僅有一件,可以選擇放或不放。

用子問題定義狀態:即f[i][v]表示前i件物品恰放入一個容量為v的揹包可以獲得的最大價值。則其狀態轉移方程便是:

f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]+w[i]}

這個方程非常重要,基本上所有跟揹包相關的問題的方程都是由它衍生出來的。所以有必要將它詳細解釋一下:“將前i件物品放入容量為v的揹包中”這個子問題,若只考慮第i件物品的策略(放或不放),那麼就可以轉化為一個只牽扯前i-1件物品的問題。如果不放第i件物品,那麼問題就轉化為“前i-1件物品放入容量為v的揹包中”,價值為f[i-1][v];如果放第i件物品,那麼問題就轉化為“前i-1件物品放入剩下的容量為v-c[i]的揹包中”,此時能獲得的最大價值就是f[i-1][v-c[i]]再加上通過放入第i件物品獲得的價值w[i]。

優化空間複雜度

以上方法的時間和空間複雜度均為O(N*V),其中時間複雜度基本已經不能再優化了,但空間複雜度卻可以優化到O(V)。

先考慮上面講的基本思路如何實現,肯定是有一個主迴圈i=1..N,每次算出來二維陣列f[i][0..V]的所有值。那麼,如果只用一個數組f[0..V],能不能保證第i次迴圈結束後f[v]中表示的就是我們定義的狀態f[i][v]呢?f[i][v]是由f[i-1][v]和f[i-1][v-c[i]]兩個子問題遞推而來,能否保證在推f[i][v]時(也即在第i次主迴圈中推f[v]時)能夠得到f[i-1][v]和f[i-1][v-c[i]]的值呢?事實上,這要求在每次主迴圈中我們以v=V..0的順序推f[v],這樣才能保證推f[v]時f[v-c[i]]儲存的是狀態f[i-1][v-c[i]]的值。虛擬碼如下:

for i=1..N

    for v=V..0

        f[v]=max{f[v],f[v-c[i]]+w[i]};

其中的f[v]=max{f[v],f[v-c[i]]}一句恰就相當於我們的轉移方程f[i][v]=max{f[i-1][v],f[i-1][v-c[i]]},因為現在的f[v-c[i]]就相當於原來的f[i-1][v-c[i]]。如果將v的迴圈順序從上面的逆序改成順序的話,那麼則成了f[i][v]由f[i][v-c[i]]推知,與本題意不符,但它卻是另一個重要的揹包問題P02最簡捷的解決方案,故學習只用一維陣列解01揹包問題是十分必要的。

事實上,使用一維陣列解01揹包的程式在後面會被多次用到,所以這裡抽象出一個處理一件01揹包中的物品過程,以後的程式碼中直接呼叫不加說明。

過程ZeroOnePack,表示處理一件01揹包中的物品,兩個引數cost、weight分別表明這件物品的費用和價值。

procedure ZeroOnePack(cost,weight)

    for v=V..cost

        f[v]=max{f[v],f[v-cost]+weight}

注意這個過程裡的處理與前面給出的虛擬碼有所不同。前面的示例程式寫成v=V..0是為了在程式中體現每個狀態都按照方程求解了,避免不必要的思維複雜度。而這裡既然已經抽象成看作黑箱的過程了,就可以加入優化。費用為cost的物品不會影響狀態f[0..cost-1],這是顯然的。

有了這個過程以後,01揹包問題的虛擬碼就可以這樣寫:

for i=1..N

    ZeroOnePack(c[i],w[i]);

初始化的細節問題

我們看到的求最優解的揹包問題題目中,事實上有兩種不太相同的問法。有的題目要求“恰好裝滿揹包”時的最優解,有的題目則並沒有要求必須把揹包裝滿。一種區別這兩種問法的實現方法是在初始化的時候有所不同。

如果是第一種問法,要求恰好裝滿揹包,那麼在初始化時除了f[0]為0其它f[1..V]均設為-∞,這樣就可以保證最終得到的f[N]是一種恰好裝滿揹包的最優解。

如果並沒有要求必須把揹包裝滿,而是隻希望價格儘量大,初始化時應該將f[0..V]全部設為0。

為什麼呢?可以這樣理解:初始化的f陣列事實上就是在沒有任何物品可以放入揹包時的合法狀態。如果要求揹包恰好裝滿,那麼此時只有容量為0的揹包可能被價值為0的nothing“恰好裝滿”,其它容量的揹包均沒有合法的解,屬於未定義的狀態,它們的值就都應該是-∞了。如果揹包並非必須被裝滿,那麼任何容量的揹包都有一個合法解“什麼都不裝”,這個解的價值為0,所以初始時狀態的值也就全部為0了。

這個小技巧完全可以推廣到其它型別的揹包問題,後面也就不再對進行狀態轉移之前的初始化進行講解。

小結

01揹包問題是最基本的揹包問題,它包含了揹包問題中設計狀態、方程的最基本思想,另外,別的型別的揹包問題往往也可以轉換成01揹包問題求解。故一定要仔細體會上面基本思路的得出方法,狀態轉移方程的意義,以及最後怎樣優化的空間複雜度。

P02: 完全揹包問題

題目

有N種物品和一個容量為V的揹包,每種物品都有無限件可用。第i種物品的費用是c[i],價值是w[i]。求解將哪些物品裝入揹包可使這些物品的費用總和不超過揹包容量,且價值總和最大。

基本思路

這個問題非常類似於01揹包問題,所不同的是每種物品有無限件。也就是從每種物品的角度考慮,與它相關的策略已並非取或不取兩種,而是有取0件、取1件、取2件……等很多種。如果仍然按照解01揹包時的思路,令f[i][v]表示前i種物品恰放入一個容量為v的揹包的最大權值。仍然可以按照每種物品不同的策略寫出狀態轉移方程,像這樣:

f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k*c[i]<=v}

這跟01揹包問題一樣有O(N*V)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態f[i][v]的時間是O(v/c[i]),總的複雜度是超過O(VN)的。

將01揹包問題的基本思路加以改進,得到了這樣一個清晰的方法。這說明01揹包問題的方程的確是很重要,可以推及其它型別的揹包問題。但我們還是試圖改進這個複雜度。

一個簡單有效的優化

完全揹包問題有一個很簡單有效的優化,是這樣的:若兩件物品i、j滿足c[i]<=c[j]且w[i]>=w[j],則將物品j去掉,不用考慮。這個優化的正確性顯然:任何情況下都可將價值小費用高得j換成物美價廉的i,得到至少不會更差的方案。對於隨機生成的資料,這個方法往往會大大減少物品的件數,從而加快速度。然而這個並不能改善最壞情況的複雜度,因為有可能特別設計的資料可以一件物品也去不掉。

這個優化可以簡單的O(N^2)地實現,一般都可以承受。另外,針對揹包問題而言,比較不錯的一種方法是:首先將費用大於V的物品去掉,然後使用類似計數排序的做法,計算出費用相同的物品中價值最高的是哪個,可以O(V+N)地完成這個優化。這個不太重要的過程就不給出虛擬碼了,希望你能獨立思考寫出虛擬碼或程式。

轉化為01揹包問題求解

既然01揹包問題是最基本的揹包問題,那麼我們可以考慮把完全揹包問題轉化為01揹包問題來解。最簡單的想法是,考慮到第i種物品最多選V/c[i]件,於是可以把第i種物品轉化為V/c[i]件費用及價值均不變的物品,然後求解這個01揹包問題。這樣完全沒有改進基本思路的時間複雜度,但這畢竟給了我們將完全揹包問題轉化為01揹包問題的思路:將一種物品拆成多件物品。

更高效的轉化方法是:把第i種物品拆成費用為c[i]*2^k、價值為w[i]*2^k的若干件物品,其中k滿足c[i]*2^k<=V。這是二進位制的思想,因為不管最優策略選幾件第i種物品,總可以表示成若干個2^k件物品的和。這樣把每種物品拆成O(log(V/c[i]))件物品,是一個很大的改進。

但我們有更優的O(VN)的演算法。

O(VN)的演算法

這個演算法使用一維陣列,先看虛擬碼:

for i=1..N

    for v=0..V

        f[v]=max{f[v],f[v-cost]+weight}

你會發現,這個虛擬碼與P01的虛擬碼只有v的迴圈次序不同而已。為什麼這樣一改就可行呢?首先想想為什麼P01中要按照v=V..0的逆序來迴圈。這是因為要保證第i次迴圈中的狀態f[i][v]是由狀態f[i-1][v-c[i]]遞推而來。換句話說,這正是為了保證每件物品只選一次,保證在考慮“選入第i件物品”這件策略時,依據的是一個絕無已經選入第i件物品的子結果f[i-1][v-c[i]]。而現在完全揹包的特點恰是每種物品可選無限件,所以在考慮“加選一件第i種物品”這種策略時,卻正需要一個可能已選入第i種物品的子結果f[i][v-c[i]],所以就可以並且必須採用v=0..V的順序迴圈。這就是這個簡單的程式為何成立的道理。

這個演算法也可以以另外的思路得出。例如,基本思路中的狀態轉移方程可以等價地變形成這種形式:

f[i][v]=max{f[i-1][v],f[i][v-c[i]]+w[i]}

將這個方程用一維陣列實現,便得到了上面的虛擬碼。

最後抽象出處理一件完全揹包類物品的過程虛擬碼,以後會用到:

procedure CompletePack(cost,weight)

    for v=cost..V

        f[v]=max{f[v],f[v-c[i]]+w[i]}

總結

完全揹包問題也是一個相當基礎的揹包問題,它有兩個狀態轉移方程,分別在“基本思路”以及“O(VN)的演算法“的小節中給出。希望你能夠對這兩個狀態轉移方程都仔細地體會,不僅記住,也要弄明白它們是怎麼得出來的,最好能夠自己想一種得到這些方程的方法。事實上,對每一道動態規劃題目都思考其方程的意義以及如何得來,是加深對動態規劃的理解、提高動態規劃功力的好方法。

P03: 多重揹包問題

題目

有N種物品和一個容量為V的揹包。第i種物品最多有n[i]件可用,每件費用是c[i],價值是w[i]。求解將哪些物品裝入揹包可使這些物品的費用總和不超過揹包容量,且價值總和最大。

基本演算法

這題目和完全揹包問題很類似。基本的方程只需將完全揹包問題的方程略微一改即可,因為對於第i種物品有n[i]+1種策略:取0件,取1件……取n[i]件。令f[i][v]表示前i種物品恰放入一個容量為v的揹包的最大權值,則有狀態轉移方程:

f[i][v]=max{f[i-1][v-k*c[i]]+k*w[i]|0<=k<=n[i]}

複雜度是O(V*Σn[i])。

轉化為01揹包問題

另一種好想好寫的基本方法是轉化為01揹包求解:把第i種物品換成n[i]件01揹包中的物品,則得到了物品數為Σn[i]的01揹包問題,直接求解,複雜度仍然是O(V*Σn[i])。

但是我們期望將它轉化為01揹包問題之後能夠像完全揹包一樣降低複雜度。仍然考慮二進位制的思想,我們考慮把第i種物品換成若干件物品,使得原問題中第i種物品可取的每種策略——取0..n[i]件——均能等價於取若干件代換以後的物品。另外,取超過n[i]件的策略必不能出現。

方法是:將第i種物品分成若干件物品,其中每件物品有一個係數,這件物品的費用和價值均是原來的費用和價值乘以這個係數。使這些係數分別為1,2,4,...,2^(k-1),n[i]-2^k+1,且k是滿足n[i]-2^k+1>0的最大整數。例如,如果n[i]為13,就將這種物品分成係數分別為1,2,4,6的四件物品。

分成的這幾件物品的係數和為n[i],表明不可能取多於n[i]件的第i種物品。另外這種方法也能保證對於0..n[i]間的每一個整數,均可以用若干個係數的和表示,這個證明可以分0..2^k-1和2^k..n[i]兩段來分別討論得出,並不難,希望你自己思考嘗試一下。

這樣就將第i種物品分成了O(log n[i])種物品,將原問題轉化為了複雜度為O(V*Σlog n[i])的01揹包問題,是很大的改進。

下面給出O(log amount)時間處理一件多重揹包中物品的過程,其中amount表示物品的數量:

procedure MultiplePack(cost,weight,amount)

    if cost*amount>=V

        CompletePack(cost,weight)

        return

    integer k=1

    while k<num

        ZeroOnePack(k*cost,k*weight)

        amount=amount-k

        k=k*2

    ZeroOnePack(amount*cost,amount*weight)

希望你仔細體會這個虛擬碼,如果不太理解的話,不妨翻譯成程式程式碼以後,單步執行幾次,或者頭腦加紙筆模擬一下,也許就會慢慢理解了。

O(VN)的演算法

多重揹包問題同樣有O(VN)的演算法。這個演算法基於基本演算法的狀態轉移方程,但應用單調佇列的方法使每個狀態的值可以以均攤O(1)的時間求解。由於用單調佇列優化的DP已超出了NOIP的範圍,故本文不再展開講解。我最初瞭解到這個方法是在樓天成的“男人八題”幻燈片上。

小結

這裡我們看到了將一個演算法的複雜度由O(V*Σn[i])改進到O(V*Σlog n[i])的過程,還知道了存在應用超出NOIP範圍的知識的O(VN)演算法。希望你特別注意“拆分物品”的思想和方法,自己證明一下它的正確性,並將完整的程式程式碼寫出來。

P04: 混合三種揹包問題

問題

如果將P01P02P03混合起來。也就是說,有的物品只可以取一次(01揹包),有的物品可以取無限次(完全揹包),有的物品可以取的次數有一個上限(多重揹包)。應該怎麼求解呢?

01揹包與完全揹包的混合

考慮到在P01P02中給出的虛擬碼只有一處不同,故如果只有兩類物品:一類物品只能取一次,另一類物品可以取無限次,那麼只需在對每個物品應用轉移方程時,根據物品的類別選用順序或逆序的迴圈即可,複雜度是O(VN)。虛擬碼如下:

for i=1..N

    if 第i件物品是01揹包

        for v=V..0

            f[v]=max{f[v],f[v-c[i]]+w[i]};

    else if 第i件物品是完全揹包

        for v=0..V

            f[v]=max{f[v],f[v-c[i]]+w[i]};

再加上多重揹包

如果再加上有的物品最多可以取有限次,那麼原則上也可以給出O(VN)的解法:遇到多重揹包型別的物品用單調佇列解即可。但如果不考慮超過NOIP範圍的演算法的話,用P03中將每個這類物品分成O(log n[i])個01揹包的物品的方法也已經很優了。

當然,更清晰的寫法是呼叫我們前面給出的三個相關過程。

for i=1..N

    if 第i件物品是01揹包

        ZeroOnePack(c[i],w[i])

    else if 第i件物品是完全揹包

        CompletePack(c[i],w[i])

    else if 第i件物品是多重揹包

        MultiplePack(c[i],w[i],n[i])

在最初寫出這三個過程的時候,可能完全沒有想到它們會在這裡混合應用。我想這體現了程式設計中抽象的威力。如果你一直就是以這種“抽象出過程”的方式寫每一類揹包問題的,也非常清楚它們的實現中細微的不同,那麼在遇到混合三種揹包問題的題目時,一定能很快想到上面簡潔的解法,對嗎?

小結

有人說,困難的題目都是由簡單的題目疊加而來的。這句話是否公理暫且存之不論,但它在本講中已經得到了充分的體現。本來01揹包、完全揹包、多重揹包都不是什麼難題,但將它們簡單地組合起來以後就得到了這樣一道一定能嚇倒不少人的題目。但只要基礎紮實,領會三種基本揹包問題的思想,就可以做到把困難的題目拆分成簡單的題目來解決。

P05: 二維費用的揹包問題

問題

二維費用的揹包問題是指:對於每件物品,具有兩種不同的費用;選擇這件物品必須同時付出這兩種代價;對於每種代價都有一個可付出的最大值(揹包容量)。問怎樣選擇物品可以得到最大的價值。設這兩種代價分別為代價1和代價2,第i件物品所需的兩種代價分別為a[i]和b[i]。兩種代價可付出的最大值(兩種揹包容量)分別為V和U。物品的價值為w[i]。

演算法

費用加了一維,只需狀態也加一維即可。設f[i][v][u]表示前i件物品付出兩種代價分別為v和u時可獲得的最大價值。狀態轉移方程就是:

f[i][v][u]=max{f[i-1][v][u],f[i-1][v-a[i]][u-b[i]]+w[i]}

如前述方法,可以只使用二維的陣列:當每件物品只可以取一次時變數v和u採用逆序的迴圈,當物品有如完全揹包問題時採用順序的迴圈。當物品有如多重揹包問題時拆分物品。這裡就不再給出虛擬碼了,相信有了前面的基礎,你能夠自己實現出這個問題的程式。

物品總個數的限制

有時,“二維費用”的條件是以這樣一種隱含的方式給出的:最多隻能取M件物品。這事實上相當於每件物品多了一種“件數”的費用,每個物品的件數費用均為1,可以付出的最大件數費用為M。換句話說,設f[v][m]表示付出費用v、最多選m件時可得到的最大價值,則根據物品的型別(01、完全、多重)用不同的方法迴圈更新,最後在f[0..V][0..M]範圍內尋找答案。

小結

當發現由熟悉的動態規劃題目變形得來的題目時,在原來的狀態中加一緯以滿足新的限制是一種比較通用的方法。希望你能從本講中初步體會到這種方法。

P06: 分組的揹包問題

問題

有N件物品和一個容量為V的揹包。第i件物品的費用是c[i],價值是w[i]。這些物品被劃分為若干組,每組中的物品互相沖突,最多選一件。求解將哪些物品裝入揹包可使這些物品的費用總和不超過揹包容量,且價值總和最大。

演算法

這個問題變成了每組物品有若干種策略:是選擇本組的某一件,還是一件都不選。也就是說設f[k][v]表示前k組物品花費費用v能取得的最大權值,則有:

f[k][v]=max{f[k-1][v],f[k-1][v-c[i]]+w[i]|物品i屬於第k組}

使用一維陣列的虛擬碼如下:

for 所有的組k

    for v=V..0

        for 所有的i屬於組k

            f[v]=max{f[v],f[v-c[i]]+w[i]}

注意這裡的三層迴圈的順序,甚至在本文的beta版中我自己都寫錯了。“for v=V..0”這一層迴圈必須在“for 所有的i屬於組k”之外。這樣才能保證每一組內的物品最多隻有一個會被新增到揹包中。

另外,顯然可以對每組內的物品應用P02中“一個簡單有效的優化”。

小結

分組的揹包問題將彼此互斥的若干物品稱為一個組,這建立了一個很好的模型。不少揹包問題的變形都可以轉化為分組的揹包問題(例如P07),由分組的揹包問題進一步可定義“泛化物品”的概念,十分有利於解題。

P07: 有依賴的揹包問題

簡化的問題

這種揹包問題的物品間存在某種“依賴”的關係。也就是說,i依賴於j,表示若選物品i,則必須選物品j。為了簡化起見,我們先設沒有某個物品既依賴於別的物品,又被別的物品所依賴;另外,沒有某件物品同時依賴多件物品。

演算法

這個問題由NOIP2006金明的預算方案一題擴充套件而來。遵從該題的提法,將不依賴於別的物品的物品稱為“主件”,依賴於某主件的物品稱為“附件”。由這個問題的簡化條件可知所有的物品由若干主件和依賴於每個主件的一個附件集合組成。

按照揹包問題的一般思路,僅考慮一個主件和它的附件集合。可是,可用的策略非常多,包括:一個也不選,僅選擇主件,選擇主件後再選擇一個附件,選擇主件後再選擇兩個附件……無法用狀態轉移方程來表示如此多的策略。(事實上,設有n個附件,則策略有2^n+1個,為指數級。)

考慮到所有這些策略都是互斥的(也就是說,你只能選擇一種策略),所以一個主件和它的附件集合實際上對應於P06中的一個物品組,每個選擇了主件又選擇了若干個附件的策略對應於這個物品組中的一個物品,其費用和價值都是這個策略中的物品的值的和。但僅僅是這一步轉化並不能給出一個好的演算法,因為物品組中的物品還是像原問題的策略一樣多。

再考慮P06中的一句話: 可以對每組中的物品應用P02中“一個簡單有效的優化”。 這提示我們,對於一個物品組中的物品,所有費用相同的物品只留一個價值最大的,不影響結果。所以,我們可以對主件i的“附件集合”先進行一次01揹包,得到費用依次為0..V-c[i]所有這些值時相應的最大價值f'[0..V-c[i]]。那麼這個主件及它的附件集合相當於V-c[i]+1個物品的物品組,其中費用為c[i]+k的物品的價值為f'[k]+w[i]。也就是說原來指數級的策略中有很多策略都是冗餘的,通過一次01揹包後,將主件i轉化為V-c[i]+1個物品的物品組,就可以直接應用P06的演算法解決問題了。

較一般的問題

更一般的問題是:依賴關係以圖論中“森林”的形式給出(森林即多叉樹的集合),也就是說,主件的附件仍然可以具有自己的附件集合,限制只是每個物品最多隻依賴於一個物品(只有一個主件)且不出現迴圈依賴。

解決這個問題仍然可以用將每個主件及其附件集合轉化為物品組的方式。唯一不同的是,由於附件可能還有附件,就不能將每個附件都看作一個一般的01揹包中的物品了。若這個附件也有附件集合,則它必定要被先轉化為物品組,然後用分組的揹包問題解出主件及其附件集合所對應的附件組中各個費用的附件所對