A背包 DP

多重背包

朴素·二进制·单调队列

本课摘要

多重背包课程回答“有限件物品怎样从朴素枚举优化到二进制或单调队列”。内容以朴素·二进制·单调队列为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。

  • 判断多重背包的适用条件与状态边界
  • 围绕“朴素·二进制·单调队列”推导转移与计算顺序
  • 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节

正在整理目录…

介于「一件」与「无限件」之间

前两类背包卡在两个极端:01 背包每种至多一件,完全背包每种无限件。现实往往在中间,第 ii 种物品恰好有 mim_i 件, 多了没有,这就是多重背包。

×3物品 1w=2v=3×2物品 2w=3v=5背包容量 m=10
每种物品带 ×m 徽标:物品 1(w=2,v=3)只有 3 件,物品 2(w=3,v=5)只有 2 件,不能像完全背包那样无限取。

最直接的想法:既然第 ii 种有 mim_i 件,那就当成 mim_i 件各不相同的 01 物品摊开,全丢进 01 背包。 正确,但慢,若每种都有上万件,物品总数 ∑mi\sum m_i 会爆炸,O(V⋅∑mi)O(V\cdot\sum m_i) 直接超时。

也别想着「一件一件试着放」:对同一种枚举「取 0 件、1 件、…、mim_i 件」,本质还是把 mim_i 件逐个塞进去,复杂度一样是 O(V⋅∑mi)O(V\cdot\sum m_i)。 问题的关键是:能不能用远少于 mim_i 个「物品」,就表达出「取 0…mim_i 件」的全部可能?这一节的主角,二进制拆分,正是干这个的。

朴素解:把每种拆成 m 件 01 物品

先把最朴素的解法写清楚,它是后面所有优化的地基。状态照搬 01 背包:f[j]f[j] 表示容量不超过 jj 时的最大价值。 对第 ii 种物品,把它当作 mim_i 件相同的 01 物品,一件一件地做逆推:

for k=1…mi:f[j]=max⁡(f[j], f[j−wi]+vi)  (j: W→wi)\text{for } k=1\dots m_i:\quad f[j]=\max\big(f[j],\ f[j-w_i]+v_i\big)\ \ (j:\,W\to w_i)

内层必须逆推,道理和 01 背包完全一样:每「件」至多取一次,逆推让 f[j−wi]f[j-w_i] 停在「这件还没放进来」的旧值上。 循环层次是「物品种 ×\times 件数 ×\times 容量」,复杂度 O(V⋅∑mi)O(V\cdot\sum m_i)。

本质 · 多重背包 = 带件数上限的 01 背包

多重背包并没有新机制:它就是 01 背包,只不过同一种物品被允许取不超过 mim_i 次。 朴素解把「mim_i 次」老老实实摊成 mim_i 件,正确但冗余。后面要做的,全是如何更省地表达这 mim_i 次。

二进制拆分:用 log 个「打包件」代替 m 件

朴素解的浪费在于:取 5 件,它非要一件一件加五次。可如果我手上有「1 件装」「2 件装」「4 件装」这样的打包件, 想凑 5 件,只需拿「1 件装 + 4 件装」,两次就够。这正是二进制拆分的思想:把 mim_i 件拆成 1, 2, 4, …, 2k−11,\ 2,\ 4,\ \dots,\ 2^{k-1} 这些 2 的幂,再加上一个余数包

r=mi−(2k−1)  (where 2k−1≤mi)r=m_i-(2^{k}-1)\ \ (\text{where } 2^{k}-1\le m_i)

每个「打包件」含 cc 件原物,就等效成一件重量 c wic\,w_i、价值 c vic\,v_i 的新物品,扔进 01 背包(逆推,每包至多用一次)。

