← 目录 第 32 / 60 节 全部 60 节

32 · 背包问题:0/1 与完全背包

一个符号决定一件物品能不能重复取:0/1 倒序,完全正序。由此派生出恰好装满的 −INF、方案计数的 dp[0]=1、多重背包的二进制拆分。背包是 DP 里唯一“模板即答案”的一类,但也最容易抄错方向。
普及/提高-预计 75 分钟背包倒序/正序方案计数
本节出现的新函数 / 新语法(先看懂再用)

背包是 DP 里最像“套路”的一类:有 n 件物品,每件有体积 w 和价值 v,背包容量 V,在总体积不超过 V 的前提下让总价值最大。P1048 采药就是它:T = 1000 的时间当容量,M = 100 株草药当物品。

0/1 背包和完全背包的代码只差一个不等号的方向,但差的方向后面是两件完全不同的事:dp[j] 被这一层更新之后,还允不允许这一层再拿它去更新别人。把这句话想通,背包就没有难点了。

倒序 = 我只看上一层的旧值,所以每件最多用一次;正序 = 我允许用刚被这一层更新的新值,所以可以无限接下去。整节背包,核心就这一句。
新函数拆解 · 一维 dp 数组:把物品维度滚动掉
int dp[MAXT]; // dp[j] = 容量上限为 j 时的最大价值
组成含义 / 格式要求
dp[j] 的含义在“已经考虑过前 i 件物品”的前提下,容量上限为 j 时能取得的最大价值。i 这一维被滚动掉了,所以在任意时刻 dp 只代表当前这一层。
下标范围0..V。V 是容量上限(采药题里是总时间 T),不是物品个数。数组开 V+5,不是 n+5。
下标 0容量 0 装不下任何有体积的物品,价值是 0;换成计数题,dp[0] 是 1(什么都不选也是一种方案)。
为什么能压成一维二维写法里第 i 行只由第 i−1 行算出。算完第 i 行,第 i−1 行再没人要——那就让两行共用同一块空间。
值的类型最大价值一般 int 够(100 株草药 × 100 价值 = 10⁴);方案数要用 long long,完全背包的超大容量题也要看清范围。
典型规模M × V = 100 × 1000 = 10⁵ 个状态,每个 O(1) 转移,总运算 10⁵ 次——暴搜是 2¹⁰⁰。
最小可运行示例
示例
// 二维写法:想清楚用这个
for (int i = 1; i <= M; ++i)
    for (int j = 0; j <= T; ++j) {
        dp2[i][j] = dp2[i - 1][j];                                  // 不选第 i 件
        if (j >= t[i])
            dp2[i][j] = max(dp2[i][j], dp2[i - 1][j - t[i]] + v[i]); // 选第 i 件,从上一层转移
    }

// 一维写法:提交用这个
for (int i = 1; i <= M; ++i)
    for (int j = T; j >= t[i]; --j)                                 // 倒序!
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);
注意

一维写法里 j 只循环到 t[i] 就停,是因为 j < t[i] 时 j − t[i] 是负数下标,会越界;而这一步在二维写法里对应的正是“这件物品装不下,只能不选”。

一维数组装不下历史层,所以它到底代表哪一层,完全由你的循环方向决定。这就是下一张卡片的全部内容。

新函数拆解 · 遍历方向:倒序(0/1)与正序(完全)
for (int j = V; j >= w; --j) // 0/1 背包 for (int j = w; j <= V; ++j) // 完全背包
组成含义 / 格式要求
倒序更新 dp[j] 时,dp[j-w] 还没被本轮碰过,读到的仍是上一层的值——每件物品最多用一次。
正序更新 dp[j] 时,dp[j-w] 已被本轮改写成本层的值——同一件物品可以再接一次,等效于无限件。
统一记忆0/1 是倒的,完全是正的。题面差一个“能不能重复取”,代码差一个符号。
起点是 wj 从 w 开始,保证 j−w ≥ 0。写 j = 0 会让下标变负,RE 或读到垃圾。
循环嵌套顺序物品循环必须在外层,容量在内层。反过来(容量在外、物品在内)在 0/1 背包上会重复选物品,必错。
判断方法写完之后问自己:dp[j-w] 此刻是旧值还是新值?答对,方向就对了。
最小可运行示例
示例
// 0/1 背包:每件最多一次
for (int i = 1; i <= M; ++i)
    for (int j = T; j >= t[i]; --j)
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);

