整理:背包 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];
}

例题1

例题2

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++;
        }
    }
}

例题

posted @ 2025-04-06 13:29  陈牧九  阅读(86)  评论(0)    收藏  举报