注:參考文獻《背包九講》.
零一背包問題
一:題目描述
有 N 件物品和一個容量為 V 的背包.放入第 i 件物品耗用的費用為Ci(即所占用背包的體積),得到的價值是 Wi.求將哪些物品裝入背包所得到的總價值最大.
二:基本思路
01背包是最基礎的背包問題,這道題的特點是每種物品僅有一件,可以選擇放或不放,且不要求背包必須被放滿,只要求最后的總價值最大.
用子問題定義狀態:F[i][v] 表示對於前 i 件物品,當背包容量為 v 時所能得到的價值最大值.設想,將 "前 i 件物品放入容量為 v 的背包中" 這個子問題,若只考慮第 i 件物品的策略(要么放要么不放),那么就可以轉化為一個之和前 i - 1 件物品相關的問題.如果不放第 i 件物品, 那么問題就轉化為 ”前 i - 1 件物品放入容量為 v 的背包中“,價值就是 F[i - 1][v]; 如果放第 i 件物品,那么問題就轉化為 ”前 i - 1 件物品放入剩下的容量為 v - Ci 的背包中”, 此時獲得的價值為 F[i - 1][v - Ci] + Wi.分析到這里則可得狀態轉移方程為:
F[i][v] = max( F[i - 1][v], F[i - 1][v - Ci] + Wi ).
在這里要特別的說明一下,這個方程非常重要,一定要知道這是怎么推出來的,幾乎后面的所有的背包問題都和這個方程有着密不可分的聯系.
偽代碼如下:
F[0...N][0...V] <--- 0
for i <--- 1 to N
for v <--- Ci to V
F[i][v] = max( F[i - 1][v], F[i - 1][v - Ci] + Wi );
具體代碼:
1 void _01Pack(int F[][MAXV], int N, int V, int C[], int W[]){ 2 memset(F, 0, sizeof(F)); 3 for(int i = 1; i <= N; i++) { 4 for(int v = C[i]; v <= V; v++) { 5 F[i][v] = max(F[i - 1][v], F[i - 1][v - C[i]] + W[i]); //放或者不放兩者之中選擇最優者 6 } 7 } 8 }
三:優化空間復雜度
可以清楚的看到上面算法的時間復雜度和空間復雜度均為 O(N * V), 這里時間復雜度已經不能得到優化,但是空間復雜度確可以優化到 O(V).
先看上面代碼是如何實現的.最外面一層循環,每次計算出二維數組 F[i][0...V] 的值,計算的時候 F[i][0...V] 是由它的上一層 F[i - 1][0...V] 而得到的.那么如果把這個數組換成一維的 F[v] 那么還能保留上一次的狀態嗎.答案是可以的.由於動態規划算法的無后效性,第 i + 1 件物品的選擇與否不會影響到第 i 件物品(即它的前一件物品)的選擇狀態.那么可以在上面第二次循環中按照 v <--- V...0 遞減的順序來計算 F[v], 這樣計算 F[v] 時所需要的狀態 F[v] 和 F[v - Ci] + Wi 仍然還是上一次的狀態.而計算 F[v] 之后, v 的順序是遞減的, F[v] 不會影響到 F[v'] (v' < v), 因為F[v'] 只與 F[v'](上一次的值) 和 F[v - Ci] 有關, 而 F[v] > F[v'] > F[v' - Ci]. 所以又可得狀態轉移方程.
F[v] = max( F[v], F[v - Ci] + Wi ).
偽代碼如下:
F[0...V] <--- 0
for i <--- 1 to N
for v <--- V to Ci
F[v] = max( F[v], F[v - Ci] + Wi );
具體代碼:
1 void _01Pack(int F[], int N, int V, int C[], int W[]){ 2 memset(F, 0, sizeof(F)); 3 for(int i = 1; i <= N; i++) { 4 for(int v = V; v >= C[i]; v--) { 5 F[i][v] = max(F[v], F[v - C[i]] + W[i]); 6 } 7 } 8 }
可以看到從第一個狀態轉移方程到第二個狀態轉移方程的空間優化效率還是挺大的:
F[i][v] = max( F[i - 1][v], F[i - 1][v - Ci] + Wi ). ----> F[v] = max( F[v], F[v - Ci] + Wi ).
在第二個方程中 F[v]1 = max(F[v]2, F[v - Ci] + Wi), 其實 F[v]2 就相當與方程一中的 F[i - 1][v], 對應的 F[v - Ci] + Wi 就相當於 F[i -1][v - Ci] + Wi.這一正確性是在內層循環遞減的前提下才成立的.否則, 將內層循環改為遞增, 那么 F[i][v] 其實是由 F[i][v] 和 F[i][v - Ci] 推出來的,這不符合基本思路中的探討.
之前說過由於 01背包 的特殊性,這里將 01背包 抽象化,方便之后的調用.
解決單個物品 01背包 的偽代碼:
def ZeroOnePack (F, C, W)
for v <--- V to C
F[v] = max( F[v], F[v - C] + W );
這么寫之后, 01背包總問題解決的偽代碼就可以改寫成:
F[0...V] <--- 0
for i <--- 1 to N
ZeroOnePack(F, C[i], W[i]);
具體代碼:
1 const int MAXN = 10000; 2 int N, V, C[MAXN], W[MAXN]; 3 4 void ZeroOnePack(int F[], int C, int W) { // 對於單個物品的決策 5 for(int v = V; v >= C; v--) { 6 F[v] = max(F[v], F[v- C] + W); 7 } 8 } 9 10 void solv(int F[]) { 11 memset(F, 0, sizeof(F)); 12 for(int i = 1; i <= V; i++) { 13 ZeroOnePack(F, C[i], W[i]); 14 } 15 }
四: 01背包問題的拓展 ------ 初始化的細節問題
在上述 01背包的問題中,僅問得是 “如何選取,才能使的最后的總價值最大”, 這里並沒有規定是否必須裝滿背包, 但是有的題將會給予這個附加條件, 即“在要求恰好裝滿背包的前提下, 如何選取物品, 才能使的最后的總價值最大 ”.
這兩種問法, 在代碼實現上相差無幾.如果是上述問法,要求 “恰好裝滿背包”, 那么在初始化時除了將 F[0] 賦值為 0 之外, 其他的 F[1...V] 都應該賦值為 -∞
,這樣就可以保證最后的得到的 F[V] 是一種恰好裝滿背包的最優解.如果沒有要求必須把背包裝滿,而是只希望價值盡量最大,初始化時應該將 F[0...V] 全部設置為 0.
之所以可以這么做,是因為初始化的 F[] 事實就是沒有任何物品放入背包時的合法狀態.如果要求背包恰好裝滿,那么只有容量為 0 的背包在什么也不裝且價值為 0 的情況下被裝 "恰好裝滿",其他容量的背包如果不裝物品, 那么默認的情況下都是不合法狀態,應該被賦值為 -∞, 即對於第一個物品而言, 其合法狀態只能由 F[0] 轉移得到.如果背包並非必須被裝滿,那么任何容量的背包在沒有物品可裝時都存在一個合法解,即什么都不裝,且這個解的價值為 0.所以將其全部初始化為 0 是可以的.
注:這個技巧完全可以拓展到其他背包問題中.
偽代碼:
def ZeroOnePack (F, C, W)
for v <--- V to C
F[v] = max( F[v], F[v - C] + W )
end def
def slov()
F[0] = 0, F[1...V] <--- -∞
for i <--- 1 to N
ZeroOnePack(F, C[i], W[i])
end def
具體代碼:
1 const int MAXN = 10000; 2 int N, V, C[MAXN], W[MAXN]; 3 4 void ZeroOnePack(int F[], int C, int W) { 5 for(int v = V; v >= C; v--) { 6 F[v] = max(F[v], F[v- C] + W); 7 } 8 } 9 10 void solv(int F[]) { 11 F[0] = 0; 12 for(int i = 1; i <= V; i++) F[i] = INT_MIN; // 除F[0] = 0之外, 其他全部賦值為負無窮 13 for(int i = 1; i <= V; i++) { 14 ZeroOnePack(F, C[i], W[i]); 15 } 16 }
五:一個常數級別的優化
上述偽代碼的:
for i <--- 1 to N
for v <--- V to Ci
可以優化為:
for i <--- 1 to N
for v <--- V to max( V - SUM(i...N)Ci, Ci)