// 完全背包:每件无限次
for (int i = 1; i <= M; ++i)
    for (int j = t[i]; j <= T; ++j)
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);
注意

把倒序写成正序,程序不会报错、样例有时还能过,但答案是“每件可以无限取”的结果,通常偏大。

这个方向规律对“加法计数”同样成立:0/1 计数倒序,允许重复计数正序。

新函数拆解 · INF / NEG 初始化:把“不可达”标出来
const int NEG = -0x3f3f3f3f; dp[0] = 0; for (int j = 1; j <= V; ++j) dp[j] = NEG;
组成含义 / 格式要求
NEG 取 −0x3f3f3f3f≈ −1.06×10⁹。它加上 int 范围内的任何正数都不会溢出,而 INT_MIN 加一次就溢出成很大的正数(未定义行为)。
memset 只能按字节填memset(dp, 0x3f, sizeof dp) 把每字节填 0x3f,拼成 int 得到 0x3f3f3f3f = 1061109567;负无穷可以用 memset(dp, 0xbf, ...) 得到 −1077952577,但 fill 更清楚。
fill 更通用fill(dp, dp + V + 1, NEG) 能填任意值。
恰好装满dp[0] = 0,其余 NEG。只有“真能凑出 j”的状态才会被转移改写,凑不出的永远是 NEG。
不要求装满整表填 0。任何容量都是合法起点——允许留空,所以答案不会因为“装不满”而无解。
方案计数dp[0] = 1,其余 0。花 0 元什么都不选,是唯一的 1 种方案。
最小可运行示例
示例
const int NEG = -0x3f3f3f3f;

// 恰好装满的 0/1 背包
dp[0] = 0;                                  // 唯一合法的起点
for (int j = 1; j <= V; ++j) dp[j] = NEG;    // 其余一律“不可达”

for (int i = 1; i <= n; ++i)
    for (int j = V; j >= w[i]; --j)
        if (dp[j - w[i]] != NEG)             // 不可达的来源不许参与转移
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

if (dp[V] < 0) cout << "impossible\n";       // 用 < 0 判,不要用 == NEG
else           cout << dp[V] << "\n";
注意

不写 if (dp[j-w] != NEG) 其实也能过很多题(NEG 加个正数还是负数,取 max 时会被自己的旧值压住),但一旦转移里有减号、或者价值可以为负,就必须显式判断。

判断无解用 dp[V] < 0。因为 NEG 可能被更新成一个“不那么负”的负数,用 == NEG 会漏判。

例题 · 采药洛谷 P1048普及-去洛谷做这题 ↗

辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。他的师父给他出了一道难题:山洞里有 M 株不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。给定总时间 T,求在规定时间内能采到的草药的最大总价值。

输入:第一行两个整数 T(1 ≤ T ≤ 1000)和 M(1 ≤ M ≤ 100),用空格隔开,T 表示总共能够用来采药的时间,M 表示山洞里的草药数目;接下来 M 行,每行两个整数,依次表示采第 i 株草药所需的时间和这株草药的价值(均不超过 100)。

输出:一行一个整数,表示在规定时间内能采到的草药的最大总价值。

输入: 70 3 71 100 69 1 1 2 输出: 3
例题怎么想:把“时间”当成容量

每株草药只有两种命运:采,或者不采。M ≤ 100 时枚举全部 2¹⁰⁰ 种组合,死了。

① 状态:dp[i][j] = 只考虑前 i 株草药、总时间不超过 j 时,能采到的最大价值。

② 转移:对第 i 株草药分两类讨论。不采:dp[i][j] = dp[i−1][j]。采(前提 j ≥ t[i]):dp[i][j] = dp[i−1][j − t[i]] + v[i]。两者取 max。

③ 边界:dp[0][j] = 0——一株草药都不考虑,价值是 0。本题不要求“恰好用满时间”,所以整表可以全填 0。

④ 答案:dp[M][T],前 M 株、容量 T 的最优值。

压成一维后,第 i 层只由第 i−1 层算出,于是写作 dp[j] = max(dp[j], dp[j-t[i]] + v[i]),并且 j 必须倒序——否则 dp[j−t[i]] 已经是本层的新值,等于把同一株草药采了两次。

复杂度:M × T = 100 × 1000 = 10⁵ 次转移,每步 O(1)。

