整理:背包 DP
关于背包DP的整理
upd:2025年2月10日 增加了不带删的尺取和线段树分治在背包问题中的应用。
0.背包DP的定义
形如:
有 \(n\) 个物品,每个物品有一个体积 \(v_i\) 和价值 \(w_i\),现在有一个大小为 \(V\) 的背包,并且对选择物品的方式有所限制,问选择物品总价值之和最大为多少
的动态规划问题。
1.01背包
每个物品只能选一次的背包问题。
1.最值问题
1.最暴力代码
\(dp[i][j]\)表示前 \(i\) 个物品,占用 \(j\) 的体积的最大价值和。
转移:\(dp[i][j]=max(dp[i-1][j-v[i]]+w[i]),(v[i]\le j \le V)\)。
for(int i=1;i<=n;i++)
for(int j=v[i];j<=V;j++)
dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i]]+w[i]);
时空复杂度均为\(O(nV)\)。
2.滚动数组优化空间
考虑到\(dp[i][j]\)的转移只和\(dp[i-1][j]\)的值有关,可以采用滚动数组优化空间。
注意:此时枚举背包容量应该从大到小枚举。
for(int i=1;i<=n;i++)
for(int j=V;j>=v[i];j--)
dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
空间复杂度降为\(O(V)\)。
2.计数问题
1.最暴力代码
\(dp[i][j]\)表示前 \(i\) 个物品,占用 \(j\) 的体积的方案数。
转移:\(dp[i][j]=\sum_{j=v[i]}^{V}(dp[i-1][j-v[i]]+w[i])\)。
初始化:\(dp[0][0]=1\)。
dp[0][0]=1;
for(int i=1;i<=n;i++)
for(int j=v[i];j<=V;j++)
dp[i][j]+=dp[i-1][j-v[i]];
时空复杂度均为\(O(nV)\)。
2.滚动数组优化空间
与最值问题相同,我们可以使用滚动数组来优化空间。
同样的,枚举背包容量应该从大到小枚举。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=V;j>=v[i];j--)
dp[j]+=dp[j-v[i]];
空间复杂度降为\(O(V)\)。
3.可行性问题
1.最暴力代码
\(dp[i][j]\)表示前 \(i\) 个物品占用 \(j\) 的体积的方案是否存在。
初始化:\(dp[0][0]=1\)。
dp[0][0]=1;
for(int i=1;i<=n;i++)
for(int j=v[i];j<=V;j++)
dp[i][j]=max(dp[i][j],dp[i-1][j-v[i]]);
2.滚动数组优化空间
同样压成一维。
一样要注意枚举体积的方式。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=V;j>=v[i];j--)
dp[i]=max(dp[i],dp[j-v[i]]);
3.bitset优化时间
对于可行性背包问题,还有一种常用来优化时间的优化方式:\(bitset\) 优化。
那么对于01背包的可行性问题,我们可以尝试使用 \(bitset\) 优化。
bitset<N>dp;
dp[0]=1;
for(int i=1;i<=n;i++)
dp|=dp>>v[i];
超快(常数直接变为原来的 \(\frac{1}{32}\),不快才怪)。
2.多重背包
每种物品有多个的背包问题。
1.最值问题
1.暴力代码(使用滚动数组优化后的代码)
很容易想到对将每一种物品分成若干个,对每个物品做一次01背包即可。
for(int i=1;i<=n;i++)
for(int j=1;j<=cnt[i];j++)
for(int k=V;k>=v[i];k--)
dp[k]=max(dp[k],dp[k-v[i]]+w[i]);
时间复杂度为\(O(V*\sum_{i=1}^{n}cnt_i)\)。
2.二进制拆分优化
想到这样一件事:使用 \(2^0,2^1,2^2,2^3......2^k\),可以拼出任意一个小于 \(2^{k+1}\) 的数,那么我们可以选择不使用若干个 \(1\) 来组成 \(cnt_i\),而是使用多个 \(2\) 的正整数幂来组成 \(cnt_i\)。
for(int i=1,p,tmp;i<=n;i++){
tmp=cnt[i],p=1;
while(tmp>=p){
for(int j=V;j>=v[i]*p;j--)dp[j]=max(dp[j],dp[j-v[i]*p]+w[i]*p);
tmp-=p;
p<<=1;
}
if(tmp)for(int j=V;j>=v[i]*tmp;j--)dp[j]=max(dp[j],dp[j-v[i]*tmp]+w[i]*tmp);
}
时间复杂度降为 \(O(V*\sum_{i=1}^{n} log(cnt_i))\)。
2.计数问题
1.暴力代码
暴力代码和01背包的计数问题类似。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=1;j<=cnt[i];j++)
for(int k=V;k>=v[i];k--)
dp[k]+=dp[k-v[i]];
2.二进制拆分优化
与最值问题相同,我们可以采用二进制拆分优化进行优化。
dp[0]=1;
for(int i=1,p,tmp;i<=n;i++){
tmp=cnt[i],p=1;
while(tmp>=p){
for(int j=V;j>=v[i]*p;j--)dp[j]+=dp[j-v[i]*p];
tmp-=p;
p<<=1;
}
if(tmp)for(int j=V;j>=v[i]*tmp;j--)dp[j]+=dp[j-v[i]*tmp];
}
3.前缀和优化
我们发现,其实第一份暴力代码就是将 \(dp\) 数组隔 \(v[i]\) 个做一次前缀和,再把选择超过 \(cnt[i]\) 个物品的情况减去,那么我们就可以进行前缀和优化。
dp[0]=1;
for(int i=1;i<=n;i++){
for(int j=v[i];j<=V;j++)dp[j]+=dp[j-v[i]];
for(int j=V;j>=v[i]*(cnt[i]+1);j--)dp[j]-=dp[j-v[i]*(cnt[i]+1)];
}
时间复杂度降为 \(O(nV)\),较之于二进制拆分更优。
3.可行性问题
1.暴力代码
暴力代码与01背包的可行性问题相似。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=1;j<=cnt[i];j++)
for(int k=V;k>=v[i];k--)
dp[k]=max(dp[k],dp[k-v[i]]);
2.二进制拆分优化
使用二进制拆分优化,与计数问题代码相似。
dp[0]=1;
for(int i=1,p,tmp;i<=n;i++){
tmp=cnt[i],p=1;
while(tmp>=p){
for(int j=V;j>=v[i]*p;j--)dp[j]=max(dp[j],dp[j-v[i]*p]);
tmp-=p;
p<<=1;
}
if(tmp)for(int j=V;j>=v[i]*tmp;j--)dp[j]=max(dp[j],dp[j-v[i]*tmp]);
}
3.二进制优化加bitset优化
由于是可行性问题,我们可以尝试在二进制拆分优化的基础上增加 \(bitset\) 优化,使时间复杂度更优。
bitset<N>dp;
dp[0]=1;
for(int i=1,p,tmp;i<=n;i++){
tmp=cnt[i],p=1;
while(tmp>=p){
dp|=dp>>(v[i]*p);
tmp-=p;
p<<=1;
}
if(tmp)dp|=dp>>(v[i]*tmp);
}
3.完全背包
每个物品可以无限选的背包问题。
1.最值问题
实际上,这份代码与01背包使用滚动空间优化后的代码十分相似,只有内层循环是从小到大枚举还是从大到小枚举的区别。
这样做是正确的原因是:由于内层循环是从小到大枚举,我们在进行转移时,实际上是充分考虑了选取多个的情况的,因为 \(j-v[i]\) 实际上已经尝试更新过了。
for(int i=1;i<=n;i++)
for(int j=v[i];j<=V;j++)
dp[j]=max(dp[j],dp[j-v[i]]+w[j]);
时间复杂度 \(O(nV)\)。
2.计数问题
同样的,这份代码与01背包的计数问题相似。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=v[i];j<=V;j++)
dp[j]+=dp[j-v[i]];
时间复杂度 \(O(nV)\)。
3.可行性问题
还是大同小异。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=v[i];j<=V;j++)
dp[j]=max(dp[j],dp[j-v[i]]);
时间复杂度 \(O(nV)\)。
实际上,完全背包的可行性问题同样可以使用 \(bitset\) 优化。
dp[0]=1;
for(int i=1;i<=n;i++)
for(int j=1;j*v[i]<=V;j++)
dp|=dp<<j*v[i];
时间复杂度 \(O(\sum_{i=1}^{n} \frac{V}{v_i})\)。
4.分组背包
有多组物品,每组物品中最多只能选择一个物品的背包问题。
将每一组抽象成物品,做一次01背包,对于每一个体积,枚举物品尝试更新,确保对于每一个可以更新的体积,都只有一个物品更新。
for(int i=1;i<=g;i++)//物品组数
for(int j=V;j;j--)
for(int k=1;k<=cnt[i];k++)
if(j>=v[i][k])
dp[j]=max(dp[j],dp[j-v[i][k]]+w[i][k]);
以上,简单的背包问题模型就总结完了。
5.混合背包
将多种背包问题混合的背包问题。
看着很难,但其实只要根据物品的特征,合理选择背包问题的模板解决即可。
for(int i=1;i<=n;i++){
if(是完全背包)for(int j=v[i];j<=V;j++)dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
if(是01背包或多重背包){
int p=1,tmp=cnt[i];
while(tmp>=p){
for(int j=V;j>=v[i]*p;j--)dp[j]=max(dp[j],dp[j-v[i]*p]+w[i]*p);
tmp-=p;
p<<=1;
}
if(tmp)for(int j=V;j>=v[i]*tmp;j--)dp[j]=max(dp[j],dp[j-v[i]*tmp]+w[i]*tmp);
}
}
但是,这种问题还有进化版:加入分组背包。
我们可以根据分组背包写出代码。
for(int i=1;i<=n;i++){
if(InGroup[i])continue;//InGroup[i] 表示物品 i 是否被分到了某一组中
if(是完全背包)for(int j=v[i];j<=V;j++)dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
if(是01背包或多重背包){
int p=1,tmp=cnt[i];
while(tmp>=p){
for(int j=V;j>=v[i]*p;j--)dp[j]=max(dp[j],dp[j-v[i]*p]+w[i]*p);
tmp-=p;
p<<=1;
}
if(tmp)for(int j=V;j>=v[i]*tmp;j--)dp[j]=max(dp[j],dp[j-v[i]*tmp]+w[i]*tmp);
}
}
for(int i=1;i<=G;i++){//G 表示组总数
memset(val,0,sizeof(val));//val[i] 表示当前组使用 i 的价值可以得到的最大价值
for(int id:Group[i]){
if(是完全背包)for(int i=1;i*v[id]<=V;i++)val[i*v[id]]=max(val[i*v[id]],i*w[id]);
if(是01背包或多重背包)for(int i=1;i<=cnt[id]&&i*v[id]<=V;i++)val[i*v[id]]=max(val[i*v[id]],i*w[id]);
}
for(int j=0;j<=V;j++)//这里其实又转换成了正常的分组背包问题,只是物品数量变多了而已
for(int k=0;k<=j;k++)
dp[j]=max(dp[j],dp[j-k]+val[k]);
}
6.二维费用背包
有两个量代表费用的背包问题。
只需要多一层循环表示第二种费用就可以了。
for(int i=1;i<=n;i++)
for(int j=V1;j>=v1[i];j--)
for(int k=V2;k>=v2[i];k2--)
dp[j][k]=max(dp[j][k],dp[j-v1[i]][k-v2[i]]+w[i]);
7.有依赖的背包
对于某些物品,需要先购买某个物品才能进行购买的背包问题。
将有依赖关系的物品放入同一个组中进行处理,我们直接进行 \(dp\) 不过需要开两个 \(dp\) 数组。
for(int i=1,p,mi;i<=n;i++){
read(p),read(mi);//读入主件的相关数据
for(int j=1;j<=mi;j++)read(c[j]),read(v[j]);//读入附件的相关数据
for(int j=1;j<=mi;j++)
for(int k=w;k>=c[j];k--)
g[k]=max(g[k],g[k-c[j]]+v[j]);
for(int j=p;j<=w;j++)g[j]=max(f[j],g[j-p]);
for(int j=1;j<=w;j++)g[j]=f[j];
}
8.不带删的尺取与线段树分治在背包中的应用
1.不带删的尺取
不带删的尺取针对某些不能直接删除但是方便合并的数据类型,例如最大公约数。
这种情况一般用两个栈模拟队列,第一个栈的栈顶为区间左端点,第二个栈的栈顶为区间右端点,入队操作直接放进第二个栈,出队时,如果第一个栈中非空,那么直接从第一个栈中出栈,否则,我们依次将第二个栈中的元素放进第一个栈中,注意此时第一个栈的栈顶为第二个栈的栈底,第一个栈的栈底为第二个栈的栈顶。
同时,需要注意的是,每一个栈的元素要同时考虑从当前元素到栈顶所有元素,以最大公约数举例,栈中元素 \(x\),对应区间中端点 \(l\),栈顶元素 \(y\),对应区间中端点 \(r\),那么元素 \(x\) 应当为 \([l,r]\) 区间的所有元素的最大公约数。
在这种情况下,想要知道现在队列维护的区间的最大公约数,只需要同时考虑第一个栈的栈顶和第二个栈的栈顶即可。
2.不带删的尺取在背包问题中的应用
我们如果有 \(n\) 个物品,求只使用 \([l,r]\) 这个区间中的所有物品,可以得到的最大值是多少,这里就可以使用不带删的尺取维护了。
由于最值背包不支持删除,所以我们可以用不带删的尺取了。
这里给出维护区间零一背包的双栈模拟的队列。
struct Queue{
struct Node{
int val,cost;
int dp[M];
void init(int _val,int _cost){
val=_val,cost=_cost;
for(int i=0;i<=w;i++)dp[i]=inf;
dp[0]=0;
}
}node[N];
int top1,top2,id;
int st1[N],st2[N];
void clear(){top1=top2=-1;}
void push(int cost,int val){
++id;
if(top2==-1){
node[id].init(val,cost);
for(int i=w;i>=cost;i--)tomin(node[id].dp[i],node[id].dp[i-cost]+val);
}else{
int x=st2[top2];
node[id].val=val,node[id].cost=cost;
for(int i=0;i<=w;i++)node[id].dp[i]=node[x].dp[i];
for(int i=w;i>=cost;i--)tomin(node[id].dp[i],node[id].dp[i-cost]+val);
}
st2[++top2]=id;
}
void pop(){
if(top1==-1){
while(top2!=-1){
int x=st2[top2--];
int val=node[x].val,cost=node[x].cost;
if(top1==-1){
node[x].init(val,cost);
for(int i=w;i>=cost;i--)tomin(node[x].dp[i],node[x].dp[i-cost]+val);
}else{
int y=st1[top1];
for(int i=0;i<=w;i++)node[x].dp[i]=node[y].dp[i];
for(int i=w;i>=cost;i--)tomin(node[x].dp[i],node[x].dp[i-cost]+val);
}
st1[++top1]=x;
}
}
top1--;
}
int Mi(){
if(top1==-1&&top2==-1)return inf;
if(top1==-1)return node[st2[top2]].dp[w];
if(top2==-1)return node[st1[top1]].dp[w];
int x=inf,id1=st1[top1],id2=st2[top2];
for(int i=0;i<=w;i++)tomin(x,node[id1].dp[i]+node[id2].dp[w-i]);
return x;
}
}q;
3.线段树分治在背包问题中应用
我们可能在背包问题中见到删除或添加物品,再求最优值。
我们可以使用线段树分治,对时间分治,将删除和添加操作更改为物品在时间上的作用域即可。
具体实现和线段树分治的模版并没有什么区别。
#define ls p<<1
#define rs p<<1|1
#define mid (l+r>>1)
void change(int p,int l,int r,int L,int R,Node v){
if(L<=l&&r<=R){
c[p].push_back(v);
return ;
}
if(mid>=L)change(ls,l,mid,L,R,v);
if(mid<R)change(rs,mid+1,r,L,R,v);
}
void dfs(int p,int l,int r,int dep){
for(int i=0;i<=V;i++)dp[dep][i]=dp[dep-1][i];
for(auto x:c[p])
for(int i=V;i>=x.c;i--)
tomax(dp[dep][i],dp[dep][i-x.c]+x.h);
if(l==r){
for(auto x:Q[l])ans[x.id]=dp[dep][x.b];
return ;
}
dfs(ls,l,mid,dep+1),dfs(rs,mid+1,r,dep+1);
}
#undef ls
#undef rs
#undef mid
9.背包的其他问题
这类问题依托于原有的背包问题,只是对答案有不同的要求。
1.输出方案
在转移时记录一下是使用什么物品转移的,然后输出即可。
for(int i=1;i<=n;i++){
for(int j=a[i];j<=V;j++){
if(dp[i-1][j-v[i]]+w[i]>dp[i][j]){
path[i][j]=i;
dp[i][j]=dp[i-1][j-v[i]]+w[i];
}
}
}
2.第 \(k\) 优解
将原来的 \(max\) 操作变成合并有序数组即可。
这里取01背包为例。
for(int i=1;i<=n;i++){
for(int j=m;j>=w[i];j--){
for(int k=1;k<=K;k++){
a[k]=dp[j][k];
b[k]=dp[j-w[i]][k]+v[i];
}
a[K+1]=b[K+1]=dp[j][0]=-1;
now=p1=p2=1;
while(now<=K&&(a[p1]!=-1||b[p2]!=-1)){
if(a[p1]>b[p2])dp[j][now]=a[p1++];
else dp[j][now]=b[p2++];
if(dp[j][now]!=dp[j][now-1])now++;
}
}
}

浙公网安备 33010602011771号