一种物品,件数上限 m=13 → 拆成 4 个「打包件」×11 件×22 件×44 件×余66 件任选若干包相加,恰好能凑出0, 1, 2, …, 13中任意件数1+2+4 覆盖 0…7;再叠加余数 6,平移补齐 8…13。用 ⌈log⌉≈4 个包代替 13 次枚举。
m=13 拆成 1、2、4、余6 四个打包件,用约 ⌈log⌉ 个包,就表达出「取 0…13 件」的每一种可能。
把上面的静态图变可玩:拖动件数上限 mm,实时看它拆成哪几个打包件(段宽 ∝ 该包件数),下方 0…m0\dots m 覆盖带示意「任选若干包相加恰好凑出每一个件数」,读数条对比朴素 mm 个 vs 二进制 ⌈log⌉ 个。试试 m=7m=7(3 包)、m=13m=13(4 包)。
件数上限 m(这一种物品有几件)
m
13
拆出 3 个 2 的幂包 + 1 个余数包,共 4 包。
二进制拆包:1 + 2 + 4 + 6 = 13(段宽 ∝ 该包件数)1,2,4,… + 余r
×11 件
×22 件
×44 件
×余66 件
这些包任选若干相加,恰好覆盖 0…13 的每一个件数14 个,全可达
012345678910111213
朴素:一件一件摊开
13个物品
Σm · 每件做一次 01 逆推
二进制:打包
4个打包件
⌈log₂(m+1)⌉ · 每包做一次 01 逆推
m=13 时,朴素要 13 个物品,二进制只用 4 个打包件, 少做 9 次 01 转移,却依旧能凑出「取 0…13 件」的每一种, 既不重复、也不遗漏。m 越大,这道差距越悬殊。

为什么这几个包能凑出 0…mim_i 的任意件数?先只看 1,2,4,…,2k−11,2,4,\dots,2^{k-1} 这几个 2 的幂,这正是二进制: 任何 00 到 2k−12^{k}-1 之间的整数,都能唯一地写成它们的子集和(比如 5=1+45=1+4、6=2+46=2+4)。 于是这部分覆盖了 0…2k−10\dots 2^{k}-1 件。再补上余数 rr 这一包:把它加进来,相当于让可凑范围整体平移 rr, 正好把上界从 2k−12^{k}-1 顶到 2k−1+r=mi2^{k}-1+r=m_i,中间不留缝。既不重复、也不遗漏,恰好 0…mi0\dots m_i。

拆完后,打包件总数从 mim_i 降到 ⌈log⁡2(mi+1)⌉\lceil\log_2(m_i+1)\rceil,复杂度随之从 O(V⋅∑mi)O(V\cdot\sum m_i) 压到 O ⁣(V⋅∑log⁡mi)O\!\big(V\cdot\sum\log m_i\big)。这是多重背包的主力解法。

两种物品:件数上限 7 与 15朴素Σm=22二进制Σlog=722 次7 次
件数上限 7 与 15 两种物品:朴素要做 22 次 01 转移,二进制拆分只需 7 次,m 越大,差距越悬殊。

本质 · 拆完就是 01 背包

二进制拆分把多重背包彻底还原成 01 背包:每个打包件就是一件普通 01 物品,逆推、取或不取,规则分毫不差。 所以它天然是 混合背包的一块拼图,01、完全、多重三种物品能同题混装,正因为它们最终都落在同一套一维转移上。

跟着算一遍:把 3 件拆成两包

用一种物品 (w,v,m)=(2,3,3)(w,v,m)=(2,3,3)、容量 6 走一遍。先拆件数 m=3m=3:取 1(剩 2),再取 2(剩 0),得两个包,×1 包(w=2,v=3w{=}2,v{=}3)与 ×2 包(w=4,v=6w{=}4,v{=}6)。

