簡單的背包問題往往是學好\(DP\)的基礎。對於許多動態規划問題,我們都要通過局部的最優值推出當前結果的最優值。是無后效性的。而對於這些最優值的狀態,我們往往使用\(dp[]\),\(dp[][]\)來存儲。那么,背包問題的狀態又該如何表示呢?
一 \(01\)背包
\(01\)背包是最基礎的背包問題,如果是初學者,我們可以使用二維數組先來理解。首先,對於它們的狀態我們可以使用\(dp[i][j]\)來存儲。表示面對前\(i\)個物品\(j\)個空間所能取到的最大價值。下面我們列舉一個栗子:
通過這張表不難看出,\(dp[n][m]\)就是最終答案。那么,這個值是如何推導出來的呢?
\(01\)背包本身就是取和不取兩種情況。如果不取,那么當前的\(dp[i][j]\)就是\(dp[i-1][j]\),表示上個物品面對\(j\)個空間的最優值。如果取了,那么當前的\(dp[i][j]\)就是取\(dp[i-1][j-w[i]]+v[i]\)的值,表示上個物品面對\(j-w[i]\)的最優價值加上自己本身的價值。兩者取最大,得到公式:
\(Code\):
#include<bits/stdc++.h>
using namespace std;
int n,m,w[1010],v[1010],dp[1010][1010];
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>m>>n;
for(int i=1;i<=n;i++){
cin>>w[i]>>v[i];
}
for(int i=1;i<=n;i++){//枚舉每件物品
for(int j=1;j<=m;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]);
//如果這個值取得了,那么可取可不取,兩者挑出最大值
}
}
cout<<dp[n][m]<<endl;//答案就是N件物品面對M個容量
return 0;
}
\(01\)背包的一維做法
首先觀察代碼中的\(dp[i-1][j]\)。
#include<bits/stdc++.h>
using namespace std;
int w[1010],v[1010],dp[1010];
int n,m;
int main(){
cin>>m>>n;
for(int i=1;i<=n;i++){
cin>>w[i]>>v[i];
}
for(int i=1;i<=n;i++){//枚舉每件物品
for(int j=m;j>=w[i];j--){//枚舉當前物品取到的容量
dp[j]=max(dp[j-w[i]]+v[i],dp[j]);
//可取可不取,兩者挑出最大值
}
}
cout<<dp[m]<<endl;//答案就是背包容量為m時的狀態
return 0;
}
- 好了,你會發現一維的解法不過把二維數組中第一個維度完全舍去了,而第二個維度則保持不變。仔細理清一下邏輯,面對當前的\(ij\),\(dp[j]\)就是上個\(i\)面對\(j\)個空間的最優值,而\(dp[j-w[i]]+v[i]\)就是上個\(i\)面對\(j-w[i]\)的最優值加上自己的值。所以很明顯,\(dp[j]\)就是不取的情況,\(dp[j-w[i]]+v[i]\)就是取的情況,\(dp[i][j]\)顯然是兩者之間挑最大值。因此我們就可以將空間壓到一維:
- \(OK\),此時再看代碼,發現公式問題好像解決了,但你可能會產生新疑問:為啥\(j\)要倒着循環呢 ? 假若正着循環,那么\(dp[j]\)還是可以理解成\(dp[i-1][j]\),但是\(dp[j-w[i]]\)的值卻可能會在\(i\)相同的情況下更新,所以\(dp[j-w[i]]\)實際是取的\(dp[i][j-w[i]]\)的值,所以是不對的。
二 完全背包
完全背包和\(01\)背包大體都是一樣的。唯一的區別就是\(01\)背包在面對某種物品時只有取與不取兩種情況。而完全背包可以隨你取多少個,\(0\)個,\(1\)個,\(2\)個一直到無限個都有可能,只是這取決於你的背包容量。所以,完全背包也可以叫做無限背包。
接下來,我們可以這樣思考。對於某件物品,我們可以盡最大化取,比如背包容量是\(601\),物品重\(150\),這時我們只需再填一個循環來模擬取幾個,那么對於這個例子,循環明顯只要跑\(4\)次,所以在程序實現中,我們只要判斷背包容量是否還夠取,如果夠,那么就按\(01\)背包的方法得到\(dp[j]\),如果不夠,那么\(break\)即可。
最后提一下公式,想必各位已經不用我說了吧:
\(p\)指的是當前\(i\)物品取的個數
\(Code\):
#include<bits/stdc++.h>
using namespace std;
int w[1010],v[1010],dp[1010];
int n,m;
int main(){
cin>>m>>n;
for(int i=1;i<=n;i++){
cin>>v[i]>>w[i];
}
for(int i=1;i<=n;i++){
for(int j=m;j>=w[i];j--){
for(int p=1;j-w[i]*p>=0;p++){//空間不足,跳出循環
dp[j]=max(dp[j-w[i]*p]+v[i]*p,dp[j]);
//公式沒必要解釋
}
}
}
cout<<dp[m]<<endl;
return 0;
}
下面我們來試着找找\(O(nm)\)算法
先試着想想\(01\)背包的反例。
假如如果用\(01\)背包正着循環,當前的\(dp[j]\)還是\(dp[i-1][j]\),而\(dp[j-w[i]]\)卻變成了\(dp[i][j-w[i]]\),那么此時你觀察一下\(dp[i][j-w[i]]\),仔細想想,它指的是什么呢?沒錯,它就是指當前這個\(i\)面對當前自己已經取過的\(j-w[i]\)的值,也就是說,\(dp[j-w[i]]\)此時已經是經過當前\(i\)的刷新了。而根據完全背包,每個\(i\)可以無限的取,所以不管\(dp[j-w[i]]\)是否取了\(i\),當前的\(dp[j]\)都可以通過\(dp[j-w[i]]\)來得到自己。
- 那么概括來說,就是要讓當前的\(dp[j-w[i]]\)被\(i\)刷新過才能達到被重復取的效果!
\(Code\):
#include<bits/stdc++.h>
using namespace std;
int n,m,w[1010],v[1010],dp[1010];
int main(){
cin>>m>>n;
for(int i=1;i<=n;i++){
cin>>w[i]>>v[i];
}
for(int i=1;i<=n;i++){
for(int j=w[i];j<=m;j++){//注意是正向循環
dp[j]=max(dp[j-w[i]]+v[i],dp[j]);//01公式
}
}
cout<<dp[m]<<endl;
return 0;
}
三 多重背包
多重背包也稱為有限背包,顧名思義,在多重背包中,每個值取的最大數量是給定的。
那么不妨這樣想,對於一個物品可以選\(k\)次,我們可以轉化成\(k\)個物品可以選一次,於是就又變回了\(01\)背包。
\(Code\):
#include<bits/stdc++.h>
using namespace std;
int n,m,len,w[1005],v[1005],k[1005];
int W[100005],V[100005],dp[1005];
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>w[i]>>v[i]>>k[i];
for(int j=1;j<=k[i]&&w[i]*j<=m;j++){//轉化
W[++len]=w[i],V[len]=v[i];
}
}
for(int i=1;i<=len;i++){
for(int j=m;j>=W[i];j--){
dp[j]=max(dp[j],dp[j-W[i]]+V[i]);//01背包
}
}
cout<<dp[m]<<endl;
return 0;
}
- 多重背包二進制優化
這個優化一般用在\(k\)普遍比較大的時候。
因為我們每次都將一個\(k\)拆成\(k\)個\(1\),所以有時如果\(k\)較大空間和時間可能都不會允許。
但其實還能換種方式拆。
不妨讓\(k\)二進制拆分
可以舉個栗子:\(k=20\)
我們將其拆成\(1,2,4,8,5\),再把花費和價值乘以次數即可。
於是你會發現取\(1\)種,取\(2\)種\(……\)取\(20\)種的情況都可以被表示。其本質是因為二進制數可以拼湊成范圍內的所有數,相信可以理解吧。
故復雜度降至\(O(nmlogk)\)
\(Code:\)
#include<bits/stdc++.h>
using namespace std;
int n,m,len,w[1005],v[1005],k[1005];
int W[100005],V[100005],dp[1005];
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>w[i]>>v[i]>>k[i];
k[i]=min(k[i],m/w[i]);//嚴格保證k[i]*w[i]<=m
int tot=0;
for(int j=1;;j=j<<1){
if(tot+j>k[i]){//不夠拆了
tot=k[i]-tot;
W[++len]=w[i]*tot,V[len]=v[i]*tot;//把剩下的數記上
break;
}
tot+=j;
W[++len]=w[i]*j,V[len]=v[i]*j;//二進制拆分
}
}
for(int i=1;i<=len;i++){
for(int j=m;j>=W[i];j--){
dp[j]=max(dp[j],dp[j-W[i]]+V[i]);//01背包
}
}
cout<<dp[m]<<endl;
return 0;
}
四 背包求種類
在龐大的背包問題中,求種類數也是很常見的。沒錯,我們在做許多題時,都會遇見像在\(n\)個數中取到值為\(m\)的種類數是多少。其實這類問題很簡單易懂,下面我為大家普及一下這種模板類型的題。
首先,狀態不用說,\(dp[i][j]\)表示第\(i\)個值面對\(j\)個空間的種類數。在這其中,我們先考慮\(j\)為\(0\)的情況,沒錯,很明顯就是\(1\)個。因為一個值都不能取本身就是\(1\)種情況。接下來,對於\(dp[i][j]\),依然考慮取與不取兩種情況。若取即是\(dp[i-1][j-w[i]]\),不取即是\(dp[i-1][j]\)。將兩者種類數相加即可。
把它壓到一維,也就是:
\(\operatorname{Update}\) \(\operatorname{On}\) \(\operatorname{2018.12.25}\)