问题描述
多重背包问题是经典的动态规划问题之一,是 01 背包和完全背包的泛化:
给定 n 种物品和一个容量为 m 的背包。第
i 种物品的重量为
wei[i],价值为
val[i],并且数量有限,最多只能取
s[i] 件。
目标是在不超过背包容量的前提下,选择物品装入背包,使得总价值最大。
- 与 01 背包的区别:01 背包中每种物品只有 1
件(
s[i] = 1)。 - 与完全背包的区别:完全背包中每种物品有无限件(
s[i] = ∞)。
问题分析
在学习了前两种背包的基础上,多重背包的问题还是很清晰的
可以看出,多重背包是介于 01 背包和完全背包之间的一种更通用的模型:
- 当
s[i] = 1时,退化为 01 背包 - 当
s[i]足够大(大于m/wei[i])时,退化为完全背包
那么,01 背包的状态转移 是这样的
1 | // 01背包:对于第i件物品,选或不选 |
- 只有两种选择:不选第 i 件物品(继承
dp[i-1][j]),或选第 i 件物品(从dp[i-1][j-wei[i]]转移)。 - 内层循环是逆序遍历容量,防止重复选取。
而01背包是否可以考虑成,每个物品数量只有一件的多重背包呢?
所以说,我们可以再扩展一维数组,用于表示枚举选择装入背包的该物品件数
前面我们学习了完全背包的决策优化,所以,物品(装得下)的件数肯定可以被转换成体积来枚举
所以可以得到这样的转移
1 | // dp[i][j]前i件物品放入容量为j的背包的最大价值 |
整体转移如下
1 | // 物品 |
分析完整的状态转移
通用形式(多重背包)
1
2
3
4// dp[i][j] = max{ dp[i-1][j - k*wei[i]] + k*val[i] },其中 0 ≤ k ≤ s[i] 且 k*wei[i] ≤ j
for (int k = 0; k <= s[i] && k * wei[i] <= j; k++) {
dp[i][j] = Math.max(dp[i][j], dp[i-1][j - k*wei[i]] + k*val[i]);
}特殊情况 :01 背包(
s [i] = 1)1
2
3// 此时k只能取0或1,方程简化为:
dp[i][j] = Math.max(dp[i-1][j], // k=0,不选
dp[i-1][j-wei[i]] + val[i]); // k=1,选1件特殊情况 :完全背包(
s [i]→+∞ )1
2
3// 此时k可以取到j/wei[i],方程可以优化为:
dp[i][j] = Math.max(dp[i-1][j], // k=0,不选
dp[i][j-wei[i]] + val[i]); // 选k件等价于先选k-1件再加1件
多重背包的边界条件与 01 背包、完全背包保持一致,因为它们共享相同的状态定义:
1 | // dp[i][j] 表示前i种物品放入容量为j的背包的最大价值 |
时间复杂度分析
- 状态总数:
O(n × m),其中n是物品种类数,m是背包容量。 - 每个状态的计算量:对于每个状态
dp[i][j],需要枚举k从 0 到s[i](最多j/wei[i]次),即O(s_max),其中s_max是物品的最大数量。 - 总时间复杂度:
O(n × m × s_max)。
空间复杂度
- 二维数组实现:
O(n × m),需要存储整个状态表。
很明显,这完全可以优化
滚动数组与决策优化
首先,和前面的01背包和完全背包一样,第一优化的思路是滚动数组与决策优化,来省下空间和时间
在朴素解法中,我们使用二维数组 dp[i][j] 表示前
i 种物品、容量为 j
时的最大价值。观察状态转移方程:
1 | dp[i][j] = max(dp[i][j], dp[i-1][j - k*wei[i]] + k*val[i]); |
和 01背包 一样,可以发现,计算 dp[i][j]
时只依赖于上一层(i-1)的状态,而与更早的层(i-2,
i-3…)无关。因此,我们可以用一维数组来复用存储空间。
那么此时,状态变量可以这样定义
1 | int[] dp = new int[m + 1]; |
此时 dp[j] 表示:处理到当前物品时,容量为
j 的背包能装的最大价值。
那么多重背包是否也应该进行逆序遍历容量,和01背包一样,多重背包在滚动掉一维之后也需要进行逆序枚举
如果正序遍历(如完全背包),会导致同一物品被多次选取(因为
dp[j - wei[i]]
已经是本轮更新后的值,你正序遍历会把之前的状态再选择一次)
1 | for(int j = m; j >= wei[i]; j--) { // 逆序枚举容量 |
那么决策优化如何优化呢?
从数量下手,枚举数量的边界条件是k <= s[i] && k * wei[i] <= j,这是双重限制:
k <= s[i]:不超过物品的数量上限。k * wei[i] <= j:不超过当前背包容量。
实际上,我在上面写的时候,应该是用三维数组的,因为上面的代码我在想的时候就把完全背包的体积枚举也写进去了
那么状态转移如下
对于每个容量 j,我们比较两种选择:
- 不选当前物品:保持
dp[j]不变(即上一轮的最优值)。 - 选 k 件当前物品:用
dp[j - k*wei[i]] + k*val[i]更新dp[j]。
时间复杂度仍为
O(n×m×s_max)。空间复杂度是O(m)
当物品数量 s[i] 很大时(如
1000),效率依然不高。此时可以用二进制拆分优化
二进制拆分
二进制拆分就是把 “数量有限的物品” 转化为 “若干组 01
背包物品”,从而将时间复杂度从 O(n×m×s_max) 降到
O(n×m×log s_max)。
朴素解法的问题在于:当物品数量 s[i] 很大时(比如
s[i]=1000),内层枚举 k 的循环会执行 1000
次,时间效率极低。
二进制拆分的核心思路是:用少量 “组合物品” 覆盖 0~s [i] 的所有选取数量,把多重背包转化为 01 背包求解,大幅减少枚举次数。
二进制拆分的数学原理是这样的
对于任意整数 s(物品数量),可以拆分为若干个形如
2^0, 2^1, 2^2, ..., 2^k, r 的数(其中
r = s - (2^(k+1)-1),且
0 < r < 2^(k+1)),这些数的组合可以表示
0~s 之间的任意整数。
比如:
s=12:拆分为1(2^0)、2(2^1)、4(2^2)、5(剩余),1+2+4+5=12,且 1~12 的任意数都能由这几个数组合得到(如 3=1+2、7=1+2+4、12=1+2+4+5);s=50:拆分为1、2、4、8、16、19,1+2+4+8+16=31,剩余 19,31+19=50,0~50 的任意数都能组合出来。
这样拆分就快了很多,因为少枚举的东西少了很多,比如当s[i]=1000时:
- 朴素解法需要枚举 1000 次;
- 二进制拆分只需枚举
log2(1000)≈10次,效率提升 100 倍。
那么如何拆分呢
先定义这样的一些量
1 | static final int N = 2005; // 背包容量上限 |
二进制拆分每一种物品的代码如下
1 | int num = 1; // 拆分后物品的计数(从1开始,对应01背包的物品编号) |
也就是说,每个物品的重量和价值每次 *2 拆分,不足的直接单开
那么这样,问题和上面转换成 01背包
的了,因为此时的wei[i]和val[i]都是拆分后的
“组合物品”,每个转换后的物品都只有一件,所以只能选一次
1 | // 枚举拆分后的所有物品(01背包) |
- 注意逆序遍历容量:保证每个组合物品只被选一次,和之前的 01 背包滚动数组逻辑完全一致;
单调队列优化
思路如何转到使用单调队列优化
二进制优化通过将物品拆分为 log(s) 个二进制组合,转化为
01 背包问题,但时间复杂度仍为
O(nW log s)。而单调队列优化能将时间复杂度降至
O(nW),效率更高。
首先,我们还是使用一维空间优化的定义: f[i] 代表容量不超过 i 时的最大价值。
这样,多重背包的状态转移方程为:
1 | f[k] = max{ f[k - x*w] + x*v } (其中 x 为选取数量,0 ≤ x ≤ min(s, k/w)) |
- 即对于容量
k,最优解是从「不选当前物品」「选 1 个」…「选 s 个」中取最大值。
在朴素的多重背包解决方案中,当我们在处理某一个物品从
f[0] 到 f[C]
的状态时,每次都是通过遍历当前容量 x
能够装多少件该物品,然后从所有遍历结果中取最优。
但事实上,转移只会发生在「对当前物品体积取余相同」的状态之间。

可以发现,取余相同的物品可以被划分为一组,例如,f[6]可以通过f[4],f[2],f[0]来更新
观察发现,对于同一物品,容量 k 与 k + w
仅差一个物品的重量。因此可以按 k mod w
分组(共 w 组),每组内的容量满足
k = j + t*w(j 为余数,t
为非负整数)。
- 例如,
w=3时,余数j=0的组包含0,3,6,9...,j=1的组包含1,4,7,10...等。
同一组内的状态转移只与组内元素相关,因为增加物品数量只会在组内移动(每次加
w)。
所以,可以这样总结,f是按照类更新的,那么可以把
f[0] 到 f[m]的按照体积 v 的余数拆分成 v
个类
可以引入滑动窗口的最值问题了,对每组 的余数j,设
t = k/w(即
k = j + t*w),转移方程可改写为:
1 | f[j + t*w] = max{ g[j + (t - x)*w] + x*v } (0 ≤ x ≤ min(s, t)) |
其中 g 是上一轮的 f
数组(备份,避免覆盖)。
令 t' = t - x,则
x = t - t',方程变为:
1 | f[j + t*w] = max{ g[j + t'*w] - t'*v } + t*v (t' ≥ t - s 且 t' ≤ t) |
此时,max{...} 部分是关于 t'
的滑动窗口(窗口大小为
s+1),可以用单调队列维护窗口内的最大值,从而将每组的更新时间从
O(W/w * s) 降至 O(W/w)。
单调队列优化背后详细的数学推导
很多人卡在这里是因为不理解为什么要构造
g[j + t' * w] - t'* v
这样一个奇怪的数值放入队列。
老实说,我也不是很清晰
首先,回顾一下,对于体积为 w,价值为 v
的物品。
当我们计算 f[j] 时,只能由 f[j − w], f[j − 2w]… 转移而来。
- f[5] 的值只可能来自 f[5], f[5 − w], f[5 − 2w]…
- f[4] 的值只可能来自 f[4], f[4 − w], f[4 − 2w]…
f[5] 和 f[4] 之间在当前这一轮物品的计算中是老死不相往来的。
对于按余数 j 分组的情况(0 ≤ j < w,w 为物品重量),组内的容量满足: k = j + t × w 所以,我们才把整个背包容量 W 按照 对 w 取模的余数 r (0 ≤ r < w) 分成 w 组。
接下来,我们只盯着其中一组看。假设这一组的容量序列是,事实上,每一组都能被以下列的形式拆分 r, r + w, r + 2w, r + 3w, …, r + t ⋅ w, … 为了方便书写,我们把这一组的状态单独拿出来,定义为一个简单的数组 G
- 令 G[t] = g[r + t ⋅ w] (g 是上一轮的 DP 状态,即还没放入当前物品时的状态)。
假设我们现在要计算 当前容量 下的最大价值。
设当前容量为 j = r + t ⋅ w。我们最多能选 s 个物品。
所以,按照我们上面的推导,我们可以选 x 个物品 (0 ≤ x ≤ s)。 f[j] = max {g[j − x ⋅ w] + x ⋅ v} 我们要把这个方程里的下标换成 t(组内编号):
- 当前容量下标对应 t。
- 前面某个容量下标对应 t′ = t − x,即选了 x 个物品后剩下的空间对应的下标。也就是,选取 x 个物品后,前一个状态的容量是 $ k - x w = j + (t - x) w,对应组内编号t’ = t - x$
- 因为 x = t − t′,所以选的物品数量就是
(t − t′)。等于当前组内编号
t 与前一个状态组内编号 t′ 的差,这是必然的
- t 表示 “当前容量比基础余数 j 多了 t 个 w”,即总空间可以容纳 t 个该物品(理论上,不考虑数量限制 s)。
- t′ 表示 “前一个状态的容量比基础余数 j 多了t′ 个 w”,即前一个状态已经用了t′ 个 w 的空间。
- 两者的差 t − t′ 就是 “从 t′ 到 t 新增的空间”,恰好等于新增的物品数量 x(每个物品占 w 空间)。
代入公式: f[r + t ⋅ w] = max {g[r + t′ ⋅ w] + (t − t′) ⋅ v} 这里的限制条件是:
- x ≤ s ⟹ t − t′ ≤ s ⟹ t′ ≥ t − s (不能回头太远)
- x ≥ 0 ⟹ t′ ≤ t (不能超过当前位置)
所以,t′ 的范围是 [t − s, t]。这是一个长度为 s + 1 的滑动窗口。
接下来进行变形,因为这个不太方便计算
看这个式子: max {g[r + t′ ⋅ w] + (t − t′) ⋅ v} 我们要针对 t′ 找最大值。请注意式子里的项:
- g[r + t′ ⋅ w]:只和 t′ 有关。
- t ⋅ v:只和 t(当前位置)有关,对于 t′ 的遍历来说,它是常数。
- −t′ ⋅ v:只和 t′ 有关。
我们可以把常数项 t ⋅ v 提到 max 外面:
f[r + t ⋅ w] = max {g[r + t′ ⋅ w] − t′ ⋅ v} + t ⋅ v 这就是需要的那个公式。
那么其实还存在一个问题,就是公式内max {g[r + t′ ⋅ w] − t′ ⋅ v}中,为什么要减去 t′ ⋅ v?这有什么物理意义?
原始公式的物理意义是:
当前容量 (r + t ⋅ w)的最大价值 = 所有合法前序容量(r + t′ ⋅ w)的价值中的最大值 + 新增物品的价值
单调队列的核心作用是 维护一个滑动窗口内的 “最值”,且这个 “最值” 必须只和 “窗口内的元素本身” 有关,和 “当前窗口的位置” 无关。
原始公式内max {g[r + t′ ⋅ w] + (t − t′) ⋅ v}展开后发现g[r + t′ ⋅ w] + t ⋅ v − t′ ⋅ v,它被包含在 max 里面,导致:
- 每次计算 max 时,都要带着这个随 t 变化的常数;
- 单调队列无法提前维护 “窗口内元素的固定最值”—— 因为这个常数会让每个 t 对应的 “比较基准” 都不一样。
所以我们才需要把 随 t 变化的项$ t v$ 从 max 里面剥离出来 g[r + t′ ⋅ w] + (t − t′) ⋅ v = (g[r + t′ ⋅ w] − t′ ⋅ v) + t ⋅ v 因为 t ⋅ v 对同一个 t 的所有 t′ 都是常数,所以可以提到 max 外面,得到: f[r + t ⋅ w] = max {g[r + t′ ⋅ w] − t′ ⋅ v} + t ⋅ v 这意味着:对于滑动窗口内的所有 t′,我们可以提前计算出这个值,用单调队列维护它的最大值 —— 当 t 滑动时,只需取出队列中的最大值,再加上当前的 t ⋅ v,就能得到当前状态的最大价值。
现在公式变成了:F[t] = max {Val(t′)} + t ⋅ v
Val(t′) 的数值一旦算出来,就不会随着滑动窗口的移动而改变了。这正是单调队列能够维护的前提!
那么,该单调队列就这样维护和操作,队列里存放的是下标 k。
在这个队列里,我们要维护 Val(k) = g[r + k ⋅ w] − k ⋅ v 是单调递减的。
对于分组中的每一个位置 t(从 0 到 最大):
计算当前 t 的潜力值: temp = g[r + t ⋅ w] − t ⋅ v。
入队维护单调性: 如果队列不空,且队尾元素的潜力值 ≤ temp,说明队尾那个 t′ 虽然位置靠后,但潜力太差,甚至不如现在的 t(t 更靠后且潜力大,肯定更优)。 扔掉队尾,直到队尾潜力值 > temp。 将 t 入队。
出队维护窗口大小: 如果队头元素的下标 qhead < t − s(说明这个最优解太远了,超过了物品数量限制 s),扔掉队头。
计算当前 f: 队头就是当前窗口内 Val 最大的下标 best。 f[r + t ⋅ w] = (Val(best)) + t ⋅ v 即: f[r + t ⋅ w] = (g[r + best ⋅ w] − best ⋅ v) + t ⋅ v
最后我们举个栗子
设 W = 10, w = 2, v = 4, s = 2(每件体积2,价值4,最多2件)。
按 j (mod 2) 分组,我们看 r = 0 这一组:0, 2, 4, 6, 8, 10。
假设上一轮的 g 数组(部分)如下: g[0] = 0, g[2] = 3, g[4] = 5, g[6] = 15, g[8] = 15, g[10] = 20 (注:这些数是我编的,为了演示)
对应的组内下标 t:
- t = 0→ 真实容量 0
- t = 1→ 真实容量 2
- t = 2→ 真实容量 4
- …
计算 Val(t) = g[t] − t ⋅ v:
- t = 0 : Val = 0 − 0 * 4 = 0
- t = 1 : Val = 3 − 1 * 4 = −1
- t = 2 : Val = 5 − 2 * 4 = −3
- t = 3 : Val = 15 − 3 * 4 = 3
开始滑动窗口 (窗口大小 s = 2):
- t = 0:
- 入队:Val(0) = 0。队列:
[0] - 计算 f[0]:队头是0。MaxVal = 0。
- f[0] = 0 + 0 * 4 = 0。
- 入队:Val(0) = 0。队列:
- t = 1:
- 入队:Val(1) = −1。比队尾0小,直接入。队列:
[0, 1] - 检查过期:0 ≥ 1 − 2,没过期。
- 计算 f[2]:队头是0 (Val = 0)。
- f[2] = 0 + 1 * 4 = 4。(即:从0开始,拿1个)
- 入队:Val(1) = −1。比队尾0小,直接入。队列:
- t = 2:
- 入队:Val(2) = −3。比队尾-1小,直接入。队列:
[0, 1, 2] - 检查过期:0 ≥ 2 − 2,没过期。
- 计算 f[4]:队头是0 (Val = 0)。
- f[4] = 0 + 2 * 4 = 8。(即:从0开始,拿2个)
- 入队:Val(2) = −3。比队尾-1小,直接入。队列:
- t = 3:
- 入队:Val(3) = 3。
- 队尾是2 (Val = −3),3 > -3,弹出2。
- 队尾是1 (Val = −1),3 > -1,弹出1。
- 队尾是0 (Val = 0),3 > 0,弹出0。
- 放入3。队列:
[3]
- 检查过期:队头3,当前3。3 ≥ 3 − 2,没过期。
- 计算 f[6]:队头是3 (Val = 3)。
- f[6] = 3 + 3 * 4 = 15。(注意这里!它选择了保留原来的 g[6] = 15,也就是一个都不拿,因为 g[6] 实在太大了,比从 f[2] 或 f[4] 转移过来都大)。
- 入队:Val(3) = 3。
模板代码
朴素实现
1 | // 多重背包 |
滚动数组与决策优化
1 | import java.util.Scanner; |
二进制拆分
1 | import java.util.Scanner; |
单调队列优化
1 | import java.util.Scanner; |
1 | // 单调队列 O(n*W) |
例题
问题
[P1782 旅行商的背包][https://www.luogu.com.cn/problem/P1782]
P1782 旅行商的背包
题目描述
小 S 坚信任何问题都可以在多项式时间内解决,于是他准备亲自去当一回旅行商。在出发之前,他购进了一些物品。这些物品共有 n 种,第 i 种体积为 Vi,价值为 Wi,共有 Di 件。他的背包体积是 C。怎样装才能获得尽量多的收益呢?作为一名大神犇,他轻而易举的解决了这个问题。
然而,就在他出发前,他又收到了一批奇货。这些货共有 m 件,第 i 件的价值 Yi 与分配的体积 Xi 之间的关系为:Yi = aiXi2 + biXi + ci。这是件好事,但小 S 却不知道怎么处理了,于是他找到了一位超级神犇(也就是你),请你帮他解决这个问题。
输入格式
第一行三个数 n, m, C,如题中所述;
以下 n 行,每行有三个数 Vi, Wi, Di,如题中所述;
以下 m 行,每行有三个数 ai, bi, ci,如题中所述。
输出格式
仅一行,为最大的价值。
输入输出样例 #1
输入 #1
1
2
3
4 2 1 10
1 2 3
3 4 1
-1 8 -16输出 #1
1 10说明/提示
样例解释
前两种物品全部选走,最后一个奇货分给 4 的体积,收益为2 × 3 + 4 × 1 + (−1) × 16 + 8 × 4 + (−16) = 10。
限制与约定
对于 100% 的数据,1 ≤ n ≤ 104,1 ≤ m ≤ 5,1 ≤ C ≤ 104,$ 1 W_i,V_i,D_i ,-1000 a_i,b_i,c_i $。
问题分析
这道题目将多重背包问题(Multiple Knapsack)与分组背包(Group Knapsack/泛化物品)结合在了一起。
这个 N 来说是较大的了,有104,而且如何合并特殊的二次函数物品。
题目明显分为两个部分,我们需要在同一个背包容量 C 下解决:
- 标准的多重背包
- 这些物品共有 n 种,第 i 种体积为 Vi,价值为 Wi,共有 Di 件,如果使用朴素拆分(当做 0/1 背包),复杂度是 O(C ⋅ ∑Di),会超时。二进制拆分复杂度为 O(C ⋅ n ⋅ logD),计算量约为 109,在 Java 中非常危险,容易 TLE。因此,我们需要单调队列优化。
- 奇货(二次函数物品)
- 奇货共有 m 件,第 i 件的价值 Yi 与分配的体积 Xi 之间的关系为:Yi = aiXi2 + biXi + ci。
- m 非常小
- 这可以看作分组背包。对于每一个“奇货”,我们可以选择分配 0, 1, 2, …, C 的体积给它。这就相当于一组互斥的物品,我们只能从中选一种“分配方案”。
所以,总体考虑这样的策略
- 先处理 n 种普通物品,使用单调队列优化的多重背包算法,得到数组 dp[0…C],其中 dp[j] 表示仅使用普通物品、容量为 j 时的最大价值。
- 再处理 m 个奇货。对于每个奇货,我们遍历背包容量 j(从 C 到 0),尝试枚举分配给该奇货的体积 k(从 0 到 j),更新 DP 数组。
那么多重背包考虑单调队列优化
先列出朴素情况下的状态转移方程 dp[j] = max {dp[j − k ⋅ Vi] + k ⋅ Wi} (0 ≤ k ≤ Di) 假设当前处理物品重量为 v,价值为 w,数量为 d。 我们将背包容量 j 按照模 v 的余数 r 分组:j = q ⋅ v + r。 对于固定的余数 r,方程变为: $$ dp[q \cdot v + r] = \max_{\substack{0 \leq k \leq d}} \left\{ dp\left[(q - k) \cdot v + r\right] + k \cdot w \right\} $$ 令 t = q − k,则 q − d ≤ t ≤ q。代入k = q − t,这样就让单调队列内的非单调项消去了 dp[q ⋅ v + r] = max {dp[t ⋅ v + r] + (q − t) ⋅ w} 移项提取常数 q ⋅ w: dp[q ⋅ v + r] = max {dp[t ⋅ v + r] − t ⋅ w} + q ⋅ w 这样就推到成了我们需要在一个长度为 d 的滑动窗口 [q − d, q] 内,找到 dp[t ⋅ v + r] − t ⋅ w 的最大值。
处理完所有普通物品后,我们得到了基础的 dp 数组。 对于第 i 个奇货(参数 a,b,c):
它实际上是一个包含 C+1 种选择的物品组:
- 选择 1:体积 0,价值 0(不选)
- 选择 2:体积 1,价值 a(1)2 + b(1) + c
- …
- 选择 C:体积 C,价值 a(C)2 + b(C) + c
我们倒序遍历背包容量 j(从 C 到 0),再遍历分配给该奇货的体积 k(从 0 到 j): dp[j] = max (dp[j], dp[j − k] + (a ⋅ k2 + b ⋅ k + c)) 题目隐含若不选该物品则价值为0,按若分配体积 k≥1 则按公式计算。若 k=0 代入公式价值大于0,通常也视为一种可选方案
代码
1 | //package 动态规划.subject.背包.P1782_旅行商的背包; |
这是什么情况呢?
忘记开long了
1 | import java.io.*; |






