『学习笔记』动态规划 - 背包问题

· · 个人记录

\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 个物品,什么时候不选呢?

当然是取选与不选的最大价值中的较大值了。

还要注意一下,当 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 里的部分可以直接去掉,去掉后你会发现 j1 循环到 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 里的东西不需要,jk 可以直接分别从 z_ic_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 背包,那么完全背包的代码是很好背的,但关键是为什么就少一个 -1

那么我们来看一下相当于暴力的完全背包转移方程,也就是判断选多少个第 i 件物品才是最优解。

看上去需要一个三重循环才行... 但是我们注意到 $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\}$。 ## 滚动数组优化 同理,降维。 ```cpp 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 疯狂的采药](https://www.luogu.com.cn/problem/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`,所以滚动数组。 ### 代码 #### 朴素版(炸空间) ```cpp #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; } ``` #### 滚动数组优化(正解) ```cpp #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](https://www.luogu.com.cn/problem/P2722) 板子。 - [P1679 神奇的四次方数](https://www.luogu.com.cn/problem/P1679) 有变化。 - [P1853 投资的最大效益](https://www.luogu.com.cn/problem/P1853) 同上。 - [P5662 [CSP-J2019] 纪念品](https://www.luogu.com.cn/problem/P5662) - [P5020 [NOIP2018 提高组] 货币系统](https://www.luogu.com.cn/problem/P5020) # 多重背包 给定 $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 宝物筛选](https://www.luogu.com.cn/problem/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$ 时,就需要再打包一份了。 注意输入是先输入价值后输入重量。 ### 代码 ```cpp #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 提高组] 砝码称重](https://www.luogu.com.cn/problem/P2347) - [P5365 [SNOI2017] 英雄联盟](https://www.luogu.com.cn/problem/P5365) - [P2851 [USACO06DEC]The Fewest Coins G](https://www.luogu.com.cn/problem/P2851) 这题是多重+完全背包,可能有点难度。 # 分组背包 有 $g$ 组物品,每组有若干个物品,每个物品有一个重量 $v_i$ 和一个价值 $w_i$。 每组只能选择一个物品装入一个最大承重为 $V$ 的背包,请问最多可以达到多少价值? 我们可以先把每组都看作一个物品(已经决策好这一组选哪个物品),然后用 01 背包的方式去求解。 那么代码框架是这样的: ```cpp 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$,所以直接跳过。 ```cpp 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 通天之分组背包](https://www.luogu.com.cn/problem/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 种物品,每种物品可能有 1k_i\infty 个,每个物品有一个重量 v_i 和一个价值 w_i,现给定背包最大承重 V,求最大价值。

对于 \infty 个,取 \lfloor\dfrac{V}{v_i}\rfloor 即可。因为你就算有再多个物品可以取,总重量也不能有 V 大吧?

我们确定了数量就可以用多重背包的方式做了。

P1833 樱花

题目大意

n 棵樱花树,每棵有美学值 w_i 和观赏消耗的时间 v_i,可以赏 k_i 次。若 k_i0,表示可以赏无限次。

现给定可以用来赏花的时间区间(开始时间与结束时间),请求出最大的美学值。

思路

还是和多重背包风格一样,就算预处理复杂一点也一定要让后面完全等于 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;
}

推荐习题

又没了...