背包基础

观前提醒:你需要一个AcWing账号。

一.01背包

算是最最简单的背包了。

例题1:洛谷P1048&AcWingT2

给你\(n\)个物品和一个背包,该背包的最大容积是\(V\)

对于第\(i\)个物品,它有自己的体积\(v_i\)和价值\(w_i\)

你要选一些物品放入背包,使得物品的体积之和不超过最大容积\(V\),且价值之和最大。输出最大价值之和。

因为每种物品只有一个,选了可以看作1,没选就是0,所以它叫01背包。

非常经典的背包dp题目对吧。

我们令\(dp_{i,j}\)表示考虑前\(i\)个物品选法,最大容量为\(j\)时的最大价值之和。那么\(dp_{n,V}\)显然就是答案。

接下来考虑转移方程。对于第\(i\)样物品,无非就是选或不选两种情况。

如果不选最优(或者说\(j\)小于\(v_i\)所以选不了\(i\)),那么就是考虑前\(i-1\)个物品在最大容量为\(j\)时的选法,即\(dp_{i,j}=dp_{i-1,j}\)

如果\(j\)大于\(v_i\)且选\(i\)更优,那么就是在前\(i-1\)个物品里先选着,然后再把\(i\)选上,体积之和不超\(j\)

所以前\(i-1\)个物品的体积之和应该限制在\(j-v_i\)内,也就是\(dp_{i,j}=dp_{i-1,j-v_i}+w_i\)

到这里,我们的转移方程就写出来了:

\[ dp_{i,j}= \begin{cases} dp_{i-1,j} & (j<v_i) \\ max(dp_{i-1,j},dp_{i-1,j-v_i}+w_i) & (j>=v_i) \\ \end{cases} \]

显然,时间复杂度是\(O(nV)\),空间复杂度也是\(O(nV)\)的。

至此我们最朴素的01背包代码诞生了:

二维dp数组代码
#include<iostream>
using namespace std;
int V,n,v[110],w[110],dp[110][1010];
int main(){
	cin>>V>>n;
	for(int i=1;i<=n;i++){
		cin>>v[i]>>w[i];
	}
	for(int i=1;i<=n;i++){
		for(int j=1;j<=V;j++){
			if(j-v[i]>=0){
				dp[i][j]=max(dp[i-1][j],dp[i-1][j-v[i]]+w[i]);
			}else{
				dp[i][j]=dp[i-1][j];
			}
		}
	}
	cout<<dp[n][V];
	return 0;
}

当然,注意到\(i\)的dp值(不论容积是多少)只与\(i-1\)有关,我们还可以用滚动数组优化掉第一维,就像这样。

滚动数组代码
#include<iostream>
using namespace std;
int V,n,v[110],w[110],dp[2][1010];
int main(){
	cin>>V>>n;
	for(int i=1;i<=n;i++){
		cin>>v[i]>>w[i];
	}
	//x&1就是x%2的意思,也就是取除以二的余数 
	for(int i=1;i<=n;i++){
		for(int j=0;j<=V;j++){
			if(j-v[i]>=0){
				dp[i&1][j]=max(dp[(i-1)&1][j],dp[(i-1)&1][j-v[i]]+w[i]);
			}else{
				dp[i&1][j]=dp[(i-1)&1][j];
			}
		}
	}
	cout<<dp[n&1][V];
	return 0;
}

但是更常见的写法还是下面这一种,把第一维彻底滚掉:

我们画一个图就会发现,如果把dp数组看成一个方格图,那么根据上面的转移方程,\(dp_{i,j}\)一定从自己正上方,或者是左上方转移而来:

\( \begin{bmatrix} dp_{1,1} & dp_{1.2} & dp_{1,3} & \cdots & dp_{1,j-v_i} & \cdots & dp_{1,j} & \cdots & dp_{1,V} \\ dp_{2,1} & dp_{2.2} & dp_{2,3} & \cdots & dp_{2,j-v_i} & \cdots & dp_{2,j} & \cdots & dp_{2,V} \\ \vdots & \vdots & \vdots & \ddots & \vdots & \ddots & \vdots & \ddots & \vdots \\ dp_{i-1,1} & dp_{i-1.2} & dp_{i-1,3} & \cdots & dp_{i-1,j-v_i} & \cdots & dp_{i-1,j} & \cdots & dp_{i-1,V} \\ dp_{i,1} & dp_{i.2} & dp_{i,3} & \cdots & dp_{i,j-v_i} & \cdots & dp_{i,j} & \cdots & dp_{i,V} \\ \vdots & \vdots & \vdots & \ddots & \vdots & \ddots & \vdots & \ddots & \vdots \\ dp_{n,1} & dp_{n.2} & dp_{n,3} & \cdots & dp_{n,j-v_i} & \cdots & dp_{n,j} & \cdots & dp_{n,V} \\ \end{bmatrix} \)