0
初始化。 空背包,f[0..6]=0f[0..6]=0。地基和 01 背包一样。
1
放 ×1 包(w=2,v=3w=2,v=3),逆推 j:6→2j:6\to 2。每格 f[j]=max⁡(f[j],f[j−2]+3)f[j]=\max(f[j],f[j-2]+3),因来源都是旧值 0,第 1 行变成 0,0,3,3,3,3,30,0,3,3,3,3,3。这一包只代表「取 1 件」。
2
放 ×2 包(w=4,v=6w=4,v=6),逆推 j:6→4j:6\to 4。看 f[6]=max⁡(3, f[2]+6)=max⁡(3,9)=9f[6]=\max(3,\ f[2]+6)=\max(3,9)=9,f[2]=3f[2]=3 是「×1 包」留下的,加上「×2 包」的 6,正好是 1+2=3 件。
3
读答案。 f[6]=9f[6]=9,容量 6、每件重 2,最多装 3 件,价值 3×3=93\times3=9。两个包组合出的最大件数正好卡在上限 3,没有超过。
下面的演示把每个打包件逐格做 01 逆推,读数条实时显示「朴素 Σm vs 二进制 Σlog」的打包数差距。改物品的 w,v,mw,v,m 或容量试试。

看它把每个打包件逐格放进去

物品(可改重量 / 价值 / 件数)
1
重量 w
2
价值 v
3
件数 m
3
2
重量 w
3
价值 v
5
件数 m
2
背包容量
m
10
朴素枚举需 Σmᵢ = 5 个打包件 · 二进制拆分仅需 Σ⌈log⌉ = 4 个(省下 1 次转移)
0
1
2
3
4
5
6
7
8
9
10
f
0
0
0
0
0
0
0
0
0
0
0
当前计算 依赖来源 被选转移 已确定
f[j]=0f[j]=0
初始:容量 0…10 下最大价值都是 0(空背包)。本例把 2 种物品拆成 4 个打包件(朴素需 5 件、二进制仅 4 件)。
已暂停,第 1 步,共 34 步,1 倍速

再进一步:单调队列 O(V·n)(选讲)

二进制拆分已经够快,应付绝大多数题目。但还有一层理论最优:O(V⋅n)O(V\cdot n) 的单调队列优化,连那个 log⁡\log 都抹掉。

思路是:把容量 jj 按对 wiw_i 取模的余数分组,同余的那些 jj(即 r,r+wi,r+2wi,…r, r{+}w_i, r{+}2w_i,\dots)恰好构成一条「取第 ii 种若干件」的链。 在每条链上,「取不超过 mim_i 件」就变成了一个定长滑动窗口求最大值的问题,用单调队列可 O(1)O(1) 均摊维护,于是第 ii 种物品整体只花 O(V)O(V)。

设 gg 是加入当前物品前的旧数组。固定余数 rr,令容量 j=r+kwij=r+kw_i,再用 xx 表示「从旧状态出发的位置」,便有

f[r+kwi]=kvi+max⁡k−mi≤x≤k(g[r+xwi]−xvi)f[r+kw_i]=k v_i+\max_{k-m_i\le x\le k}\big(g[r+xw_i]-xv_i\big)

对每个 kk,变化的只是长度为 mi+1m_i+1 的窗口 [k−mi,k][k-m_i,k];候选分数 g[r+xwi]−xvig[r+xw_i]-xv_i 只依赖 xx。队首保存窗口内分数最大的 xx,每个下标最多进队、出队一次,于是整条链是线性的。

跟着算一遍:按余数链维护滑动窗口

处理物品 (w,v,m)=(2,3,2)(w,v,m)=(2,3,2),容量上限 8。只看偶数余数链 r=0r=0;旧数组在容量 0,2,4,6,80,2,4,6,8 上的值依次是 0,4,5,7,80,4,5,7,8。

