首先非常感谢刘汝佳的小白书、HDU刘春英老师的ACM程序设计背包算法课件以及dd_engi的背包九讲和众多大神的博客,看这么多资料最后花了整整一天时间才算大致弄懂了背包问题的思路(=_=搞这么久我确实有点笨=_=)。OK,接下来就大致讲一下我所理解的背包问题,对这些问题做一下梳理总结,便于以后的复习查阅,如果可以帮到你那就更好了~~
背包问题是动态规划(dp)的一种,但是它的解法相对来说比较特别,所以我们把它单独列出来学习和分析,但是它的基本思想还是dp。关于概念就不多说了,大家可以参考我以上所提到的资料,上面对概念的讲解都是比较独到和权威的。
而01背包是最典型最基础的背包问题,它的基本模型为:
有一个容量为v的背包,现在有n件价值为value[i],体积为volume[i]的物品(i为第i件),问怎么样放才可以在物品体积之和不超过v的前提之下使得物品的总价值最大,求出这个最大值。
这里我们的条件是每件物品只有一件,背包可以不装满。其它的情况我们在后面会有讨论,这里的容量、价值和体积只是我们具体化的概念,遇到问题时可能不一定是这样子,但是只要满足这样的关系我们就称之为01背包问题并且用01背包问题的解法来解决问题。
01背包问题的贪心想法
然后我们开始对问题进行分析,我们先按照贪心算法的思路来分析一下,如果我们先放价值大的物品(以价值从大到小排序),那么可能这个物品的价值大,但同时占用的空间也很大,那结果显然不对,如果我们先放体积小的,那么可能它价值也小结果显然也不对。那我们按照价值和体积的比率(传说中的性价比)排序呢?乍一想好像挺有道理的,那么我们具体分析一下:
我们按照性价比排序之后肯定是把性价比高的先放进去,如果我们遍历到了某一个物品,虽然它的性价比很高但同时它的体积也很大,剩下的的空间不足以容下它,OK那我们跳过它好了找下一个,遇见盛不下的就跳过找下一个,直至最后,但是,这个时候我们就要进行讨论了,我们这样子“贪出来”的方案是否就是最优解呢?现在有一种情况:当我们遍历所有物品以后,背包还没有装满怎么办(这肯定是正常的,因为之前我们跳过了很多放不下的),这个时候显然之前已经跳过的也不能再装了(因为本来就是盛不下它们才跳过的嘛),那剩下的空间就这样剩着么?稍微思考一下就知道这是不合理的,现在假设我们遍历到最后的两件物品,剩余空间还剩下6,第一件物品价值是3,占空间为1,性价比为3,第二件物品价值为6,占空间为6,性价比为1,按照我们的策略,先遍历性价比高的,OK,我们把第一件物品放进去了,价值多了3,剩下空间只剩5了,然后遍历到了第二件物品,空间不够了,不放了,遍历结束!那么很显然问题就出来了,我们选择第二件性价比比较低的物品所获取的总价值显然比选择第一件的高,而这种问题是普遍存在于所有遍历中的,我们为了解决这种矛盾只能回溯,然而回溯怎么回,反正我是不会。由此可以证明,01背包问题是不能单纯用贪心算法来解决的。(注:相信你也看出来了,我举的反例只是个别的,只是为了证明01背包问题不能全部使用贪心算法来解决,其实部分满足特定条件的01背包问题是可以用贪心算法来解决的,但是这里不深入讨论了,以后有机会在贪心算法里面写一篇博客来讨论好了。)
状态转移方程
那么贪心贪不出来,我们就只能采用dp来做了,那我们为什么要用这种算法?这种算法为什么正确?实现过程是什么?作为一只问题宝宝,虽然大神和老师的讲解中都是默认我们已经会了很多东西直接上状态转移方程(然而我并不会=_=),可是我觉得这背后的过程还是有必要深究一下的。
回到问题本身,我们要解决的问题是怎么样选择物品可以使我们最终获得的总价值最大,我们这里用到的是dp,dp的思想和贪心的类似,都是求出每一个子问题的最优解,然后推及整体的最优解,但是两者不同之处在于,贪心的每一次递推都建立在上一个子问题的解就是确定的最优解的基础之上,如果接下来一步影响到了之前子问题的最优解,那么贪心算法就无法解决问题了,(这就是上面我所举出贪心算法无法解决01背包问题的原因),而dp不同,它在由局部推及整体的时候每一次都会更新子问题的最优解.(我猜这也是为什么叫动态规划不叫静态规划的原因/2333)这也是我们可以用dp来解决01背包的原因。
它的基本实现过程就是分解为每一件物品而言,我要么取你,要么不取你(感觉有歧义的样子),然后我们只需要每次问一下,我是取了你好(获取的总价值高)呢?还是不取你好呢?把最后的结果存起来,再问下一个,直到问到最后一个,那么最后一步所得到的值自然就是我们所想要得到的最大值啦。思路是很显然的,正确性也不言而喻。
那既然思路没有问题,那我们就开始对程序进一步求精,我们怎么来比较取了好还是不取好呢?这个时候就要开始思考了,我用什么来存储这两种状态的值?自然而然我们想到了可以用数组啊,用数组分别把取了它之后的总价值和不取它的总价值存起来,然后取较大者就好了。这个时候问题就又来了,还有容量呢?你不考虑容量的话肯定是每次都取啊,肯定是取了总价值大啊?所以显然我们的数组也是要考虑到容量的,那我们怎么样在考虑容量的前提下判断哪种好呢?我们想到了用二维数组,容量同时也作为下标然后不就有办法比较了。这个时候方程已经呼之欲出了,建议你在这个时候画一张表格,自己就可以把状态转移方程列出来了。已经啰嗦太多了我就不继续啰嗦了。
dp[i][k]=max(dp[i-1][k],dp[i-1][v-volume[i]]+value[i])
其中的i表示第i件物品,k背包容量(不是总容量,而是每一个子问题的“总容量”),dp[i][j]就是用来存储答案和状态更新的数组,dp[i-1][k]就表示我不取你的最大价值,dp[i-1][v-value[i]]+value[i]则表示我取了你的最大价值。
在最核心的状态转移列出来之后,思想已经很清楚了,接下来就只是一些纯粹代码方面的实现和优化了,其中巧妙之处不再细说,自行感受。现在就以一道题为例看一下01背包问题的基本实现。
HDU-2602 Bone Collector
题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=2602
题目:
1 5 10 1 2 3 4 5 5 4 3 2 1
14
#include<iostream> #include<algorithm> #include<math.h> #define maxn 1005 using namespace std; int dp[maxn][maxn],value[maxn],volume[maxn]; int main(){ int t; cin>>t; while(t--){ for(int i=0;i<maxn;i++){ volume[i]=0;value[i]=0; for(int j=0;j<maxn;j++) dp[i][j]=0; } int n,v; cin>>n>>v; for(int i=1;i<=n;i++) cin>>value[i]; for(int i=1;i<=n;i++) cin>>volume[i]; for(int i=n;i>=1;i--){ //对个数进行循环 for(int j=0;j<=v;j++){ //对空间进行循环 dp[i][j]=(i==n?0:dp[i+1][j]); //巧妙地完成赋初值和避免递归影响的操作(亮了) if(j>=volume[i]) dp[i][j]=max(dp[i][j],dp[i+1][j-volume[i]]+value[i]); //状态转移方程 } } cout<<dp[1][v]<<endl; } return 0; }
dp[i][j]=max(dp[i][j],dp[i+1][j-volume[i]]+value[i])这个方程中第一个dp[i][j]其实还是dp[i+1][j]的值,看上面的那一行代码就知道了,我们会发现这两个数都是dp[i+1][j]这一行的,那我们何必还要用二维数组呢?赶紧滚起来用一维的吧,用一维数组就要注意了,因为我们每次更新数据所需要用到是上一行的数据,所以在使用之前我们不能对其更新,这就决定了我们第二重循环只能逆序进行。OK,滚动之后本题的代码奉上:
#include<iostream> #include<algorithm> #include<cstring> #include<math.h> #define maxn 1005 using namespace std; int dp[maxn],value[maxn],volume[maxn]; int main(){ int t; cin>>t; while(t--){ memset(dp,0,sizeof(dp)); int n,v; cin>>n>>v; for(int i=1;i<=n;i++) cin>>value[i]; for(int i=1;i<=n;i++) cin>>volume[i]; for(int i=1;i<=n;i++){ for(int j=v;j>=0;j--){ if(j>=volume[i]) dp[j]=max(dp[j],dp[j-volume[i]]+value[i]); } } cout<<dp[v]<<endl; } return 0; }