本文的网课内容学习自B站左程云老师的算法详解课程,旨在对其中的知识进行整理和分享~
一.多重背包模板
题目:P1776 宝物筛选
算法原理
-
整体原理
- 多重背包问题是背包问题的变种,其特点是每种物品有固定的数量限制(既不是无限取用,也不是只能取一次)。目标是在不超过背包容量的前提下,选择物品使得总价值最大。
-
问题特性:
-
每种物品有重量
w[i]
、价值v[i]
和数量c[i]
。 -
需要决定每种物品选取多少个(0到
c[i]
之间)。
-
-
动态规划定义:
-
dp[i][j]
表示考虑前i
种物品时,背包容量为j
时的最大价值。 -
初始时,
dp[j] = 0
(没有物品时价值为0)。
-
-
状态转移:
-
对于每种物品
i
,遍历所有可能的背包容量j
(从0到t
):-
初始时,
dp[i][j] = dp[i-1][j]
(不选当前物品)。 -
枚举选取当前物品的数量
k
(从1到c[i]
,且k * w[i] <= j
):-
更新
dp[i][j]
为:dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k * w[i]] + k * v[i]);
-
-
-
具体步骤
-
输入处理:
-
读取物品种类
n
和背包容量t
。 -
读取每种物品的价值
v[i]
、重量w[i]
和数量c[i]
。
-
-
初始化:
-
二维数组
dp
初始化为dp[j] = 0
。
-
-
动态规划填充:
-
对于每种物品
i
:-
对于每个容量
j
:-
初始化为不选当前物品的值
dp[i-1][j]
。 -
枚举选取数量
k
,更新dp[i][j]
。
-
-
-
-
空间优化:
-
使用一维数组
dp[j]
,逆序遍历j
以避免覆盖之前的状态。 -
对于每种物品
i
,从t
到0
遍历j
,并枚举k
。
-
-
结果提取:
-
dp[n][t]
或dp[t]
即为最大价值。 - 为什么需要逆序遍历?
-
逆序确保在更新
dp[j]
时,dp[j - k * w[i]]
仍然是上一轮(i-1
)的值,避免重复计算。
-
-
-
示例
- 假设
t = 5
,物品:-
物品1:
v=2
,w=1
,c=2
。 -
物品2:
v=3
,w=2
,c=1
。
-
- 动态规划过程:
-
初始化
dp = [0, 0, 0, 0, 0, 0]
。 -
处理物品1:
-
j=5
:可以选k=1
或k=2
。-
k=1
:dp = max(0, dp + 2) = 2
。 -
k=2
:dp = max(2, dp + 4) = 4
。
-
-
j=4
:类似更新。
-
-
处理物品2:
-
j=5
:可以选k=1
。-
dp = max(4, dp + 3) = 5
。
-
-
-
最终
dp = 5
。
-
- 假设
-
总结
-
通过动态规划枚举每种物品的选取数量,解决了多重背包问题。算法的时间复杂度为
O(n * t * c)
(c
是平均数量),空间复杂度为O(t)
(优化后)。适用于物品种类和背包容量适中的场景。
-
代码实现
// 多重背包不进行枚举优化
// 宝物筛选
// 一共有n种货物, 背包容量为t
// 每种货物的价值(v[i])、重量(w[i])、数量(c[i])都给出
// 请返回选择货物不超过背包容量的情况下,能得到的最大的价值
// 测试链接 : https://www.luogu.com.cn/problem/P1776
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
public class Code01_BoundedKnapsack {
public static int MAXN = 101;
public static int MAXW = 40001;
public static int[] v = new int[MAXN];
public static int[] w = new int[MAXN];
public static int[] c = new int[MAXN];
public static int[] dp = new int[MAXW];
public static int n, t;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
t = (int) in.nval;
for (int i = 1; i <= n; i++) {
in.nextToken(); v[i] = (int) in.nval;
in.nextToken(); w[i] = (int) in.nval;
in.nextToken(); c[i] = (int) in.nval;
}
out.println(compute2());
}
out.flush();
out.close();
br.close();
}
// 严格位置依赖的动态规划
// 时间复杂度O(n * t * 每种商品的平均个数)
public static int compute1() {
// dp[0][....] = 0,表示没有货物的情况下,背包容量不管是多少,最大价值都是0
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];
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];
}
// 空间压缩
// 部分测试用例超时
// 因为没有优化枚举
// 时间复杂度O(n * t * 每种商品的平均个数)
public static int compute2() {
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];
}
}
二.多重背包二进制分组优化
题目:P1776 宝物筛选
算法原理
-
整体原理
-
多重背包问题是背包问题的一个变种,其中每种物品有一定的数量限制(即每种物品可以选择多次,但不超过给定的数量)。这与完全背包问题(每种物品无限)和01背包问题(每种物品只有一个)不同。
-
二进制分组优化原理
-
直接处理多重背包问题的一个简单方法是将每种物品拆分成多个独立的物品,然后使用01背包的方法解决。例如,如果一个物品有12个,可以将其拆分成12个相同的物品。然而,这种方法在物品数量较大时效率很低。
-
二进制分组优化通过将物品的数量拆分成若干个2的幂次方的和,从而减少需要处理的物品数量。具体来说,对于数量为 cc 的物品,可以将其拆分成 1,2,4,…,2k,c−(2k+1−1)的组合。这样,任何数量的物品都可以通过这些组合的和来表示,同时物品的总数从 c 减少到 logc。
-
-
-
具体步骤
-
输入处理:读取物品的种类数 n、背包容量 t,以及每种物品的价值、重量和数量。
-
二进制分组:
-
对于每种物品,将其数量 c 拆分成若干个2的幂次方的和。例如,c=12 可以拆分成 1,2,4,5(因为 1+2+4+5=12)。
-
对于每个拆分后的部分,计算其对应的价值和重量(即部分数量乘以单个物品的价值和重量),并将其视为一个新的“衍生物品”。
-
-
01背包求解:
-
将所有衍生物品视为01背包问题中的独立物品。
-
使用动态规划求解01背包问题,其中 dp[j]表示背包容量为 j 时的最大价值。
-
初始化 dp 数组为0。
-
对于每个衍生物品,从背包容量 t 开始逆向更新 dp 数组,确保每个物品只被选择一次。
-
-
输出结果:dp[t] 即为背包容量为 t 时的最大价值。
-
代码实现细节
-
二进制分组:
-
使用循环将数量 c 拆分成 1,2,4,… 直到剩余部分不足下一个2的幂次方。
-
例如,
for (int k = 1; k <= cnt; k <<= 1)
循环中,k
依次取1, 2, 4, 8, ...,直到 k>cnt。 -
每次拆分后,更新剩余数量
cnt -= k
。 -
如果最后剩余数量不为0,将其作为一个单独的衍生物品。
-
-
01背包求解:
-
初始化
dp
数组为0。 -
对于每个衍生物品,从 t 到 w[i] 逆向更新
dp
数组:dp[j] = max(dp[j], dp[j - w[i]] + v[i])
。
-
-
-
-
时间复杂度
-
二进制分组部分:每种物品的数量 cc 被拆分成 logclogc 个衍生物品,因此总物品数为 O(∑logci)。
-
01背包部分:对于每个衍生物品,更新 t 次
dp
数组,因此总时间复杂度为 O(t⋅∑logci)。
-
-
示例
-
假设有以下输入:
-
物品种类 n=1,背包容量 t=10。
-
物品1:价值 v=2,重量 w=3,数量 c=5。
-
-
二进制分组:
-
c=5 拆分成 1,2,2(因为 1+2+2=5)。
-
-
衍生物品:
-
物品1-1:v=1×2=2,w=1×3=3。
-
物品1-2:v=2×2=4,w=2×3=6。
-
物品1-3:v=2×2=4,w=2×3=6。
-
-
01背包求解:
-
初始
dp = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
。 -
处理物品1-1:
-
更新
dp = max(0, 0 + 2) = 2
。 -
dp = [0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0]
。
-
-
处理物品1-2:
-
更新
dp = max(0, 0 + 4) = 4
。 -
更新
dp = max(0, 2 + 4) = 6
。 -
dp = [0, 0, 0, 2, 0, 0, 4, 0, 0, 6, 0]
。
-
-
处理物品1-3:
-
更新
dp = max(4, 0 + 4) = 4
。 -
更新
dp = max(6, 2 + 4) = 6
。 -
dp = [0, 0, 0, 2, 0, 0, 4, 0, 0, 6, 0]
。
-
-
最终
dp = 6
。
-
-
总结
-
二进制分组优化通过将多重背包问题转化为01背包问题,显著减少了物品数量,从而提高了算法效率。
-
代码实现
// 多重背包通过二进制分组转化成01背包(模版)
// 宝物筛选
// 一共有n种货物, 背包容量为t
// 每种货物的价值(v[i])、重量(w[i])、数量(c[i])都给出
// 请返回选择货物不超过背包容量的情况下,能得到的最大的价值
// 测试链接 : https://www.luogu.com.cn/problem/P1776
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.Arrays;
public class Code02_BoundedKnapsackWithBinarySplitting {
public static int MAXN = 1001;
public static int MAXW = 40001;
// 把每一种货物根据个数做二进制分组,去生成衍生商品
// 衍生出来的每一种商品,价值放入v、重量放入w
public static int[] v = new int[MAXN];
public static int[] w = new int[MAXN];
public static int[] dp = new int[MAXW];
public static int n, t, m;
// 时间复杂度O(t * (log(第1种商品的个数) + log(第2种商品的个数) + ... + log(第n种商品的个数)))
// 对每一种商品的个数取log,所以时间复杂度虽然大于O(n * t),但也不会大多少
// 多重背包最常用的方式
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
t = (int) in.nval;
m = 0;
for (int i = 1, value, weight, cnt; i <= n; i++) {
in.nextToken(); value = (int) in.nval;
in.nextToken(); weight = (int) in.nval;
in.nextToken(); cnt = (int) in.nval;
// 整个文件最重要的逻辑 : 二进制分组
// 一般都使用这种技巧,这段代码非常重要
// 虽然时间复杂度不如单调队列优化的版本
// 但是好写,而且即便是比赛,时间复杂度也达标
// 二进制分组的时间复杂度为O(log cnt)
for (int k = 1; k <= cnt; k <<= 1) {
v[++m] = k * value;
w[m] = k * weight;
cnt -= k;
}
if (cnt > 0) {
v[++m] = cnt * value;
w[m] = cnt * weight;
}
}
out.println(compute());
}
out.flush();
out.close();
br.close();
}
// 01背包的空间压缩代码(模版)
public static int compute() {
Arrays.fill(dp, 0, t + 1, 0);
for (int i = 1; i <= m; i++) {
for (int j = t; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[t];
}
}
三.观赏樱花
题目:P1833 樱花
算法原理
-
整体原理
- 本题是一个混合背包问题,结合了完全背包(无限数量)和多重背包(有限数量)的特点。我们需要在给定的背包容量
t
下,选择若干物品,使得总花费不超过t
,同时总价值最大。 - 关键思想:
- 完全背包(
cnt == 0
)可以视为数量极大的多重背包(如cnt = t / cost
)。 - 多重背包(
cnt > 0
)可以通过二进制分组优化转化为01背包问题,避免逐个枚举所有可能的选取方式。 - 01背包使用动态规划求解,空间优化采用滚动数组,降低空间复杂度。
- 完全背包(
- 本题是一个混合背包问题,结合了完全背包(无限数量)和多重背包(有限数量)的特点。我们需要在给定的背包容量
-
具体步骤
-
输入处理:
- 读取起始时间
(hour1:minute1)
和结束时间(hour2:minute2)
,计算背包容量t
(总可用时间)。 - 读取每种物品的
cost
(花费时间)、val
(观赏价值)、cnt
(数量):- 若
cnt == 0
,表示无限数量,设cnt = ENOUGH
(足够大的数,如t / cost
)。 - 若
cnt > 0
,直接使用给定的数量。
- 若
- 读取起始时间
-
二进制分组优化(将多重背包转化为01背包):
- 对于每个物品,若
cnt > 0
,将其拆分为若干个2的幂次方的组合:- 例如
cnt = 10
,拆分为1, 2, 4, 3
(因为1 + 2 + 4 + 3 = 10
)。 - 这样可以用
log(cnt)
个物品表示所有可能的选取方式,避免逐个枚举cnt
次。
- 例如
- 对于每个物品,若
-
转化为01背包问题:
- 将拆分后的物品视为独立的01背包物品,每个物品只能选或不选。
- 使用动态规划求解:
dp[j]
表示背包容量为j
时的最大价值。- 状态转移方程:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
,其中w[i]
是拆分后的物品花费,v[i]
是其价值。
-
空间优化:
- 使用滚动数组优化空间复杂度,从
t
到w[i]
逆向更新dp
数组,避免重复计算。
- 使用滚动数组优化空间复杂度,从
-
-
复杂度分析
-
时间复杂度:
- 二进制拆分:每个物品最多拆分为
log(cnt)
个物品,总拆分次数为O(n log cnt)
。 - 动态规划:
O(m · t)
,其中m
是拆分后的物品总数,t
是背包容量。 - 总时间复杂度:
O(n log cnt · t)
。
- 二进制拆分:每个物品最多拆分为
-
空间复杂度:
O(t)
,仅需一维dp
数组。
-
-
示例
- 输入:
- 5:00 6:30 3
- 10 100 0
- 20 200 1
- 30 300 2
- 解释:
- 时间计算:
5:00
到6:30
共90
分钟,背包容量t = 90
。
- 物品拆分:
- 第1个物品(
cost=10, val=100, cnt=0
)视为无限数量,设cnt = 90 / 10 = 9
,拆分为1, 2, 4, 2
。 - 第2个物品(
cost=20, val=200, cnt=1
)直接保留。 - 第3个物品(
cost=30, val=300, cnt=2
)拆分为1, 1
。
- 第1个物品(
- 动态规划求解:
- 拆分后的物品列表:
(10, 100), (20, 100), (40, 100), (20, 100)
(第1个物品拆分)(20, 200)
(第2个物品)(30, 300), (30, 300)
(第3个物品拆分)
- 计算
dp
的最大值。
- 拆分后的物品列表:
- 时间计算:
- 输出:
- 500
- 解释:
- 最优解:选
(30, 300)
两次,总花费60
,总价值600
(但背包容量90
足够,可以再选(20, 200)
,总价值800
)。 - 但实际拆分方式可能影响结果,需具体计算。
- 最优解:选
- 输入:
-
总结
- 适用场景:混合背包问题(完全背包 + 多重背包)。
- 优化方法:
- 二进制分组:将多重背包转化为01背包,减少计算量。
- 滚动数组:优化空间复杂度。
- 时间复杂度:
O(n log cnt · t)
,适用于t ≤ 1000
的背包问题。 - 扩展性:该方法可推广至其他背包变种问题(如分组背包、依赖背包)。
代码实现
// 观赏樱花
// 给定一个背包的容量t,一共有n种货物,并且给定每种货物的信息
// 花费(cost)、价值(val)、数量(cnt)
// 如果cnt == 0,代表这种货物可以无限选择
// 如果cnt > 0,那么cnt代表这种货物的数量
// 挑选货物的总容量在不超过t的情况下,返回能得到的最大价值
// 背包容量不超过1000,每一种货物的花费都>0
// 测试链接 : https://www.luogu.com.cn/problem/P1833
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.Arrays;
// 完全背包转化为多重背包
// 再把多重背包通过二进制分组转化为01背包
public class Code03_CherryBlossomViewing {
public static int MAXN = 100001;
public static int MAXW = 1001;
public static int ENOUGH = 1001;
public static int[] v = new int[MAXN];
public static int[] w = new int[MAXN];
public static int[] dp = new int[MAXW];
public static int hour1, minute1, hour2, minute2;
public static int t, n, m;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
in.parseNumbers();
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
hour1 = (int) in.nval;
// 跳过冒号
in.nextToken();
in.nextToken();
minute1 = (int) in.nval;
in.nextToken();
hour2 = (int) in.nval;
// 跳过冒号
in.nextToken();
in.nextToken();
minute2 = (int) in.nval;
if (minute1 > minute2) {
hour2--;
minute2 += 60;
}
// 计算背包容量
t = (hour2 - hour1) * 60 + minute2 - minute1;
in.nextToken();
n = (int) in.nval;
m = 0;
for (int i = 0, cost, val, cnt; i < n; i++) {
in.nextToken();
cost = (int) in.nval;
in.nextToken();
val = (int) in.nval;
in.nextToken();
cnt = (int) in.nval;
if (cnt == 0) {
cnt = ENOUGH;
}
// 二进制分组
for (int k = 1; k <= cnt; k <<= 1) {
v[++m] = k * val;
w[m] = k * cost;
cnt -= k;
}
if (cnt > 0) {
v[++m] = cnt * val;
w[m] = cnt * cost;
}
}
out.println(compute());
}
out.flush();
out.close();
br.close();
}
// 01背包的空间压缩代码(模版)
public static int compute() {
Arrays.fill(dp, 0, t + 1, 0);
for (int i = 1; i <= m; i++) {
for (int j = t; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[t];
}
}
四.多重背包单调队列优化
题目:P1776 宝物筛选
算法原理
-
整体原理
- 本题是一个多重背包问题,需要在给定的背包容量
t
下,选择若干物品(每种物品有数量限制),使得总重量不超过t
,同时总价值最大。 - 关键思想:
- 多重背包(
c[i]
表示物品i
的数量)可以通过单调队列优化来减少时间复杂度。 - 单调队列优化的核心思想是利用滑动窗口,在动态规划过程中维护一个递减队列,快速找到当前窗口内的最大值。
- 空间优化采用滚动数组,降低空间复杂度。
- 多重背包(
- 本题是一个多重背包问题,需要在给定的背包容量
-
具体步骤
-
输入处理:
- 读取物品数量
n
和背包容量t
。 - 读取每种物品的
v[i]
(价值)、w[i]
(重量)、c[i]
(数量)。
- 读取物品数量
-
单调队列优化:
- 同余分组:对于每个物品
i
,按照j % w[i]
进行分组,每组独立处理。 - 滑动窗口:
- 维护一个单调递减队列,队列中存储的是当前窗口内最优的决策点。
- 队列中的元素
j
满足dp[j] - (j / w[i]) * v[i]
是递减的。 - 每次移动窗口时,移除过期元素(超出数量限制
c[i]
的决策点),并加入新元素。
- 同余分组:对于每个物品
-
动态规划状态转移:
dp[j]
表示背包容量为j
时的最大价值。- 状态转移方程:
dp[j] = max(dp[j], dp[k] + (j - k) / w[i] * v[i])
,其中k
是队列中的最优决策点。
-
空间优化:
- 使用滚动数组,从右往左更新
dp
数组,确保在计算dp[j]
时,dp[k]
仍然是上一轮的值。
- 使用滚动数组,从右往左更新
-
-
复杂度分析
-
时间复杂度:
- 单调队列优化:每组物品的处理时间为
O(t)
,总共有n
个物品,因此总时间复杂度为O(n · t)
。 - 相比二进制拆分的
O(n log c · t)
,单调队列优化更高效。
- 单调队列优化:每组物品的处理时间为
-
空间复杂度:
O(t)
,仅需一维dp
数组和一个队列。
-
-
示例
- 输入:
- 3 10
- 2 3 2
- 3 4 1
- 4 5 3
- 解释:
- 物品信息:
- 物品1:
v=2, w=3, c=2
- 物品2:
v=3, w=4, c=1
- 物品3:
v=4, w=5, c=3
- 物品1:
- 单调队列优化:
- 对物品1(
w=3
),分组mod = 0, 1, 2
:mod=0
:j=0, 3, 6, 9
,队列维护dp[j] - (j / 3) * 2
的最大值。- 类似处理其他分组。
- 对物品1(
- 动态规划计算:
- 最终
dp
为最大价值。
- 最终
- 物品信息:
- 输出:
- 8
- 解释:
- 最优解:选物品1两次(
w=6, v=4
)和物品2一次(w=4, v=3
),总重量10
,总价值7
(实际可能有更优解)。
- 最优解:选物品1两次(
-
总结
- 适用场景:多重背包问题,物品数量较大时效率更高。
- 优化方法:
- 单调队列:利用滑动窗口快速找到最优决策点。
- 滚动数组:优化空间复杂度。
- 时间复杂度:
O(n · t)
,适用于t
较大的背包问题。 - 扩展性:该方法可推广至其他需要滑动窗口优化的动态规划问题。
代码实现
// 多重背包单调队列优化
// 宝物筛选
// 一共有n种货物, 背包容量为t
// 每种货物的价值(v[i])、重量(w[i])、数量(c[i])都给出
// 请返回选择货物不超过背包容量的情况下,能得到的最大的价值
// 测试链接 : https://www.luogu.com.cn/problem/P1776
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
public class Code04_BoundedKnapsackWithMonotonicQueue {
public static int MAXN = 101;
public static int MAXW = 40001;
public static int[] v = new int[MAXN];
public static int[] w = new int[MAXN];
public static int[] c = new int[MAXN];
public static int[] dp = new int[MAXW];
public static int[] queue = new int[MAXW];
public static int l, r;
public static int n, t;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
t = (int) in.nval;
for (int i = 1; i <= n; i++) {
in.nextToken();
v[i] = (int) in.nval;
in.nextToken();
w[i] = (int) in.nval;
in.nextToken();
c[i] = (int) in.nval;
}
out.println(compute2());
}
out.flush();
out.close();
br.close();
}
// 严格位置依赖的动态规划 + 单调队列优化枚举
public static int compute1() {
int[][] dp = new int[n + 1][t + 1];
for (int i = 1; i <= n; i++) {
// 同余分组
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 && value1(dp, i, queue[r - 1]) <= value1(dp, i, j)) {
r--;
}
queue[r++] = j;
if (queue[l] == j - w[i] * (c[i] + 1)) {
l++;
}
dp[i][j] = value1(dp, i, queue[l]) + j / w[i] * v[i];
}
}
}
return dp[n][t];
}
// 当前来到i号货物,需要j位置的指标,返回指标值
public static int value1(int[][] dp, int i, int j) {
return dp[i - 1][j] - j / w[i] * v[i];
}
// 空间压缩的动态规划 + 单调队列优化枚举
// 因为求dp[i][j]时需要上一行左侧的若干格子
// 所以做空间压缩时,每一行需要从右往左求
// 以此来保证左侧的格子还没有更新,还是"上一行"的状况
public static int compute2() {
for (int i = 1; i <= n; i++) {
for (int mod = 0; mod <= Math.min(t, w[i] - 1); mod++) {
l = r = 0;
// 先把c[i]个的指标进入单调队列
for (int j = t - mod, cnt = 1; j >= 0 && cnt <= c[i]; j -= w[i], cnt++) {
while (l < r && value2(i, queue[r - 1]) <= value2(i, j)) {
r--;
}
queue[r++] = j;
}
for (int j = t - mod, enter = j - w[i] * c[i]; j >= 0; j -= w[i], enter -= w[i]) {
// 窗口进入enter位置的指标
if (enter >= 0) {
while (l < r && value2(i, queue[r - 1]) <= value2(i, enter)) {
r--;
}
queue[r++] = enter;
}
// 计算dp[i][j]
dp[j] = value2(i, queue[l]) + j / w[i] * v[i];
// 窗口弹出j位置的指标
if (queue[l] == j) {
l++;
}
}
}
}
return dp[t];
}
// 当前来到i号货物,需要j位置的指标,返回指标值
public static int value2(int i, int j) {
return dp[j] - j / w[i] * v[i];
}
}
五.混合背包+多重背包窗口优化
题目:Coins
算法原理
-
整体原理
-
本题是一个混合背包问题,结合了01背包(数量为1)、完全背包(数量无限)和多重背包(数量有限)的特点。我们需要计算在钱数范围
1~m
内,能通过给定货币组合成功找零的钱数种类数。 - 关键思想:
- 动态规划:使用
dp[j]
表示钱数j
是否能被找零。 - 分类处理:
- 01背包(
cnt[i] == 1
):从右往左更新dp
数组。 - 完全背包(
val[i] * cnt[i] > m
):从左往右更新dp
数组。 - 多重背包(
cnt[i] > 1
):通过滑动窗口优化(同余分组)减少重复计算。
- 01背包(
- 动态规划:使用
-
-
具体步骤
-
输入处理:
- 读取货币种类数
n
和钱数范围m
。 - 读取每种货币的
val[i]
(面值)和cnt[i]
(数量)。
- 读取货币种类数
-
初始化动态规划数组:
dp = true
(钱数为0时不需要找零)。- 其他
dp[1..m]
初始化为false
。
-
分类处理货币:
- 01背包(
cnt[i] == 1
):- 从
m
到val[i]
逆向更新dp[j]
: - for (int j = m; j >= val[i]; j--) {
if (dp[j - val[i]]) {
dp[j] = true;
}
}
- 从
- 完全背包(
val[i] * cnt[i] > m
):- 从
val[i]
到m
正向更新dp[j]
: - for (int j = val[i]; j <= m; j++) {
if (dp[j - val[i]]) {
dp[j] = true;
}
}
- 从
- 多重背包(
cnt[i] > 1
):- 同余分组:按
j % val[i]
分组,每组独立处理。 - 滑动窗口优化:
- 维护一个窗口大小为
cnt[i] + 1
的计数器trueCnt
,统计窗口内dp[j] = true
的数量。 - 逆向更新
dp[j]
,利用trueCnt
快速判断当前钱数是否可找零: -
for (int mod = 0; mod < val[i]; mod++) { int trueCnt = 0; for (int j = m - mod, size = 0; j >= 0 && size <= cnt[i]; j -= val[i], size++) { trueCnt += dp[j] ? 1 : 0; } for (int j = m - mod, l = j - val[i] * (cnt[i] + 1); j >= 1; j -= val[i], l -= val[i]) { if (dp[j]){ trueCnt--; } else { if (trueCnt != 0){ dp[j] = true; } } if (l >= 0) { trueCnt += dp[l] ? 1 : 0; } } }
- 维护一个窗口大小为
- 同余分组:按
- 01背包(
-
- 统计结果:
- 遍历
dp[1..m]
,统计dp[j] = true
的数量。
- 遍历
-
复杂度分析
-
时间复杂度:
- 01背包:
O(n · m)
。 - 完全背包:
O(n · m)
。 - 多重背包:
O(n · m)
(滑动窗口优化后)。 - 总时间复杂度:
O(n · m)
。
- 01背包:
-
空间复杂度:
O(m)
,仅需一维dp
数组。
-
-
示例
- 输入:
- 3 10
- 1 2 3
- 2 1 1
- 3 1 2
- 解释:
- 货币信息:
- 货币1:
val=1, cnt=2
(多重背包)。 - 货币2:
val=2, cnt=1
(01背包)。 - 货币3:
val=3, cnt=1
(01背包)。
- 货币1:
- 动态规划过程:
- 初始化
dp = [T, F, F, F, F, F, F, F, F, F, F]
。 - 处理货币1(
val=1, cnt=2
):- 同余分组
mod=0
:- 窗口滑动更新
dp[1..10]
,最终dp = [T, T, T, F, F, F, F, F, F, F, F]
(可找零1、2)。
- 窗口滑动更新
- 同余分组
- 处理货币2(
val=2, cnt=1
):- 逆向更新
dp
,dp = [T, T, T, T, F, F, F, F, F, F, F]
(新增3)。
- 逆向更新
- 处理货币3(
val=3, cnt=1
):- 逆向更新
dp
,dp = [T, T, T, T, T, T, F, F, F, F, F]
(新增4、5、6)。
- 逆向更新
- 初始化
- 统计结果:
dp[1..10]
中true
的数量为6
(1, 2, 3, 4, 5, 6)。
- 货币信息:
- 输出:
- 6
- 输入:
-
总结
- 适用场景:混合背包问题(01背包 + 完全背包 + 多重背包)。
- 优化方法:
- 滑动窗口优化:高效处理多重背包的同余分组。
- 分类处理:根据货币数量选择01背包或完全背包更新策略。
- 时间复杂度:
O(n · m)
,适用于m
较大的情况。 - 扩展性:可推广至其他需要动态规划优化的组合问题。
代码实现
// 混合背包 + 多重背包普通窗口优化
// 能成功找零的钱数种类
// 每一种货币都给定面值val[i],和拥有的数量cnt[i]
// 想知道目前拥有的货币,在钱数为1、2、3...m时
// 能找零成功的钱数有多少
// 也就是说当钱数的范围是1~m
// 返回这个范围上有多少可以找零成功的钱数
// 比如只有3元的货币,数量是5张
// m = 10
// 那么在1~10范围上,只有钱数是3、6、9时,可以成功找零
// 所以返回3表示有3种钱数可以找零成功
// 测试链接 : http://poj.org/problem?id=1742
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.Arrays;
public class Code05_MixedKnapsack {
public static int MAXN = 101;
public static int MAXM = 100001;
public static int[] val = new int[MAXN];
public static int[] cnt = new int[MAXN];
public static boolean[] dp = new boolean[MAXM];
public static int n, m;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
m = (int) in.nval;
if (n != 0 || m != 0) {
for (int i = 1; i <= n; i++) {
in.nextToken();
val[i] = (int) in.nval;
}
for (int i = 1; i <= n; i++) {
in.nextToken();
cnt[i] = (int) in.nval;
}
out.println(compute());
}
}
out.flush();
out.close();
br.close();
}
// 直接提供空间压缩版
public static int compute() {
Arrays.fill(dp, 1, m + 1, false);
dp[0] = true;
for (int i = 1; i <= n; i++) {
if (cnt[i] == 1) {
// 01背包的空间压缩实现是从右往左更新的
for (int j = m; j >= val[i]; j--) {
if (dp[j - val[i]]) {
dp[j] = true;
}
}
} else if (val[i] * cnt[i] > m) {
// 完全背包的空间压缩实现是从左往右更新的
for (int j = val[i]; j <= m; j++) {
if (dp[j - val[i]]) {
dp[j] = true;
}
}
} else {
// 多重背包的空间压缩实现
// 每一组都是从右往左更新的
// 同余分组
for (int mod = 0; mod < val[i]; mod++) {
int trueCnt = 0;
for (int j = m - mod, size = 0; j >= 0 && size <= cnt[i]; j -= val[i], size++) {
trueCnt += dp[j] ? 1 : 0;
}
for (int j = m - mod, l = j - val[i] * (cnt[i] + 1); j >= 1; j -= val[i], l -= val[i]) {
if (dp[j]) {
trueCnt--;
} else {
if (trueCnt != 0) {
dp[j] = true;
}
}
if (l >= 0) {
trueCnt += dp[l] ? 1 : 0;
}
}
}
}
}
int ans = 0;
for (int i = 1; i <= m; i++) {
if (dp[i]) {
ans++;
}
}
return ans;
}
}