資料結構和演算法面試題系列—揹包問題總結

ssjhust發表於2018-10-02

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

0 概述

揹包問題包括0-1揹包問題、完全揹包問題、部分揹包問題等多種變種。其中,最簡單的是部分揹包問題,它可以採用貪心法來解決,而其他幾種揹包問題往往需要動態規劃來求解。本文主要來源於《揹包問題九講》,我選擇了比較簡單的0-1揹包問題和完全揹包問題進行彙總。同時給出實現程式碼,如有錯誤,請各位大蝦指正。本文程式碼在 這裡

1 部分揹包問題

部分揹包問題描述: 有 N 件物品和一個容量為 C 的揹包。第 i 件物品的重量是 w[i],價值是 v[i]。求解將哪些物品裝入揹包可使價值總和最大。注意這裡不要求把物品整個裝入,可以只裝入一個物品的部分。

解法: 部分揹包問題常採用貪心演算法來解決,先對每件物品計算其每單位重量價值 v[i]/w[i],然後從具有最大單位價值的物品開始拿,然後拿第二大價值的物品,直到裝滿揹包。按照這種貪心策略拿到的必然是價值總和最大,這個比較簡單,實現程式碼就略去了。

2 0-1揹包問題

0-1揹包問題描述

有 N 件物品和一個容量為 C 的揹包。第 i 件物品的重量是 w[i],價值是v[i]。求解將哪些物品裝入揹包可使價值總和最大。注意物品只能要麼拿要麼不拿,這也正是 0-1 的意義所在。可以把部分揹包問題看作是拿金粉,而 0-1 揹包問題則是拿金塊,一個可分,一個不可分。

分析

這是最基礎的揹包問題,特點是:每種物品僅有一件,可以選擇放或不放。 用子問題定義狀態:即 f[i][w] 表示前 i 件物品恰放入一個容量為 c 的揹包可以獲得的最大價值。則其狀態轉移方程便是:

f[i][c] = max{f[i-1][c], f[i-1][c-w[i]]+v[i]} 
複製程式碼

這個方程非常重要,基本上所有跟揹包相關的問題的方程都是由它衍生出來的。所以有必要將它詳細解釋一下:將前 i 件物品放入容量為 c 的揹包中 這個子問題,若只考慮第i件物品的策略(放或不放),那麼就可以轉化為一個只牽扯前 i-1 件物品的問題。

  • 如果不放第 i 件物品,那麼問題就轉化為 前 i-1 件物品放入容量為 v 的揹包中,價值為 f[i-1][c]
  • 如果放第i件物品,那麼問題就轉化為 前 i-1 件物品放入剩下的容量為 c-w[i] 的揹包中,此時能獲得的最大價值就是 f[i-1][c-w[i]]再加上通過放入第 i 件物品獲得的價值 v[i]。

優化空間複雜度

以上方法的時間和空間複雜度均為 O(CN),其中時間複雜度應該已經不能再優化了,但空間複雜度卻可以優化到 O(N)。 由於在計算 f[i][c] 的時候,我們只需要用到 f[i-1][c]f[i-1][c-w[i]],所以完全可以通過一維陣列儲存它們的值,這裡用到的小技巧就是需要從 c=C...0 開始反推,這樣就能保證在求 f[c] 的時候 f[c-w[i]] 儲存的是 f[i-1][c-w[i]] 的值。注意,這裡不能從 c=0...C 這樣順推,因為這樣會導致 f[c-w[i]] 的值是 f[i][c-w[i]] 而不是 f[i-1][c-w[i]。這裡可以優化下界,其實只需要從 c=C...w[i] 即可,可以避免不需要的計算。虛擬碼如下所示:

for i=0..N-1
    for c=C..w[i]
        f[c]=max{f[c],f[c-w[i]]+v[i]};
複製程式碼

最終實現程式碼如下:

int knap01(int N, int C, int w[], int v[])
{
    int *f = (int *)calloc(sizeof(int), C+1);
    int i, c;

    for (i = 0; i < N; i++) {
        for (c = C; c >= w[i]; c--) {
            f[c] = max(f[c], f[c-w[i]] + v[i]);
        }
        printf("%d: ", i+1);
        printIntArray(f, C+1); // 列印f陣列
    }
    return f[C];
}
複製程式碼

測試結果如下,即在揹包容量為 10 的時候裝第1和第2個物品(索引從0開始),總重量為 4+5=9,最大價值為 5+6=11。

引數:
w = [3, 4, 5] //物品重量列表
v = [4, 5, 6] //物品價值列表
C = 10

結果(列印陣列f,i為選擇的物品索引,c為揹包重量,值為揹包物品價值):
         
i/c 0 1 2 3 4 5 6 7 8 9 10
 0: 0 0 0 4 4 4 4 4 4 4 4 
 1: 0 0 0 4 5 5 5 9 9 9 9 
 2: 0 0 0 4 5 6 6 9 10 11 11 

KNap01 max: 11
複製程式碼

初始化的細節問題

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

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

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

3 完全揹包問題

問題描述

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

基本思路

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

f[i][c] = max{f[i-1][c-k*w[i]]+ k*w[i]| 0<=k*w[i]<=c }
複製程式碼

這跟0-1揹包問題一樣有O(CN)個狀態需要求解,但求解每個狀態的時間已經不是常數了,求解狀態 f[i][c] 的時間是 O(c/w[i]),總的複雜度可以認為是 O(CN*Σ(c/w[i])),是比較大的。實現程式碼如下:

/*
 * 完全揹包問題
 */
int knapComplete(int N, int C, int w[], int v[])
{
    int *f = (int *)calloc(sizeof(int), C+1);
    int i, c, k;
    for (i = 0; i < N; i++) {
        for (c = C; c >= 0; c--) {
            for (k = 0; k <= c/w[i]; k++) {
                f[c] = max(f[c], f[c-k*w[i]] + k*v[i]);
            }
        }
        printf("%d: ", i+1);
        printIntArray(f, C+1);
    }
    return f[C];
}
複製程式碼

使用與0-1揹包問題相同的例子,執行程式結果如下,最大價值為 13,即選取 2個重量3,1個重量4的物品,總價值最高,為 4*2 + 5 = 13

i/c: 0 1 2 3 4 5 6 7 8 9 10
0:   0 0 0 4 4 4 8 8 8 12 12 
1:   0 0 0 4 5 5 8 9 10 12 13 
2:   0 0 0 4 5 6 8 9 10 12 13 

KNapComplete max: 13
複製程式碼

轉換為0-1揹包問題

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

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

進一步優化—O(CN)解法

我們可以採用與0-1揹包問題相反的順序遍歷,從而可以得到 O(CN) 的解法,虛擬碼如下:

for i=0..N-1
    for c=w[i]..C
        f[c]=max{f[c],f[c-w[i]]+v[i]};
複製程式碼

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

/**
 * 完全揹包問題-仿01揹包解法
 */
int knapCompleteLike01(int N, int C, int w[], int v[])
{
    int *f = (int *)calloc(sizeof(int), C+1);
    int i, c;
    for (i = 0; i < N; i++) {
        for (c = w[i]; c <= C; c++) {
            f[c] = max(f[c], f[c-w[i]] + v[i]);
        }
        printf("%d: ", i+1);
        printIntArray(f, C+1);

    }
    return f[C];
}
複製程式碼

參考資料

相關文章