『学习笔记』背包问题

\(\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;
}

推荐习题

欸嘿,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;
}

推荐习题

多重背包

给定 \(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;
}

推荐习题

分组背包

\(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;
}

推荐习题

又没了...

posted @ 2022-06-11 15:09  仙山有茗  阅读(122)  评论(0编辑  收藏  举报