背包动态规划问题的特征
背包问题具有显然的拓扑性,因此常被用作动态规划的入门讲解题目.
典型特征是“按某种规则消耗某种有限的资源获得最大的收益”,那么显然可以围绕这种资源的递减设计状态.
(事实上这个定义是宽泛的,只要有一个单调递减的变量可以用来设计dp顺序,就可看作这一类问题).
最简单的形式
背包容量为M,有N个不同的物品,第i个物品消耗x[i]的容量,对应y[i]的收益,求最大收益
SOLUTION
朴素算法:
枚举(2^n)种方案.之后我们设计算法时一定要想想它和朴素算法比起来怎么样,如果比朴素算法还差,或者实现之后比朴素算法优化不多,则这样的算法是没什么意义的.
在考场上经常有人消耗大量时间实现一个得分并不比朴素算法高多少的算法.所以我想强调一下.
动态规划
我们不妨先根据之前提到的单调递减量,设计f[i],表示消耗的容量总共为i的时候所能获得的最大收益是多少.
如何转移?我们似乎陷入了困境!任何算法都是“一口一口吃饭”,我们一次只能放进去一个物品.对于f[i]这个状态,我们放进去一个大小为k的物品可以变成f[i+k]这个状态.如果所有物品都是无限多的,那么转移起来当然无所谓,我们从小到大枚举i,然后再对每个i,枚举最后放进去(或者接下来放进去的,这对应"填表法"和"刷表法"两种转移方式)的物品.这可以得到一个"完全背包问题"的解法,但对我们这个题无法奏效.因为我们不知道某个物体之前是否使用过.那么我们可以用二进制数字表示集合,用集合表示状态,f[i][S]表示使用了集合S的物品,消耗了i的容量,所能获得的最大收益.然后我们发现对于一个集合的物品,其花费之和是确定的,于是我们只需要定义f[S]表示使用S集合中的物品能获得的最大收益,之前剩余容量递减的dp顺序自然蕴含在集合中物品的不断增加中.
这算法似乎没有比朴素算法高明到哪里去.
仔细考虑我们算法失败的原因.我们存储了大量的信息,任何时刻我们都知道当前包里有哪些物品已经放进去了.但是这似乎...知道的太多了?
再思考一下...我们其实在每个时刻都重新考虑了全部物品.这有必要吗?对每个物品其实只有“放进来”“不放进来”两种情况,对吧?任何两个物品之间,选择的结果都是独立的,可以直接加和.
那么我们完全可以逐个考虑每一个物品是否放进来!
那么就可以用f[j][i]表示"已经考虑了前j个物品“是否需要放进来,且当前已经使用的总容量为i时可以获得的最大收益是多少.于是得到了经典的背包问题实现.
实现
我在注释中做出了一些说明.主要是大家初学动态规划时经常失误的点.
f[0][0]=0;
for(int i=1;i<=m;++i)f[0][i]=-INF;//INF表示一个很大的正数,比如const int INF=0x3f3f3f3f;
//关于初始化:要符合状态的实际意义,这样你的dp数组求出来的才是你想要的东西.如果全部初始化为0也可以得到正确答案,但状态的含义有所不同,是什么呢?请你思考.
for(int j=1;j<=n;++j){//关于循环的嵌套顺序:要考虑清楚状态转移的顺序,这需要你清楚状态设计的来龙去脉以及状态转移的那些弯弯绕.
for(int i=0;i<x[j];++i)//避免数组越界:尤其在动态转移中出现下标减法的时候容易出现越界.有时不是会RE而是会WA.
f[j][i]=f[j-1][i];
for(int i=x[j];i<=m;++i)
f[j][i]=max(f[j-1][i],f[j-1][i-x[j]]+y[j]);//可以放第j个物品,也可以不放.如果你不是避免STL的强迫症患者,max函数,min函数建议使用<algorithm>库.
}
//为了便于阅读我删除了一些花括号.做题中建议加上所有可有可无的花括号.这将便于你在循环中添加一些调试语句或者之前忘记的语句.
//请注意数据类型的使用.背包问题同样可以爆int.但是为了讲述方便我默认都是int.实际上,任何问题都可以爆int.
关于滚动数组:n个物体,n轮循环中,每轮循环其实只用到了数组的两行,那么我们可以使用一个两行的数组,f[2][m+1]完成这个dp过程,将占用的空间由(n+1)(m+1)个int变为2*(m+1)个int,在渐进复杂度上干掉了一个n.来回倒就行了.
代码长这样:
f[0][0]=0;for(int i=1;i<=m;++i)f[0][i]=-INF;
int flag=0;
for(int j=1;j<=n;++j,flag^=1){
for(int i=0;i<x[j];++i)
f[flag^1][i]=f[flag][i];
for(int i=x[j];i<=m;++i)
f[flag^1][i]=max(f[flag][i],f[flag][i-x[i]]+y[i]);
}
由于01背包问题比较特殊,甚至都不需要两个数组来回倒,一个数组就搞定了.
f[0]=0;
for(int i=1;i<=m;++i)f[i]=-INF;
for(int j=1;j<=n;++j){
for(int i=m;i>=x[j];--i){//这个循环顺序很重要.只有倒着循环它才和我们之前的算法等价.
f[i]=max(f[i],f[i-x[j]]+y[j]);
}
}
错误的01背包:
f[0]=0;
for(int i=1;i<=m;++i)f[i]=-INF;
for(int j=1;j<=n;++j){
for(int i=x[j];i<=m;++i){//这个循环顺序的含义:每个物品允许使用任意多次.理解了这个东西你就会写"完全背包"了.
f[i]=max(f[i],f[i-x[j]]+y[j]);
}
}
思考:如何输出01背包最优解的一组方案?如何统计01背包最优解的方案数?
每个物品可用多次的背包问题
每个物品有一个使用次数z[i],拿到包里的个数可以在0到z[i]之间改变
较好理解的:二进制拆分.
(挖坑待填)
较不好理解:单调队列优化
(挖坑待填)