背包是 DP 里最像“套路”的一类:有 n 件物品,每件有体积 w 和价值 v,背包容量 V,在总体积不超过 V 的前提下让总价值最大。P1048 采药就是它:T = 1000 的时间当容量,M = 100 株草药当物品。
0/1 背包和完全背包的代码只差一个不等号的方向,但差的方向后面是两件完全不同的事:dp[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] 是负数下标,会越界;而这一步在二维写法里对应的正是“这件物品装不下,只能不选”。
一维数组装不下历史层,所以它到底代表哪一层,完全由你的循环方向决定。这就是下一张卡片的全部内容。
| 组成 | 含义 / 格式要求 |
|---|---|
倒序 | 更新 dp[j] 时,dp[j-w] 还没被本轮碰过,读到的仍是上一层的值——每件物品最多用一次。 |
正序 | 更新 dp[j] 时,dp[j-w] 已被本轮改写成本层的值——同一件物品可以再接一次,等效于无限件。 |
统一记忆 | 0/1 是倒的,完全是正的。题面差一个“能不能重复取”,代码差一个符号。 |
起点是 w | j 从 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 计数倒序,允许重复计数正序。
| 组成 | 含义 / 格式要求 |
|---|---|
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 会漏判。
辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。他的师父给他出了一道难题:山洞里有 M 株不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。给定总时间 T,求在规定时间内能采到的草药的最大总价值。
输入:第一行两个整数 T(1 ≤ T ≤ 1000)和 M(1 ≤ M ≤ 100),用空格隔开,T 表示总共能够用来采药的时间,M 表示山洞里的草药数目;接下来 M 行,每行两个整数,依次表示采第 i 株草药所需的时间和这株草药的价值(均不超过 100)。
输出:一行一个整数,表示在规定时间内能采到的草药的最大总价值。
每株草药只有两种命运:采,或者不采。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](每件最多一次) |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 0 | 0 |
| 2 | 3(dp[0] + 3) | 3(dp[0] + 3) |
| 3 | 3(dp[1] + 3) | 3(dp[1] + 3) |
| 4 | 6(dp[2] + 3,dp[2] 已含一件) | 3(dp[2] 还是 0) |
| 5 | 6 | 3 |
| 6 | 9(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";把 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";| 组成 | 含义 / 格式要求 |
|---|---|
拆什么 | 第 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 | 全 0 | dp[V] |
| 多重背包 | 最多 c 次 | 拆成 log c 组,每组倒序 | 全 0 | dp[V] |
| 恰好装满 | 同原题 | 同原题 | dp[0]=0,其余 −INF | dp[V] < 0 则无解 |
| 方案计数 | 0 或 1 次 | 倒序 | dp[0]=1,其余 0 | dp[m] |
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) 的全部来源。
饭店里有 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 元”,答案偏大。 |