【动态规划】详解多重背包问题
目录
- 1. 前置文章
- 2. 多重背包
- 3. 多重背包朴素代码和空间压缩
- 4. 二进制优化
- 5.洛谷 P1833 樱花
- 5.1 题目
- 5.2 代码
- 6. 单调队列优化思想
- 7. 单调队列优化代码 - 二维动态规划
- 8. 单调队列优化代码 - 空间优化版本
- 9. 整体代码
- 10.小结
1. 前置文章
本文前置文章:
- 【动态规划】详解 0-1背包问题
- 【动态规划】详解完全背包问题
- 【动态规划】详解分组背包问题
下面是三种背包模式的区别:
0 - 1 背包
是说:有 n 个物品和一个重量为 t 的背包,这 n 个物品中第 i 个物品的重量为 w[i],价值为 v[i],那么这个背包能装下的物品最大价值是多少,注意一个物品只能选一次。完全背包
是说:有 n 个物品和一个重量为 t 的背包,这 n 个物品中第 i 个物品的重量为 w[i],价值为 v[i],那么这个背包能装下的物品最大价值是多少,注意一个物品可以选无数次。分组背包
是说:有 n 组物品和一个重量为 t 的背包,每个物品都有自己的体积,价值和组号,代表这个物品属于哪一组,要求在不超过背包容量的前提下,从每组物品中最多选择一个物品,使得背包中物品的总价值最大。
2. 多重背包
多重背包是指:有 n 个物品和一个重量为 t 的背包,这 n 个物品中第 i 个物品的重量为 w[i],价值为 v[i],数量为 c[i],那么这个背包能装下的物品最大价值是多少,注意一个物品可以选择的次数限制为 c[i]。
多重背包和完全背包很像,只是多重背包每一个物品都有数量限制,我们设定 dp[i][j] 为 1-i 号物品自有选择,每种物品数据不超过限制,容量也不超过 j 的前提下获取到的最大价值。
- 如果不需要第 i 号商品,那么 dp[i][j] = dp[i-1][j]
- 如果需要第 i 号商品,那么
- 如果需要 1 个商品, d p [ i ] [ j ] = d p [ i − 1 ] [ j − w [ i ] ] + v [ i ] dp[i][j] = dp[i-1][j - w[i]] + v[i] dp[i][j]=dp[i−1][j−w[i]]+v[i]
- 如果需要 2 个商品, d p [ i ] [ j ] = d p [ i − 1 ] [ j − 2 ∗ w [ i ] ] + 2 ∗ v [ i ] dp[i][j] = dp[i-1][j - 2 * w[i]] + 2 * v[i] dp[i][j]=dp[i−1][j−2∗w[i]]+2∗v[i]
- …
3. 多重背包朴素代码和空间压缩
那么我们以洛谷的 P1776 为例子,这道题可以看作是多重背包模板了。
- 洛谷的 P1776
import java.util.Scanner;
public class Main {
static int n = 0, t = 0;
static int[] v, w, c;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 物品数
n = scanner.nextInt();
// 背包重量
t = scanner.nextInt();
// 物品的价值
v = new int[n + 1];
// 物品的重量
w = new int[n + 1];
// 物品的数量
c = new int[n + 1];
for (int i = 1; i <= n; i++) {
v[i] = scanner.nextInt();
w[i] = scanner.nextInt();
c[i] = scanner.nextInt();
}
System.out.println(computeV1());
}
// O(n * t * 物品平均数)
public static int computeV1() {
int[][] dp = new int[n + 1][t + 1];
// 遍历物品
for (int i = 1; i <= n; i++) {
// 遍历背包
for (int j = 0; j <= t; j++) {
// 不选当前物品
dp[i][j] = dp[i - 1][j];
// 选当前物品,遍历数量,最多 c[i] 件物品,或者当前背包放不下这么多物品了
for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
}
}
}
return dp[n][t];
}
// 空间压缩版本
public static int computeV2() {
int[] dp = new int[t + 1];
// 遍历物品
for (int i = 1; i <= n; i++) {
// 遍历背包
for (int j = t; j >= 0; j--) {
for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
dp[j] = Math.max(dp[j], dp[j - k * w[i]] + k * v[i]);
}
}
}
return dp[t];
}
}
上面是完全背包朴素写法,整体代码的时间复杂度是 O(n * t * 物品的平均数)
,空间优化和不优化的版本都是,但是很可惜时间复杂度太高了,没办法通过用例。
4. 二进制优化
上面版本的多重背包会超时,是因为整体时间复杂度 O(n * t * 物品平均数)
其中平均物品数量太大了,看题目的限制:
m 的总和不超过
1
0
5
10^5
105,如果是 100 件物品,总和假设是
1
0
5
10^5
105,这样算起来物品平均就是
1
0
3
10^3
103,这样一相乘
n
∗
t
∗
平均数
n * t * 平均数
n∗t∗平均数 就有
1
0
9
10^9
109 次方了。
其实对于多重背包而言,我们可以从二进制的角度去看问题,假设现在有一件物品,这件物品的总数是 19,物品的重量为 4, 价值为 5,我们可以使用二进制对这个物品进行拆分,下面是拆分后的各个组:
- 组合 1,物品数: 2 0 = 1 2^0 = 1 20=1,价值 5,重量 4,剩余物品数 18
- 组合 2,物品数: 2 1 = 2 2^1 = 2 21=2,价值 10,重量 8,剩余物品数 16
- 组合 3,物品数: 2 2 = 4 2^2 = 4 22=4,价值 20,重量 16,剩余物品数 12
- 组合 4,物品数: 2 3 = 8 2^3 = 8 23=8,价值 40,重量 32,剩余物品数 4
- 组合 5,物品数: 4 4 4,价值 20,重量 16,剩余物品数 0
为什么要拆分成这几个组呢?假设最大价值的物品包含了 x 个上述物品,1 <= x <= 19,对于朴素算法,需要从 1 遍历到 19,然后一个一个算价值,而拆分过后,可以任取里面几个组合成 x,举个例子:
- 假设 x = 1,直接取组合 1
- 假设 x = 2,直接取组合 2
- 假设 x = 3,直接取组合 1 + 组合 2
- …
通过上面的组合就可以拼出 x 来,为什么可以这样呢?其实对于二进制,如果你现在有 1
、2
、4
、8
,那么通过这几个数我们可以随意组合出 1 ~ 15
的任意一个数字,这个是二进制的性质,最后加上组合 5,我们就能随意组合出 1 ~ 19
的任意一个数字。
将上述组合转换成新的物品,假设上述物品下标是 0,经过转换之后结果如下:
物品
0
,
w
[
0
]
=
4
,
v
[
0
]
=
5
,
c
[
0
]
=
19
=
>
{
物品
01
,
w
[
0
]
=
4
,
v
[
0
]
=
5
物品
02
,
w
[
0
]
=
8
,
v
[
0
]
=
10
物品
03
,
w
[
0
]
=
16
,
v
[
0
]
=
20
物品
04
,
w
[
0
]
=
32
,
v
[
0
]
=
40
物品
05
,
w
[
0
]
=
16
,
v
[
0
]
=
20
物品 0,w[0] = 4,v[0] = 5,c[0] = 19 =>\left\{ \begin{aligned} 物品 01,w[0] = 4,v[0] = 5\\ 物品 02,w[0] = 8,v[0] = 10\\ 物品 03,w[0] = 16,v[0] = 20\\ 物品 04,w[0] = 32,v[0] = 40\\ 物品 05,w[0] = 16,v[0] = 20\\ \end{aligned} \right.
物品0,w[0]=4,v[0]=5,c[0]=19=>⎩
⎨
⎧物品01,w[0]=4,v[0]=5物品02,w[0]=8,v[0]=10物品03,w[0]=16,v[0]=20物品04,w[0]=32,v[0]=40物品05,w[0]=16,v[0]=20
这就意味者,我们可以将所有物品通过二进制转换成衍生的物品,然后通过 0-1 背包算法进行计算。
所以我们来改造下上面的方法,将每一个物品的数量都拆分成二进制表达的形式:
import java.util.Scanner;
public class Main {
static int MAX = 2000;
static int n = 0, t = 0;
static int[] v, w, c;
static int index = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 物品数
n = scanner.nextInt();
// 背包重量
t = scanner.nextInt();
// 物品的价值
v = new int[MAX];
// 物品的重量
w = new int[t + 1];
// 物品的数量
c = new int[MAX];
for (int i = 1; i <= n; i++) {
int value = scanner.nextInt();
int weight = scanner.nextInt();
int count = scanner.nextInt();
// 二进制分组,将每一个物品通过二进制分组转换成新的物品
for (int k = 1; k <= count; k <<= 1) {
v[index] = k * value;
w[index] = k * weight;
count -= k;
index++;
}
if (count > 0) {
v[index] = count * value;
w[index] = count * weight;
index++;
}
}
// 使用 0-1 背包进行计算
System.out.println(compute());
}
// 0-1 背包
public static int compute() {
int[] dp = new int[t + 1];
for (int i = 0; i < index; i++) {
for (int j = t; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[t];
}
}
5.洛谷 P1833 樱花
洛谷 P1833 樱花。
5.1 题目
题目背景
《爱与愁的故事第四弹·plant》第一章。
题目描述
爱与愁大神后院里种了 n n n 棵樱花树,每棵都有美学值 C i ( 0 ≤ C i ≤ 200 ) C_i(0 \le C_i \le 200) Ci(0≤Ci≤200)。爱与愁大神在每天上学前都会来赏花。爱与愁大神可是生物学霸,他懂得如何欣赏樱花:一种樱花树看一遍过,一种樱花树最多看 P i ( 0 ≤ P i ≤ 100 ) P_i(0 \le P_i \le 100) Pi(0≤Pi≤100) 遍,一种樱花树可以看无数遍。但是看每棵樱花树都有一定的时间 T i ( 0 ≤ T i ≤ 100 ) T_i(0 \le T_i \le 100) Ti(0≤Ti≤100)。爱与愁大神离去上学的时间只剩下一小会儿了。求解看哪几棵樱花树能使美学值最高且爱与愁大神能准时(或提早)去上学。
输入格式
共 n + 1 n+1 n+1行:
第
1
1
1 行:现在时间
T
s
T_s
Ts(几时:几分),去上学的时间
T
e
T_e
Te(几时:几分),爱与愁大神院子里有几棵樱花树
n
n
n。这里的
T
s
T_s
Ts,
T
e
T_e
Te 格式为:hh:mm
,其中
0
≤
h
h
≤
23
0 \leq hh \leq 23
0≤hh≤23,
0
≤
m
m
≤
59
0 \leq mm \leq 59
0≤mm≤59,且
h
h
,
m
m
,
n
hh,mm,n
hh,mm,n 均为正整数。
第 2 2 2 行到第 n + 1 n+1 n+1 行,每行三个正整数:看完第 i i i 棵树的耗费时间 T i T_i Ti,第 i i i 棵树的美学值 C i C_i Ci,看第 i i i 棵树的次数 P i P_i Pi( P i = 0 P_i=0 Pi=0 表示无数次, P i P_i Pi 是其他数字表示最多可看的次数 P i P_i Pi)。
输出格式
只有一个整数,表示最大美学值。
输入输出样例 #1
输入 #1
6:50 7:00 3
2 1 0
3 3 1
4 5 4
输出 #1
11
说明/提示
100 % 100\% 100% 数据: T e − T s ≤ 1000 T_e-T_s \leq 1000 Te−Ts≤1000(即开始时间距离结束时间不超过 1000 1000 1000 分钟), n ≤ 10000 n \leq 10000 n≤10000。保证 T e , T s T_e,T_s Te,Ts 为同一天内的时间。
样例解释:赏第一棵樱花树一次,赏第三棵樱花树 2 2 2 次。
5.2 代码
这道题就是多重背包问题,虽然说 P i = 0 P_i = 0 Pi=0 可以看无数次,但是有时间限制,所以哪怕在规定时间内都看这种树,也会有一个上限的,下面给出代码。
import java.util.Scanner;
public class Main {
static int MAX = 100001;
static int[] v = new int[MAX], w = new int[MAX];
static int n = 0, t = 0;
static int index = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
String line = scanner.nextLine();
String[] str = line.split(" ");
// 物品数
n = Integer.parseInt(str[2]);
// 多少分钟
String start = str[0];
String end = str[1];
String[] ss = start.split(":");
String[] es = end.split(":");
// 计算时间,就是背包重量
t = Integer.parseInt(es[0]) * 60 + Integer.parseInt(es[1]) - Integer.parseInt(ss[0]) * 60 - Integer.parseInt(ss[1]);
for (int i = 0; i < n; i++) {
int weight = scanner.nextInt();
int value = scanner.nextInt();
int count = scanner.nextInt();
if (count == 0) {
count = t / weight;
}
// 二进制解构
for (int k = 1; k <= count; k <<= 1) {
v[index] = k * value;
w[index] = k * weight;
count -= k;
index++;
}
if(count > 0){
v[index] = count * value;
w[index] = count * weight;
index++;
}
}
System.out.println(compute());
}
// 0-1 背包
public static int compute() {
int[] dp = new int[t + 1];
for (int i = 0; i < index; i++) {
for (int j = t; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[t];
}
}
6. 单调队列优化思想
单调队列优化是多重背包优化的最终版本,性能比二进制的要好很多。那首先我们来看一下什么是单调队列优化,下面把最初版本的代码给粘贴过来。
public static int computeV1() {
int[][] dp = new int[n + 1][t + 1];
// 遍历物品
for (int i = 1; i <= n; i++) {
// 遍历背包
for (int j = 0; j <= t; j++) {
// 不选当前物品
dp[i][j] = dp[i - 1][j];
// 选当前物品,遍历数量,最多 c[i] 件物品,或者当前背包放不下这么多物品了
for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
}
}
}
return dp[n][t];
}
我们观察最里面的 k 的 for 循环,这个循环其实就是在找当前重量 j 依赖上一行的哪些物品,比如下面有一个例子,假设物品 i 的重量是 3,价值是 5,数量是 4,假设现在处理第 i 行的 dp 表,且当前 j = 20,下面就是依赖关系。
可以看到上面 dp[i][20] 依赖的是 i - 1 行这 5 个,为什么是 5 个呢?因为这个物品只能选 4 个,所以只能选到 dp[i-1][8]。那么我们再来看当 j = 19 和 j = 21 的依赖关系,如下:
不知道大家发现上面的关系没有:
- 19、16、13、10、7 对 3 取余数 = 1
- 20、17、14、11、8 对 3 取余数 = 2
- 21、18、15、12、9 对 3 取余数 = 0
- …
也就是说我们其实可以把重量 j 根据余数 0、1、2 分为三组,每一组在计算 dp 的时候都只会依赖这一组里面的其他值,不依赖其他组,啥意思,就是在计算 dp[i-1][19] 的时候是不会依赖到 dp[i-1][17] 或者 dp[i-1][18] 的,因为依赖公式是 dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]
,j 只会依赖 j - w[i] * k
的值,这些值我们就可以根据余数进行分组。
好了,有了余数分组这个概念之后,我们再来看传统方法里面求 dp 的代码。
// 遍历背包
for (int j = 0; j <= t; j++) {
// 不选当前物品
dp[i][j] = dp[i - 1][j];
// 选当前物品,遍历数量,最多 c[i] 件物品,或者当前背包放不下这么多物品了
for (int k = 1; k <= c[i] && w[i] * k <= j; k++) {
dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
}
}
对于第 i
号物品,在求 dp 的时候显然是需要从 i - 1
行依赖的这几个下标的值中找到最大值,那么传统方法的瓶颈就在这了!!!每一次要求 dp[i][j],都需要从 j 往前去遍历,遍历次数可以达到 c[i],一旦 c[i] 比较大,时间复杂度就很大了,所以有没有什么办法我不需要遍历这么多次,我只需要 O(1) 的时间就能找到最大值呢?当然有了,就是单调队列。
但是现在有一个问题,比如下面的图。
在求依赖关系的时候 dp[i][22] 依赖的 dp[i-1][19] + 1 * 5
,但是左边当 j = 19 的时候,依赖 dp[i-1][19] + 0 * 5
,所以如果用单调队列维护这几个依赖关系,那当 j 从 19 变化到 22 的时候,所依赖的数据都要加上一个 5,这就说明单调队列里面的数值是不断变化的。
这种情况就很难办了,我们总不可能每求一个 dp[i][j] 的值都把单调队列里面的数据全部修改一遍吧,所以有没有什么办法能把依赖项后面的 k * w[i]
给删掉呢 (红色部分)?
那就从定义来看,在 j 从 19 -> 22 -> 25
的过程中,是不断 + 3 的,所以可以把上面的依赖关系改成下面这样。
接着我们发现了,当求 dp[i][19] 的时候,依赖的这几项里面都有 19 / 3 * 5 这个公共项,当求 dp[i][22] 的时候,依赖的这几项里面都有 22 / 3 * 5 这个公共项,所以下面进一步改写。
当依赖项改写成这样之后,我们发现依赖的红色部分在 j 变化的时候是不变的了!!! 所以单调队列里面的元素就按照下面代码这样写,代码表示如下,另外多说一句,单调队列的整体时间复杂度可以优化到 O(n * t)
。
// 假设 i 号物品重量 3, 价值 5, 个数 4
// 当前 dp[i][j] 依赖 dp[i-1][j] + index * w[i] / v[i]
// 比如 dp[i][20] 依赖
// 1. dp[i-1][20] + 0 * 5
// 2. dp[i-1][17] + 1 * 5
// 3. dp[i-1][14] + 2 * 5
// 4. dp[i-1][11] + 3 * 5
// 5. dp[i-1][8] + 4 * 5
// 单调队列本意就是维护上面 5 个数字的大小关系,从大到小,这样就不需要每次计算 j 都往回倒推
// 但是当求 dp[i][23] 的时候,依赖 dp[i-1][20] + 1 * 5
// 这时候单调队列每一个元素都要加上一个 5,就变化了,所以我们要把这个 5 给消掉
// 方法就是 dp[i][20] 依赖
// 1. dp[i-1][20] - 20 / 3 * 5
// 2. dp[i-1][17] - 17 / 3 * 5
// 3. dp[i-1][14] - 14 / 3 * 5
// 4. dp[i-1][11] - 11 / 3 * 5
// 5. dp[i-1][8] - 8 / 3 * 5
// 最终让最大值加上 20 / 3 * 5,这样结果是不变的,但是单调队列里面维护的信息就不变了,当继续求 dp[i][23] 的时候就加上 23 / 3 * 5
public static int getQueueValue(int[][] dp, int i, int j) {
return dp[i - 1][j] - j / w[i] * v[i];
}
7. 单调队列优化代码 - 二维动态规划
下面直接给出单调队列优化的二维动态规划代码。
public static int compute() {
int[][] dp = new int[n + 1][t + 1];
for (int i = 1; i <= n; i++) {
// 同余分组计算,t 是整体重量,其中假设当前物品重量是 3,那么下标 0,3,6,9 ... 为一组
// 1,4,7,10 ... 为一组, 2,5,8,11,14 ... 为一组
for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
l = r = 0;
for (int j = mod; j <= t; j += w[i]) {
while (l < r && getQueueValue(dp, i, queue[r - 1]) <= getQueueValue(dp, i, j)) {
r--;
}
// 找到第一个大于当前 j 求出来的值的位置
queue[r++] = j;
// 删除过期的节点,比如当前重量是 23,由于物品数是 4,所以单调队列里面就是
// 23,20,17,14,11,剩下的 8 就要移除了
if (queue[l] == j - w[i] * (c[i] + 1)) {
l++;
}
dp[i][j] = getQueueValue(dp, i, queue[l]) + j / w[i] * v[i];
}
}
}
return dp[n][t];
}
public static int getQueueValue(int[][] dp, int i, int j) {
return dp[i - 1][j] - j / w[i] * v[i];
}
代码有三个 for 循环,最外层的循环就是在遍历物品,第二层 for 循环是在遍历余数,为什么要 mod <= Math.min(t, w[i] - 1)
,因为物品重量假设是 100,但是背包重量才 20,这种情况下 mod 最高就遍历到 19 就行了。
接着 for (int j = mod; j <= t; j += w[i])
就是在遍历每一个分组,也就是上面说的 0,3,6,9 ...
,1,4,7,10 ...
,2,5,8,11,14 ...
一组一组来求。
下面就是维护单调队列了,维护的代码也不难,单调队列是从大到小,这个单调队列是提前创建好的数组 => static int[] queue = new int[40001]
,代码逻辑是首先从右往左找到第一个比要加入的数据大的下标。
while (l < r && getQueueValue(dp, i, queue[r - 1]) <= getQueueValue(dp, i, j)) {
r--;
}
接着设置当前的值到单调队列里面。
queue[r++] = j;
最后删除过期的数据,比如当前重量 j 从 20 变成 23,由于物品数是 4,所以单调队列里面就维护 23,20,17,14,11,剩下的 8 就要移除了。
if (queue[l] == j - w[i] * (c[i] + 1)) {
l++;
}
下面是图解维护单调队列的流程,注意下面单调队列里面的值
8. 单调队列优化代码 - 空间优化版本
// 空间压缩
public static int computeV2() {
int[] dp = new int[t + 1];
for (int i = 1; i <= n; i++) {
for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
l = r = 0;
// 先把一维数组的c[i]个值入队列
int cnt = 1;
for (int j = t - mod; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, j)) {
r--;
}
queue[r++] = j;
}
// 开始从后往前遍历
for (int j = t - mod, last = j - w[i] * c[i]; j >= 0; j -= w[i], last -= w[i]) {
// 设置最后一个应该放的位置
if(last >= 0){
while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, last)) {
r--;
}
queue[r++] = last;
}
// 计算 dp[j]
dp[j] = getQueueValue2(dp, i, queue[l]) + j / w[i] * v[i];
// 判断是否过期,因为是从后往前遍历,所以 j 就是要过期的元素
if (queue[l] == j) {
l++;
}
}
}
}
return dp[t];
}
public static int getQueueValue2(int[] dp, int i, int j) {
return dp[j] - j / w[i] * v[i];
}
空间压缩的版本和二维 dp 的版本遍历是一样的,但是由于当前 dp[i][j] 依赖的是上一层的左上的节点,所以需要先把 c[i] 个值放到队列里面。
比如求 dp[19] 的时候先把 10-19 这四个入队,那剩下的 7 呢?别急,先入队 4 个,然后从后往前遍历的时候把 dp[7]-7/3*5
也入队,然后调整队列,再删除过期值。
// 先把一维数组的c[i]个值入队列
int cnt = 1;
for (int j = t - mod; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, j)) {
r--;
}
queue[r++] = j;
}
上面代码就是将 c[i] 个值入队,大家要注意如果物品个数有 c[i],那么队列里面的元素就是 c[i] + 1 个,因为还有一个是 dp[i-1][j] - j / 3 * 5。
j = t - mod
表示从后往前遍历,因为是依赖上一层的左边和上边的节点,所以需要从后往前遍历。
下面 for 循环才是真正从后往前遍历的逻辑,上面只是提前设置好 c[i] 个元素到单调队列。
last = j - w[i] * c[i]
就是接下来要入队的节点了,上面不是入队了 4 个节点吗,下面把 dp[7] - 7 / 3 * 5 也入队了,但是要注意 last 必须大于 0 才可以入队,否则小于 0 就不行了。
注意上面在解释的时候都是按照重量为 3,价值为 5,个数为 4 来解析的。
最后判断是否过期直接判断 queue[l] == j
,因为 j 是从后往前遍历的,假设当前 j = 22,那么当处理完 dp[j] 往前遍历到 dp[19] 的时候,如果 quue[l] == 22
,那么就说明这个最大值过期了。
if (queue[l] == j) {
l++;
}
9. 整体代码
下面直接给出整体的代码。
import java.util.Scanner;
public class Main {
static int MAX = 101;
static int n = 0, t = 0;
static int[] v = new int[MAX], w = new int[MAX], c = new int[MAX];
static int[] queue = new int[40001];
static int index = 0;
// 单调队列的两个指针
static int l = 0, r = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 物品数
n = scanner.nextInt();
// 背包重量
t = scanner.nextInt();
for (int i = 1; i <= n; i++) {
v[i] = scanner.nextInt();
w[i] = scanner.nextInt();
c[i] = scanner.nextInt();
}
System.out.println(computeV2());
}
public static int compute() {
int[][] dp = new int[n + 1][t + 1];
for (int i = 1; i <= n; i++) {
// 同余分组计算,t 是整体重量,其中假设当前物品重量是 3,那么下标 0,3,6,9 ... 为一组
// 1,4,7,10 ... 为一组, 2,5,8,11,14 ... 为一组
for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
l = r = 0;
for (int j = mod; j <= t; j += w[i]) {
while (l < r && getQueueValue(dp, i, queue[r - 1]) <= getQueueValue(dp, i, j)) {
r--;
}
// 找到第一个大于当前 j 求出来的值的位置
queue[r++] = j;
// 删除过期的节点,比如当前重量是 23,由于物品数是 4,所以单调队列里面就是
// 23,20,17,14,11,剩下的 8 就要移除了
if (queue[l] == j - w[i] * (c[i] + 1)) {
l++;
}
dp[i][j] = getQueueValue(dp, i, queue[l]) + j / w[i] * v[i];
}
}
}
return dp[n][t];
}
// 假设 i 号物品重量 3, 价值 5, 个数 4
// 当前 dp[i][j] 依赖 dp[i-1][j] + index * w[i] / v[i]
// 比如 dp[i][20] 依赖
// 1. dp[i-1][20] + 0 * 5
// 2. dp[i-1][17] + 1 * 5
// 3. dp[i-1][14] + 2 * 5
// 4. dp[i-1][11] + 3 * 5
// 5. dp[i-1][8] + 4 * 5
// 单调队列本意就是维护上面 5 个数字的大小关系,从大到小,这样就不需要每次计算 j 都往回倒推
// 但是当求 dp[i][23] 的时候,依赖 dp[i-1][20] + 1 * 5
// 这时候单调队列每一个元素都要加上一个 5,就变化了,所以我们要把这个 5 给消掉
// 方法就是 dp[i][20] 依赖
// 1. dp[i-1][20] - 20 / 3 * 5
// 2. dp[i-1][17] - 17 / 3 * 5
// 3. dp[i-1][14] - 14 / 3 * 5
// 4. dp[i-1][11] - 11 / 3 * 5
// 5. dp[i-1][8] - 8 / 3 * 5
// 最终让最大值加上 20 / 3 * 5,这样结果是不变的,但是单调队列里面维护的信息就不变了,当继续求 dp[i][23] 的时候就加上 23 / 3 * 5
public static int getQueueValue(int[][] dp, int i, int j) {
return dp[i - 1][j] - j / w[i] * v[i];
}
// 空间压缩
public static int computeV2() {
int[] dp = new int[t + 1];
for (int i = 1; i <= n; i++) {
for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
l = r = 0;
// 先把一维数组的c[i]个值入队列
int cnt = 1;
for (int j = t - mod; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, j)) {
r--;
}
queue[r++] = j;
}
// 开始从后往前遍历
for (int j = t - mod, last = j - w[i] * c[i]; j >= 0; j -= w[i], last -= w[i]) {
// 设置最后一个应该放的位置
if(last >= 0){
while (l < r && getQueueValue2(dp, i, queue[r - 1]) <= getQueueValue2(dp, i, last)) {
r--;
}
queue[r++] = last;
}
// 计算 dp[j]
dp[j] = getQueueValue2(dp, i, queue[l]) + j / w[i] * v[i];
// 判断是否过期,因为是从后往前遍历,所以 j 就是要过期的元素
if (queue[l] == j) {
l++;
}
}
}
}
return dp[t];
}
public static int getQueueValue2(int[] dp, int i, int j) {
return dp[j] - j / w[i] * v[i];
}
}
直接到洛谷提交也是能通过的。
10.小结
文章里面的例子都来自左神的视频:算法讲解075【必备】背包dp-多重背包、混合背包,讲的确实很清楚,后面还有一些混合背包的内容下一篇文章再单独说下。
如有错误,欢迎指出!!!