news 2026/10/23 10:58:41

多重背包优化:从二进制拆分到单调队列

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多重背包优化:从二进制拆分到单调队列

1. 从一道题看多重背包的本质

多重背包问题,是动态规划里一个非常经典的模型。它的核心场景很朴素:有若干种物品,每种物品有有限的件数,每件物品有自己的重量和价值,现在给你一个固定容量的背包,问在不超过背包容量的前提下,能装走的最大价值是多少。

它和01背包、完全背包的区别,就在“件数”这个维度上。01背包每种物品只有一件,要么拿要么不拿;完全背包每种物品有无限件,想拿多少拿多少;而多重背包卡在中间——每种物品有确定的件数上限。这个“有限件数”看似只是加了一个约束,实际上把问题的复杂度往上抬了一个台阶。

我第一次接触这类题的时候,直觉想法特别简单:把每种物品的若干件“摊开”,当成若干件独立的物品,然后跑一遍01背包不就完了?这个思路确实能过一部分数据,但一旦某种物品的件数很大,比如上千上万件,摊开之后物品总数爆炸,时间复杂度直接顶不住。所以多重背包的优化,本质上就是在解决“如何不把物品摊开,也能正确处理件数限制”这个问题。

这篇文章我会围绕多重背包这个模型,从最朴素的解法一路讲到单调队列优化,把每一步的推导逻辑、代码实现、参数计算、踩坑经验都摊开讲清楚。适合已经了解01背包和完全背包、想进一步吃透多重背包优化的读者。如果你现在还在被“二进制拆分”和“单调队列”绕得头晕,那这篇内容应该能帮你把思路理顺。

2. 多重背包的三种解法与选型逻辑

2.1 朴素解法:为什么它一定会超时

朴素解法的思路最直接:对于第i种物品,它有c[i]件,那我就枚举拿0件、1件、2件……直到c[i]件,对每一种取法都做一次状态转移。

用状态定义来说,设dp[j]表示容量为j时能获得的最大价值。那么对于每种物品,转移方程可以写成:

dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]) 其中 1 <= k <= c[i]

这个做法的复杂度是多少?外层枚举物品是n,内层枚举容量是m,最里面还要枚举件数k,最坏情况下k可以达到c[i]。所以整体复杂度是O(n * m * max(c))。当物品件数很大的时候,这个复杂度是灾难性的。

我拿一个具体的数字感受一下:假设n=100,m=1000,每种物品平均100件,那运算量大概是100 * 1000 * 100 = 一千万次。看起来还行?但如果m变成10000,件数变成1000,那就是100 * 10000 * 1000 = 十亿次。这个量级在一般的评测环境下基本就是超时没跑了。

所以朴素解法只能作为理解问题的基础,真正提交必须优化。

2.2 二进制拆分:把件数变成log级别

二进制拆分是多重背包最常用的优化手段,思路非常巧妙。它的核心观察是:任何一个正整数,都可以用若干个2的幂次加上一个余数来表示。

举个例子,假设某种物品有13件。13可以拆成1、2、4、6这四组。为什么是这四组?因为1、2、4可以组合出0到7之间的任何数,再加上6,就能组合出0到13之间的任何数。也就是说,我把“13件物品”重新打包成了“4组物品”,每组要么全拿要么不拿,就能等价地表示原来拿0到13件的所有情况。

这样一拆,原本c[i]件物品就变成了log(c[i])组物品,每组做一次01背包的转移。整体复杂度从O(n * m * max(c))降到了O(n * m * log(max(c)))。还是用上面的数字,1000件物品拆完只有10组左右,运算量直接降了两个数量级。

二进制拆分的代码实现大概是这个结构:

for (int i = 1; i <= n; i++) { int k = 1; int remain = c[i]; while (k <= remain) { // 把k件物品打包成一组,做01背包 for (int j = m; j >= k * w[i]; j--) { dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]); } remain -= k; k <<= 1; } if (remain > 0) { // 剩下的余数单独成一组 for (int j = m; j >= remain * w[i]; j--) { dp[j] = max(dp[j], dp[j - remain * w[i]] + remain * v[i]); } } }

这里有个细节要注意:内层循环必须从大到小遍历容量,这是01背包的标准写法,保证每组物品只被拿一次。如果写成从小到大,那就变成完全背包了,每种组合会被重复拿,结果就错了。

二进制拆分的优点是实现简单、容易理解、适用面广,绝大多数多重背包题目用它都能过。但它的复杂度里还带着一个log,如果题目对时间卡得特别死,或者件数上限极大,那还是有可能被卡。这时候就需要单调队列优化出场了。

2.3 单调队列优化:把复杂度压到O(n*m)

单调队列优化是多重背包的终极形态,它能把复杂度从O(n * m * log(c))进一步压到O(n * m),彻底去掉log因子。代价是实现难度陡增,理解门槛也高不少。

它的核心思想是这样的:对于同一种物品,容量j和容量j-w[i]之间存在递推关系。我们把容量按照对w[i]取模的余数分成若干组,每组内部的下标构成一个等差数列。在这个等差数列上,状态转移就变成了一个“滑动窗口求最大值”的问题,而滑动窗口最大值正好可以用单调队列在O(1)均摊时间内解决。

具体推导我放到后面章节详细讲,这里先给结论:单调队列优化适合那些件数上限极大、二进制拆分也会超时的场景。如果你只是应付常规题目,二进制拆分足够;如果你想彻底吃透多重背包,或者面对的是竞赛级别的数据,那单调队列必须掌握。

三种解法的对比如下:

解法时间复杂度实现难度适用场景
朴素枚举O(n * m * max(c))低仅用于理解原理
二进制拆分O(n * m * log(max(c)))中绝大多数常规题目
单调队列优化O(n * m)高件数极大或时间卡死

3. 单调队列优化的推导与实现细节

3.1 从转移方程到滑动窗口

我们先把多重背包的转移方程写出来。对于第i种物品,重量w,价值v,件数c:

dp[j] = max(dp[j - k * w] + k * v) 其中 0 <= k <= c 且 k * w <= j

现在关键的一步来了:我们把容量j按照对w取模的余数分组。设j = r + q * w,其中r是余数(0 <= r < w),q是组内的序号。那么j - k * w = r + (q - k) * w。

令p = q - k,则k = q - p。代入转移方程:

dp[r + q * w] = max(dp[r + p * w] + (q - p) * v) 其中 q - c <= p <= q

把和q相关的项提出来:

dp[r + q * w] = max(dp[r + p * w] - p * v) + q * v 其中 q - c <= p <= q

这时候你会发现,max里面那一坨dp[r + p * w] - p * v只和p有关,而p的取值范围是一个长度为c+1的滑动窗口。也就是说,对于每个余数r,我们沿着q递增的方向,维护一个窗口大小为c+1的单调队列,队列里存的是dp[r + p * w] - p * v的最大值对应的p。

这就是单调队列优化的全部精髓。把原本需要枚举k的内层循环,变成了一个滑动窗口取最大值的问题,而滑动窗口最大值可以用单调队列O(1)均摊解决。

3.2 单调队列的具体维护方式

单调队列维护的是候选p的集合,队列头部是当前窗口内dp[r + p * w] - p * v最大的那个p。维护规则有两条:

第一,队头过期检查。如果队头的p小于q - c,说明它已经超出了件数限制的范围,要弹出。

第二,队尾单调性维护。当新加入一个p时,如果队尾对应的值小于等于新值,就把队尾弹掉,直到队尾值大于新值或者队列为空,然后把新p加入队尾。

这样队列始终保持从队头到队尾值递减的顺序,队头永远是窗口内的最大值。

用代码表达大概是这个结构:

for (int i = 1; i <= n; i++) { int w = weight[i], v = value[i], c = count[i]; for (int r = 0; r < w; r++) { // 对每个余数r单独处理 deque<int> dq; // 存的是p值 for (int q = 0; r + q * w <= m; q++) { int cur = dp[r + q * w] - q * v; // 队尾维护单调性 while (!dq.empty() && dp[r + dq.back() * w] - dq.back() * v <= cur) { dq.pop_back(); } dq.push_back(q); // 队头过期检查 while (dq.front() < q - c) { dq.pop_front(); } // 更新dp dp[r + q * w] = dp[r + dq.front() * w] - dq.front() * v + q * v; } } }

这段代码有几个地方特别容易写错,我在后面踩坑部分会详细说。

3.3 参数计算与边界处理

单调队列优化里,有几个边界条件必须想清楚,否则很容易数组越界或者结果错误。

第一个是余数r的范围。r从0到w-1,每个余数对应一个独立的等差数列。如果w比m还大,那只有r=0这一组有意义,其他组根本装不下任何物品,可以直接跳过。

第二个是q的最大值。对于余数r,q的最大值是(m - r) / w。循环条件写成r + q * w <= m是最稳妥的,不容易算错。

第三个是件数c的处理。如果c * w >= m,说明这种物品实际上可以无限拿(因为背包容量根本装不下超过m/w件),这时候问题退化成完全背包,可以用完全背包的O(m)写法,不需要单调队列。这是一个很重要的优化,能省不少时间。

第四个是队列的过期判断。队头p的有效范围是[q - c, q],所以当dq.front() < q - c时要弹出。注意这里是严格小于,因为p = q - c是合法的(拿c件)。

提示:单调队列里存的是p值而不是具体的dp值,因为我们需要通过p来定位dp数组的下标。如果直接存dp值,过期判断就没法做了。

4. 完整实现与逐行解析

4.1 数据读入与初始化

假设题目给出n种物品,背包容量为m。每种物品给出重量w、价值v、件数c。我们用数组存下来,然后初始化dp数组。

#include <bits/stdc++.h> using namespace std; const int MAXN = 105; const int MAXM = 40005; int w[MAXN], v[MAXN], c[MAXN]; int dp[MAXM]; int main() { int T; cin >> T; while (T--) { int m, n; cin >> m >> n; for (int i = 1; i <= n; i++) { cin >> w[i] >> v[i] >> c[i]; } memset(dp, 0, sizeof(dp)); // 后续处理 } return 0; }

dp数组初始化为0,因为题目求的是“不超过背包容量的最大价值”,不要求恰好装满。如果题目要求恰好装满,那dp[0]=0,其他初始化为负无穷。这个区别一定要看清楚题目描述。

4.2 二进制拆分版本实现

先给一个二进制拆分的完整实现,作为保底方案:

for (int i = 1; i <= n; i++) { int k = 1; int remain = c[i]; while (k <= remain) { int tw = k * w[i]; int tv = k * v[i]; for (int j = m; j >= tw; j--) { dp[j] = max(dp[j], dp[j - tw] + tv); } remain -= k; k <<= 1; } if (remain > 0) { int tw = remain * w[i]; int tv = remain * v[i]; for (int j = m; j >= tw; j--) { dp[j] = max(dp[j], dp[j - tw] + tv); } } }

这段代码的逻辑是:每次取k件打包,k从1开始翻倍,直到超过剩余件数。最后如果还有剩余,单独打包。每一组都做一次01背包的逆序更新。

4.3 单调队列版本实现

单调队列版本我用手写数组模拟队列,比STL的deque快不少:

int q[MAXM]; // 单调队列,存p值 for (int i = 1; i <= n; i++) { int wi = w[i], vi = v[i], ci = c[i]; // 如果件数足够多,退化成完全背包 if (ci * wi >= m) { for (int j = wi; j <= m; j++) { dp[j] = max(dp[j], dp[j - wi] + vi); } continue; } for (int r = 0; r < wi; r++) { int head = 0, tail = 0; // [head, tail) 区间 for (int qq = 0; r + qq * wi <= m; qq++) { int cur = dp[r + qq * wi] - qq * vi; // 队尾维护 while (head < tail && dp[r + q[tail-1] * wi] - q[tail-1] * vi <= cur) { tail--; } q[tail++] = qq; // 队头过期 while (q[head] < qq - ci) { head++; } // 更新 dp[r + qq * wi] = dp[r + q[head] * wi] - q[head] * vi + qq * vi; } } }

这里用head和tail两个指针维护一个左闭右开的区间。q数组存的是p值,也就是组内序号。

4.4 关键步骤的逐行说明

我挑几个容易出错的点详细说一下。

第一,ci * wi >= m这个判断。它的意思是这种物品的总体积已经超过背包容量了,所以件数限制实际上不起作用,可以当成完全背包处理。完全背包的内层循环是从小到大的,和01背包正好相反。这个优化能显著减少单调队列的处理次数。

第二,队尾维护用的是<=而不是<。为什么?因为如果新值和队尾值相等,保留旧的没有意义,新的p更大(在窗口里存活更久),所以应该把旧的弹掉。用<=能保证队列里没有冗余元素。

第三,队头过期检查必须在更新dp之前做。因为我们要保证队列里的p都在[q-c, q]范围内,如果先更新dp再检查,可能会用到已经过期的p。

第四,更新dp时用的是dp[r + q[head] * wi],注意这里q[head]是p值,不是dp值。整个表达式的含义是:从p件物品的状态转移过来,再加上(q - p)件物品的价值。

5. 踩坑实录与常见问题排查

5.1 二进制拆分的三个经典错误

第一个错误是内层循环方向写反。二进制拆分本质上是01背包,内层必须逆序遍历容量。我见过不少人写着写着就写成正序了,结果每种组合被重复拿,答案偏大。

第二个错误是拆分时余数处理不当。比如13件,拆成1、2、4之后还剩6件,这个6必须单独处理。如果直接忽略,就会漏掉拿8到13件的情况。

第三个错误是数组开小了。二进制拆分后物品组数会变多,如果还用原来的数组大小,可能越界。稳妥的做法是把数组开到n * log(max(c))的大小。

5.2 单调队列的四个高频bug

单调队列优化的bug率明显高于二进制拆分,我整理了几个最常见的:

问题现象可能原因排查方法
答案偏小队头过期检查条件写错检查是否用了< q - c而非<= q - c
答案偏大队尾维护用了<而非<=改为<=试试
数组越界q数组开小了q的大小应该是m/w+1的量级
结果完全错误余数分组循环写错检查r的范围是否是0到w-1

还有一个隐蔽的坑:当w[i]为0时,取模运算会出问题。虽然正常题目不会给重量为0的物品,但如果遇到了,需要特判。重量为0的物品如果价值为正,那应该无限拿;如果价值为负,那就不拿。

5.3 复杂度实测与选型建议

我拿几组数据实测过三种解法的耗时。在n=100、m=40000、每种物品件数平均500的情况下:

  • 朴素解法:直接超时,跑不出来
  • 二进制拆分:大约200ms
  • 单调队列:大约80ms

差距还是很明显的。但单调队列的代码量大概是二进制拆分的两倍,调试时间也更长。所以我的建议是:先用二进制拆分提交,如果超时再换单调队列。不要一上来就写单调队列,除非你对自己的实现非常有信心。

提示:有些题目会卡常数,这时候即使用了单调队列,也要注意用数组模拟队列而不是STL的deque,后者在数据量大时会有明显的性能损耗。

6. 从多重背包延伸出去的思考

多重背包这个模型,表面上看只是一道DP题,但它背后的优化思路其实很有普适性。二进制拆分体现的是“用组合表示范围”的思想,单调队列体现的是“滑动窗口取最值”的思想。这两个思想在很多其他问题里都会反复出现。

比如二进制拆分的思想,在多重集合的表示、状态压缩、快速幂等问题里都能看到影子。而单调队列优化,在斜率优化DP、决策单调性优化里也是基础工具。把多重背包吃透,其实是在为后面更复杂的DP优化打地基。

我在实际写题的时候,习惯先把朴素解法写出来对拍,确认状态定义和转移方程没问题,再往上加优化。这样即使优化写挂了,也能快速定位是优化本身的问题还是基础逻辑的问题。这个习惯帮我省了很多调试时间。

另外,单调队列优化的推导过程值得反复看几遍。第一次看可能觉得绕,但当你自己动手把那个dp[r + p * w] - p * v的变换推一遍,就会发现它其实很自然——无非是把和k相关的项拆开,让窗口内的值只和p有关。这个“分离变量”的技巧,在很多DP优化里都是关键一步。

最后分享一个小技巧:如果你在写单调队列的时候总是搞不清q数组里存什么,就记住一句话——队列里存的是“候选决策点的下标”,不是dp值。所有的比较和更新都通过这个下标去dp数组里取值。想清楚这一点,代码就不容易写错了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 16:03:27

PL/SQL流程控制实战:条件分支、循环与性能优化

1. 程序流程控制&#xff1a;为什么这是PL/SQL的“命根子”我最早写PL/SQL的时候&#xff0c;觉得流程控制嘛&#xff0c;不就是IF套循环、循环套IF&#xff0c;谁不会&#xff1f;直到后来接手一个某公司订单折扣计算的存储过程&#xff0c;原开发者用三十多层IF嵌套实现了三个…

作者头像 李华
网站建设 2026/10/21 5:31:18

PL/SQL流程控制实战:条件分支、循环与异常处理详解

1. 条件分支结构&#xff1a;IF与CASE的选型逻辑1.1 从IF-THEN-ELSIF开始&#xff1a;结构与拼写陷阱PL/SQL的条件分支&#xff0c;绝大多数开发者上手的第一个结构就是IF。它和我们平时在Java、Python里写的if不太一样&#xff0c;最关键的一点是&#xff0c;PL/SQL用的是ELSI…

作者头像 李华
网站建设 2026/10/23 9:28:28

Sqlserver2000数据库文件膨胀深度压缩:日志截断与索引重建实战

简介&#xff1a;面向SQL Server 2000数据库管理员与运维人员&#xff0c;这份资源系统解决数据文件冗余空间难以释放的问题&#xff0c;重点讲解如何利用DBCC命令实现数据库文件的深度压缩。内容涵盖DBCC SHRINKDATABASE与DBCC SHRINKFILE的具体语法与执行策略、通过sysfiles查…

作者头像 李华
网站建设 2026/10/22 8:04:53

ESP32 ModbusTCP分片缓存实战:从半包粘包到状态机完整拆解

做过串口Modbus的老手&#xff0c;第一次把程序搬到ESP32上用ModbusTCP通信时&#xff0c;很容易踩一个很隐蔽的坑&#xff1a;上位机发来的请求或设备返回的响应&#xff0c;明明在抓包软件里看是完整一帧&#xff0c;可代码里一处理就只剩半截。而且问题不是每次都能复现&…

作者头像 李华
网站建设 2026/10/22 5:39:39

深入理解人工智能 AI和ML的关系

一句话&#xff1a;**AI 是更大的范畴&#xff0c;ML 是 AI 的一个子集&#xff0c;也是当前实现 AI 的核心方法之一。** 层级关系通常可表示为&#xff1a;**AI ⊃ ML ⊃ DL&#xff08;深度学习&#xff09;**。可以这样理解&#xff1a;- **AI&#xff08;人工智能&#x…

作者头像 李华
网站建设 2026/10/23 6:40:37

低功耗继电器选型避坑指南:从线圈电压到脉冲驱动的完整实战解析

低功耗继电器&#xff0c;光听名字容易产生一种错觉&#xff0c;觉得"功耗低就是省电&#xff0c;选个功率小的型号总没错"。我最初也是这么想的&#xff0c;直到连着做了几个电池供电的项目&#xff0c;才意识到这个品类里藏着的门道比想象中多得多。线圈电压匹配、…

作者头像 李华