『学习笔记』背包问题
\(\textsf{update on 2022/6/11 稍微修改码风。}\\\textsf{update on 2023/4/30 修改几处错误。}\)
有 \(n\) 件物品,每件物品有一个占用空间 \(v_i\) 和一个价值 \(w_i\)。现在有一个空间为 \(V\) 的背包,求能装下物品的最大价值。
这类题就是典型的 01 背包问题。
01 背包
01 背包中,01 的意思就是每种物品有选与不选两种方案。
我们可以定义状态 \(f_{i,j}\) 为:用前 \(i\) 个物品,用 \(j\) 单位个空间,能装下的最大价值。
那么第 \(i\) 件物品可以考虑选与不选,在使用 \(j\) 单位个空间的情况下:
- 选第 \(i\) 个物品,那么 \(f_{i,j}=f_{i-1,j-v_i}+w_i\)。为什么要取用前 \(i-1\) 个物品,填满 \(j-v_i\) 的最大价值呢?因为要选择第 \(i\) 个物品,用 \(j\) 个单位空间时,需要有 \(v_i\) 个空间剩余,同时也要是最大价值。这个值再加上第 \(i\) 个物品的价值,就是选择第 \(i\) 个物品下的 \(f_{i,j}\) 了。
- 不选第 \(i\) 个物品,那么 \(f_{i,j}\) 只需要取 \(f_{i-1,j}\) 即可。不需要任何变化。
那么什么时候选第 \(i\) 个物品,什么时候不选呢?
当然是取选与不选的最大价值中的较大值了。
还要注意一下,当 \(j \leq v_i\) 时,则不能选第 \(i\) 个物品,总不能用负数的空间吧。
那么在 \(j>v_i\) 时的状态转移方程就是 \(f_{i,j}=\max\{f_{i-1,j},f_{i-1,j-v_i}+w_i\}\),否则 \(f_{i,j}=f_{i-1,j}\)。
现在来看道题吧!
P1048 采药
题目大意
有 \(n\) 株草药,每株草药有一个采摘所需的时间 \(v_i\) 和这株草药的价值 \(w_i\),你有 \(V\) 单位时间。
在限定的时间内,采到的草药的最大价值是多少?
思路
01 背包板子。
我们把每株草药看作物品,把采摘所需时间看作重量,价值看作物品的价值,限定的时间 \(V\) 看作背包容量即可。
代码
#include <iostream>
using namespace std;
const int N=1e3+5;
int n,V,ans=-1; // 草药株数和时间限制,ans 记录答案
int v[N],w[N]; // 每株草药的时间和价值
int f[N][N]; // dp 数组
int main(){
scanf("%d%d",&V,&n)
for(int i=1; i<=n; i++) scanf("%d%d",&v[i],&w[i]);
for(int i=1; i<=n; i++) // 用前 i 株草药
for(int j=1; j<=V; j++) // 用 j 时间
// 可以选择第 i 株草药
if(j>=v[i]) f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+w[i]); // 转移方程
else f[i][j]=f[i-1][j]; // 不能选择第 i 株草药
for(int i=1; i<=V; i++) ans=max(ans,f[n][i]); // 使用不同时间采摘到的价值总和并不一定是升序的,所以要取最大值
printf("%d\n",ans);
return 0;
}
滚动数组优化
通过代码中的转移方程可以发现,\(f_{i,j}\) 就只和 \(f_{i-1,j}\) 有关,于是可以考虑滚动数组。
先来尝试把上面的代码的一部分等价替换一下:
for(int i=1; i<=n; i++)
for(int j=1; j<=V; j++)
if(j>=v[i]) f[j]=max(f[j],f[j-v[i]]+w[i]);
else f[j]=f[j];
可以发现 else
里的部分可以直接去掉,去掉后你会发现 \(j\) 从 \(1\) 循环到 \(v_i-1\) 的那一段都是无用的,因为只有当 \(j \geq v_i\) 时才会执行操作。
于是 \(j\) 可以直接从 \(v_i\) 开始循环,去掉判断条件:
for(int i=1; i<=n; i++)
for(int j=v[i]; j<=V; j++)
f[j]=max(f[j],f[j-v[i]]+w[i]);
但是你这么跑一边样例,WA 了。
为什么呢?我们把滚动数组的方程换成原来的看看:f[i][j]=max(f[i-1][j],f[i-1][j-v[i]]+w[i])
。
我们注意到 \(f_{i-1,j-v_i}\)。如果你的 \(j\) 是从小到大的,那么你在更新一个 \(j\) 比较大的 \(f_{i,j}\) 时,需要用到 \(f_{i-1,j-v_i}\),但是在滚动数优化下,你之前已经更新过 \(f_{j-v_i}\) 了,变成了 \(f_{i,j-v_i}\) 了。所以你更新 \(f_j\) 时取的其实是 \(f_{i,j-v_i}\),而不是想要的 \(f_{i-1,j-v_i}\)。
举个例子吧,例如你正在更新 \(f_7\),而更新 \(f_7\),需要取 \(f_{i-1,7-3}\),但你之前更新过 \(f_{7-3}\) 了,\(f_{7-3}\) 由原来的 \(f_{i-1,7-3}\) 变成了 \(f_{i,7-3}\),所以没取到 \(f_{i-1,7-3}\)。
那么针对这个问题,怎么解决呢?
很简单,你的 \(j\) 从大到小循环就行了。更新大 \(j\) 时,需要用到 \(j-v_i\),而你的小 \(j\) 暂时没有更新,还是 \(f_{i-1,j-v_i}\),所以就能够成功地求出正解了。
那么核心的一块代码就是:
for(int i=1; i<=n; i++)
for(int j=V; j>=v[i]; j--)
f[j]=max(f[j],f[j-v[i]]+w[i]);
二维 01 背包
二维 01 背包照样是有 \(n\) 件物品,但每件物品有一个体积 \(z_i\) 和一个质量 \(c_i\) ,要求的答案是在体积不超过 \(x\) 且质量不超过 \(y\) 时,可以达到的最大价值。
思路和 01 背包一样,\(f_{i,j,k}\) 就是用前 \(i\) 个物品,用 \(j\) 个体积,\(k\) 个质量能达到的最大价值。
那么就要开三重循环了,分别枚举前 \(i\) 件物品,\(j\) 个体积,\(k\) 个质量。
转移方程也和 01 背包同理,\(f_{i,j,k}=f_{i-1,j-z_i,k-c_i}\),理解了上面 01 背包的应该很容易理解吧?
那么代码大概长这样子:
for(int i=1; i<=n; i++)
for(int j=1; j<=x; j++)
for(int k=1; k<=y; k++)
if(j>=z[i] && k>=c[i]) f[i][j][k]=max(f[i-1][j][k],f[i-1][j-z[i]][k-c[i]]+w[i]);
else f[i][j][k]=f[i-1][j][k];
三维数组,显然在某些情况下会炸掉内存,还是考虑和 01 背包一样的滚动数组优化。
直接去掉第一维是这样子的:
for(int i=1; i<=n; i++)
for(int j=1; j<=x; j++)
for(int k=1; k<=y; k++)
if(j>=z[i] && k>=c[i]) f[j][k]=max(f[j][k],f[j-z[i]][k-c[i]]+w[i]);
else f[j][k]=f[j][k];
还是老样子,else
里的东西不需要,\(j\) 和 \(k\) 可以直接分别从 \(z_i\) 和 \(c_i\) 开始循环。
for(int i=1; i<=n; i++)
for(int j=z[i]; j<=x; j++)
for(int k=c[i]; k<=y; k++)
f[j][k]=max(f[j][k],f[j-z[i]][k-c[i]]+w[i]);
同上,需要倒着跑。
for(int i=1; i<=n; i++)
for(int j=x; j>=z[i]; j--)
for(int k=y; k>=c[i]; k--)
f[j][k]=max(f[j][k],f[j-z[i]][k-c[i]]+w[i]);
P1507 NASA的食物计划
题目大意
有 \(n\) 个食品,每个食品有相应的体积 \(z_i\),质量 \(c_i\),价值为 \(w_i\),给定体积最大值 \(x\),质量最大值 \(y\),求最大价值。
代码
#include <iostream>
using namespace std;
const int N=5e2+5;
int n,x,y;
int z[N],c[N],w[N];
int f[N][N];
int main(){
scanf("%d%d%d",&x,&y,&n);
for(int i=1; i<=n; i++) scanf("%d%d%d",&z[i],&c[i],&w[i]);
for(int i=1; i<=n; i++)
for(int j=x; j>=z[i]; j--)
for(int k=y; k>=c[i]; k--)
f[j][k]=max(f[j][k],f[j-z[i]][k-c[i]]+w[i]);
printf("%d\n",f[x][y]);
return 0;
}
推荐习题
- P2871 [USACO07DEC]Charm Bracelet S 板子,不用多说。
- P1049 [NOIP2001 普及组] 装箱问题 01 背包稍微变化。
- P1060 [NOIP2006 普及组] 开心的金明 同上。
- P1164 小A点菜 同上。
- P1926 小书童——刷题大军 变化得比上面几道要大一点。
- P1910 L国的战斗之间谍 二维 01 背包板子。
- P1877 [HAOI2012]音量调节 二维 01 背包变形。
欸嘿,01 背包讲完了。然后是完全背包。
完全背包
完全背包与 01 背包不同的点就在于每种物品可以选择无限个。
但是,代码和 01 背包的差别特别小,唯一的区别就在状态转移方程上:
- 01 背包:\(f_{i,j}=\max\{f_{i-1,j},f_{i-1,j-v_i}+w_i\}\)。
- 完全背包:\(f_{i,j}=\max\{f_{i-1,j},f_{i,j-v_i}+w_i\}\)。
要是理解了 01 背包,那么完全背包的代码是很好背的,但关键是为什么就少一个 \(-1\)。
那么我们来看一下相当于暴力的完全背包转移方程,也就是判断选多少个第 \(i\) 件物品才是最优解。
\(f_{i,j}=\max\{f_{i-1,j},f_{i-1,j-v_i}+w_i,f_{i-1,j-2v_i}+2w_i,\dots\}\)。
看上去需要一个三重循环才行...
但是我们注意到 \(f_{i,j-v_i}\) 的转移方程:
\(f_{i,j-v_i}=\max\{f_{i-1,j-v_i},f_{i-1,j-2v_i}+w_i,f_{i-1,j-3v_i}+2w_i,\dots\}\)。
通过对比,我们可以发现,\(f_{i,j-v_i}\) 的每一项和 \(f_{i,j}\) 的第 \(2\) 项及之后的项非常像,只有一个 \(w_i\) 的差别。
显然,\(f_{i-1,j-v_i}+w_i,f_{i-1,j-2v_i}+2w_i,\dots\) 就可以等价替换成 \(f_{i,j-v_i}+w_i\)。
那么就可以得到完全背包的方程了:\(f_{i,j}=\max\{f_{i-1,j},f_{i,j-v_i}+w_i\}\)。
滚动数组优化
同理,降维。
for(int i=1; i<=n; i++)
for(int j=v[i]; j<=V; j++)
f[j]=max(f[j],f[j-v[i]]+w[i]);
但是循环不需要逆序了。01 背包要逆序是因为会取到 \(f_{i,j}\),而想要的是 \(f_{i-1,j}\)。而完全背包正好是要取 \(f_{i,j}\),所以原来的必须先更新。
P1616 疯狂的采药
题目大意
有 \(n\) 种草药,每种草药有无限株。采集一株第 \(i\) 种草药需要花费 \(v_i\) 的时间,有 \(w_i\) 的价值。现在给定可以用的时间 \(V\),求最大可以达到的草药总价值。
思路
把草药看作物品,时间看作重量,跑一边完全背包就行了。
记住转移方程:\(f_{i,j}=\max\{f_{i-1,j},f_{i,j-v_i}+w_i\}\)。
这个代码应该很容易就能记住了吧?
但是,如果直接用朴素解法,要开 \(10^4 \times 10^7=10^{11}\) 个 long long
,所以滚动数组。
代码
朴素版(炸空间)
#include <iostream>
using namespace std;
typedef long long ll;
const int N=1e4+5,M=1e7+5;
ll n,V;
ll v[N],w[N];
ll f[N][M];
int main(){
scanf("%lld%lld",&V,&n);
for(int i=1; i<=n; i++) scanf("%lld%lld",&v[i],&w[i]);
for(int i=1; i<=n; i++)
for(int j=1; j<=V; j++)
if(j>=v[i]) f[i][j]=max(f[i-1][j],f[i][j-v[i]]+w[i]);
else f[i][j]=f[i-1][j];
printf("%lld\n",f[n][V]);
return 0;
}
滚动数组优化(正解)
#include <iostream>
using namespace std;
typedef long long ll;
const int N=1e4+5,M=1e7+5;
ll n,V,ans=-1;
ll v[N],w[N];
ll f[M];
int main(){
scanf("%lld%lld",&V,&n);
for(int i=1; i<=n; i++) scanf("%lld%lld",&v[i],&w[i]);
for(int i=1; i<=n; i++)
for(int j=v[i]; j<=V; j++)
f[j]=max(f[j],f[j-v[i]]+w[i]);
printf("%lld\n",f[V]);
return 0;
}
推荐习题
- P2722 [USACO3.1]总分 Score Inflation 板子。
- P1679 神奇的四次方数 有变化。
- P1853 投资的最大效益 同上。
- P5662 [CSP-J2019] 纪念品
- P5020 [NOIP2018 提高组] 货币系统
多重背包
给定 \(n\) 种物品的重量 \(v_i\) 和价值 \(w_i\),每种物品有 \(k_i\) 个可以选择。
给定背包承重 \(V\),求最多可以达到多少价值。
最暴力的解法就是把每件物品都复制 \(k_i\) 份,然后跑一遍 01 背包。
但是某些题的数据范围不允许这么做,我们就只能使用二进制优化了。
二进制优化
我们拿 \(32\) 为例,要拆一些数使得这些数可以凑出 \(1\sim32\) 间所有的数。
那么可以拆分成功的一种方案就是:\(1+2+4+8+16+1\)。
我们观察前五个数,发现其实是 \(2\) 的整数幂,二进制分别为:\(1_{(2)},10{(2)},100{(2)},1000{(2)},10000{(2)}\)。
可以发现可以完美地凑出 \(1_{(2)}\sim11111_{(2)}\),也就是 \(1\sim31\)。
显然,\(1_{(2)}\sim11111_{(2)}\) 间,每一位可以是 \(0\),也可以是 \(1\)。那么是 \(1\) 的那一位刚好需要一个 \(2\) 的整数幂的大小,那五个数正好可以满足这个要求。
还有一个 \(32\),只需要把拆分方案中加上一个 \(1\) 就可以凑出来了。
这些拆分出来的数的个数就是第 \(i\) 个物品需要复制的个数,但复制的各个物品需要将重量和价值分别乘上一个拆分出来的数,俗称 "打包"。
就比如说第 \(i\) 个物品有 \(32\) 个,一共要打包六份,每份分别有 \(1,2,4,8,16,1\) 个物品打包。
P1776 宝物筛选
题目大意
有 \(n\) 种宝物,每种宝物都有 \(k_i\) 件,每种物品的重量为 \(v_i\),价值为 \(w_i\)。
有一个最大载重为 \(V\) 的采集车,请问不超载的情况下,最多可以拿到多少价值的宝物?
思路
很明显又是板子。
多重背包我比较习惯输入完成后完完全全地变成 01 背包,变量名都不变的那种...
所以输入的 \(n\) 我们写成 \(t\),重量和价值分别为 \(x_i,y_i\),\(k_i\) 不变。
为了方便拆分,每输入一种物品就循环,\(i\) 从 \(1\) 开始,直到 \(>k\),每次增大一倍,这个 \(i\) 就是 \(2\) 的 \(i\) 次方了。
为什么终止条件是 \(>k\)?为了获得选择完所有 \(2\) 的整数幂后剩余的那个数,可以每循环一次就将 \(k\) 减去 \(i\),循环结束后判断 \(k\) 是否不为 \(0\),不为 \(0\) 时,就需要再打包一份了。
注意输入是先输入价值后输入重量。
代码
#include <iostream>
using namespace std;
const int N=1e5+5;
int t,n,V,x,y,k; // t=种类数,n=打包数,V=最大载重,x=每种物品的重量,y=每种物品的价值,k=每种物品的数量
int v[N],w[N]; // 打包后的重量和价值
int f[N]; // dp 数组
int main(){
scanf("%d%d",&t,&V);
while(t--){ // 种类数之后用不到,所以直接 t--
scanf("%d%d%d",&x,&y,&k);
for(int i=1; i<=k; i<<=1){
n++; // 多一个打包
w[n]=x*i; // 打包后的价值
v[n]=y*i; // 重量
k-=i; // 打包后要让物品份数减一下
}
if(k){ // 还有物品没打包
n++;
w[n]=x*k;
v[n]=y*k; // 再一起打包一下
}
}
for(int i=1; i<=n; i++) // 于是就可以直接上 01 背包了
for(int j=V; j>=v[i]; j--)
f[j]=max(f[j],f[j-v[i]]+w[i]);
printf("%d\n",f[V]);
return 0;
}
推荐习题
- P2347 [NOIP1996 提高组] 砝码称重
- P5365 [SNOI2017] 英雄联盟
- P2851 [USACO06DEC]The Fewest Coins G 这题是多重+完全背包,可能有点难度。
分组背包
有 \(g\) 组物品,每组有若干个物品,每个物品有一个重量 \(v_i\) 和一个价值 \(w_i\)。
每组只能选择一个物品装入一个最大承重为 \(V\) 的背包,请问最多可以达到多少价值?
我们可以先把每组都看作一个物品(已经决策好这一组选哪个物品),然后用 01 背包的方式去求解。
那么代码框架是这样的:
for(int i=1; i<=g; i++)
for(int j=V; j>=0; j--)
// 状态转移方程
注意 \(j \geq 0\) 而不是 \(v_i\),因为 \(i\) 是组数,而不是物品编号。
那么现在的问题就是如何决策出这一组选的物品了。
其实也很简单,定义 \(m_i\) 为第 \(i\) 组的物品数,\(r_{i,j}\) 为第 \(i\) 组第 \(j\) 个物品的下标,也就是可以通过 \(r_{i,j}\) 得到第 \(i\) 组第 \(j\) 个物品的重量及价值的下标。
那么转移方程:\(f_j=\max\{f_j,f_{j-v[r[i][1]]}+w_{r[i][1]},f_{j-v[r[i][2]]}+w_{r[i][2]},\dots,f_{j-v[r[i][m[i]]}+w_{r[i][m[i]]}\}\)。
我们只需要加一层循环 \(k \rightarrow m_i\) 即可。每次取 \(f_j\) 和 \(f_{j-v[r[i][k]]}+w_r[i][k]\) 的 \(\max\)。
但是要注意 \(j<v_{r[i][k]}\) 的情况,这种情况不需要让 \(f_j=f_j\),所以直接跳过。
for(int i=1; i<=g; i++)
for(int j=V; j>=0; j--)
for(int k=1; k<=m[i]; k++){
if(j<v[r[i][k]]) continue;
f[j]=max(f[j],f[j-v[r[i][k]]]+w[r[i][k]]);
}
P1757 通天之分组背包
题目大意
有 \(n\) 件物品,背包容量为 \(V\)。
每件物品有一个组数 \(k_i\),重量 \(v_i\) 和价值 \(w_i\),组数相同的物品集合中,只能选择一件物品。
求背包最多能装多少价值。
思路
这里就讨论一下输入及处理吧。
这题输入是一次输入每件物品的重量,价值和组数。
每输入一件物品,就记录 \(k_i\) 的最大值,并把第 \(k_i\) 组的物品数加一,再将第 \(k_i\) 组的物品编号集合 push
一个 \(i\)。
\(k_i\) 的最大值就是组数 \(g\),记录物品数的数组就是 \(m\),编号集合就是 \(r\)。
可以发现 \(k_i\) 之后用不到了,就可以不存下来,用一个变量 \(gp\) 来输入。
$ \begin{array}{l}
g \gets \max{g,gp}\
m_{gp} \gets m_{gp}+1\
r_{i,m_gp} \gets i
\end{array} $
于是这题就可以愉快地做完啦。
代码
#include <iostream>
using namespace std;
const int N=1e3+5;
int n,V,g,gp; // n 物品个数,V 最大重量,g 总组数,gp 输入的组数
int v[N],w[N]; // 每个物品的重量及价值
int m[N],r[N][N]; // m[i] 第 i 组物品总数,r[i][j] 第 i 组第 j 个物品的编号
int f[N]; // dp 数组
int main(){
scanf("%d%d",&V,&n);
for(int i=1; i<=n; i++){
scanf("%d%d%d",&v[i],&w[i],&gp);
g=max(g,gp);
m[gp]++;
r[gp][m[gp]]=i;
}
for(int i=1; i<=g; i++) // g 个组
for(int j=V; j>=0; j--) // 重量
for(int k=1; k<=m[i]; k++){ // 枚举第 i 组中的物品
if(j<v[r[i][k]]) continue; // 这种情况不需要且不能更新
f[j]=max(f[j],f[j-v[r[i][k]]]+w[r[i][k]]);
}
printf("%d\n",f[V]);
return 0;
}
推荐习题
对,就这,没题了...
混合背包
差不多就是一个多重背包。
有 \(n\) 种物品,每种物品可能有 \(1\),\(k_i\),\(\infty\) 个,每个物品有一个重量 \(v_i\) 和一个价值 \(w_i\),现给定背包最大承重 \(V\),求最大价值。
对于 \(\infty\) 个,取 \(\lfloor\dfrac{V}{v_i}\rfloor\) 即可。因为你就算有再多个物品可以取,总重量也不能有 \(V\) 大吧?
我们确定了数量就可以用多重背包的方式做了。
P1833 樱花
题目大意
有 \(n\) 棵樱花树,每棵有美学值 \(w_i\) 和观赏消耗的时间 \(v_i\),可以赏 \(k_i\) 次。若 \(k_i\) 为 \(0\),表示可以赏无限次。
现给定可以用来赏花的时间区间(开始时间与结束时间),请求出最大的美学值。
思路
还是和多重背包风格一样,就算预处理复杂一点也一定要让后面完全等于 01 背包!
没什么好讲的,只是可以说说时间计算部分。
设开始时间为 h1:m1
,结束时间为 h2:m2
。
首先我们不考虑任何因素,计算分钟数是这样的:\(V=(h2-h1) \times 60+m2-m1\)。这个应该很好理解。
但有可能 \(m2 \leq m1\),这时候就要让 \(m2\) 变大了。
怎么个变大法呢?自然是将一个小时的 \(60\) 分钟加上去了!注意这样操作后 \(h2\) 需要减一。
代码
#include <iostream>
using namespace std;
const int N=1e6+5;
int h1,h2,m1,m2;
int n,V,t,k;
int a[N],b[N];
int v[N],w[N];
int f[N];
int main(){
scanf("%d:%d%d:%d%d",&h1,&m1,&h2,&m2,&t);
if(m2>m1) m2+=60,h2--;
V=(h2-h1)*60+m2-m1; // 计算时间
for(int i=1; i<=t; i++){
scanf("%d%d%d",&a[i],&b[i],&k);
if(!k) k=V/a[i]; // 为 0 时有无限个
for(int j=1; j<=k; j<<=1){ // 和多重背包一样
n++;
v[n]=a[i]*j;
w[n]=b[i]*j;
k-=j;
}
if(k){
n++;
v[n]=a[i]*k;
w[n]=b[i]*k;
}
}
for(int i=1; i<=n; i++) // 01 背包板子
for(int j=V; j>=v[i]; j--)
f[j]=max(f[j],f[j-v[i]]+w[i]);
printf("%d\n",f[V]);
return 0;
}
推荐习题
又没了...