由此我们想到:如果把表示物品\(i\)的那一维压缩掉,只保留最大容量\(j\)那一维,且倒序枚举最大容量\(j\),那么在更新\(dp_j\)的时候,用到的就是\(dp_j\)\(dp_{j-v_i}\),存的都是\(i-1\)时的dp值。由此转移方程就变成了:

\[ dp_{j}= \begin{cases} dp_{j} & (j<v_i) \\ max(dp_{j},dp_{j-v_i}+w_i) & (j>=v_i) \\ \end{cases} \]

这样我们又能进行一个小优化:\(j\)只需要从\(v_i\)~\(V\)枚举,因为显然\(j<v_i\)时dp值就是它本身。

代码:

一维dp代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){//快读
	int x=0;char c=getchar();
	while(c<48) c=getchar();
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=1314;
const int WT=1314;
int n,V,v[N],w[N],dp[WT]; 

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		v[i]=read(),w[i]=read();
	}
	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]);
		}
	}
	printf("%lld",dp[V]);
	return 0;
} 

例题2:洛谷P1164&AcWingT11

顺着刚才求最优方案的思路,我们同样可以做背包计数类问题。

先说AcWing的T11。我们考虑在dp数组之外另建一个cnt数组,\(cnt_{i,j}\)表示考虑前\(i\)个物品,最大容量为\(j\)(体积之和不超过\(j\))时的合法方案数。

根据dp的转移方程,如果\(j<v_i\),那么显然方案只有不选\(i\)一种,\(cnt_{i,j}=cnt_{i-1,j}\)

如果\(j>=v_i\),那么就比较\(dp_{i-1,j}\)\(dp_{i-1,j-v_i}+w_i\)的大小,谁大就让\(dp_{i,j}\)等于谁。

对于\(cnt_{i,j}\),先考虑一种最特殊的情况:\(dp_{i-1,j}=dp_{i-1,j-v_i}+w_i\)。此时不论选不选\(i\),都能选到当前最优价值。根据加法原理,显然\(cnt_{i,j}=cnt_{i-1,j}+cnt_{i-1,i-v_i}\)

否则,如果\(dp_{i-1,j}>dp_{i-1,j-v_i}+w_i\),那么说明不选\(i\)为最优解,\(cnt_{i,j}=cnt_{i-1,j}\);反之选\(i\)为最优解,\(cnt_{i,j}=cnt_{i-1,j-v_i}\)

代码(AcWingT11):

AcWingT11代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0,f=1;char c=getchar();
	while(c<48){
		if(c=='-') f=-1;
		c=getchar();
	}
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x*f; 
}

const int N=1314;
const int WT=1314;
const int mod=1e9+7;
int n,V,dp[N][N],cnt[N][N];

signed main(){
	n=read(),V=read();
    //没有容量或0号物品时无法选物品,也是一种最优方案,记得初始化为1
	for(int i=1;i<=n;i++){
		cnt[i][0]=1;
	}
	for(int i=0;i<=V;i++){
		cnt[0][i]=1;
	}
	for(int i=1;i<=n;i++){
		int v=read(),w=read();
		for(int j=1;j<=V;j++){
			if(j<v){
				dp[i][j]=dp[i-1][j];cnt[i][j]=cnt[i-1][j];
			}
			else{
				if(dp[i-1][j]>dp[i-1][j-v]+w){
					cnt[i][j]=cnt[i-1][j];
				}
				else if(dp[i-1][j]==dp[i-1][j-v]+w){
					cnt[i][j]=(cnt[i-1][j]+cnt[i-1][j-v])%mod;
				}
				else{
					cnt[i][j]=cnt[i-1][j-v];
				}
				dp[i][j]=max(dp[i-1][j],dp[i-1][j-v]+w);
			}
		}
	}
	printf("%lld\n",cnt[n][V]);
	return 0;
}