0
先减去线性项。 对链下标 x=0,1,2,3,4x=0,1,2,3,4,候选分数 g[r+xw]−xvg[r+xw]-xv 依次为 0,1,−1,−2,−40,1,-1,-2,-4。单调队列只需要维护这串分数的窗口最大值。
1
算容量 6。 此时 k=3k=3,最多取 2 件,所以窗口是 x∈[1,3]x\in[1,3]。最大分数是 x=1x=1 的 1,故 f[6]=3×3+1=10f[6]=3\times3+1=10;也就是从旧状态 g[2]=4g[2]=4 出发,再取 2 件当前物品,得到 4+2×3=104+2\times3=10。
2
窗口右移到容量 8。 k=4k=4 时合法窗口变成 x∈[2,4]x\in[2,4],x=1x=1 因为会取 3 件而从队首过期。新的最大分数是 x=2x=2 的 −1,于是 f[8]=4×3−1=11f[8]=4\times3-1=11,正好对应 g[4]+2×3=11g[4]+2\times3=11。

队列里到底存什么

队列存的是链下标 xx:队首过期条件是 x<k−mix<k-m_i,队尾则按 g[r+xwi]−xvig[r+xw_i]-xv_i 从大到小淘汰。每次把当前 x=kx=k 也入队,表示「当前物品取 0 件」,因此不会漏掉沿用旧状态的选择。

下面是与 P1776 输入格式一致的完整写法。代码比二进制拆分繁琐,竞赛里通常仍首选二进制;当容量与物品种数允许、但件数上限很大时,再考虑这套 O(Vn)O(Vn) 优化。

#include <deque>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
    int n, W;
    cin >> n >> W;
    vector<long long> f(W + 1, 0);

    for (int i = 1; i <= n; i++)
    {
        int v, w, m;                         // 价值、重量、件数上限
        cin >> v >> w >> m;
        vector<long long> g = f;             // g:还没加入第 i 种物品的旧状态

        for (int r = 0; r < w && r <= W; r++) // 容量按 j mod w 分成 w 条链
        {
            deque<int> q;                    // 存链上的下标 x,不是容量 j
            auto score = [&](int x) {
                return g[r + x * w] - 1LL * x * v;
            };

            for (int k = 0, j = r; j <= W; k++, j += w)
            {
                while (!q.empty() && q.front() < k - m)
                    q.pop_front();           // 超过 m 件,窗口左端失效
                while (!q.empty() && score(q.back()) <= score(k))
                    q.pop_back();            // 保持候选分数单调递减
                q.push_back(k);              // x=k 表示这一种取 0 件

                int x = q.front();
                f[j] = g[r + x * w] + 1LL * (k - x) * v;
            }
        }
    }

    cout << f[W] << endl;
    return 0;
}

常见陷阱 · 别把三法记混

三法的分界很清晰:朴素 O(V∑mi)O(V\sum m_i) 是把 mim_i 件摊开;二进制 O(V∑log⁡mi)O(V\sum\log m_i) 是把 mim_i 打包(主力);单调队列 O(Vn)O(Vn) 是按同余滑窗(选讲)。三者拿的都是同一个 f[W]f[W],只是把「取不超过 mim_i 件」表达得越来越省。切莫把二进制的「打包件」当成真的多买了物品,打包只是转移的组织方式,取用件数始终不超过 mim_i。

例题

P2347[NOIP1996 提高组] 砝码称重NOIP1996 提高普及/提高-
题意
有 6 种面值(1,2,3,5,10,201,2,3,5,10,20)的砝码,各给定数量,问用它们能称出多少种不同的重量(重量 >0>0)。
对应关系
每种砝码有限个 → 多重背包;不求最值而求可行性:f[j]f[j] 表示「重量 jj 能否被称出」,转移用逻辑或代替 max⁡\max。
为什么选它
数据极小(总重量上界才几百),正好拿来把朴素多重解法写透:三重循环「种 ×\times 件 ×\times 容量」,一件一件放。是理解「多重 = 带上限的 01」最干净的一题。
转移 · 复杂度
f[j] ∣= f[j−wi]f[j]\ |=\ f[j-w_i](对每种的每件逆推);时间 O(S⋅∑mi)O(S\cdot\sum m_i),SS 为总重量上界。
参考代码(朴素多重 + 布尔可达)
#include <iostream>
using namespace std;