完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXT = 1005, MAXM = 105;
int t[MAXM], v[MAXM];          // t:采这株草药要花的时间;v:它的价值
int dp[MAXT];                  // dp[j]:时间上限为 j 时能采到的最大价值

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int T, M;
    cin >> T >> M;
    for (int i = 1; i <= M; ++i) cin >> t[i] >> v[i];

    // 物品在外层、容量在内层:这是 0/1 背包唯一正确的嵌套顺序
    for (int i = 1; i <= M; ++i)
        for (int j = T; j >= t[i]; --j)                 // 倒序:j-t[i] 还是上一层,草药只采一次
            dp[j] = max(dp[j], dp[j - t[i]] + v[i]);

    cout << dp[T] << "\n";                              // 容量 T 时的最优值就是答案
    return 0;
}
逐行拆解
代码片段含义与理由
int dp[MAXT];一维数组,只有容量这一维。物品维度靠“外层循环一轮一轮覆盖”来滚动。
for (int i = 1; i <= M; ++i)外层是物品。每跑完一轮,dp 数组的含义就从“前 i−1 株”变成“前 i 株”。
for (int j = T; j >= t[i]; --j)内层倒序,这是 0/1 背包的核心。j 从 T 往 t[i] 走,被依赖的 dp[j-t[i]] 下标更小,还没被这一轮碰过,读到的是旧值。
j >= t[i]j < t[i] 时这株草药根本装不下,dp[j-t[i]] 还会越界成负下标,所以循环到 t[i] 就停。
dp[j - t[i]] + v[i]“采第 i 株”的收益:把它占掉的时间腾出来(容量降到 j-t[i]),价值加上 v[i]。
max(dp[j], ...)采和不采二选一。dp[j] 在等号左边出现,就是“不采”的那一支。
cout << dp[T];答案是容量上限 T 处,不是 dp[M]。dp 数组里没有物品那一维了,最后只剩容量。

把“为什么倒序”钉死。设只有一件物品:w = 2、v = 3、V = 6,dp 全 0。看两种方向跑出来的表。

j正序 dp[j](每件可无限次)倒序 dp[j](每件最多一次)
000
100
23(dp[0] + 3)3(dp[0] + 3)
33(dp[1] + 3)3(dp[1] + 3)
46(dp[2] + 3,dp[2] 已含一件)3(dp[2] 还是 0)
563
69(dp[4] + 3,三件)3(dp[4] 还是 0)

正序时 j = 4 读到的 dp[2] 已经是 3(本轮刚算出来的),于是 3 + 3 = 6——同一件物品被用了两次。倒序时 j = 4 读到的 dp[2] 还是 0,只能得 3。

一句话总结:一维数组同时扮演“上一层”和“本层”两个角色,方向决定你读到的是哪一个。倒序读到旧层(0/1),正序读到新层(完全背包)。

所以完全背包不需要新写法,只要把方向反过来:正序时 dp[j−w] 刚被本层更新过,允许“在这件物品已经用过的基础上再用一次”,正好等于无限件。

完全背包
// 完全背包:每件物品可以取任意多次
for (int i = 1; i <= M; ++i)
    for (int j = t[i]; j <= T; ++j)          // 正序:允许接着用这件物品
        dp[j] = max(dp[j], dp[j - t[i]] + v[i]);

模板题是 P1616 疯狂的采药:每株草药可以无限采,其余读入格式与 P1048 一模一样,改动只有内层方向一处。它的 T 比 P1048 大得多,所以还要多算一步账:dp 数组的内存 = T × 4 字节。T = 10⁷ 时就是 40 MB,开两个数组直接 MLE。

另外注意:完全背包的 dp 初值仍然全填 0。因为“一件都不取、容量随便剩多少”本身就是合法的 0 价值方案,不需要 −INF 那一套。

恰好装满是背包的第二种语义:不是“不超过 V”,而是“必须正好凑到 V”。状态转移一字不改,改的是初值。

为什么初值能改变语义:dp[j] 的值来自“从某个更小的 j 转移过来”,一路追溯下去终点一定是 dp[0]。如果 dp[0] = 0 而其余全填 −INF,那么只有“真能由若干件物品体积相加得到 j”的状态才会被更新成非负值,其余全是 −INF。最后 dp[V] 若仍小于 0,说明凑不出来。

恰好装满
const int NEG = -0x3f3f3f3f;
int dp[MAXT];

dp[0] = 0;                                  // 容量 0 天然装满,价值 0
for (int j = 1; j <= V; ++j) dp[j] = NEG;    // 其余容量都是“还没做到”

for (int i = 1; i <= n; ++i)
    for (int j = V; j >= w[i]; --j)          // 0/1:倒序
        if (dp[j - w[i]] != NEG)             // 来源不可达就不能转移
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