再说P1164。我们定义\(g_{i,j}\)为考虑前\(i\)个物品,体积之和恰好\(j\)的方案数,显然答案为\(g_{n,V}\)

这样一来转移方程呼之欲出:当\(j<v_i\)时,无法选\(i\)号物品,显然只能考虑前\(i-1\)个物品恰好填满\(j\)的方案,\(g_{i,j}=g_{i-1,j}\)

如果\(j>=v_i\),那么一方面可以是不选\(i\),让\(i-1\)个物品填满\(j\);另一方面可以选\(i\),让其余\(i-1\)个物品体积之和为\(j-v_i\),此时\(g_{i,j}=g_{i-1,j}+g_{i-1,j-v_i}\)

代码:

洛谷P1164代码
#include<iostream>
using namespace std;
int n,V,v[520],g[110][10010];
int main(){
	cin>>n>>V;
	for(int i=1;i<=n;i++) cin>>v[i];
	for(int i=0;i<=n;i++) g[i][0]=1;//无法装东西时容积为0,j=0时也是一种方案 
	for(int i=1;i<=n;i++){
		for(int j=1;j<=V;j++){
			if(j-v[i]>=0) g[i][j]=g[i-1][j]+g[i-1][j-v[i]];
			else g[i][j]=g[i-1][j];
		}
	}
	cout<<g[n][V];
	return 0;
}

例题3.AcWingT12

01背包输出字典序最小的方案类问题。

无疑1号物品是n个物品里面编号最小的一个。如果选1号物品能使方案最优的话肯定选1号物品,否则再去2~n里选择物品。

由此我们定义\(dp_{i,j}\)表示考虑i ~ n里的物品,最大容量是\(j\)时的最优解。由于物品的顺序并不重要,它的转移方程跟普通的01背包类似,为:

\[ dp_{i,j}= \begin{cases} dp_{i+1,j} & (j<v_i) \\ max(dp_{i+1,j},dp_{i+1,j-v_i}+w_i) & (j>=v_i) \\ \end{cases} \]

倒序算dp有一个好处,当我们顺序考虑到第\(i\)个物品时,因为1 ~ i-1已经考虑完了,此时\(i\)已经是最小的数字了。

这样,如果\(dp_{i,j}=dp_{i-1,j-v_i}\),说明选上\(i\)有最优方案,不论\(dp_{i,j}\)是否还等于\(dp_{i-1,j}\),即不选i也有最优方案。因为\(i\)是最小的一个了,所以我们要尽可能的选上\(i\)。否则说明不选\(i\)更优,就不输出\(i\)

注意。选完\(i\)后最大容量会扣掉一个\(v_i\)

代码:

点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0,f=1;char c=getchar();
	while(c<48){
		if(c=='-') f=-1;
		c=getchar();
	}
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x*f;
}

const int N=1314;
const int WT=1314;
int n,V,v[N],w[N],dp[N][N];

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		v[i]=read(),w[i]=read();
	}//普通01背包dp 
	for(int i=n;i>=1;i--){
		for(int j=0;j<=V;j++){
			dp[i][j]=dp[i+1][j];
			if(j>=v[i]){
				dp[i][j]=max(dp[i][j],dp[i+1][j-v[i]]+w[i]);
			}
		}
	}
	int cur=V;
	//统计答案 
	for(int i=1;i<=n;i++){
		if(i==n&&cur>=v[n]){//判断边界 
			printf("%lld",i);
			return 0;
		}
		if(cur<=0) return 0;//此时没法选中新的物品,直接结束循环 
		if(cur>=v[i]&&dp[i][cur]==dp[i+1][cur-v[i]]+w[i]){
			printf("%lld ",i);cur-=v[i];//输出并减小剩余容量 
		}
	}
	return 0;
}

例题4.二维费用背包 AcWingT8

无非是又增加了一个限制条件。我们增加质量这一维后,还是走01背包的思路,令\(dp_{i,j,k}\)为考虑了前\(i\)个物品,最大容量为\(j\),最大载重是\(k\)的情况下的最大价值之和。考虑第\(i\)种物品选或不选(同样,选的话要满足限制条件),易得转移方程如下:

\[ dp_{i,j,k}= \begin{cases} dp_{i-1,j,k} & (j<v_i) \\ max(dp_{i-1,j,k},dp_{i-1,j-v_i,k-m_i}+w_i) & (j>=v_i) \\ \end{cases} \]

那么仍然走01背包思路,考虑压缩掉dp第一维。这个东西和01背包几乎一模一样,仍然是倒序枚举j和k,这样我们更新\(dp_{j,k}\)时,用到的\(dp_{j,k}\)\(dp_{j-v_i,k-m_i}\)就都是\(i-1\)层的值了。

这样我们的时间复杂度就是\(O(nMV)\),空间复杂度是\(O(MV)\)

代码:

点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0;char c=getchar();
	while(c<48) c=getchar();
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=1010;
const int WT=105;
const int MS=105;
int n,V,M,v[N],m[N],w[N],dp[WT][MS];

signed main(){
	n=read(),V=read(),M=read();
	for(int i=1;i<=n;i++){
		v[i]=read(),m[i]=read(),w[i]=read();
	}
	//倒序枚举
	for(int i=1;i<=n;i++){
		for(int j=V;j>=v[i];j--){
			for(int k=M;k>=m[i];k--){
				dp[j][k]=max(dp[j][k],dp[j-v[i]][k-m[i]]+w[i]);
			}
		}
	}
	printf("%lld",dp[V][M]);
	return 0;
} 

二.完全背包

例题.洛谷P1616&AcWingT3

与01背包不同的是,完全背包的物体有无限个。

我们还是可以沿用01背包设计的状态,设\(dp_{i,j}\)为考虑前\(i\)个物品,最大容积为\(j\)时的最大价值和。

这样我们沿用01背包的思路,第\(i\)种物品可以不选、可以只选一个、可以选两个……直到容积超了为止。

这样朴素的转移方程就是\(dp_{i,j}=\max\limits_{k=0}^{k*v_i<=j}dp_{i-1,j-k*v_i}+k*w_i\)

但是这也太慢了……所以我们肯定要考虑优化。

如果感性理解的话,每种物品都有两种情况:要么一个不放,要么放至少一个。如果是一个不放,那就同01背包,\(dp_{i,j}=dp_{i-1,j}\)。如果放多个呢,那么就是在原来\(i\)可能放了好几个的基础上再放一个,\(dp_{i,j}=dp_{i,j-v_i}+w_i\)(注意下标和01背包的不同)。因为我们这里要考虑\(i\)物品已经有一部分在包里的情况。当然,此时最大容积\(j\)必须大于等于\(v_i\)(否则显然放不开吧……)

那么我们的转移方程就是:

\[ dp_{i.j}= \begin{cases} dp_{i-1,j} & (j<v_i) \\ max(dp_{i-1,j},dp_{i,j-v_i}+w_i) & (j>=v_i) \\ \end{cases} \]

有人可能觉得这太感性了,那接下来我们理性证明一下:

以下是最原始的转移方程递推出来的式子,为了好看我们对一下齐:

dp[i][j] = max( dp[i-1][j] , dp[i-1][j-v[i]]+w[i] , dp[i-1][j-2v[i]]+2w[i] , dp[i-1][j-3v[i]]+3w[i] , .....)
dp[i][j-v[i]]=max( dp[i-1][j-v[i]] , dp[i-1][j-2v[i]] + w[i] , dp[i-1][j-3v[i]]+2*w[i] , .....)

发现没有?\(dp_{i,j}\)取的其实就是\(dp_{i-1,j}\)\(dp_{i,j-v_i}+w_i\)的最大值,完美符合我们的转移方程式。

于是最最朴素的代码有了:

二维dp
#include<iostream>
using namespace std;
int V,n,v[1010],w[1010],dp[1010][1010];
int main(){
	cin>>n>>V;
	for(int i=1;i<=n;i++){
		cin>>v[i]>>w[i];
	}
	for(int i=1;i<=n;i++){
		for(int j=1;j<=V;j++){
			if(j-v[i]>=0){
				dp[i][j]=max(dp[i-1][j],dp[i][j-v[i]]+w[i]);
			}else{
				dp[i][j]=dp[i-1][j];
			}
		}
	}
	cout<<dp[n][V];
	return 0;
}

当然呢,同01背包,我们也可以把dp数组优化成滚动数组和一维数组。

滚动数组优化无非是将第一维时刻%2,没什么好说的。重点说说如何压缩成一维数组。

其实也很简单,逻辑和01背包极其类似,不同点是,这里我们用的是第\(i\)层的\(j-v_i\),所以我们改成将\(j\)正向枚举,这样用到的\(dp_{j-v_i}\)就是第\(i\)轮更新过的了。

代码:

一维dp
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0;char c=getchar();
	while(c<48) c=getchar();
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=1314;
const int WT=1314;
int n,V,v[N],w[N],dp[WT]; 

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		v[i]=read(),w[i]=read();
	}
	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[i]);
		}
	}
	printf("%lld",dp[V]);
	return 0;
} 

三.多重背包

例题1:AcWingT4

和前两个背包都不同的是,每个物品都不止只有一个,也不是无穷个,而是一个给我们的特定的数量。

首先最朴素的想法是:当01背包做,把多个同种物品拆开,当成多个只有一件的不同物品。然后这个题就AC了。

时间复杂度\(O(nm \times s_{max})\)

T4代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0;char c=getchar();
	while(c<48) c=getchar();
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=123;
int n,V,v[N*N],w[N*N],dp[N*N],tot;

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		int x=read(),y=read(),z=read();
		while(z--){//这里是拆分物品 
			tot++;v[tot]=x;w[tot]=y;
		}
	}
	for(int i=1;i<=tot;i++){
		for(int j=V;j>=v[i];j--){
			dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
		}
	}
	printf("%lld",dp[V]);
	return 0;
} 

然鹅这也太慢了……如果数据再大点就过不了了。

我们重新思考一下。我们这么做的目的是什么?是确保\(i\)号物品选择\(0 - s_i\)的所有情况都被考虑到。那如果能换一种拆分数目更少的、而且能通过多个物品相加表示出\(0 - s_i\)中所有数字的方式就好了。可是真的有这种方式吗?

当然了。我们按照二进制拆分一下,将第\(i\)样物品的个数\(s_i\)拆成1、2、4、8……直到剩余的数已经不满下一个2的次方了,那就把这部分单独留下就行了。

比如,18按照这个拆法,就应该是1、2、4、8、3(因为1+2+4+8=15,下一个次方是16,18此时剩3,不够了,3就单出来了)。

要证明也很简单,不考虑最后剩的数字,假设前面的数到了\(2^x\)次方,物品总个数为\(num\),那么根据二进制,每一个2的次方就相当于控制某一位上的数字。选是1,不选是0。

比如,选择1、2、8,对应到二进制上就是1011,也就是11。这样易得,\([0,2^{x+1}-1]\)内的数都能表示出来。这是不考虑剩余的那个数(即\(num-2^{x+1}+1\))能表示出来的数。

那么考虑了这个数呢?那就是\([num-2^{x+1}+1,num]\)呗。由于\(num-2^{x+1}+1<2^{x+1}\)(否则能凑出来下一个幂次\(2^{x+1}\)),所以\(num-2^{x+1}+1<=2^{x+1}-1\),所以两个区间一定能完全把\([0,num]\)区间里的整数囊括在内。

至此我们就证明了这个方法的可行性。显然,这样做,时间复杂度是\(O(nmlogs)\)的,比刚才快了不少。(因为会有\(nlogs\)件物品)。

代码:

T5代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0;char c=getchar();
	while(c<48) c=getchar();
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=123;
int n,V,v[N*N],w[N*N],dp[N*N],tot;

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		int x=read(),y=read(),z=read();
		int qwq;
		for(qwq=1;qwq<=z;z-=qwq,qwq*=2){//拆分过程同解析 
			v[++tot]=x*qwq;w[tot]=y*qwq;
		}
		if(z){//剩余部分单独装起来 
			v[++tot]=x*z;w[tot]=y*z;
		}
	}
	//拆分完就是普通01背包啦,注意物品已经不是n件了 
	for(int i=1;i<=tot;i++){
		for(int j=V;j>=v[i];j--){
			dp[j]=max(dp[j],dp[j-v[i]]+w[i]);
		}
	}
	printf("%lld",dp[V]);
	return 0;
} 

但是,为了装X精益求精,我们可以继续优化。首先我们明确一下多重背包的原始转移方程:

\(dp_{i,j}=\max\limits_{k=0}^{k*v_i<=j且k<=s_i}dp_{i-1,j-k*v_i}+k*w_i\)

首先我们发现,\(dp_{i,j}\)一定是从上一层的、有关\(v_i\)\(j\)同余的某个容量\(x\)转移来的。我们不妨记这个余数为\(r\),然后我们把截至j的所有余数为r的容量第i层的状态都写下来,如下:

(方便起见,我们省略w、v、s数组的下标i)

\[\begin{cases} dp_{i,j}=max(dp_{i-1,j},dp_{i-1,j-v}+w,dp_{i-1,j-2*v}+2*w,\cdots,dp_{i-1,j-s*v}+s*w) \\ \\ dp_{i,j-v}=max(dp_{i-1,j-v},dp_{i-1,j-2*v}+w,dp_{i-1,j-3*v}+2*w,\cdots,dp_{i-1,j-(s+1)*v}+s*w) \\ \\ dp_{i,j-2*v}=max(dp_{i-1,j-2*v},dp_{i-1,j-3*v}+w,dp_{i-1,j-4*v}+2*w,\cdots,dp_{i-1,j-(s+2)*v}+s*w) \\ \\ \cdots \\ \\ dp_{i,r+s*v}=max(dp_{i-1,r+s*v},dp_{i-1,r+(s-1)*v}+w,dp_{i-1,r+(s-2)*v}+2*w,\cdots,dp_{i-1,r}+s*w) \\ \\ dp_{i,r+(s-1)*v}=max(dp_{i-1,r+(s-1)*v},dp_{i-1,r+(s-2)*v}+w,dp_{i-1,r+(s-3)*v}+2*w,\cdots,dp_{i-1,r}+(s-1)*w) \\ \\ \cdots \\ dp_{i,r+2*v}=max(dp_{i-1,r+2*v},dp_{i-1,r+v}+w,dp_{i-1,r}+2*w) \\ \\ dp_{i,r+v}=max(dp_{i-1,r+v},dp_{i-1,r}+w) \\ \\ dp_{i,r}=dp_{i-1,r} \\ \\ \end{cases} \]

我们从下往上去看这一坨式子,首先对于当前每一个\(j\),它都是由之前某个容量的dp值加上装若干个\(i\)带来的价值转移来的。

