背包是 DP 里最经典的一类模型 —— 今天只要把「为什么容量必须倒序枚举」这一件事想透,后面所有背包题都好办
| 0 - 25 min | 0/1 背包是什么,为什么贪心不行(第 1 节) |
| 25 - 55 min | 从二维写法推到一维写法(第 2 节) |
| 55 - 100 min | ★ 核心:逐格手推「正序 vs 倒序」,把原因想透(第 3 节) |
| 100 - 120 min | 背包的四个常见变体(第 4 节) |
| 120 - 230 min | 做 4 道题,每题限时 25 分钟 |
| 230 - 260 min | 对照本文题解 + 做自测题 |
昨天的线性 DP 里,数字三角形是「一层一层往下推」,过河卒是「一格一格往右推」——它们的共同点是:每个阶段做一个决策,然后进入下一个阶段。
0/1 背包就是同一个套路,只不过:
· 「阶段」变成了物品(一件一件考虑);
· 「位置」变成了容量(还剩多少空间);
· 每个阶段的决策变成了最经典的二选一 —— 放这件物品,还是不放。
所以昨天那套四步法(状态 → 转移 → 边界 → 答案)今天照样能用,一个字都不用改。
你有一个容量为 W 的背包,面前有 n 件物品,第 i 件重 w[i]、价值 v[i]。
每件物品你只有两种选择:要么整个装进去,要么完全不要。问在不超过容量的前提下,能带走的最大总价值是多少。
每件物品的状态只有两种:0 = 不拿,1 = 拿。
不能拿半个(那是"部分背包"),也不能拿两次(那是"完全背包"),更不能拿三份。
还记得 Day 14 的 P2240 部分背包吗?那题里的金币可以随意切分,所以做法是按性价比(价值 ÷ 重量)从高到低贪心拿,拿不下就把最后一样切开装一部分。
那 0/1 背包能不能照抄这个贪心?不能。看一个例子:
| 物品 | 重量 | 价值 | 性价比 |
|---|---|---|---|
| A | 6 | 36 | 6.0(最高) |
| B | 5 | 25 | 5.0 |
| C | 5 | 25 | 5.0 |
背包容量 W = 10。
贪心的思路:性价比最高的是 A(6.0),先拿 A。拿了 A 之后剩 4 的容量,B 和 C 都要 5,一个都放不进去。总价值 36。
真正的最优解:不拿 A,拿 B + C,重量 5 + 5 = 10 正好装满,总价值 25 + 25 = 50。
贪心拿了"最划算的那件",结果损失了 14 点价值 —— 因为物品不能切分,「性价比高」不代表「该拿」,拿了它可能把容量浪费掉。
上面这组数据的程序输出:
贪心(按性价比) = 36 (拿了 6 时间那株,剩下 4 装不下别的) 0/1 背包 = 50 (放弃 6,改拿 5+5,正好装满 10)
把同样这组数据喂给本文 P1048 的正式解法(输入 10 3 / 6 36 / 5 25 / 5 25),输出也是 50。
能切分 → 贪心(按性价比);不能切分 → DP(背包)。
以后看到"可以分割/可以只拿一部分",先想贪心;看到"每种只有一份、要么拿要么不拿",那就是背包。
| 步骤 | 填什么 |
|---|---|
| ① 状态定义 | dp[i][j] = 只考虑前 i 件物品、背包容量为 j 时能获得的最大价值 |
| ② 转移方程 | 第 i 件物品只有两种命运: · 不拿:价值 = dp[i-1][j](容量没变,问题退化成"前 i-1 件、容量 j")· 拿:得先给它腾出 w[i] 的空间,价值 = dp[i-1][j-w[i]] + v[i]取两者较大: dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) |
| ③ 边界 | dp[0][j] = 0(一件都不考虑,价值当然是 0) |
| ④ 答案 | dp[n][W](考虑完 n 件、容量用满 W 的最优值) |
dp[i-1] 是整件事的关键
注意"拿"的那一支写的是 dp[i-1][j-w[i]],是 i-1,不是 i。
意思是:在"还没考虑过第 i 件物品"的世界里,用剩下的 j-w[i] 容量去装前面的物品。
正因为用了 i-1,才保证了第 i 件物品最多只被拿一次 —— 这就是"0/1"的体现。
如果写成 dp[i][j-w[i]],就变成"第 i 件可以拿了再拿",那是完全背包。
看转移方程:dp[i][j] 只用到第 i-1 行的数据,跟第 i-2 行、第 i-3 行毫无关系。
既然旧的行用完就废了,那就不用开 n 行 —— 只留一行,边算边把旧的覆盖掉,这就是滚动数组(一维写法):
// 二维写法 dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i]); // 去掉 i 这一维,变成一维(★ 内层倒序) for(int j = W; j >= w[i]; j--) dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
| 一维写法里的东西 | 对应二维写法里的谁 |
|---|---|
dp[j](等号左边,被写入) | dp[i][j](正在算的这一行) |
dp[j](等号右边第一个) | dp[i-1][j](旧值,代表"不拿") |
dp[j - w[i]] | dp[i-1][j - w[i]](也必须是旧值!) |
第一个 dp[j] 我们想要旧值(还没被本轮改过的),第二个 dp[j-w[i]] 我们也想要旧值。
但一维数组只有一个,怎么保证读到的都是旧的、而不是刚刚被自己改过的?
答案就是下一节:靠枚举顺序。这也是今天唯一一个必须彻底想明白的地方。
别背结论,我们拿一组最小的数据逐格手推一遍,看正序到底错在哪里。
背包容量 W = 6,只有 1 件物品:重量 w = 3,价值 v = 5。
先自己想一想:只有一件物品、它又不能切分,那正确答案显然是 5(拿它或者不拿它,拿到就是 5)。
数组 dp[0..6] 本来全是 0。
| j | 执行 dp[j] = max(dp[j], dp[j-3] + 5) | 这一轮之后的 dp[0..6] |
|---|---|---|
| 3 | max(dp[3]=0, dp[0]+5 = 0+5) = 5 | 0 0 0 5 0 0 0 |
| 4 | max(dp[4]=0, dp[1]+5 = 0+5) = 5 | 0 0 0 5 5 0 0 |
| 5 | max(dp[5]=0, dp[2]+5 = 0+5) = 5 | 0 0 0 5 5 5 0 |
| 6 | max(dp[6]=0, dp[3]+5 = 5+5) = 10 | 0 0 0 5 5 5 10 |
看最后一行:算 j = 6 时,读到的 dp[3] 已经不是 0 了,而是同一个物品刚刚在 j = 3 那一轮写进去的 5。
于是 dp[6] = 5 + 5 = 10 —— 意思是"这件物品被用了两次"(3 + 3 = 6 的容量,5 + 5 = 10 的价值)。
可我们只有 1 件物品,怎么可能拿两次?
正序枚举 = 读到了"本行"刚更新过的新值 = 允许同一件物品重复使用 = 完全背包的语义。
| j | 执行 dp[j] = max(dp[j], dp[j-3] + 5) | 这一轮之后的 dp[0..6] |
|---|---|---|
| 6 | max(dp[6]=0, dp[3]+5 = 0+5) = 5 ← dp[3] 还是旧值 0 | 0 0 0 0 0 0 5 |
| 5 | max(dp[5]=0, dp[2]+5 = 0+5) = 5 | 0 0 0 0 0 5 5 |
| 4 | max(dp[4]=0, dp[1]+5 = 0+5) = 5 | 0 0 0 0 5 5 5 |
| 3 | max(dp[3]=0, dp[0]+5 = 0+5) = 5 | 0 0 0 5 5 5 5 |
这次算 j = 6 时,dp[3] 还没被这一轮碰过(因为在倒序里,3 排在 6 后面才轮到),所以读到的是干净的旧值 0,得到 dp[6] = 5。正确!
倒序枚举时,容量 j 从大到小走,而 dp[j - w[i]] 的下标一定比 j 小 —— 也就是说,它"还没有被本轮更新过",读到的必然是"没考虑第 i 件物品"时的旧值,正好对应二维写法里的 dp[i-1][j-w[i]]。
反过来说:正序枚举会让 dp[j-w[i]] 读到本轮刚写进去的新值,等于让第 i 件物品被反复使用,那就变成完全背包了。
W = 6、一件物品 (w=3, v=5),两种枚举顺序的实际输出:
容量 j : 0 1 2 3 4 5 6 正序 : 0 0 0 5 5 5 10 <- 错!dp[6]=10 说明这件物品被用了 2 次 倒序 : 0 0 0 5 5 5 5 <- 对,dp[6]=5 这件物品只用了 1 次
和手推的结果完全吻合。
「倒序 0/1,正序完全」
· 0/1 背包(每件最多一次):内层容量倒序 for(j = W; j >= w[i]; j--)
· 完全背包(每件无限次):内层容量正序 for(j = w[i]; j <= W; j++)(明天 Day 27 学)
唯一的区别就是那个 >= 和 <=,写反了题目就从 0/1 变成完全背包,答案必错。
// ✅ 正确:外层物品,内层容量(倒序)
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]);
为什么必须「外层物品、内层容量」?因为外层循环一次代表「引入了一件新物品」,
内层倒序代表「在这件物品只能选一次的前提下,更新所有容量的答案」。两层各管一件事,配合起来才等于二维的 dp[i][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]);
今天下面四道题,有三道都是这三行,只是 w 和 v 的含义换了换。
模板一样,但题目问的东西不一样,初始化和答案的输出位置就不一样。下面这张表是今天的第二重点。
| 变体 | 含义 | 初始化 | 今天的题 |
|---|---|---|---|
| ① 不超过容量 求最大价值 |
容量可以不装满,只要不超就行 | dp 全部为 0int dp[W+5] = {0}; |
P1048 采药 P1060 开心的金明 |
| ② 恰好装满 求最大价值 |
容量必须刚好用光,用不光就算无解 | dp[0] = 0,其余 -INF |
今天没有(但要会) |
| ③ 价值 = 重量 |
不求价值,求"能装进去多少体积" | dp 全部为 0,转移里 v[i] 换成 w[i] | P1049 装箱问题 |
| ④ 求方案数 (恰好装满) |
不数价值,数"有几种选法" | dp[0] = 1,其余 0 |
P1164 小 A 点菜 |
dp[j] = 0 的含义是:「容量为 j 时,我什么也不装,价值就是 0」。这是一个完全合法的方案,所以可以放心地从 0 出发取 max。
题目只要求"不超过",那么多出来的容量闲着不用是允许的,所以任何容量都至少有个"什么都不装"的答案 0。
如果题目要求容量必须刚好用光,那"什么都不装"在容量 j > 0 时就是非法状态,不能拿它去参与 max(否则它会把非法答案 0 带给后面的状态)。
工程上的做法:把非法状态设成一个极小的数(-1e9 之类),这样它在 max 里永远竞争不过任何合法方案。
// 恰好装满的初始化 const int NEG = -1000000000; // 用 -INF 表示"这个容量凑不出来" dp[0] = 0; // 容量 0 恰好装满 = 什么都不装,合法 for(int j = 1; j <= W; j++) dp[j] = NEG; // 转移完全一样 for(int i = 1; i <= n; i++) for(int j = W; j >= w[i]; j--) if(dp[j - w[i]] != NEG) // 前面得凑得出来才行 dp[j] = max(dp[j], dp[j - w[i]] + v[i]); // 答案:dp[W] 如果还是 NEG,说明无解 if(dp[W] == NEG) cout << "无解" << endl; else cout << dp[W] << endl;
P1048、P1049、P1060 都是"不超过",P1164 是"恰好",但它是计数不是求最大值,用的是加法和 dp[0] = 1(见变体 ④)。
不过 -INF 这个套路必须会,它是背包题里非常高频的一个变体,以后一定会遇到。
P1049 问的是「箱子剩余空间最小」,等价于问「最多能装进去多少体积」。
于是把物品的体积同时当成价值:重量 = v[i],价值 = v[i]。跑完背包后 dp[V] 就是「能装进去的最大体积」,答案就是 V - dp[V]。
dp[j] = max(dp[j], dp[j - v[i]] + v[i]); // 注意:加的是 v[i],不是别的 cout << V - dp[V] << endl; // 剩余空间 = 总容量 - 装进去的
把「max 求最大价值」换成「+= 数方案数」,转移方程的形状完全不变:
| 求最大价值 | 求方案数 | |
|---|---|---|
| 转移 | dp[j] = max(dp[j], dp[j-w] + v) | dp[j] += dp[j-w] |
| 初始化 | dp 全 0 | dp[0] = 1,其余 0 |
| 枚举 | 容量倒序 | 容量倒序(还是 0/1!) |
dp[0] = 1
问自己:「花 0 元有多少种点法?」答案是 1 种 —— 什么都不点。
如果 dp[0] = 0,那所有方案数都会是 0(因为每个方案最后都要追溯到"花 0 元"这个起点)。
计数类的 DP,起点的方案数永远是 1,不是 0。这是最容易忘的一行。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过(0 warning),
并且跑过官方样例和额外的边界数据,输出是你看到的真实结果。
题意:有 T 的时间可以采药,山洞里有 M 株草药,采第 i 株要花 wi 的时间、得到 vi 的价值。每株最多采一次(采完就没了),问在 T 时间内能获得的最大总价值。
1 ≤ T ≤ 1000,1 ≤ M ≤ 100,每株药的采摘时间与价值都在 1 ~ 100 之间。
时间 T 最大 1000,所以 dp 数组只需要开到 1005 左右。总复杂度 O(M × T) = 100 × 1000 = 10 万次,飞快。
注意输入的第一个数是时间 T、第二个是草药数 M,别读反了。
这是 0/1 背包的教科书模板题,把题目里的词一一对应过去就行:
| 背包里的叫法 | 本题里的叫法 |
|---|---|
| 背包容量 W | 总时间 T |
| 物品个数 n | 草药株数 M |
| 物品重量 w[i] | 采摘第 i 株药需要的时间 |
| 物品价值 v[i] | 第 i 株药的价值 |
| "每件最多选一次" | "每株药只能采一次" |
四步法:① dp[j] = 用时不超过 j 能采到的最大价值;② dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
③ dp 全 0(不超过时间,闲着也行);④ 答案 dp[T]。内层容量倒序。
70 3 71 100 69 1 1 2
3
第一株要 71 的时间,超过了 70,采不了;剩下两株 69 + 1 = 70 正好用完时间,价值 1 + 2 = 3。
#include <iostream> #include <algorithm> using namespace std; int w[105], v[105]; // w[i] 采药时间,v[i] 价值 int dp[1005]; // dp[j]:容量(时间)为 j 时的最大价值 int main(){ int T, M; cin >> T >> M; for(int i = 1; i <= M; i++) cin >> w[i] >> v[i]; for(int i = 1; i <= M; i++) for(int j = T; j >= w[i]; j--) // ★ 容量必须倒序枚举 dp[j] = max(dp[j], dp[j - w[i]] + v[i]); cout << dp[T] << endl; return 0; }
官方样例 70 3 / 71 100 / 69 1 / 1 2 → 输出 3 完全一致
最大数据 T = 1000、M = 100、每株都是 (100, 5) → 输出 50 正确
每株占 100 的时间,1000 的时间最多采 10 株,10 × 5 = 50。实测耗时 26 ms。
贪心对照数据 10 3 / 6 36 / 5 25 / 5 25 → 输出 50(贪心会得 36,见本文第 1 节)
编译:g++ -std=c++14 -O2 -Wall → 0 warning
for(j = w[i]; j <= T; j++):那就变成完全背包(每株能采无限次),样例就过不去。题意:箱子容量为 V,有 n 个物品,每个物品有一个体积。从中任取若干个装进箱子(也可以一个都不取),使箱子剩余空间最小,输出这个最小值。
0 < n ≤ 30,1 ≤ V ≤ 20000。
n 只有 30,但不能用「枚举 2³⁰ 种取法」硬搜(10 亿级别,超时);而 V 最大 20000,
所以 dp 数组按容量 V 开到 20005,跑 O(n × V) = 30 × 20000 = 60 万次,非常轻松。
敲数组大小时,看清值域是 V 还是 n —— 这题是 V。
题目要「剩余空间最小」,也就是「装进去的总体积尽量大」。所以我们干脆把物品的体积当成价值:
重量 = v[i],价值 = v[i] → 跑一遍 0/1 背包 → 答案 = V − dp[V]
这样 dp[V] 就是"容量不超过 V 时能装进去的最大体积",再用总容量一减就是剩余空间。
换个角度理解:这题本质是子集和问题——问"哪些体积 s 能被某些物品凑出来",然后找 ≤ V 的最大 s。背包的 dp 数组其实就顺手把这件事做了。
24 6 8 3 12 7 9 7
0
六个物品体积 8、3、12、7、9、7。选 8 + 9 + 7 = 24,正好把箱子装满,所以剩余 0。
#include <iostream> #include <algorithm> using namespace std; int v[35]; int dp[20005]; // dp[j]:容量为 j 时能装进去的最大体积 int main(){ int V, n; cin >> V >> n; for(int i = 1; i <= n; i++) cin >> v[i]; for(int i = 1; i <= n; i++) for(int j = V; j >= v[i]; j--) // ★ 倒序 dp[j] = max(dp[j], dp[j - v[i]] + v[i]); // 价值 = 体积 cout << V - dp[V] << endl; // 剩余空间 = 容量 - 装进去的 return 0; }
官方样例 → 输出 0 完全一致
边界 V = 20000,n = 30,每件体积都是 1999 → 输出 10 正确
1999 × 10 = 19990 是能塞进去的最大体积,20000 − 19990 = 10。实测耗时 29 ms。
编译:g++ -std=c++14 -O2 -Wall → 0 warning
dp[V]:题目问的是剩余空间,答案是 V - dp[V],别把最大装入体积直接交上去。int dp[35] 会越界。这题要开 dp[20005]。2^n 枚举硬搜:n = 30 时是 10 亿种取法,必超时,要老老实实用 DP。j >= v[i] 直接不成立,自动跳过,不用特判。题意:妈妈给 N 元钱买 m 件物品,第 j 件价格 vj、重要度 pj。在不超支的前提下,让 「价格 × 重要度」的总和最大,输出这个最大值。
n < 3×10⁴,m < 25;每件价格 v ≤ 10⁴,重要度 1 ≤ p ≤ 5;答案保证 < 10⁸。
钱数 n 最大到 3 万,所以 dp 数组要开到 30005 —— 这是本题最容易踩的坑,很多人看 m 只有 25 就顺手开 105,直接越界。
答案上界 10⁸,在 int 范围内(2.1×10⁹),不用 long long。
标准 0/1 背包,一一对应:
| 背包术语 | 本题术语 |
|---|---|
| 容量 W | 总钱数 n |
| 物品数 | m |
| 重量 w[i] | 物品价格 vj |
| 价值 v[i] | 价格 × 重要度(vj × pj) |
也就是说「价值」不是直接给的,而是算出来的:v * p。这是很多背包题的常见花样——
先想清楚「谁是重量、谁是价值」,题目就变成模板了。
1000 5 800 2 400 5 300 5 400 3 200 2
3900
1000 元的最优买法:400(重要度 5)+ 300(重要度 5)+ 200(重要度 2),共 900 元, 价值 = 400×5 + 300×5 + 200×2 = 2000 + 1500 + 400 = 3900。
#include <iostream> #include <algorithm> using namespace std; int dp[30005]; // dp[j]:花 j 元钱能得到的最大「价格 x 重要度」总和 int main(){ int n, m; cin >> n >> m; for(int i = 1; i <= m; i++){ int v, p; cin >> v >> p; for(int j = n; j >= v; j--) // ★ 倒序,物品只能买一次 dp[j] = max(dp[j], dp[j - v] + v * p); // 价值 = 价格 x 重要度 } cout << dp[n] << endl; return 0; }
官方样例 1000 5 / 800 2 / 400 5 / 300 5 / 400 3 / 200 2 → 输出 3900 完全一致
边界 100 2 / 60 5 / 40 5 → 输出 500 正确
60×5 + 40×5 = 300 + 200 = 500,两件刚好花完 100 元。
编译:g++ -std=c++14 -O2 -Wall → 0 warning
dp[30]):本题值域是钱数 n(最大 3 万),必须开 30005 以上。n m(钱数、物品数),之后每行是 v p(价格、重要度)。题意:uim 身上有 M 元钱,餐馆有 N 种菜,第 i 种卖 ai 元,每种菜只有一份。 小 A 必须刚好把 M 元花完,问一共有多少种点菜方法。
0 < M ≤ 10000,1 ≤ N ≤ 100,0 < ai ≤ 1000(价格可能重复)。
答案保证在 [0, 2³¹−1] 之内 —— 也就是说题目承诺用 int 存得下,这是题目给的保证。
dp 数组按钱数 M 开到 10005。
注意「每种菜只有一份」,所以是 0/1 背包;而价格重复的菜算作不同的菜(方案数按"第几种菜"区分,不是按价格区分)。
把昨天的「转移方程」从"求最值"换成"数方案":
| 0/1 背包求最大值 | 本题:求方案数 | |
|---|---|---|
| 状态 | dp[j] = 容量 j 的最大价值 | dp[j] = 刚好花掉 j 元的点法数 |
| 转移 | dp[j] = max(dp[j], dp[j-a]+v) | dp[j] += dp[j-a[i]] |
| 初始化 | 全 0 | dp[0] = 1 |
| 答案 | dp[M] | dp[M] |
为什么转移是加法?因为「刚好花 j 元的方案」= 「不点第 i 种菜的方案」+「点了第 i 种菜的方案」, 两类方案互不重叠、合起来就是全部,所以直接相加,不需要取 max。
为什么 dp[0] = 1?因为"花 0 元"只有一种做法:什么都不点。这是所有方案的起点,设成 0 的话答案会全变 0。
内层仍然要倒序——每种菜只有一份,不能重复点。
4 4 1 1 2 2
3
菜品价格是 1、1、2、2(两种 1 元菜、两种 2 元菜)。刚好花 4 元的三种点法:
· 两份 2 元菜(第 3 + 第 4 种) · 两份 1 元菜 + 第 3 种(1+1+2) · 两份 1 元菜 + 第 4 种(1+1+2)
最后两种看起来一样,但点的是不同的菜,所以算两种方案。
#include <iostream> using namespace std; int a[105]; int dp[10005]; // dp[j]:刚好花掉 j 元的点菜方案数 int main(){ int N, M; cin >> N >> M; for(int i = 1; i <= N; i++) cin >> a[i]; dp[0] = 1; // ★ 花 0 元 = 什么都不点,算 1 种方案 for(int i = 1; i <= N; i++) for(int j = M; j >= a[i]; j--) // ★ 倒序,每道菜只点一次 dp[j] += dp[j - a[i]]; // 计数用加法 cout << dp[M] << endl; return 0; }
官方样例 4 4 / 1 1 2 2 → 输出 3 完全一致
组合数校验 N = 30、M = 100、30 道菜全是 10 元 → 输出 30045015 正确
这正好是从 30 道菜里挑 10 道的组合数 C(30,10) = 30045015,程序输出与之完全吻合。
对拍验证:随机生成 3000 组数据(N ≤ 12、M ≤ 24、价格 1~8),与「暴力枚举所有 2ⁿ 种子集、数出和为 M 的个数」的结果全部一致。
编译:g++ -std=c++14 -O2 -Wall → 0 warning
dp[0] = 1:这是本题第一大坑。设成 0 的话所有答案都是 0,样例就会输出 0。max:本题是数方案,要用 +=。看到"有多少种"就该条件反射地想加法。| 错误写法 | 后果 | 正确写法 |
|---|---|---|
for(j = w[i]; j <= W; j++)(内层正序) |
0/1 背包退化成完全背包,同一件物品被反复使用,答案偏大 | for(j = W; j >= w[i]; j--)倒序 0/1,正序完全 |
| 循环嵌套写反:外层容量、内层物品 | 物品之间互相"串味",得到没有意义的结果 | 外层物品 i,内层容量 j |
| 数组按物品数 n 开 | 值域其实是容量 / 钱数,直接越界 RE 或玄学 WA | 按容量值域 + 5 开:P1048→1005、P1049→20005、P1060→30005、P1164→10005 |
P1049 输出 dp[V] |
交的是"最多装多少",题目问的是"剩多少" | cout << V - dp[V] |
P1164 忘了 dp[0] = 1 |
所有方案数都是 0 | 计数背包起点方案数为 1 |
P1164 转移用 max |
求成了"最多能点几道菜"之类的怪东西 | 数方案用 dp[j] += dp[j-a[i]] |
| 把 0/1 背包当部分背包做贪心 | 物品不能切分时贪心是错的(实测 36 vs 最优 50) | 不能切分 → DP;能切分 → 才用贪心 |
| "恰好装满"用全 0 初始化 | 非法状态(容量凑不出来)被当成合法答案 0 参与 max | dp[0] = 0,其余 -INF |
| P1048 把 T 和 M 读反 | 数组大小、循环边界全错 | 看清题面:先时间 T,再草药数 M |
| P1060 价值直接填 p | 求的不是"价格 × 重要度"的总和 | 价值 = v * p |
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]);
三个细节:外层物品、内层容量、容量倒序。少一个都不行。
答:一维写法里,dp[j-w[i]] 必须代表"还没考虑第 i 件物品"时的旧值(也就是二维写法里的 dp[i-1][j-w[i]]),这样第 i 件物品才只会被用一次。
倒序枚举时,容量 j 从大到小走,而 j - w[i] 一定比 j 小,还没被本轮更新过,读到的正是旧值 —— 语义正确。
正序枚举时,j - w[i] 已经在更早的轮次里被本件物品更新过了,读到的是"拿了第 i 件之后"的新值,等于允许第 i 件被重复拿,那就变成完全背包了。
验证:W = 6、一件物品 (w=3, v=5) 时,正序得 dp[6] = 10(用了两次),倒序得 dp[6] = 5(正确)。
答:当题目要求「恰好装满」时。dp[0] = 0(容量 0 恰好装满 = 什么都不装,合法),其余全部设为 -INF,表示"这个容量目前凑不出来"。
如果此时用全 0 初始化,那些"容量凑不出来"的非法状态会带着答案 0 参与 max,最终可能输出一个"没装满"的答案。
今天四道题里 P1048 / P1049 / P1060 都是"不超过"(全 0),P1164 是"恰好"但属于计数,用 dp[0] = 1 即可。
答:把物品的体积同时当成价值(重量 = 体积,价值 = 体积),转移写 dp[j] = max(dp[j], dp[j-v[i]] + v[i])。
这样 dp[V] 就是"容量不超过 V 时能装进去的最大体积",题目要的"最小剩余空间"= V - dp[V]。
它本质上是在做子集和:判断「体积 s 能不能被凑出来」,再找 ≤ V 的最大的 s。
dp[0] = 1?答:因为状态的含义是"方案数"。dp[j] = 刚好花掉 j 元的点法数。花掉 j 元的所有方案可以分成两类:不点第 i 种菜、点第 i 种菜。这两类互不重叠且覆盖全部,所以直接把它们的方案数相加 —— dp[j] += dp[j-a[i]]。
dp[0] = 1 是因为「花 0 元」只有一种做法:什么都不点。它是所有方案的源头,如果设成 0,那么任何方案数都会被推成 0。
const int NEG = -1000000000; // -INF,表示"这个容量凑不出来" dp[0] = 0; for(int j = 1; j <= W; j++) dp[j] = NEG; for(int i = 1; i <= n; i++) for(int j = W; j >= w[i]; j--) if(dp[j - w[i]] != NEG) // 前驱必须凑得出来 dp[j] = max(dp[j], dp[j - w[i]] + v[i]); // 输出:dp[W] 仍是 NEG 就说明无解 if(dp[W] == NEG) cout << "无解" << endl; else cout << dp[W] << endl;
【0/1 背包 · 一维模板(背下来)】 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]); 复杂度和记忆:O(n x W);倒序 0/1,正序完全 【为什么倒序】 dp[j-w[i]] 必须是「没考虑第 i 件」的旧值 倒序 -> j-w[i] 还没被本轮更新 -> 旧值 -> 每件只用一次 ✅ 正序 -> j-w[i] 已被本轮更新 -> 新值 -> 每件用无限次 ❌ 【二维对照】 dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) ↑ 不拿 ↑ 拿(注意是 i-1) 【四种变体】 ① 不超过容量求最大价值:dp 全 0,答案 dp[W] ② 恰好装满求最大价值:dp[0]=0,其余 -INF;NEG 则无解 ③ 价值 = 重量 :v[i] 处写 w[i],答案 W - dp[W] ④ 求方案数(恰好) :dp[0] = 1,dp[j] += dp[j-w[i]] 【数组开多大】 按【容量值域 + 5】开,不是按物品数! P1048 T≤1000 -> dp[1005] P1049 V≤20000 -> dp[20005] P1060 n<3e4 -> dp[30005] P1164 M≤10000 -> dp[10005] 【怎么认出一道题是背包】 有「容量上限」+ 每件东西「要么选要么不选」+ 求最大/最小/方案数 能切分 -> 贪心(按性价比);不能切分 -> 背包(DP)
完全背包
今天留了一个尾巴:把内层循环的 >= 改成 <=(容量正序枚举),
背包的语义就从「每件最多一次」变成了「每件可以用无限次」 —— 这就是明天的完全背包。
明天还会遇到几道"看不出是背包"的题(比如 P1466 集合 Subset Sums、P1387 最大正方形), 到时候你会发现:判断一道题是不是背包,关键就是找到"谁是容量、谁是物品、每次决策选或不选"。
今天的四道题已经把背包的四种常见问法(求最大值、求剩余、价值=重量、求方案数)走了一遍, 明天只多一个变化:物品可以重复拿。把今天的「倒序」想透了,明天几乎不用学新东西。