int a[7];                    // 六种砝码的个数
int val[7] = {0, 1, 2, 3, 5, 10, 20};  // 对应面值
bool f[1005];                // f[j]:重量 j 能否被称出

int main()
{
    for (int i = 1; i <= 6; i++)
        cin >> a[i];

    int S = 0;                          // 可达重量的上界
    for (int i = 1; i <= 6; i++)
        S += a[i] * val[i];

    f[0] = true;                        // 重量 0 恒可达(不放砝码)
    for (int i = 1; i <= 6; i++)        // 逐种砝码
        for (int k = 1; k <= a[i]; k++) // ★朴素:这一种一件一件地放
            for (int j = S; j >= val[i]; j--)   // 每件都是一次 01 逆推
                if (f[j - val[i]])
                    f[j] = true;

    int cnt = 0;
    for (int j = 1; j <= S; j++)        // 统计非零可达重量的种数
        if (f[j]) cnt++;

    cout << "Total=" << cnt << endl;
    return 0;
}
P1776宝物筛选NOI导刊2010提高+/省选-
题意
nn 种宝物,第 ii 种价值 viv_i、重量 wiw_i、数量 mim_i,背包承重 WW,求最大总价值。
为什么选它
∑mi\sum m_i 可达十万级、WW 到四万,朴素摊开必然超时,逼你上二进制拆分。是多重背包二进制模板的标准练手题(∑mi\sum m_i 规模也容得下单调队列,想进一步优化可以试)。
转移 · 复杂度
二进制拆分成打包件后逐件 01 逆推 f[j]=max⁡(f[j],f[j−w′]+v′)f[j]=\max(f[j],f[j-w']+v');时间 O ⁣(W⋅∑log⁡mi)O\!\big(W\cdot\sum\log m_i\big)。
参考代码(二进制拆分)
#include <iostream>
#include <algorithm>
using namespace std;

int f[40005];                // f[j]:容量不超过 j 的最大价值
int w2[100005], v2[100005];  // 二进制拆分后的「打包件」
int cnt;                     // 打包件总数

int main()
{
    int n, W;
    cin >> n >> W;
    for (int i = 1; i <= n; i++)
    {
        int v, w, m;                    // 价值、重量、件数上限
        cin >> v >> w >> m;

        int k = 1;                      // ★二进制拆分:1,2,4,… 各捆一包
        while (k < m)
        {
            cnt++;
            w2[cnt] = k * w;            // 一包含 k 件,等效重量 k*w
            v2[cnt] = k * v;            // 等效价值 k*v
            m -= k;
            k <<= 1;                    // k 翻倍
        }
        if (m > 0)                      // 余数单独成一包
        {
            cnt++;
            w2[cnt] = m * w;
            v2[cnt] = m * v;
        }
    }

    for (int i = 1; i <= cnt; i++)      // 每个打包件当一件做 01 背包
        for (int j = W; j >= w2[i]; j--)    // ★逆推:一包至多用一次
            f[j] = max(f[j], f[j - w2[i]] + v2[i]);

    cout << f[W] << endl;
    return 0;
}

练习

P6771[USACO05DEC] Space Elevator 太空电梯多重背包 + 可达性:每种方块有限块且有高度上限,先按高度上限从小到大排序,再逐种做「不超过 m 件」的可达 DP。在洛谷打开
P1077[NOIP2012 普及组] 摆花有限件求方案数:f[j] 表示用前几种花恰好摆 j 盆的方案数,每种不超过 a_i 盆;那一维可用前缀和把枚举件数优化掉。在洛谷打开
P1833樱花题内含「无限 / 有限 / 恰一」多种分支,本质是混合背包;先把每种有限的樱花当多重物品做二进制拆分,无限的按完全背包正推。在洛谷打开
到 A 部分页的「装包大师」时,把某件宝物想成「库存只有有限件、拿完就没」,这份「有限件」的斤斤计较,正是多重背包要拆包处理的核心。

已进入 多重背包 · 背包 DP · DP大师