简述
0 1 背包可以说是讲述动态规划的最适合入门的问题了,所谓动态规划,其实可以简单理解为一种特殊的分而治之的策略,和我们常见的如二分查找等分而治之策略的不同在于,可以采用分而治之策略的问题,它划分出来的若干子问题是没有交集的,即子问题的求解完全不受其他因素的干扰,就如下图所示:
我们只需要将目光聚集解决如何解决每一个小问题,再将所有待解决的小问题合并,复杂的大问题也就迎刃而解。
但对于一般的动态规划问题,它分出来的子问题却往往互相交错,你中有我我中有你,使得问题变得十分复杂,在求解一个子问题的时候还需要考虑其他子问题对该问题的影响,这使得我们陷入了一种复杂的递归调用中。
问题
01背包问题的描述大概就是我有一个负重固定的背包,还有一地物品,物品的重量和价值随机,但每件物品都只有一个,其实有相同的也无所谓,现在需要尽量装走这些物品,使得在背包负重足够的情况下,被背包装入的物品总价值最大。
上图中的W代表每个物品的重量,V代表每个物品对应的价值,我们可以很快地想到一个方法,每次拿上未拿的物品中 v/w 最大的那个物品,直到背包装不下为止,这种想法是非常自然的,但往往却不是正确的,以上述图为例, v/m 最大的是第五个物品,其次是第四个,然后是第六个, 此时这三个物品重量为 3+4+9 = 16,已经放不下任何一个其他物品了,这三个物品的总价值为 4+7+8 = 19;然而经过观察,我们发现明明取 1 , 2, 4 , 5 这四个物品,总重量为19 , 总价值为20,比贪心策略求出的值还要大!!
解
我们当然可以遍历所有的情况,然后找出那个 v 最大的情况,但这种遍历是非常耗时的,我们有更好的解法,只需将思路稍微转变一下即可。
我们将目光聚焦在每次做抉择的时刻,假设我已经挑选过前i-1个物品,这些物品我有的拿了有的没拿,在背包负重不足的条件下(前i-1个物品并未全部放入或者全部放入后放不下第i个物品),这是我这个状态下背包存放物品的最优解,那么在遇见第i个物品后,我便要思考拿不拿这个物品了, 无非有两种情况,将背包里的一些物品丢弃,且并不需要考虑丢了谁,只关注背包负重,直到可以装下第i个物品 ,然后将这个物品放入背包; 或者干脆不用管这个第i个物品, 因为就算我为它腾出了空间,带上它以后价值居然还没有我原来背包大,我们当然选择使得我背包价值尽量大的那种选择,选择完毕后,目前这个背包的状态,就是探寻了前i个物品后我得到的最优的背包!
这很好解释,简单来说,我之前的状态是最优的,那么我仅需要考虑这次的选择最优,那么我总体的状态也就是最优的。
有了上述一层关系,就可以很简单地得到如下推导:
假设 i 为 前 i 个物品, j 为当前背包负重,那么f(i,j)表示:只考虑前i个物品的情况下,负重 j情况下 所能得到的最优解,递推式如下:
f(i,j) = max{ f(i-1 , j) , f( (i-1) , j-i.w ) + i.v}
一般的,我们称上述推导式为状态转移方程。
假设总共有 M 件物品 , 背包的总负重为 G , 不妨再通过上面的描述再解释一遍 ,考虑前 M 件物品 , 负重G情况下所能得到的最优解,道法自然 , 这不正就是我们所要求的最后的问题的解吗?
关于上述的状态转换方程的解释,我提到了一个条件,在背包负重不足的情况下,那么如果背包的负重大到可以将所有物品都存放进去,这个方程还成立吗?
换一种角度就能够想到,我们实际的操作其实就是填表,背包负重越大,表越长,填到最后如果负重足够,所有的物品也都是可以顺利被包含在内的。
小问题
还有一个小问题值得我们思考,假设我们通过递归的思想,不断地递归递归,直到原始状态,先不考虑原始状态的情况,将重点放在 状态转移方程上,这两个比对的上一层状态,会不会是之前已经出现过的呢? 如果已经出现过了,我们自然没有必要耗费资源再计算一遍,这也是文章开头我提到的交叉子问题的情况,很显然,确实会发生这种问题,而且还十分普遍。
如何解决这种不断重复的问题,最简单明了的方法就是记下来,将我们之前计算过的所有 f() 都记录在对应位置,这样当我们要求解一个子问题,我们先查询之前是否有计算过,如果有,直接返回,如果没有,则计算该值,并记录下来留作下次查询。当然,还有一种方法是迭代的自底而上全都求解一遍,下面的代码就采取的是迭代方式而不是递归。
代码
const int N = 2002; //根据题目要求顶下数组大小
int f[N][N] ;
int getAnswer(int n , int bag , int* w , int* v){
// n代表物品个数 , bag指的是背包负重 , w存放物品质量,v存放物品价值
memset(f , 0 , sizeof(0)); //数组初始化
for(int i = 1 ; i <= n ; i++){
for(int j = w[i] ; j <= bag ; j++){
//细节:在这个背包负重不超过它的重量时,它的值一直都是0,表示没有放进去
f[i][j] = max( f[i-1][j] , f[i-1][j-w[i]] + v[i]);
}
}
return f[n][bag];
}
上述的代码实际上就是展示一种填表的操作,将所有存放所有最优状态的表填好,答案自然就在这张表的最末尾,由此可以得到该算法的复杂度 O( n * bag) ,所以背包问题通常它的物品数或者背包负重都能太大。
优化:滚动数组
我们再来看一下它的状态转移方程:
f(i,j) = max{ f(i-1 , j) , f( (i-1) , j-i.w ) + i.v}
结合上面的代码就不难发现,第 i 行的状态的求解 , 仅与第 i-1行有关 , 我们需要的答案在那张表的最末端 , 也就是说,这张表的其他地方对于我来说是无所谓的,那么是否可以仅开一个一维的数组,存放当前行的最优状态,等到下一行时,再通过原有的值刷新一遍,得到新的一行的状态值呢?
答案是当然的,方式也很简单,仅需要逆向遍历。
为何要逆向遍历呢? 再仔细观察一下状态转移方程中第二个子状态:f(i -1 , j-i.w) + i.v,如果做顺序遍历,那么它找到的背包实际上是已经更新过以后的背包了, 原来的数据被抹去无法找回,但如果是逆序,则这个子状态需要的上一层的数据,都是未曾动过的。
此时的状态转换方程为:f(j) = max{ f(j) , f(j - i.w) + i.v }
优化后的代码如下:
const int N = 2002; //根据题目要求顶下数组大小
//int f[N][N] ;
int f[N];
int getAnswer(int n , int bag , int* w , int* v){ //采用滚动数组
memset(f,0,sizeof(f)); //数组初始化
for(int i = 1 ; i <= n ; i++){
for(int j = bag ; j >= w[i] ; j--){
//逆序遍历刷新
f[j] = max(f[j] , f[j-w[i]] + v[i]);
}
}
return f[bag];
}
完整代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 2002; //根据题目要求顶下数组大小
//int f[N][N] ;
int f[N];
int getAnswer(int n , int bag , int* w , int* v){ //采用滚动数组
memset(f,0,sizeof(f)); //数组初始化
for(int i = 1 ; i <= n ; i++){
for(int j = bag ; j >= w[i] ; j--){
//逆序遍历刷新
f[j] = max(f[j] , f[j-w[i]] + v[i]);
}
}
return f[bag];
}
// int getAnswer(int n , int bag , int* w , int* v){ //二维数组遍历
// memset(f , 0 , sizeof(0)); //数组初始化
// for(int i = 1 ; i <= n ; i++){
// for(int j = w[i] ; j <= bag ; j++){
// f[i][j] = max( f[i-1][j] , f[i-1][j-w[i]] + v[i]);
// }
// }
// return f[n][bag];
// }
int main(int argc, char const *argv[])
{
int n ; // 物件数量
int bag; //背包大小
cin>>n>>bag;
int w[n+2]; int v[n+2]; // 保存物品的重量和价值
for(int i = 1 ; i <= n ; i++){
int tw , tv; //临时变量,记录重量和价值
cin>>tw>>tv;
w[i] = tw ;
v[i] = tv ;
}
cout<<getAnswer(n,bag,w,v);
return 0;
}