if (dp[V] < 0) cout << "impossible\n";       // 装不满
else           cout << dp[V] << "\n";
−INF 初值最坑的一点

把 NEG 写成 INT_MIN:NEG + v[i] 直接溢出(未定义行为),得到一个很大的正数,“不可达”瞬间变成“最优解”,输出荒谬的大数。用 −0x3f3f3f3f。

用 dp[V] == NEG 判断无解:某个不可达状态可能被转移改成另一个负数,判不出来。用 < 0。

方案计数是第三种语义:把 max 换成加法,dp 的含义从“最优值”变成“方案数”。此时初值又变了:dp[0] = 1,其余 0。

dp[0] = 1 的理由:什么都不选、恰好用掉 0 的容量,这也算一种方案。写 0 的话,所有加法都会乘上 0,整张表恒等于 0。这和“恰好装满写 −INF”是同一件事的两面——不可达的状态必须贡献 0(加法)或者被排除(取 max)。

方案计数
// 0/1 方案计数:n 个数里选若干,使和恰好等于 m,问方案数
long long dp[10005];
dp[0] = 1;                                  // 和为 0 的方案:一个都不选

for (int i = 1; i <= n; ++i)
    for (int j = m; j >= a[i]; --j)          // 倒序:每个数只能用一次
        dp[j] += dp[j - a[i]];               // 加法原理:不合这个数 + 合这个数

cout << dp[m] << "\n";
新函数拆解 · 二进制拆分:把“最多 c 件”拆成 log c 个 0/1 物品
for (int k = 1; k <= rest; k <<= 1) { 用 k 件做一次 0/1; rest -= k; }
组成含义 / 格式要求
拆什么第 i 种物品最多取 c 件。不拆就是 c 次 0/1,拆完只要 ⌊log₂c⌋+1 次。
怎么拆取 1, 2, 4, 8, … 件各打包成一件“新物品”,体积 k·w、价值 k·v;剩下的零头单独成一组。
为什么能表示所有数量1, 2, 4, …, 2ᵏ⁻¹ 能拼出 0..2ᵏ−1 中任意整数(二进制);再加零头 r,就能拼出 0..c 全部整数,一个不漏。
每组的更新打包后的物体积是 k·w,所以内层写 j 从 V 倒到 k·w,跟普通 0/1 完全一样。
复杂度O(V · Σ⌊log₂c_i⌋)。c = 10⁵ 时从 10⁵ 组降到 17 组。
最小可运行示例
示例
// 第 i 种物品:体积 w、价值 v、最多 c 件
int rest = c;
for (int k = 1; k <= rest; k <<= 1) {
    // 把 k 件打包成一件新物品,然后当 0/1 处理
    for (int j = V; j >= k * w; --j)
        dp[j] = max(dp[j], dp[j - k * w] + k * v);
    rest -= k;                       // 这 k 件已经被打包掉了
}
if (rest > 0) {                      // 零头单独成一组
    for (int j = V; j >= rest * w; --j)
        dp[j] = max(dp[j], dp[j - rest * w] + rest * v);
}
注意

注意 rest 是独立变量。直接改 c 会污染循环条件,for 的边界每轮都在变,容易写错。

二进制拆分只对“同种物品完全相同”成立。如果同种物品各有各的体积价值,只能一件一件转 0/1。

类型每件取几次内层方向初始化输出
0/1 背包0 或 1 次倒序 j = V → w全 0(不要求装满)dp[V]
完全背包无限次正序 j = w → V全 0dp[V]
多重背包最多 c 次拆成 log c 组,每组倒序全 0dp[V]
恰好装满同原题同原题dp[0]=0,其余 −INFdp[V] < 0 则无解
方案计数0 或 1 次倒序dp[0]=1,其余 0dp[m]
背包的 7 种典型死法(含症状)

WA(答案偏大):0/1 背包写成正序。拿 P1048 的样例跑一遍:正确答案 3,正序输出 140(第 3 株草药 t=1、v=2 被重复采了 70 次)。

WA(该无解却说有解):恰好装满忘了写 −INF,整表 0 起步。容量 4、物品体积 3 价值 5,3 凑不出 4,正确答案是“无解”,程序却输出 5——它把“装了 3、空 1”当成了合法方案。

WA(全 0):方案计数忘了 dp[0] = 1。所有转移都是 0 + 0,输出恒为 0。

WA / RE(溢出):NEG 用 INT_MIN,加法溢出成很大的正数,“不可达”被当成“最优”,症状是输出一个几亿的奇怪数字。

RE(越界):内层写成 for (int j = V; j >= 0; --j) 却仍访问 dp[j−w],j < w 时下标为负。循环起点必须是 w。

MLE:V 到 10⁷ 时一个 dp 数组就要 4×10⁷ 字节(约 40 MB),再顺手开个二维就爆内存了。开数组前先算 V × 4 字节。

WA(多测):多组数据的题忘了每组重新初始化 dp,第二组直接继承第一组的答案,越跑越离谱。

自己动手复现

关掉页面,从空文件把 P1048 敲一遍并提交。

改一改 ①:把内层方向改成 for (int j = t[i]; j <= T; ++j),用样例 70 3 / 71 100 / 69 1 / 1 2 再跑一次,输出从 3 变成 140。这 140 是 70 × 2,说明第 3 株草药被采了 70 次——把方向和作用对上号。

改一改 ②:把它改成“恰好用完 T 个时间”。初始化改为 dp[0]=0、其余 −INF,样例仍输出 3(69 + 1 正好 70);再把 T 改成 50,此时应输出无解——71 和 69 都超过 50,只剩 1,凑不出 50。

改一改 ③:把第 3 株草药的数目限制成最多 5 株,用二进制拆分重写。检查拆出来的组数:5 = 1 + 2 + 2,三组,而不是五组。

数学补给 · 两条证明:一维化为什么等价,二进制拆分为什么成立

一、倒序 = 上一层,正序 = 本层。记二维状态 F(i, j) 表示“只考虑前 i 件物品、容量为 j”的最优值,它的递推是

F(i, j) = max( F(i−1, j), F(i−1, j−w) + v )

一维数组 d[j] 想模拟 F(i, ·)。更新 d[j] 的那一刻,d[j−w] 里到底是什么?

若 j 从大到小:j−w < j,还没被这一轮写过,所以 d[j−w] = F(i−1, j−w),代入得 d[j] = max(F(i−1, j), F(i−1, j−w) + v) = F(i, j),与二维完全等价。

若 j 从小到大:d[j−w] 这一轮已经写过了,它等于 F(i, j−w),代入得 d[j] = max( F(i−1, j), F(i, j−w) + v )。这不是错式,它正好是完全背包的递推——第 i 件物品可以取多次。所以“正序”不是 bug,是另一个问题的正确答案。

二、二进制拆分。设第 i 种物品有 c 件,取整数 k 使 2ᵏ − 1 ≤ c < 2ᵏ⁺¹ − 1,令零头 r = c − (2ᵏ − 1),则由下界得 r ≥ 0、由上界得 r < 2ᵏ。拆出的组是 1, 2, 4, …, 2ᵏ⁻¹, r,共 k+1 组,总和恰好是 c。

要证 0..c 中每个整数都能写成这些组的某个子集和。① 若 x < 2ᵏ:用 1, 2, …, 2ᵏ⁻¹ 表示(这正是 x 的二进制分解),不需要 r。② 若 x ≥ 2ᵏ:取 r,剩下 x − r。由 x ≤ c 得 x − r ≤ c − r = 2ᵏ − 1;又 x ≥ 2ᵏ > r 得 x − r > 0,所以 x − r 落在 [0, 2ᵏ−1],仍可由前 k 组表示。两类合起来覆盖全部 x,证毕。

组数是 k+1 = O(log c):c = 10⁵ 时是 17 组,c = 10⁶ 时是 20 组。这就是二进制拆分把 O(V·Σc) 压成 O(V·Σlog c) 的全部来源。

举一反三 · 想清楚这些再往下走
  1. P1048 里如果每株草药能采无限次(即 P1616 疯狂的采药),代码只需要改哪一处?为什么改完就自动对了,不需要追加任何“用了几次”的信息?
  2. 方案计数题如果把倒序改成正序,求到的是什么?(提示:允许重复点同一道菜时,1+2 和 2+1 会被算成一种还是两种?)
  3. “恰好装满”用 −INF、“不超过”用 0,为什么初值就能定义语义?把 dp[j] 的值一路追溯下去,终点是哪个格子?
  4. 多重背包的 c_i 很小时(比如都 ≤ 3),二进制拆分和“一件一件拆成 c 件”,哪个更快?分界点大概在哪?
  5. 如果还要求输出选了哪些物品,一维 dp 还能不能反向追溯?二维 dp 要怎么走?
  6. 两件完全相同的物品,在“方案计数”里算一种还是两种方案?这由题面哪句话决定,代码要不要改?
