說明
在上一篇中,我們對01背包問題進行了比較深入的研究,這一篇里,我們來聊聊另一個背包問題:完全背包。
完全背包
有N種物品和一個容量為T的背包,每種物品都就可以選擇任意多個,第i種物品的價值為P[i],體積為V[i],求解:選哪些物品放入背包,可卡因使得這些物品的價值最大,並且體積總和不超過背包容量。
跟01背包一樣,完全背包也是一個很經典的動態規划問題,不同的地方在於01背包問題中,每件物品最多選擇一件,而在完全背包問題中,只要背包裝得下,每件物品可以選擇任意多件。從每件物品的角度來說,與之相關的策略已經不再是選或者不選了,而是有取0件、取1件、取2件...直到取⌊T/Vi⌋(向下取整)件。
貪心算法
看到可以選擇任意多件,你也許會想,那還不容易,選性價比最高的就好了。
於是開啟貪婪模式,把每種物品的價格除以體積來算出它們各自的性價比,然后只選擇性價比最高的物品放入背包中。
嗯,聽起來好像沒什么毛病,但仍舊有一個問題,那就是同一種物品雖然可以選擇任意多件,但仍舊只能以件為單位,也就是說單個物品是無法拆分的,不能選擇半件,只能多選一件或者少選一件。這樣就造成了一個問題,往往無法用性價比最高的物品來裝滿整個背包,比如背包空間為10,性價比最高的物品占用空間為7,那么剩下的空間該如何填充呢?
你當然會想到用性價比第二高的物品填充,如果仍舊無法填滿,那就依次用第三、第四性價比物品來填充。
聽起來似乎可行,但我只需要舉一個反例便能證明這個策略行不通。
想要舉反例很簡單,比如只有兩個物品:物品A:價值5,體積5,物品B:價值8:體積7,背包容量為10,物品B的性價比顯然要比物品A高,那么用貪心算法必然會選擇放入一個物品B,此時,剩余的空間已無法裝下A或者B,所以得到的最高價值為8,而實際上,選擇放入兩個物品A即可得到更高的價值10。所以這里貪心算法並不適用。
遞歸法
像上一篇中的那樣,我們只需要找到遞推關系式,就很容易使用遞歸解法來求解了。
用ks(i,t)表示前i種物品放入一個容量為t的背包獲得的最大價值,那么對於第i種物品,我們有k種選擇,0 <= k * V[i] <= t,即可以選擇0、1、2...k個第i種物品,所以遞推表達式為:
ks(i,t) = max{ks(i-1, t - V[i] * k) + P[i] * k}; (0 <= k * V[i] <= t)
同時,ks(0,t)=0;ks(i,0)=0;
使用上面的栗子,我們可以先用遞歸來求解:
public static class Knapsack {
private static int[] P={0,5,8};
private static int[] V={0,5,7};
private static int T = 10;
@Test
public void soleve1() {
int result = ks(P.length - 1,10);
System.out.println("最大價值為:" + result);
}
private int ks(int i, int t){
int result = 0;
if (i == 0 || t == 0){
// 初始條件
result = 0;
} else if(V[i] > t){
// 裝不下該珠寶
result = ks(i-1, t);
} else {
// 可以裝下
// 取k個物品i,取其中使得總價值最大的k
for (int k = 0; k * V[i] <= t; k++){
int tmp2 = ks(i-1, t - V[i] * k) + P[i] * k;
if (tmp2 > result){
result = tmp2;
}
}
}
return result;
}
}
同樣,這里的數組P和V分別添加了一個元素0,是為了減少越界判斷而做的簡單處理,運行如下:
最大價值為:11
如果你對比一下01背包問題中的遞歸解法,就會發現唯一的區別便是這里多了一層循環,因為01背包中,對於第i個物品只有選和不選兩種情況,只需要從這兩種選擇中選出最優的即可,而完全背包問題則需要在k種選擇中選出最優解,這便是最內層循環在做的事情。
for (int k = 0; k * V[i] <= t; k++){
// 選取k個第i件商品的最優價值為tmp2
int tmp2 = ks(i-1, t - V[i] * k) + P[i] * k;
if (tmp2 > result){
// 從中拿出最大的值即為最優解
result = tmp2;
}
}
最優化原理和無后效性
那這個問題可以不可以像01背包問題一樣使用動態規划來求解呢?來證明一下即可。
首先,先用反證法證明最優化原理:
假設完全背包的解為F(n1,n2,...,nN)(n1,n2 分別代表第1、第2件物品的選取數量),完全背包的子問題為,將前i種物品放入容量為t的背包並取得最大價值,其對應的解為:F(n1,n2,...,ni),假設該解不是子問題的最優解,即存在另一組解F(m1,m2,...,mi),使得F(m1,m2,...,mi) > F(n1,n2,...,ni),那么F(m1,m2,...,mi,...,nN) 必然大於 F(n1,n2,...,nN),因此 F(n1,n2,...,nN) 不是原問題的最優解,與原假設不符,所以F(n1,n2,...,ni)必然是子問題的最優解。
再來看看無后效性:
對於子問題的任意解,都不會影響后續子問題的解,也就是說,前i種物品如何選擇,只要最終的剩余背包空間不變,就不會影響后面物品的選擇。即滿足無后效性。
因此,完全背包問題也可以使用動態規划來解決。
動態規划
既然知道了可以使用動態規划求解,接下來就是要找到這個問題的狀態轉移方程。
其實前面的遞推法中,已經找到了遞推關系式,它便已經是我們需要的狀態轉移方程。
自上而下記憶法
ks(i,t) = max{ks(i-1, t - V[i] * k) + P[i] * k}; (0 <= k * V[i] <= t)
public static class Knapsack {
private static int[] P={0,5,8};
private static int[] V={0,5,7};
private static int T = 10;
private Integer[][] results = new Integer[P.length + 1][T + 1];
@Test
public void solve2() {
int result = ks2(P.length - 1,10);
System.out.println("最大價值為:" + result);
}
private int ks2(int i, int t){
// 如果該結果已經被計算,那么直接返回
if (results[i][t] != null) return results[i][t];
int result = 0;
if (i == 0 || t == 0){
// 初始條件
result = 0;
} else if(V[i] > t){
// 裝不下該珠寶
result = ks2(i-1, t);
} else {
// 可以裝下
// 取k個物品,取其中使得價值最大的
for (int k = 0; k * V[i] <= t; k++){
int tmp2 = ks2(i-1, t - V[i] * k) + P[i] * k;
if (tmp2 > result){
result = tmp2;
}
}
}
results[i][t] = result;
return result;
}
}
找出遞歸解法后,動態規划的解法其實就很簡單了,只是多使用了一個二維數組來存儲中間的解。
自下而上填表法
最后,還可以使用填表法來解決,此時需要將數組P和V額外添加的元素0去掉。
為了方便理解,還是再畫一個圖吧:
對於第i種物品,我們可以選擇的目標其實是從上一層中的某幾個位置挑選出價值最高的一個。
這里當t=10時,因為最多只能放得下1個i2物品,所以只需要將兩個數值進行比較,如果t=14,那么就需要將取0個、1個和兩個i2物品的情況進行比較,然后選出最大值。
public static class Knapsack {
private static int[] P={5,8};
private static int[] V={5,7};
private static int T = 10;
private int[][] dp = new int[P.length + 1][T + 1];
@Test
public void solve3() {
for (int i = 0; i < P.length; i++){
for (int j = 0; j <= T; j++){
for (int k = 0; k * V[i] <= j; k++){
dp[i+1][j] = Math.max(dp[i+1][j], dp[i][j-k * V[i]] + k * P[i]);
}
}
}
System.out.println("最大價值為:" + dp[P.length][T]);
}
}
跟01背包問題一樣,完全背包的空間復雜度也可以進行優化,具體思路這里就不重復介紹了,可以翻看前面的01背包問題優化篇。
優化后的狀態轉移方程為:
ks(t) = max{ks(t), ks(t - Vi) + Pi}
public static class Knapsack {
private static int[] P={0,5,8};
private static int[] V={0,5,7};
private static int T = 10;
private int[] newResults = new int[T + 1];
@Test
public void resolve4() {
int result = ksp(P.length,T);
System.out.println(result);
}
private int ksp(int i, int t){
// 開始填表
for (int m = 0; m < i; m++){
for (int n = V[m]; n <= t; n++){
newResults[n] = Math.max(newResults[n] , newResults[n - V[m]] + P[m]);
}
// 可以在這里輸出中間結果
System.out.println(JSON.toJSONString(newResults));
}
return newResults[newResults.length - 1];
}
}
輸出如下:
[0,0,0,0,0,0,0,0,0,0,0]
[0,0,0,0,0,5,5,5,5,5,10]
[0,0,0,0,0,5,5,8,8,8,10]
10
其實完全背包問題也可以轉化成01背包問題來求解,因為第i件物品最多選 ⌊T/Vi⌋(向下取整) 件,於是可以把第i種物品轉化為⌊T/Vi⌋件體積和價值相同的物品,然后再來求解這個01背包問題。具體方法這里就不多說了,留給大家自行解決。如果遇到問題,可以翻開前面關於01背包問題的兩篇文章。
總結
完全背包問題跟01背包有很多相似之處,比較一下他們的狀態轉移方程以及各種解法,就會發現他們其實是異父異母的親兄弟。
這兩個背包問題的關鍵都在於狀態轉移方程的尋找,如果對於類似的問題沒有思路,可以先嘗試找出遞歸解法,然后自上而下的記憶法便水到渠成了。
當然,最重要的還是解題思路,理解記憶法和填表法的精髓,有助於之后舉一反三,去解決類似的延伸問題。
關於完全背包問題的解析到此就結束了,祝大家五一愉快!
如果有疑問或者有什么想法,也歡迎關注我的公眾號進行留言交流: