背包DP笔记
背包问题
01 背包问题
有 \(n\) 件物品和一个容量为 \(V\) 的背包,第 \(i\) 件物品的体积为 \(w[i]\),价值为 \(v[i]\)。请选择将哪些物品装入背包,使得价值总和最大。
思路:每种物品仅有一件,可以选择放或不放。令 \(f[i][j]\) 表示前 \(i\) 件物品放入一个容量为 \(j\) 的背包可以获得的最大价值,容易得到状态转移方程为:
显然,\(f[i-1][j]\) 表示不选择的情况,\(f[i-1][j-w[i]]+v[i](j \geq w[i])\) 表示选择的情况。
该算法的时间复杂度为 \(O(nV)\),空间复杂度为 \(O(nV)\)。
覆盖式填写
考虑将空间优化至 \(O(V)\),容易发现每一行的填写之依赖上一行的左侧,那么开一个一维数组即可,注意此时要从右往左填写,以保证填写 \(f[i][j]\) 时 \(f[i-1][j]\) 和 \(f[i-1][j-w[i]]\) 没有改变。
void 01pack(int w,int v){
for(int i=V;i>=w;i) f[i]=max(f[i],f[i-w]+v;
return ;
}
下文中,如果出现处理一件 01 背包中的物品,将会直接调用此函数而不加说明。
完全背包问题
有 \(n\) 种物品和一个容量为 \(V\) 的背包,每种物品有无数件,第 \(i\) 种物品的体积为 \(w[i]\),价值为 \(v[i]\)。请选择将哪些物品装入背包,使得价值总和最大。
这个问题与“01背包问题”很类似,不同的是每种物品能取无数件,令 \(f[i][j]\) 表示前 \(i\) 种物品放入一个容量为 \(j\) 的背包可以获得的最大价值,对于第 \(i\) 种物品,可以取 \(0 \sim k(k=\lfloor \frac{j}{c[i]}\rfloor)\) 件,那么状态转移方程为:
这样的做法时间复杂度超过了 \(O(nV)\),显然不够优,因此要考虑改进这个做法。
贪心优化
若两件物品 \(i_1,i_2\) 满足 \(w[i_1] \geq w[i_2]\) 且 \(v[i_1] \leq v[i_2]\),那么 \(i_1\) 一定不如 \(i_2\) 优(如果把 \(i_1\) 全换成 \(i_2\),不仅空间更小,价值也更大),可以不考虑。
对于随机生成的数据,这个方法可以大大加快速度,但可能被特别设计的数据卡掉。
还有一种做法:首先将体积大于 \(V\) 的物品去掉,然后使用计数排序计算体积相同的物品种最高的价值是哪个,可以在 \(O(V+n)\) 的时间内完成优化。
该优化会在之后多次提到。
转换为01背包求解
最简单的想法是,把第 \(i\) 种物品转化为 \(\lfloor \frac{j}{c[i]}\rfloor\) 件费用与价值相同的物品,然后使用01背包求解,但显然这一方法不能改进时间复杂度。
第二种想法是使用二进制拆分,把第 \(i\) 种物品拆成体积 \(w[i]·2^k\),价值 \(v[i]·2^k\) 的若干见物品,其中 \(w[i] · 2^k \leq V\)。这是很大的改进,但仍旧没有达到 \(O(nV)\)。
\(O(nV)\) 的算法
首先,为什么01背包的覆盖式填写要从右往左?这是因为要保证 \(f[i][j]\) 是由 \(f[i-1][j-w[i]]\) 得到,而不是由 \(f[i][j-w[i]]\) 得到。换句话,这是保证该物品只选 \(1\) 次。如果改成从左往右填写,那么 \(f[i][j]\) 就依赖 \(f[i][j-w[i]]\) 了,也就是选择 \(i\) 以后仍旧可以选择 \(i\) 件,满足完全背包“每个物品都能选择无限件”的特点,因此该思路成立。
void INFpack(int w,int v){
for(int i=w;i<=V;i++) f[i]=max(f[i],f[i-w]+v;
return ;
}
多重背包问题
有 \(n\) 种物品和一个容量为 \(V\) 的背包,每种物品有 \(t[i]\) 件,第 \(i\) 种物品的体积为 \(w[i]\),价值为 \(v[i]\)。请选择将哪些物品装入背包,使得价值总和最大。
这个问题只要将完全背包的方程略加改动即可,令 \(f[i][j]\) 表示前 \(i\) 种物品放入一个容量为 \(j\) 的背包可以获得的最大价值,那么状态转移方程为:
时间复杂度是 \(O(V·\displaystyle \sum_{i=1}^nt[i])\)。
转化为01背包问题
仍然考虑二进制拆分的思路,将第 \(i\) 种物品拆成若干件,系数分别为 \(1,2,4,...,2^{k-1},t[i]-2^k+1\)。其中 \(k\) 是满足 \(t[i]-2^k+1>0\) 的最大整数,这种方式可以通过若干件物品相加得到 \(0 \sim t[i]\) 中的任意一个整数,对于每件物品进行01背包求解即可。
void multipack(int w,int v,int t){
int k=1;
while(k<t){
01pack(k*w,k*v);
t-=k,k<<=1;
}
if(t) 01pack(t*w,t*v);
}
时间复杂度是 \(O(V·\displaystyle \sum_{i=1}^n \log t[i])\)
\(O(nV)\) 算法
多重背包同样具有 \(O(nV)\) 的算法,该算法基于单调队列优化,使得每一个状态都可以均摊成 \(O(1)\) 求解。
对于该状态转移方程:
考虑状态 \(j\) 和 \(j-w[i]\),此时只有一个新决策加入候选集合,一个旧决策被排除,因此我们把状态 \(j\) 按照除以 \(w[i]\) 的余数分组。对每一组分别计算,不同组之间的状态不会互相转移。
把倒序循环 \(j\) 的过程,改为对每个余数 \(u \in [0,w[i]-1]\),倒序循环 \(p=\lfloor \frac{V-u}{w[i]} \rfloor \sim 0\),对应的状态就是 \(j=u+p·w[i]\),第 \(i\) 种物品有 \(t[i]\) 个,因此能转移到 \(j=u+p·w[i]\) 的候选集合就是 \(\{u+k·w[i]|p-t[i] \leq k \leq p-1\}\),状态转移方程为:
将 \(p·v[i]\) 从 \(\displaystyle\max_{p-t[i]\leq k \leq p-1} \{f[u+k·w[i]]+(p-k)·v[i]\}\) 中提出,得:
这样将右侧的式子分为 \(2\) 部分,一部分仅与 \(p\) 相关,另一部分仅与 \(k\) 相关,可以使用单调队列优化。
把 \(i,u\) 看做定值,当 \(p\) 减小 \(1\) 时,决策 \(k\) 的范围上下界也单调减小 \(1\),因此我们可以建立一个 \(k\) 单调递减,\(f[u+k·w[i]]-k·v[i]\) 单调递减的队列,用于维护候选集合。
整个算法的时间复杂度为 \(O(nV)\)。
混合背包问题
有 \(N=n+m+k\) 种物品和一个容量为 \(V\) 的背包,第 \(1 \sim n\) 种物品有 \(1\) 件,第 \(n+1 \sim n+m\) 种物品有 \(t[i-n]\) 件,第 \(n+m+1 \sim n+m+k\) 种物品有无数件,第 \(i\) 种物品的体积为 \(w[i]\),价值为 \(v[i]\)。请选择将哪些物品装入背包,使得价值总和最大。
该问题看似困难,但只要分别调用以上三种背包即可解决,思路也很直观:
for(int i=1;i<=n;i++) 01pack(w[i],v[i]);
for(int i=n+1;i<=n+m;i++) multipack(w[i],v[i],t[i-n]);
for(int i=n+m+1;i<=n+m+k;i++) INFpack(w[i],v[i]);
此处的时间复杂度取决于 multipack
函数的复杂度,如果使用单调队列则是 \(O(VN)\)。但二进制拆分也已经足够优了。
二维费用背包问题
二维费用的背包问题是:对于每件物品,有 \(2\) 种不同的费用,选择该物品必须同时付出这两种代价,假设这两种代价分别为 \(a[i],b[i]\),可以付出的最大值为 \(U,V\),物品的价值为 \(v[i]\)。
对于这种题目,只需在原来的基础上将状态也加 \(1\) 维即可。设 \(f[i][j][k]\) 表示前 \(i\) 件物品付出 \(j,k\) 代价可以获得的最大价值,状态转移方程为:
二维费用的背包也可使用覆盖式填写,因此时间复杂度为 \(O(UVn)\),空间复杂度为 \(O(UV)\)。
物品个数的限制
有时,背包要求最多取 \(m\) 件物品,这时也可以看做是二维费用背包,多了一个“件数”的费用,每样物品的件数费用均为 \(1\),换句话说,\(f[i][j][k]\) 表示前 \(i\) 件物品,最多选择 \(j\) 件,付出费用 \(k\) 时能获得的最大价值。
当发现由熟悉的动态规划题目变形得来的题目时,在原来的状态中加一纬以满足新的限制是一种比较通用的方法。
分组背包问题
有 \(n\) 件物品和一个容量为 \(V\) 的背包,第 \(i\) 件物品属于第 \(p[i]\) 组,体积为 \(w[i]\),价值为 \(v[i]\),每组中最多选择 \(1\) 件物品,请选择将哪些物品装入背包,使得价值总和最大。
令 \(f[i][j]\) 表示前 \(i\) 组物品装入容量为 \(j\) 的背包可以获得的最大价值,这样决策就变成了选择该组的哪一组物品,亦或不选择,状态转移方程为:
由于我们不清楚每组物品有几个,因此可以使用 vector
存储。
注意:三层循环的顺序为 “所有的组 \(i\) \(\to\) 背包的大小 \(0 \sim V\) \(\to\) 所有属于 \(i\) 的 \(k\)”,这样才能保证每一组内最多只有一个物品会被添加到背包中。
for(int i=1;i<=cnt;i++){
for(int j=V;j>=0;j){
for(int k=0;k<id[i].size();k++){
if(j>=w[id[i][k]]) f[j]=max(f[j],f[j-w[id[i][k]]]+v[id[i][k]];
}
}
}
另外,可以对每组内的物品应用贪心优化,详情见“完全背包问题”。
依赖背包问题
这种背包的物品之间存在某种依赖关系,也就是说, \(i\) 依赖于 \(j\),表示如果要选择 \(i\),就必须先选择 \(j\)。
化简的问题
我们假设,没有物品既依赖与别的物品,又被别的物品依赖,同时,没有物品依赖多个物品
这个问题由 NOIP2006金明的预算 一题扩展而来,我们称不依赖于别的物品的物件成为 “主件”,依赖于别的物品的物件成为 “附件”,可知所有的物品组由一个 “主件” 和若干个 “附件” 集合而成。
最直接的思路是:依次考虑每一个物品集,但可用的策略极多,包括:一个物品也不选、选择主件、选择主件和 \(1\) 个附件......对于一个 \(n\) 个附件的物品集,策略的数量为 \(2^n+1\)。
考虑到所有的策略都互斥,所以一个主件和它的附件集合相当于 “分组背包问题” 里的一个物品组,只能从中选择一个策略,它的价值和话费都是这个策略中的物品和,但每个物品组的物品数仍旧很多,和原思路的策略数相同(原因显然)。
再考虑使用 “完全背包问题” 里提到过的贪心优化,对于一个物品组内,费用相同的物品只留下价值最大的那一个,不影响结果。所以,我们可以对每个主件的附件集合进行01背包,得到费用 \(0 \sim V-w[i]\) 对应的最大价值 \(f'[0 \sim V-w[i]]\)。那么这个主件与它的附件集合就相当于 \(V-w[i]+1\) 个物品组成的物品集。其中费用为 \(w[i]+k\) 的价值为 \(v[i]+f'[i]\),也就是说原来的策略集中有数量庞大的冗余,通过一次01背包就可以转化为分组背包解决。
一般的问题
一般的问题是:依赖关系以图论中的 “森林” 形式给出(森林即多叉树的集合),也就是说,主件的附件依然可以具有自己的附件集合,限制是只有一个主件,且不会出现环。
解决这个问题任然可以用将每个主件及其附件集合都转化为物品组,但不同的是,若这个附件也有附件集合,则它要先转化为物品组,然后用分组背包解出主件与附件集合所对应的附件组中各个费用的附件所对应的价值。
事实上,这是树形DP,其特点是每个父结点都需要对它各个儿子的属性进行DP已求得自己的相关属性。
泛化物品背包问题
考虑这样一种物品,它没有固定的费用和价值,它的价值随着你分配的费用的变化而变化,这就是 “泛化物品”。
更严格的定义,在容量为 \(V\) 的背包问题中,泛化物品就是一个定义域为 \(0 \sim V\) 的整数函数 \(h(x)\),其费用为 \(x\),价值为 \(h(x)\)。
一个费用为 \(w\) 价值为 \(v\) 的物品,如果它是01背包中的物品,那么也可以看做一个 \(h(w)=v\),其它函数值均为 \(0\) 的泛化物品。如果它是完全背包中的物品,可以看做一个 \(h(w·c)=v·c\),其它函数值均为 \(0\) 的泛化物品。
一个物品组可以看做一个泛化物品,对于 \(0 \sim V\) 中的 \(w\),若物品组中不存在费用为 \(w\) 的物品,则 \(h(w)=0\),否则 \(h(w)\) 为所有费用为 \(w\) 的最大价值。“有依赖的背包问题” 中由主件和附件组成的物品组自然可以看成泛化物品。
泛化物品的和
对于两个泛化物品 \(h,l\),要用给定的费用 \(V\) 从两个泛化物品中得到最大的价值,该如何解决?最简单的思路就是枚举每个物品能得到的费用。同样的,对于 \(0 \sim V\) 的每个整数 \(v\),可以求得费用 \(v\) 分配到 \(h,l\) 中的最大价值 \(f(v)\),也即 \(f(v)=\displaystyle\max_{0\leq k \leq v}\{h(k)+l(v-k)\}\)
由此定义泛化物品的和:\(h,l\) 为 \(2\) 个泛化物品,若泛化物品满足 \(f(v)=\displaystyle\max_{0\leq k \leq v}\{h(k)+l(v-k)\}\),则称 \(f\) 是 \(h\) 与 \(l\) 的和,写作 \(f=h+l\)。这个运算的时间复杂度取决于背包的容量,复杂度为 \(O(V^2)\)。
泛化物品的定义说明:在背包问题中,若将 \(2\) 个泛化物品代以它们的和,不影响背包的答案,因此求解泛化物品背包,就是求所有泛化物品之和的过程,设此和为 \(s\),则答案为 \(\displaystyle \max_{0 \leq x \leq V}\{s(x)\}\)。
背包问题的泛化物品
一个背包问题肯定能对应于某个泛化物品,也就是说,给定所有条件后,我们就可以对每个非负数 \(v\) 求得:若背包容量为 \(v\),将物品装入背包可以得到的最大价值,得到的答案集合就是在非负整数集上的一件泛化物品,一般而言,求得这个泛化物品的一个子域的值(例如 \(0 \sim V\))后,就可以根据函数的取值得到背包的答案。
综上所述,一般而言,求解背包问题即求解这个问题所对应的函数,也就是该问题所对应的泛化物品,而求解泛化物品的方法就是把它表示成若干个泛化物品的和然后求之。
背包问题的特殊问法
背包问题的问法非常灵活,例如,求解最多可以放置多少件物品或者最多可以装满多少背包空间,都可以根据具体问题利用前面的方程求解出所有状态的值(即 \(f\) 数组)后得到。
输出方案
一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的方案,可以参照一般动态规划输出方案的方法:记录下每个状态的最优值是由哪个状态转移得来的,换句话说,记录它是由哪个策略推出来的。便可根据这个状态继续往上寻找,直到寻找到最初状态。
以01背包为例,如果要求输出“选择了哪几个物品”,可以再加一个数组 \(g[i][j]\) 表示 \(f[i][j]\) 选择的方案,其中 \(g[i][j]=0\) 表示没有选择这个物品,\(g[i][j]=1\) 表示选择了这个物品。
或者,可以根据 \(f[i][j]\) 的值实时计算选择的方案,如果 \(f[i][j]=f[i-1][j]\) 则表示没有选择这个物品,如果 \(f[i][j]=f[i-1][j-w[i]]+v[i]\) 则代表选择了这个物品。
输出字典序最小的最优方案
这里字典序最小的意思是 \(1 \sim n\) 号物品的选择方案排列出来后字典序最小,以输出 01 背包的最小字典序的方案为例。
一般而言,求字典序最小的方案只需要在转移时注意策略,首先,如果存在一个选择 \(1\) 的最优方案,则答案一定包含 \(1\) ,原问题转化为容量 \(V-w[i]\),物品 \(2 \sim n\) 的最大价值,反之是容量 \(V\),物品 \(2 \sim n\) 的最大价值。无论答案如何,子问题的物品必定是 \(i \sim n\) 而不是 \(1 \sim i\)。
但也许更加简单的方法是将物品逆序排列一下。可以按照经典方法的状态转移方程来求值,只是输出方案时,如果 \(f[i][j]=f[i-1][j]\) 和 \(f[i][j]=f[i-1][j-w[i]]+v[i]\) 同时成立,则应该选择后者(即选择物品 \(i\),因为 \(i\) 的字典序更小)来输出方案。换句话说,优先判断 \(f[i][j]=f[i-1][j-w[i]]+v[i]\) 是否成立。
输出方案总数
对于给定 \(n,V,w[i],v[i]\) 和物品之间的关系的背包问题,除了可以得到最大价值外,也可以得到最大价值的方案总数。
这类问题一般只需将状态转移方程中的 \(\texttt{max}\) 改为 \(\texttt{sum}\) 即可,仍旧以01背包问题举例,状态转移方程改为:
初始条件为 \(f[0][0]=1\)
这种做法的可行性在于状态转移方程已经考察了所有可能的背包组成方案。
输出最优方案的总数
这里的方案总数指的是物品总价值最大的方案数,以01背包为例:
令 \(f[i][j]\) 表示前 \(i\) 个物品装入容量为 \(j\) 的包的最大价值,\(g[i][j]\) 代表前 \(i\) 个物品装入容量为 \(j\) 的背包时的最优方案数,程序如下:
for(int i=1;i<=n;i++){
for(int j=0;j<=V;j++){
f[i][j]=f[i-1][j];
if(j>=w[i]) f[i][j]=max(f[i][j],f[i-1][j-w[i]]+v[i]);
if(f[i][j]==f[i-1][j]) g[i][j]+=g[i-1][j];
if(f[i][j]==f[i-1][j-w[i]]+v[i]) g[i][j]+=g[i-1][j-w[i]];
}
}
求第 \(k\) 优解
对于求第 \(k\) 优解这一类的问题,如果相应的最优解问题能写出状态转移方程,那么 \(k\) 优解则比求最优解的复杂度增加一个系数 \(k\)。
其基本思想是将每个状态表示成有序队列,将状态转移方程中的 \(\texttt{max}/\texttt{min}\) 转化为有序队列的合并,以01背包为例:
经典01背包求解状态转移方程为:
如果要求解第 \(k\) 优解,那么状态 \(f[i][j]\) 就应该是一个大小为 \(k\) 的数组 \(f[i][j][1 \sim k]\),表示前 \(i\) 个物品装入容量为 \(j\) 的背包时的第 \(k\) 优解。
因此原方程可以理解为:\(f[i][j]\) 这个有序队列是由 \(f[i-1][j][1 \sim k]\) 和 \(f[i-1][v-w[i]][1 \sim k]+v[i]\) 这两个有序队列合并得到的。合并这两个有序队列,并将结果的前 \(k\) 项存储到 \(f[i][j][1 \sim k]\) 中,时间复杂度为 \(O(k)\),最后的答案为 \(f[n][V][k]\),时间复杂度为 \(O(nVk)\)。
一个正确的状态转移方程的求解过程遍历了所有的可用策略,也就覆盖了问题的所有方案,只不过是因为求解最优解,所以其它在任何一个策略上达不到最优的方案都被忽略了。如果把每个状态表示成一个大小为 \(k\) 的数组,并在这个数组中有序的保存该状态可取到的 \(k\) 个最优值,那么,对于任意 \(2\) 个状态的 \(\max\) 运算等价于两个由大到小的有序队列的合并。
另外还要注意题目对 “第 \(k\) 优解” 的定义,将策略不同但权值相同的方案是否看作同一个解,这决定有序序列里是否需要保留相同的值。