自测:能全部回答再进下一节
挑战题 · 小A点菜
洛谷 P1164普及-去洛谷做这题 ↗

饭店里有 n 种菜,第 i 种菜卖 aᵢ 元,每种菜只有一份。小 A 身上有 m 元钱,他想把身上的钱恰好花完。

问一共有多少种点菜的方案。两种方案不同,当且仅当点的菜的集合不同。

输入:第一行两个整数 n、m(1 ≤ n ≤ 100,1 ≤ m ≤ 10⁴);第二行 n 个整数 a₁…aₙ(1 ≤ aᵢ ≤ 1000)。输出:一行一个整数,表示方案数。

引导性提示(先只看这里)

不要枚举“点哪几道菜”,那是指数级。把“钱数”当成背包容量,一道菜就是一件体积为 aᵢ 的物品。

初值先想一个问题:一道菜都不点,恰好花掉 0 元,这算不算一种方案?算的话 dp[0] 该写几?

这是“恰好花完”的计数题,不是“花不超过 m”的计数题。答案只取 dp[m] 一个格子。

想看思路了?点开完整思路

状态:dp[j] = 用当前考虑过的这些菜,恰好花掉 j 元的方案数。

转移:对第 i 道菜,只有“点”和“不点”两种。不点的方案数已经记在 dp[j] 里;点的方案数等于“花掉 j − aᵢ 元”的方案数,所以 dp[j] += dp[j − aᵢ]。

边界:dp[0] = 1——什么都不点、恰好花 0 元,这是唯一的 1 种方案;其余 dp[j] 初值 0。

方向:内层 j 从 m 倒序到 aᵢ。倒序保证 dp[j − aᵢ] 还没被第 i 道菜更新过,每道菜最多点一次。写成正序就变成“每道菜可以点任意多份”,答案会明显偏大。

答案:dp[m]。注意不是 sum(dp[0..m])——那求的是“花不超过 m 元”的总方案数。

复杂度:n × m = 100 × 10⁴ = 10⁶ 次加法,很快。空间 O(m)。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXM = 10005;        // m ≤ 10⁴,开 10005
int a[105];
long long dp[MAXM];            // dp[j]:恰好花掉 j 元的方案数

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i) cin >> a[i];

    dp[0] = 1;                            // 花 0 元的唯一方案:什么都不点

    for (int i = 1; i <= n; ++i)          // 逐道菜考虑
        for (int j = m; j >= a[i]; --j)   // 倒序:保证每道菜最多点一次
            dp[j] += dp[j - a[i]];        // 不点这道菜 + 点这道菜

    cout << dp[m] << "\n";                // 恰好花完 m 元的方案数
    return 0;
}
关键片段拆解
片段作用
long long dp[MAXM];计数题的值增长很快,用 long long 一步到位。最大值 2³¹ 附近时 int 会溢出成负数。
dp[0] = 1;计数题的灵魂。写 0 的话所有加法都是 0,输出恒为 0——这是最常见的死法。
for (int i = 1; i <= n; ++i)外层是菜。每跑完一轮,dp 的含义从“前 i−1 道菜”变成“前 i 道菜”。
for (int j = m; j >= a[i]; --j)倒序,0/1 的标记。正序会让 dp[j−a[i]] 含“已点过第 i 道菜”的情况,同一道菜被点多次,方案数虚高。
dp[j] += dp[j - a[i]];加法原理:凑出 j 元的方案 = 不含这道菜的方案 + 含这道菜的方案。含这道菜的每一种方案,去掉它之后都是“凑出 j−a[i]”的方案,一一对应。
cout << dp[m];只输出恰好花完 m 元。写成 ∑dp[0..m] 就变成“花不超过 m 元”,答案偏大。
这题最容易错的地方
  • dp[0] 写成 0:整张表恒为 0,样例输出 0。
  • 内层写成正序 j = a[i] → m:变成完全背包,同一道菜可以重复点,答案比正确答案大。
  • 输出 sum(dp[0..m]) 或 dp[m] 之外的格子:题目要求恰好花完 m 元。
  • 数组规模按 m 开,不是按 n 开:n ≤ 100 而 m 可以到 10⁴,dp 只开 105 就必 RE。
  • 用 int 存 dp:方案数很大时会溢出成负数,输出一个负数。
延伸阅读 / 参考