目录
经典01背包
问题描述:
有 N 件物品,每件物品有两个属性:价值和重量,第 i 件物品的价值为 v[i] ,重量为 w[i] ,你有一个容量为 M 的背包,求能装下物品的最大价值。
例如有4件物品,背包容量为10。物品价值和重量如下表
样例的答案是:13 (选择第1 2 4件物品,价值最大是13)
思路:
现在用一个二维数组 f[i][j] 来表示前 i 件物品在背包剩余容量为 j 时所能得到的最大价值。(先不要考虑为什么这样定义,后边会再解释)。这里要注意,至于 f[i][j] 在前 i 件物品中选择了哪些,我们是不知道的,我们只知道在只涉及前 i 件物品,并且背包剩余空间是 j 的时候,获得的最大价值是 f[i][j] 。
如果假设我们对前 i-1 件物品已经做好了选择,那么在面对第 i 件物品时,我们就只有两种选择:花费 w[i] 的容量装下他来获得 v[i] 的价值,或者不选择它。选择与不选择,这也是01背包名字的由来。
在已知 f[i-1][j] 的情况下,对于前面所说的选择或者不选择第 i 件物品,我们就可以用式子表示出来:也就是最重要的递推公式:
选择第 i 件物品, f [ i ] [ j ] = f [ i - 1 ] [ j - w [ i ] ] + v [ i ] ;
不选择第 i 件物品,f [ i ] [ j ] = f [ i - 1 ] [ j ] ;
不选择的情况很好理解,f [ i ] [ j ] = f [ i - 1 ] [ j ] ;背包剩余容量不变,价值也不变,只是原来是只考虑前 i-1 件,现在考虑到前 i 件了。
选择的话,新的价值计算应该是 f [ i - 1 ] [ j - w [ i ] ] + v [ i ],也就是要用前 i 件物品在背包余量为 f [ i - 1 ] [ j - w [ i ] ] 的最大价值,而不是f [ i - 1 ] [ j ],因为要提前为第 i 件物品的重量 w[i] 提供空间,才能代表选择了第 i 件物品,才能使其最大价值加上 v[i] 。
这里也很明显看出,j > w[i] 装得下才可以选择,如果背包余量不够的话,直接就是不选择。
选不选的情况我们讨论了,最终决策要看两种情况哪个价值更大,所以要从中取max,最核心的代码如下:
if (j>=w[i]) f[i][j]=max(f[i-1][j],f[i-1][j-w[i]]+v[i]);
else f[i][j]=f[i-1][j];
模拟:
我们按照样例模拟一下 f[i][j] 的求解过程,如果你在学习之后能独立填出这个 f 数组,你也就真正意义上理解了01背包。
注意:i 代表第 i 件物品, j 代表背包剩余容量。
初始表格如下:
在0件物品的时候,不管背包多大,最大价值都是0,同理,在背包容量是0的时候,不管有几件物品,最大价值也还是0。所以可以很容易把表填成如下图所示:(对0这种情况的考虑,主要是针对边界,因为后面的计算会用到f[0][5],f[2][0]之类的带有下标0的量。)
接下来,我们第一件物品,第两件物品的从上到下,背包容量为0 1 2 3的从左到右来填写表格。
当i=1时,即只有物品1可供选择,在容量不足时,装不下,价值为0,那么如果容量足够的话,最大价值自然就是物品1的价值了。
当i=2时,有两个物品可供选择,此时应用上面的递推关系式进行判断即可。这里以i=2,j=3为例进行分析。
在容量为0-2时,第2件物品装不下,自然无法选择,所以f[i][j]=f[i-1][j];
在容量为3时,可以选择第2件物品了,至于到底选不选,我们需要比较一下:
不选的话 f[2][3]=f[1][3]=2; 选择的话,f[2][3]=f[1][3-w[2]]+v[2]=f[1][0]+4=4;
因为选择价值更大,所以我们用更大的4来填充f[2][3]。
后面的表格,我们一样按照这个规矩填写,只要当前背包剩余容量 j 大于当前第 i 件物品的重量,也就是说能装下的情况,我们都会比较一下试试,看看哪个结果更优。
最终得到表格:
最终的答案也就是f[n][m]。
代码:
#include <iostream>
using namespace std;
int f[1005][1005];
//f[i][j]表示前i件物品剩余空间j的情况下最大价值是f[i][j]
int v[1005];
int w[1005];
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=1; j<=m; j++){
if (j>=w[i]) f[i][j]=max(f[i-1][j],f[i-1][j-w[i]]+v[i]);
else f[i][j]=f[i-1][j];
}
}
for (int i=0; i<=n; i++){
for (int j=0; j<=m; j++) cout<<f[i][j]<<" ";
cout<<endl;
}
cout<<f[n][m]<<endl;
}
思考:
1.为什么模拟填表格是按照从上到下,从左到右的顺序?枚举变量i,j是从小到大的顺序?
对于物品的枚举变量 i ,从递推公式中可以看出,第 i 件物品的决策要用到 i-1 的答案,也就是说只有先把第 i-1 件物品处理完,得到了f[i-1][j],才能继续处理第 i 件物品的决策。所以 i 对于物品的枚举,必须是从小到大。
对于容量的枚举变量 j ,f[i][j] 用到的是 f[i-1][j] 和 f[i-1][j-w[i]] , 也就是说同一个物品的容量 j 枚举是没有顺序的,因为用到的都是f[i-1]那一层的数据,是已经存在的,j 的枚举从大到小还是从小到大是没有区别的。
2.f[i][j]数组为什么这么定义?
我们定义的 f[i][j] 来表示前 i 件物品在背包剩余容量为 j 时所能得到的最大价值,这是因为这样定义最容易理解,或者最容易状态转移,你也可以随心定义 f[i][j] 表示前 i 件物品花费了 j 的容量所得到的最大价值,那么你就要想出对应的递推公式,总之,定义状态是为了更好的求解,所以动态规划问题想轻松解决的话,需要定义一个好的状态,如果你的状态定义的很差,那么你将花更多时间去推导递推公式。
复杂度:
时间复杂度是O(n*m),空间复杂度为O(n*m),空间复杂度可优化为O(m),后边会讲空间优化。
如何求出01背包中具体选择了每些物品?
利用倒推的思想,很容易在二维数组f[i][j]中求出具体选择了哪些物品。
首先,二维数组每个格子的数据来源就只有两个,要么是没有选择第 i 件物品,那么 f [ i ] [ j ] = f [ i - 1 ] [ j ] ;要么是选择了第 i 件物品,那么f [ i ] [ j ] = f [ i - 1 ] [ j - w [ i ] ] + v [ i ] ;所以我们只需要倒着从 f[n][m] 开始,查看以上两个等式哪个成立,如果f [ i ] [ j ] = f [ i - 1 ] [ j ]x ,那就说明第 i 件物品没被选,j 也不需要改动,反之就是被选择了,对应的背包容量 j 要减去 w[i] 。
代码:
#include <iostream>
using namespace std;
int f[1005][1005];
//f[i][j]表示前i件物品剩余空间j的情况下最大价值是f[i][j]
int v[1005];
int w[1005];
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=m; j>=1; j--){
if (j>=w[i]) f[i][j]=max(f[i-1][j],f[i-1][j-w[i]]+v[i]);
else f[i][j]=f[i-1][j];
}
}
int j=m;
for (int i=n; i>=1; i--){
if (f[i][j]!=f[i-1][j]) {
j=j-w[i];
cout<<i<<" ";
}
}
cout<<endl<<f[n][m]<<endl;
}
空间优化后的一维01背包:
思想:
在上面的探讨中我们发现,第 i 件物品的决策只与第 i-1 件有关,与其他无关, f[i][j] 只与 f[i-1][j] 有关,i-2,i-3 这些空间的数据是不会再使用的,空间就浪费了,如果我们开一维的数组,新的状态直接覆盖在旧的上面,迭代使用,就把空间复杂度从O(n*m)优化为O(m)。
为什么要倒序枚举?
直接将二维数组改为一维是不够的,对于枚举顺序,我们也要有约定,在思考的问题1中我们知道,二维01背包的枚举变量 j 顺序是无所谓的,因为用到的是 i-1 的已存在数据,如果优化为一维01背包的话,枚举顺序就必须是倒序。
如果还是正序的话,f[i][j]在使用f[i-1][j]的时候,那个旧的f[i-1][j]已经被覆盖了,你使用的是新的f[i][j]。
例如计算f[3][8]即当枚举到 i=3,j=8时,(也就是说你在枚举到第3个物品【重量为5,价值为3】,当前背包容量j为8),计算时需要使用f[i-1][j-w[i]],也就是f[2][3],如果 j 的枚举是正序,意味着你在求 f[3][8] 之前就会先计算 f[3][3],而因为优化空间了,f[3][3]存放的位置是f[2][3],所以这个时候你使用的就不再是f[2][3],而是已经覆盖的f[3][3]。这样明显会让答案不正确并且偏大。
倒序枚举 j 就解决了这种覆盖的问题,因为大的j只会用到前面的小的j,在倒序中,不会覆盖i-1的数据。
代码:
#include <iostream>
using namespace std;
int f[1005];
//f[j]表示前i件物品剩余空间j的情况下最大价值是f[j]
int v[1005];
int w[1005];
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=m; j>=1; j--){
if (j>=w[i]) f[j]=max(f[j],f[j-w[i]]+v[i]);
}
}
//for (int j=0; j<=m; j++) cout<<f[j]<<" "; cout<<endl;
cout<<f[m]<<endl;
}
再改进下:j >= w[i] 才执行,所以可以直接枚举到w[i],不再需要 if 判断。
#include <iostream>
using namespace std;
int f[1005];
//f[j]表示前i件物品剩余空间j的情况下最大价值是f[j]
int v[1005];
int w[1005];
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=m; j>=w[i]; j--){
f[j]=max(f[j],f[j-w[i]]+v[i]);
}
}
//for (int j=0; j<=m; j++) cout<<f[j]<<" "; cout<<endl;
cout<<f[m]<<endl;
}