假设当前容量为\(j\),那我们令\(f_{i,k}=dp_{i,k}+(j-k)/v_i*w_i\),保证\(k \equiv j(mod v_i)\)。显然\(dp_{i,j}\)的值就是,\([1,j]\)区间内 (注意是闭区间,也就是可以算上\(j\)\(j\)同余的数中,某个这样的数对应的最大\(f_{i,k}\)。当\(j\)加上一个\(v_i\)时,显然\(j\)前的所有数的f值会加\(v_i\),也就是相对大小关系不变。

那我们发现对于特定的\(i\)和余数\(r\),从小到大枚举\(j\)的时候,dp_{i,j}其实就是在一个区间里找最大值而已,而且这个区间的左右端点还是单调不降的(具体请看借用过来的这个图),那我们就可以使用单调队列优化的dp求解。(不会单调队列的出门左转去找P1886,其实这个题的转移区间很类似滑动窗口)

屏幕截图 2025-08-25 123426

总之这是单调队列优化dp的一个变型,把原模板题里连续几个位置的数变成了模\(v_i\)意义下余数为\(r\)的数里,连续的几个数。时间复杂度成功被优化到\(O(nm)\)

空间优化前是\(O(nm)\)的,实测过不去。因为第\(i\)层的结果还是只和第\(i-1\)层有关,所以可以滚动数组滚掉一维,或者新开一个拷贝数组存第\(i-1\)层的结果。总之这样下来空间复杂度就变成了\(O(m)\)

代码(这里只放滚动数组版的):

点击查看代码
#include<bits/stdc++.h>
#define int long long 
using namespace std;

inline int read(){
	int x=0;char s=getchar();
	while(s<48) s=getchar();
	while(s>47) x=(x<<1)+(x<<3)+(s^48),s=getchar();
	return x;
}

const int N=1010;
const int WT=2e4+2;//开大一丢丢,不然容易被卡 
int n,V,v[N],w[N],s[N],dp[2][WT],q[WT],fr,tl; 

signed main(){
	n=read(),V=read();
	//以下代码没有真正定义f数组,不过我们可以这么理解 
	for(int i=1;i<=n;i++){
		v[i]=read(),w[i]=read(),s[i]=read();
	}
	for(int i=1;i<=n;i++){
		for(int r=0;r<v[i];r++){//枚举余数 
			fr=1;tl=0;//每次都是赋值操作,对队头队尾指针初始化即可 
			for(int j=r;j<=V;j+=v[i]){
				while(fr<=tl&&j-q[fr]>s[i]*v[i]) fr++;//如果队头超出了区间范围就出队 
				while(fr<=tl&&dp[(i-1)&1][q[tl]]+(j-q[tl])/v[i]*w[i]<=dp[(i-1)&1][j]) tl--;
				//保证单调队列的f值单调递减,如果j前面的数的f比j小,那么j入队后队列不单调,而j迟早要入队,所以前面的元素出队 
				q[++tl]=j;dp[i&1][j]=dp[(i-1)&1][q[fr]]+(j-q[fr])/v[i]*w[i];
				//入队&统计答案 
			}
		}
	}
	printf("%lld",dp[n&1][V]);
	return 0;
} 

四.混合背包

例题:AcWingT7

01背包、完全背包、多重背包大杂烩。

其实不难:对于第\(i\)个物品,如果它的\(s\)是-1,就走01背包转移方程;它的\(s\)是0,就走完全背包转移方程;否则走多种背包转移方程。

为什么能这么做呢?每个物品互不影响,所以考虑到第\(i\)个物品的时候,我们会给前\(i-1\)个物品的dp值分配一个体积,它就返回一个价值,相当于给前面物品合成了一个大物品,不用我们操心了。

这里我们采用滚动数组优化空间、单调队列优化多重背包,时间复杂度\(O(nm)\),空间复杂度\(O(m)\)

代码:

点击查看代码
#include<bits/stdc++.h>
#define int long long 
using namespace std;

inline int read(){
	int x=0;char s=getchar();
	while(s<48) s=getchar();
	while(s>47) x=(x<<1)+(x<<3)+(s^48),s=getchar();
	return x;
}

const int N=1314;
const int WT=1314;
int n,V,v[N],w[N],s[N],dp[2][WT],q[WT],fr,tl; 

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		v[i]=read(),w[i]=read(),s[i]=read();
	}
	for(int i=1;i<=n;i++){
		if(s[i]==-1){
			for(int j=0;j<=V;j++){
				dp[i&1][j]=dp[(i-1)&1][j];
				if(j>=v[i]){
					dp[i&1][j]=max(dp[i&1][j],dp[(i-1)&1][j-v[i]]+w[i]);
				}
			}
		}
		else if(s[i]==0){
			for(int j=0;j<=V;j++){
				dp[i&1][j]=dp[(i-1)&1][j];
				if(j>=v[i]){
					dp[i&1][j]=max(dp[i&1][j],dp[i&1][j-v[i]]+w[i]);
				}
			}
		}
		else{
			for(int r=0;r<v[i];r++){
				fr=1;tl=0;//每次都是赋值操作,对队头队尾指针初始化即可 
				for(int j=r;j<=V;j+=v[i]){
					while(fr<=tl&&j-q[fr]>s[i]*v[i]) fr++;
					while(fr<=tl&&dp[(i-1)&1][q[tl]]+(j-q[tl])/v[i]*w[i]<=dp[(i-1)&1][j]) tl--;
					q[++tl]=j;dp[i&1][j]=dp[(i-1)&1][q[fr]]+(j-q[fr])/v[i]*w[i];
				}
			}	
		}
	}
	printf("%lld",dp[n&1][V]);
	return 0;
} 

五.分组背包

例题:AcWingT9

大致题面见上。

我们沿用01背包的思路,令\(dp_{i,j}\)为考虑完前\(i\)个组,当前最大容量为\(j\)的情况下的最大价值之和。

\(i\)个组无非是那\(s_i+1\)种情况:一个都不选、选第一个、选第二个、……、选第\(s_i\)个。

那我们就根据这个思路很快写出了转移方程:

\(dp_{i,j}=\max(dp_{i-1,j},\max\limits_{k=1}^{s_i}{dp_{i-1,j-v_{i,k}}+w_{i,k}})\)

最终答案就是\(dp_{n,V}\)。时间复杂度是\(O(nV \times s_{max})\),空间复杂度是\(O(nV)\),足够通过本题。

代码:

点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0;char c=getchar();
	while(c<48) c=getchar();
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=520;
const int M=520;
const int WT=520;
int n,V,s[N],v[N][M],w[N][M],dp[N][WT];

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		s[i]=read();
		for(int j=1;j<=s[i];j++){
			v[i][j]=read(),w[i][j]=read();
		}
	}
	//二维dp 
	for(int i=1;i<=n;i++){
		for(int j=0;j<=V;j++){
			dp[i][j]=dp[i-1][j];
			for(int k=1;k<=s[i];k++){
				if(j>=v[i][k]){
					dp[i][j]=max(dp[i][j],dp[i-1][j-v[i][k]]+w[i][k]);
				}
			}
		}
	}
	printf("%lld",dp[n][V]);
	return 0;
} 

六.有依赖的背包问题

例题:AcWingT10

同样的01背包,不同的是各个物品之间有依赖,依赖关系还是一个树形的。原来的01背包,第\(i\)层从第\(i-1\)层转移来,是个线性dp。现在这个树状的依赖关系,那就需要用树形dp解决。

我们先设计一下状态:设\(dp_{u,i,j}\)为考虑当前节点\(u\),考虑前\(i\)个儿子的选择,并且给\(u\)及子树的最大容量为\(j\)的情况下,最大的价值之和。

那么当我们考虑第\(i\)个儿子的时候,第\(i\)个孩子及其子树可以不选,也可以选。(如果选的话还要考虑给当前第\(i\)个儿子的体积\(k\))根据这两种情况,我们经过亿点点思考,就可以得到如下转移方程:(其实也不难理解吧)

\(dp_{u,i,j}=\max\limits_{k=0}^{j-v_u}dp_{u,i-1,j-k}+dp_{v,cnt_v,k}\),其中\(v\)是当前枚举的\(u\)的第\(i\)个儿子,\(cnt_v\)表示\(v\)的孩子个数。(显然我们要用到的是考虑了\(v\)全部儿子时的最优答案)

显然呢,如果我们考虑分给\(v\)及子树的最大容量是\(k\)的情况,那么前\(i-1\)个孩子分到的最大容量就是\(j-k\)。这个转移方程应该很好理解。

这样时间复杂度就是\(O(nV^2)\),空间优化前的复杂度是\(O(n^2V)\),足够通过此题。

另外,由于第\(i\)个儿子的信息只与前\(i-1\)个儿子有关,所以很多题解的代码压掉了这一维,但是为了方便理解,我们还是放出三维dp代码吧。

以及有人认为这可以看作分组背包,转移方程式的确很像,但是本人比较蒟蒻,所以就不展开讲了 (逃

点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;

inline int read(){
	int x=0,f=1;char c=getchar();
	while(c<48){
		if(c=='-') f=-1;
		c=getchar();
	}
	while(c>47) x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x*f;
}

const int N=123;
int n,V,vol[N],w[N],h[N],cnt[N],tot,dp[N][N][N],root;
//vol:volume的缩写,英文里的体积 
struct sw{
	int u,v,nxt;
}e[N];

inline void add(int u,int v){
	e[++tot]={u,v,h[u]};h[u]=tot;
}

inline void dfs(int u){
	for(int i=vol[u];i<=V;i++){//初始化,只选u不选儿子的价值是u自己价值 
		dp[u][0][i]=w[u];
	}
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].v;
		dfs(v);
		cnt[u]++;
		for(int j=vol[u];j<=V;j++){//至少需要够装下u节点,下面同理,剩余空间要扣掉u节点体积 
			for(int k=0;k<=j-vol[u];k++){
				dp[u][cnt[u]][j]=max(dp[u][cnt[u]][j],dp[u][cnt[u]-1][j-k]+dp[v][cnt[v]][k]);
			} 
		}
	}
}

signed main(){
	n=read(),V=read();
	for(int i=1;i<=n;i++){
		vol[i]=read(),w[i]=read();int fa=read();
		if(fa==-1){//没有依赖关系的是根节点 
			root=i;
		}
		else{//与父节点建边 
			add(fa,i);
		}
	}
	dfs(root);
	printf("%lld",dp[root][cnt[root]][V]);//显然这就是最终答案 
	return 0;
} 

(未完待续)

参考资料:
1.AcWingT3题解
2.AcWingT4题解
3.AcWingT5题解
4.AcWingT6题解
5.AcWingT11题解1
6.AcWingT11题解2
7.AcWingT12题解
8.AcWingT8题解
9.AcWingT10题解

posted @ 2025-08-25 13:14  qwqSW  阅读(12)  评论(0)    收藏  举报