揹包四講
揹包問題(Knapsack problem)是一種組合優化的NP完全問題。問題可以描述為:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。問題的名稱來源於如何選擇最合適的物品放置於給定揹包中。相似問題經常出現在商業、組合數學,計算複雜性理論、密碼學和應用數學等領域中。也可以將揹包問題描述為決定性問題,即在總重量不超過W的前提下,總價值是否能達到V?它是在1978年由Merkle和Hellman提出的。
---百度百科
本筆記參考視訊與部落格:
dd大牛的《揹包九講》 - 賀佐安 - 部落格園 (cnblogs.com)
但是筆者做了簡化,只選取了其中較為方便理解的4種揹包問題進行詳解,故取名為揹包四講(AcWing演算法基礎課?四講)。
- 具體四種問題如下圖:
0x1 01揹包
問題描述
有 N 件物品和一個容量是 V 的揹包。每件物品只能使用一次。
第 i 件物品的體積是 vi,價值是 wi。
求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。
輸入格式
第一行兩個整數,N,V,用空格隔開,分別表示物品數量和揹包容積。
接下來有 N 行,每行兩個整數 vi,wi,用空格隔開,分別表示第 i 件物品的體積和價值。
輸出格式
輸出一個整數,表示最大價值。
資料範圍
0<N,V≤1000
0<vi,wi≤1000
輸入樣例
4 5
1 2
2 4
3 4
4 5
輸出樣例
8
分析
樸素版做法
解答
#include<iostream>
#include<algorithm>
using namespace std;
const int N=1010;
int n,m;
int v[N],w[N];//分別記錄每個物品的體積和價值
int f[N][N];//f[i][j]來記錄前i個物品且體積不超過j的最大價值
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
//1.當前不把第i件物品放入揹包
f[i][j]=f[i-1][j];
//2.
//如果當前物品體積沒有超過j(v[i]<=j),則把第i件物品放入揹包
//由於要選取第i件物品,之前之前的f[i-1][]不僅不能超過f[][j],還要全部預留v[i]的體積空間。
//這樣就得到了從前i-1個物品裡選體積不超過j-v[i]的最大價值:f[i-1][j-v[i]],最後加上w[i]。
//這兩種情況取最大者
if(v[i]<=j) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
}
}
cout<<f[n][m]<<endl;
return 0;
}
空間優化(一維化)
我們已經得到了樸素版做法。這裡要注意揹包問題的分析與程式碼的優化是沒有關聯的,一般是先根據分析,寫出樸素做法,再經過推導得到優化程式碼。
分析:通過猜想,我們發現可以試圖將 f[i][j] 降為成 f[j] 以優化空間。現在對原來程式碼進行修改。
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
//當前不把第i件物品放入揹包的情況可以直接刪[i]與[i-1],
//因為f[j]=f[j]正好是把上層迴圈舊的f[j]更新掉
f[j]=f[j];
//當前物品體積沒有超過j(v[i]<=j),則把第i件物品放入揹包的情況不能直接刪,
//因為j是遞增的,而j-v[i]一定小於j,
//所以下面的語句在給f[][j]賦值時,f[][j-v[i]]一定是先更新過的,所以就是最新的f[i][j-v[i]],我們知道f[i][j-v[i]]不一定等於f[i-1][j-v[i]],這顯然是不合題意的。
//if(v[i]<=j) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]);
//所以簡單寫成下面這樣是錯誤的
//if(v[i]<=j) f[j]=max(f[j],f[j-v[i]]+w[i]);
//這裡我們發現如果j從大到小迴圈,由於j是遞減的,並且j-v[i]一定小於j,所以在賦值給f時j-v[i]是還未更新的上層迴圈值,也就是f[i-1][]的值,這樣就符合題意。
}
}
修改後程式碼如下
for(int i=1;i<=n;i++)
{
for(int j=m;j>=0;j--)
{
f[j]=f[j];
f[j]=max(f[j],f[j-v[i]]+w[i]);
if(v[i]<=j) f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
進一步刪掉多餘程式碼,整合判斷條件後如下(終極版):
#include<iostream>
#include<algorithm>
using namespace std;
const int N=1e3+10;
int n,m;
int v[N],w[N];
int f[N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=m;j-v[i]>=0;j--)
{
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[m]<<endl;
return 0;
}
總結
01揹包問題是最基本的揹包問題,它包含了揹包問題中設計狀態、方程的最基本思想,另外,別的型別的揹包問題往往也可以轉換成01揹包問題求解。故一定要仔細體會上面基本思路的得出方法,狀態轉移方程的意義,以及最後怎樣優化的空間複雜度。
0x2 完全揹包
問題描述
有 N 種物品和一個容量是 V 的揹包,每種物品都有無限件可用。
第 i 種物品的體積是 vi,價值是 wi。
求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。
輸入格式
第一行兩個整數,N,V,用空格隔開,分別表示物品種數和揹包容積。
接下來有 N 行,每行兩個整數 vi,wi,用空格隔開,分別表示第 i 種物品的體積和價值。
輸出格式
輸出一個整數,表示最大價值。
資料範圍
0<N,V≤1000
0<vi,wi≤1000
輸入樣例
4 5
1 2
2 4
3 4
4 5
輸出樣例:
10
分析
樸素版做法
解答
//樸素版做法
#include <iostream>
#include <algorithm>
using namespace std;
const int N=1010;
int f[N][N];
int v[N],w[N];
int n,m;
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
for(int k=0;k*v[i]<=j;k++)//不選的時候,k=0,0<=j恆成立,和題。
//選的時候,體積最大情況為只選當前第i件物品,只要k*v[i]不比j大就行。
{
f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
}
}
}
cout<<f[n][m]<<endl;
return 0;
}
時間優化(簡單推導)
上面樸素做法最壞情況為O(n*m^2) v[i]取1時
達到10^9, 在超時的邊緣瘋狂試探?
我們決定對時間進行優化,觀察發現
f[i][j]=max(f[i-1][j],f[i-1][j-v]+w,f[i-1][j-2v]+2w,...f[i-1][取到最小])
f[i][j-v]=max(f[i-1][j-v],f[i-1][j-2v]+w,...f[i-1][取到最小])
我們發現可以使用下面的f[i][j-v]+w替換上面的後半部分式子
故 f[i][j]=max(f[i-1][j],f[i][j-v]+w)
,此時的狀態轉移方程就得到了簡化
簡化版DP 時間複雜度O(n^2)
//時間優化
#include<iostream>
#include<algorithm>
using namespace std;
const int N=1e3+10;
int n,m;
int v[N],w[N];
int f[N][N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
for(int j=0;j<=m;j++)
{
f[i][j]=f[i-1][j];
if(j-v[i]>=0) f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
}
cout<<f[n][m]<<endl;
return 0;
}
在時間優化基礎上優化空間
//時間優化+空間優化
#include<iostream>
#include<algorithm>
using namespace std;
const int N=1e3+10;
int n,m;
int v[N],w[N];
int f[N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++)
for(int j=v[i];j<=m;j++)//if(j-v[i]<0)迴圈會直接跳過,所以j從v[i]開始就好了。
{
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
cout<<f[m]<<endl;
return 0;
}
總結
完全揹包問題也是一個相當基礎的揹包問題。希望你能夠對狀態轉移方程和優化的推導過程都仔細地體會,不僅記住,也要弄明白它們是怎麼得出來的,最好能夠自己想一種得到這些方程的方法。事實上,對每一道動態規劃題目都思考其方程的意義以及如何得來,是加深對動態規劃的理解、提高動態規劃功力的好方法。
0x3 多重揹包
問題描述
有 N 種物品和一個容量是 V 的揹包。
第 i 種物品最多有 si 件,每件體積是 vi,價值是 wi。
求解將哪些物品裝入揹包,可使物品體積總和不超過揹包容量,且價值總和最大。
輸出最大價值。
輸入格式
第一行兩個整數,N,V,用空格隔開,分別表示物品種數和揹包容積。
接下來有 N 行,每行三個整數 vi,wi,si,用空格隔開,分別表示第 i 種物品的體積、價值和數量。
輸出格式
輸出一個整數,表示最大價值。
資料範圍
由下面解法確定
輸入樣例
4 5
1 2 3
2 4 1
3 4 3
4 5 2
輸出樣例:
10
樸素版做法
資料範圍
0<N,V≤100
0<vi,wi,si≤100
分析
這題目和完全揹包問題很類似。基本的方程只需將完全揹包問題的方程略微一改即可,因為對於第i種物品有n[i]+1種策略:取0件,取1件……取 n[i]件。令f[i][v]表示前i種物品恰放入一個容量為v的揹包的最大權值,則:f[i][v]=max{f[i-1][j-k*v[i]]+ k*w[i]}。只不過每件物品有件數限制k<=s[i]
。那麼直接模仿照抄完全揹包的樸素版本一定是可行的。
解答
#include<iostream>
#include<algorithm>
using namespace std;
const int N=1e4+10;
int n,m;
int v[N],w[N];
int f[N][N];
int s[N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>v[i]>>w[i]>>s[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
for(int k=0;k*v[i]<=j&&k<=s[i];k++)
{
f[i][j]=max(f[i][j],f[i-1][j-k*v[i]]+k*w[i]);
}
}
}
cout<<f[n][m]<<endl;
return 0;
}
但是這裡我們想模仿完全揹包時間優化的程式碼卻不可以。
對於完全揹包我們有
f[i][j]=max(f[i-1][j],f[i-1][j-v]+w,f[i-1][j-2v]+2w,...f[i-1][取到最小])
f[i][j-v]=max(f[i-1][j-v],f[i-1][j-2v]+w,...f[i-1][取到最小])
但是多重揹包由於有件數限制,雖然下面的式子是成立的,但是f[i-1,j-sv]不一定能取到最小。
第二行多的f[i-1,j-(s+1)v]+sw使得我們的推導無法進行下去,因為從遞推式中減去一項是不容易做到的。
時間優化(二進位制優化)
資料範圍
0<N≤10000<N≤1000
0<V≤20000<V≤2000
0<vi,wi,si≤20000<vi,wi,si≤2000
提示:
本題考查多重揹包的二進位制優化方法。
分析
由樸素做法的最後一段分析可知,我們需要一種新的方法來優化多種揹包問題。否則是無法通過這一題的,因為時間複雜度超時。這裡介紹二進位制優化方法。
二進位制優化方法原理淺談
我們知道二進位制由0和1兩個陣列成,任意的10進位制數都可以由二進位制數表示。
而0,1可以類比成揹包問題的第i個數選(1)與不選(0)。
那麼通過列舉全部(1,2,4,8,...,2^n)也就是二進位制的(1,10,100,1000,10000,...),這些數的選與不選就可以表示所有的10進位制數。
例如:(十進位制數)11=(二進位制數)1000+10+1;只要選這三個數就可以表示11。
並且這些二進位制數選與不選都是唯一的,所以相當於把問題轉換成了01揹包問題。
這種優化對於大數尤其明顯,例如有1024個商品,在正常情況下要列舉1025次 , 二進位制思想下轉化成01揹包只需要列舉10次。
解答
這裡我們直接寫二進位制優化+空間優化的版本(因為資料範圍較大,不優化空間會MLE)
#include<iostream>
#include<algorithm>
using namespace std;
const int N=2e6+10,M=2010;
int v[N],w[N];
int n,m;
int f[M];
int cnt;
int main()
{
cin>>n>>m;
int a,b,s;
//下面是二進位制優化過程
for(int i=1;i<=n;i++)
{
cin>>a>>b>>s;
for(int j=1;j<=s;j*=2)//把二進位制件數(1,10,100,1000...)存進去
{
cnt++;
v[cnt]=j*a;
w[cnt]=j*b;
s-=j;
}
if(s>0)//這裡s的值一定小於2^(j+1),s為二進位制表示迴圈後(s-=j;)剩的數。
{
cnt++;
v[cnt]=s*a;
w[cnt]=s*b;
}
}
n=cnt;//一定要更新n的值,因為cnt才是二進位制優化後的實際物品個數
//下面和01揹包空間優化程式碼一致
for(int i=1;i<=n;i++)
for(int j=m;j>=v[i];j--)
{
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
cout<<f[m]<<endl;
return 0;
}
總結
這裡二進位制優化十分的優雅,請務必在掌握前面幾種揹包題型後好好理解,運用。
0x4 分組揹包
樸素做法
分析
解答
#include<iostream>
#include<algorithm>
using namespace std;
const int N=110;
int n,m;
int s[N],v[N][N],w[N][N];//這裡多一維記錄這一組的第幾個就好
int f[N][N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>s[i];
for(int k=1;k<=s[i];k++)
cin>>v[i][k]>>w[i][k];
}
for(int i=1;i<=n;i++)
{
for(int j=0;j<=m;j++)
{
f[i][j]=f[i-1][j];
for(int k=1;k<=s[i];k++)//唯一和01揹包不同點,每一組的每個都要判斷一遍,用迴圈寫
{
if(j>=v[i][k]) f[i][j]=max(f[i][j],f[i-1][j-v[i][k]]+w[i][k]);
}
}
}
cout<<f[n][m];
return 0;
}
空間優化(一維化)
#include<iostream>
#include<algorithm>
using namespace std;
const int N=110;
int n,m;
int s[N],f[N];
int v[N][N],w[N][N];
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>s[i];
for(int k=1;k<=s[i];k++)
{
cin>>v[i][k]>>w[i][k];//這裡用k不用j,和下面程式碼保持一致,方便讀者理解
}
}
for(int i=1;i<=n;i++)
{
for(int j=m;j>=0;j--)
{
for(int k=1;k<=s[i];k++)
{
if(j>=v[i][k]) f[j]=max(f[j],f[j-v[i][k]]+w[i][k]);//借鑑01揹包空間優化
}
}
}
cout<<f[m]<<endl;
return 0;
}
總結
分組的揹包問題將彼此互斥的若干物品稱為一個組,這建立了一個很好的模型。不少揹包問題的變形都可以轉化為分組的揹包問題,由分組的揹包問題進一步可定義“泛化物品”的概念,十分有利於解題。
小結
這四種揹包問題被選入AcWing演算法基礎課動態規劃第一章。大家應細細體會程式碼的優雅性,與推理的嚴謹性。作為學習演算法的基礎好好掌握。(基礎不等同於簡單,base != easy)由於筆者整理的較為倉促,難免有紕漏,歡迎評論指出。
最後引用 賀大牛的一句話送給大家,觸類旁通、舉一反三,應該也是一個OIer應有的品質吧。