```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

上一篇 DP 入门 建立了”状态 + 转移”的框架。这一篇聚焦 DP 最经典的模型——背包问题

场景很简单:你有一个容量有限的背包,面前有很多物品,每个有重量和价值。你想在不超过背包容量的前提下,使选中物品的总价值最大。

听起来朴素,但这个模型极其强大。“去不去旅行”是 0-1 背包(每件事要么做要么不做),“零钱兑换”是完全背包(每种硬币可以用无限次),“分组背包”是加了约束的变种。掌握了背包,你就掌握了 DP 最重要的一类状态设计。

问题的本质

0-1 背包的核心矛盾

每件物品要么选,要么不选——没有”选一半”这种操作。暴力枚举 2N2^N 种方案不可行。

DP 的关键观察:如果我已经决定了前 i1i-1 件物品怎么选,第 ii 件物品只有两种可能——选或不选。我只需要知道”用了多少容量”就足够了,不需要知道具体选了哪些物品。

这就是状态设计:dp[j]dp[j] = 容量恰好为 jj 时能获得的最大价值。

循环方向的秘密

0-1 背包内层循环 jj 必须从大到小。为什么?

考虑当前处理物品 ii(重量 ww,价值 vv)。如果 jj 从小到大遍历:

j = w:   dp[w]  = max(dp[w], dp[0] + v)     ← 用了 dp[0]
j = 2w:  dp[2w] = max(dp[2w], dp[w] + v)    ← 但 dp[w] 刚被更新过!

这意味着物品 ii 被选了两次——违反了”每件只能选一次”的规则。

从大到小就没这个问题:

j = 2w:  dp[2w] = max(dp[2w], dp[w] + v)    ← dp[w] 还是旧值
j = w:   dp[w]  = max(dp[w], dp[0] + v)     ← 现在才更新 dp[w]

物品 ii 每个容量只被考虑一次。✓

完全背包恰恰相反——每件物品可以选无限次,所以 jj 从小到大,允许同一物品被多次使用。

WW 太大时:互换重量和价值

标准的 0-1 背包时间 O(NW)O(NW),但如果 W=109W = 10^9,直接做会 TLE。

关键技巧:如果价值 viv_i 很小(比如 1000\le 1000),那就把状态定义换过来——dp[j]dp[j] = 达到总价值 jj 所需的最小重量。 这样状态数从 WW 变成 N×vmaxN \times v_{\max}

理论 + 代码

0-1 背包(标准版)

状态dp[j]dp[j] = 容量 jj 下的最大价值。

转移dp[j]=max(dp[j], dp[jwi]+vi)dp[j] = \max(dp[j],\ dp[j - w_i] + v_i)jjWWwiw_i 倒序。

long long dp[100006]; // dp[j] = 容量 j 时的最大价值

for (int i = 1; i <= N; i++)
    for (int j = W; j >= w[i]; j--)
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]); // ① 选 or 不选

逐行解析

  • ① 对于容量 jj,要么不选物品 ii(保持 dp[j]dp[j]),要么选(dp[jwi]+vidp[j-w_i]+v_i)。取较大值。
  • jjWWwiw_i 倒序,保证每件物品只被考虑一次。

模拟N=3,W=8N=3, W=8,物品 (3,30),(4,50),(5,60)(3,30), (4,50), (5,60)

阶段dp[0]dp[3]dp[4]dp[5]dp[7]dp[8]
初始000000
物品1(w=3,v=30)0300000
物品2(w=4,v=50)03050508080
物品3(w=5,v=60)03050608090

dp[8]=90dp[8] = 90,对应选物品 1 和 3(3+5=83+5=8, 30+60=9030+60=90)。✓

0-1 背包(WW 很大时)

状态dp[j]dp[j] = 达到总价值 jj 所需的最小重量。

转移dp[j]=min(dp[j], dp[jvi]+wi)dp[j] = \min(dp[j],\ dp[j - v_i] + w_i)

答案:最大的 jj 使得 dp[j]Wdp[j] \le W

long long dp[100009]; // dp[j] = 价值 j 时的最小重量
// 初始化:dp[0] = 0, 其余 = INF

for (int i = 1; i <= N; i++)
    for (int j = Vmax; j >= v[i]; j--)
        dp[j] = min(dp[j], dp[j - v[i]] + w[i]); // ① 互换!

// ② 找最大的 j 使得 dp[j] <= W
long long ans = 0;
for (int j = 0; j <= Vmax; j++)
    if (dp[j] <= W) ans = j;
  • ① 和标准 0-1 背包结构完全一样,只是把 wwvv 互换了。
  • ② 答案不再是 dp[W]dp[W],而是遍历所有可能的价值 jj,找最大的满足重量约束的。

例题

例题 1:M&A 030 — Knapsack 1

题目NN 个物品,重量 wiw_i,价值 viv_i,背包容量 WW。求最大总价值。

数据范围1N1001 \le N \le 1001W1051 \le W \le 10^51vi1091 \le v_i \le 10^9

—— AtCoder M&A 030

分析:标准 0-1 背包。W105W \le 10^5,用 O(NW)O(NW) 的标准做法。注意 viv_i 最大 10910^9,答案要用 long long

输入样例

3 8
3 30
4 50
5 60

输出90

代码

#include <cstdio>
#include <algorithm>
using namespace std;

long long dp[100006];

int main() {
    int N, W;
    scanf("%d%d", &N, &W);
    for (int i = 0; i < N; i++) {
        int w, v;
        scanf("%d%d", &w, &v);
        for (int j = W; j >= w; j--)           // ① 倒序!
            dp[j] = max(dp[j], dp[j - w] + v); // ② 选 or 不选
    }
    printf("%lld\n", dp[W]);
}

逐行解析

  • jjWWww 倒序,保证每件物品只被选一次。
  • dp[jw]+vdp[j-w]+v 表示”如果选了当前物品,从剩余容量 jwj-w 的最优解加上当前价值”。

例题 2:TB B19 — Knapsack 2(WW 很大)

题目NN 个物品,重量 wiw_i,价值 viv_i,背包容量 WW。求最大总价值。

数据范围1N1001 \le N \le 1001W1091 \le W \le 10^91vi10001 \le v_i \le 1000

—— AtCoder Tessoku Book B19

分析W=109W = 10^9,标准 O(NW)O(NW) 不可行。但 vi1000v_i \le 1000,所以总价值最多 N×1000=105N \times 1000 = 10^5互换重量和价值——dp[j]dp[j] = 达到价值 jj 所需的最小重量。

教材 4.4 节专门讲解了这个技巧。

输入样例

4 7
3 13
3 17
5 29
1 10

输出40

代码

#include <cstdio>
#include <algorithm>
using namespace std;

const long long INF = 1e18;
long long dp[100009]; // dp[j] = 价值 j 时的最小重量

int main() {
    int N, W;
    scanf("%d%d", &N, &W);
    int w[109], v[109];
    for (int i = 1; i <= N; i++) scanf("%d%d", &w[i], &v[i]);

    // ① 初始化
    for (int j = 0; j <= 100000; j++) dp[j] = INF;
    dp[0] = 0;                                     // 价值 0 不需要任何重量

    // ② DP
    for (int i = 1; i <= N; i++)
        for (int j = 100000; j >= v[i]; j--)
            dp[j] = min(dp[j], dp[j - v[i]] + w[i]); // ③ 互换!

    // ④ 找答案
    long long ans = 0;
    for (int j = 0; j <= 100000; j++)
        if (dp[j] <= W) ans = j;                    // ⑤ 满足容量约束的最大价值
    printf("%lld\n", ans);
}

逐行解析

  • dp[0]=0dp[0]=0(价值 0 不需要重量),其余初始化为 INF(不可达)。
  • ③ 和标准 0-1 背包结构一样,但 wwvv 互换了位置。
  • ⑤ 遍历所有价值,找满足 dp[j]Wdp[j] \le W 的最大 jj

模拟:样例 1,N=4,W=7N=4, W=7,物品 (3,13),(3,17),(5,29),(1,10)(3,13), (3,17), (5,29), (1,10)。总价值上限 = 13+17+29+10=6913+17+29+10=69

物品 1 后:dp[13]=3dp[13]=3。 物品 2 后:dp[17]=3dp[17]=3dp[30]=6dp[30]=6。 物品 3 后:dp[29]=5dp[29]=5dp[42]=8dp[42]=8dp[46]=8dp[46]=8dp[59]=11dp[59]=11。 物品 4 后:dp[10]=1dp[10]=1dp[23]=4dp[23]=4dp[27]=4dp[27]=4dp[30]=min(6,4)=4dp[30]=\min(6, 4)=4dp[40]=min(7,6)=6dp[40]=\min(7, 6)=6

最大 jj 使得 dp[j]7dp[j] \le 7j=40j=40dp[40]=67dp[40]=6 \le 7。✓


例题 3:M&A 096 — Cooking(双炉灶调度)

题目NN 道菜,第 ii 道需要时间 TiT_i。两个炉灶同时工作,每道菜只能在一个炉灶上做。求做完所有菜的最短时间。

数据范围1N1001 \le N \le 1001Ti1031 \le T_i \le 10^3

—— AtCoder M&A 096

分析:两个炉灶,每道菜分配到其中一个。总时间 = 两个炉灶中较大的那个完成时间。我们希望这个最大值尽可能小。

设总时间 S=TiS = \sum T_i。如果炉灶 A 的工作时间为 jj,那炉灶 B 的时间是 SjS - j。总时间 = max(j,Sj)\max(j, S-j)。要让这个值最小,jj 应尽量接近 S/2S/2

所以问题转化成:从 NN 个数中选一些,使它们的和尽可能接近 S/2S/2。这就是一个子集和问题——0-1 背包的判定版本。

代码

#include <cstdio>
#include <algorithm>
using namespace std;

int main() {
    int N;
    scanf("%d", &N);
    int T[109], S = 0;
    for (int i = 0; i < N; i++) { scanf("%d", &T[i]); S += T[i]; }

    bool dp[100009] = {};
    dp[0] = true;                                   // ① 和为 0 恒可达
    for (int i = 0; i < N; i++)
        for (int j = S; j >= T[i]; j--)
            dp[j] = dp[j] || dp[j - T[i]];          // ② bool 版 0-1 背包

    int ans = S;
    for (int j = 0; j <= S; j++)
        if (dp[j]) ans = min(ans, max(j, S - j));   // ③ 取最优分割
    printf("%d\n", ans);
}

逐行解析

  • dp[0]=truedp[0] = true:不选任何数,和为 0。
  • ② 标准的 0-1 背包转移,但不需要记录价值,只记录”和为 jj 是否可达”。
  • ③ 枚举所有可达的和 jj,炉灶 A 用 jj,炉灶 B 用 SjS-j,取两者最大值的最小值。

例题 4:TB A18 — Subset Sum(子集和判定)

题目NN 个正整数 A1,,ANA_1, \ldots, A_N,目标 SS。能否从中选一些使和恰好为 SS?如果能,输出方案。

数据范围1N601 \le N \le 601S100001 \le S \le 10000

—— AtCoder Tessoku Book A18

分析:0-1 背包的判定版本。dp[i][j]dp[i][j] = 考虑前 ii 个数,和为 jj 是否可达。如果还要还原方案,需要额外记录”选择了哪个”。

代码

#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;

bool dp[69][10009]; // dp[i][j] = 前 i 个数中和 j 是否可达

int main() {
    int N, S;
    scanf("%d%d", &N, &S);
    int A[69];
    for (int i = 1; i <= N; i++) scanf("%d", &A[i]);

    dp[0][0] = true;                                // ① 初始
    for (int i = 1; i <= N; i++)
        for (int j = 0; j <= S; j++) {
            if (j < A[i]) dp[i][j] = dp[i-1][j];           // ② 不够选
            else dp[i][j] = dp[i-1][j] || dp[i-1][j-A[i]]; // ③ 选 or 不选
        }

    if (!dp[N][S]) { printf("-1\n"); return 0; }

    // ④ 路径还原
    vector<int> ans;
    int pos = S;
    for (int i = N; i >= 1; i--) {
        if (dp[i-1][pos]) continue;                 // ⑤ 不选 i
        ans.push_back(i);
        pos -= A[i];                                // ⑥ 选了 i
    }
    reverse(ans.begin(), ans.end());
    printf("%d\n", (int)ans.size());
    for (int i = 0; i < (int)ans.size(); i++) {
        if (i) printf(" ");
        printf("%d", ans[i]);
    }
    printf("\n");
}

逐行解析

  • ②③ 转移:要么不选 AiA_idp[i1][j]dp[i-1][j]),要么选(dp[i1][jAi]dp[i-1][j-A_i]),任一可达即可。
  • ⑤⑥ 从终点 SS 倒推:如果 dp[i1][pos]dp[i-1][pos] 为 true,说明不选 AiA_i 也能达到 pospos;否则一定选了 AiA_ipospos 减去 AiA_i

模拟N=5,S=9,A=[2,2,3,5,5]N=5, S=9, A=[2, 2, 3, 5, 5]

i\j023456789
0T
1(2)TT
2(2)TTT
3(3)TTTTTT
4(5)TTTTTTTTT

dp[4][9]=truedp[4][9] = true,还原路径:95=4,42=2,22=09-5=4, 4-2=2, 2-2=0。选了 1,2,4。✓


例题 5:T90 037 — Don’t Leave the Spice(★5,区间背包)

题目NN 种料理,料理 ii 的价值 ViV_i,消耗香料 [Li,Ri][L_i, R_i](可以选择范围内任意量)。能否恰好消耗 WW 香料?如果能,求最大总价值。

数据范围1W1041 \le W \le 10^41N5001 \le N \le 500

—— AtCoder Typical 90 037

分析:这是 0-1 背包的变体——每件物品的重量不是固定值,而是一个区间 [Li,Ri][L_i, R_i]。对于每个容量 jj,物品 ii 可以贡献 [Li,Ri][L_i, R_i] 中的任意值。

状态还是 dp[j]dp[j] = 消耗恰好 jj 香料时的最大价值。转移时,需要从 dp[jk]dp[j-k] 转移过来,其中 LikRiL_i \le k \le R_i

朴素做法对每个 jj 枚举 kkO(N×W×R)O(N \times W \times R) 会超时。用**滑动窗口最大值(单调队列)**优化:对于固定的物品 ii,按 jmodLij \bmod L_i 分组,每组内用单调队列维护窗口 [jRi,jLi][j-R_i, j-L_i] 中的最大值。

但更简单的做法是:O(N×W×(RiLi))O(N \times W \times (R_i - L_i)) 在本题数据范围内可以接受(N=500,W=104,RLN=500, W=10^4, R-L 平均较小)。

代码(朴素版本):

#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;

const long long INF = 1e18;
long long dp[10009], ndp[10009];

int main() {
    int W, N;
    scanf("%d%d", &W, &N);
    int L[509], R[509], V[509];
    for (int i = 1; i <= N; i++) scanf("%d%d%d", &L[i], &R[i], &V[i]);

    for (int j = 0; j <= W; j++) dp[j] = -INF;
    dp[0] = 0;                                       // ① 消耗 0 香料,价值 0

    for (int i = 1; i <= N; i++) {
        for (int j = 0; j <= W; j++) ndp[j] = dp[j]; // ② 不选料理 i
        for (int j = L[i]; j <= W; j++) {
            // ③ 枚举选了料理 i,消耗 k 香料
            for (int k = L[i]; k <= R[i] && j - k >= 0; k++) {
                if (dp[j - k] >= 0)
                    ndp[j] = max(ndp[j], dp[j - k] + V[i]);
            }
        }
        memcpy(dp, ndp, sizeof(dp));
    }

    printf("%lld\n", dp[W] >= 0 ? dp[W] : -1);       // ④ 判断是否可达
}

逐行解析

  • dp[0]=0dp[0]=0:消耗 0 香料的价值是 0,其他状态不可达。
  • ndp[j] = dp[j]:不选料理 ii 的方案,价值不变。
  • ③ 选料理 ii,消耗 k[Li,Ri]k \in [L_i, R_i] 香料,从 dp[jk]dp[j-k] 转移。
  • ④ 如果 dp[W]<0dp[W] < 0,说明恰好消耗 WW 香料不可行,输出 -1。

模拟W=100,N=4W=100, N=4,料理 (30,40,120),(30,40,30),(30,40,1500),(30,40,40)(30,40,120), (30,40,30), (30,40,1500), (30,40,40)

选料理 1,3,4,各消耗 100/333100/3 \approx 33(在 [30,40][30,40] 内),总价值 120+1500+40=1660120+1500+40=1660。✓

参考文献

教材讲解 — 競技プログラミングの鉄則 第 4 章

基础练习 — アルゴリズムと数学 演習問題集

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