ACM 一个月学习计划 · DAY 26

Day 26 · 0/1 背包:为什么要倒序枚举容量

背包是 DP 里最经典的一类模型 —— 今天只要把「为什么容量必须倒序枚举」这一件事想透,后面所有背包题都好办

今日目标:掌握 0/1 背包的一维标准写法,能说清倒序枚举容量的原因
预计用时:约 3.5 小时(含做题)  难度:普及-
题目来源:洛谷 P1048 / P1049 / P1060 / P1164
← Day 25 · 线性 DP:四步法与三大模型

⏱今日时间安排

0 - 25 min0/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 背包就是同一个套路,只不过:

· 「阶段」变成了物品(一件一件考虑);
· 「位置」变成了容量(还剩多少空间);
· 每个阶段的决策变成了最经典的二选一 —— 放这件物品,还是不放。

所以昨天那套四步法(状态 → 转移 → 边界 → 答案)今天照样能用,一个字都不用改。

10/1 背包是什么:一个决策做一次

场景

你有一个容量为 W 的背包,面前有 n 件物品,第 i 件重 w[i]、价值 v[i]。

每件物品你只有两种选择:要么整个装进去,要么完全不要。问在不超过容量的前提下,能带走的最大总价值是多少。

🎯 「0/1」这个名字就是字面意思

每件物品的状态只有两种:0 = 不拿,1 = 拿。
不能拿半个(那是"部分背包"),也不能拿两次(那是"完全背包"),更不能拿三份。

和 Day 14 的「部分背包」比一比

还记得 Day 14 的 P2240 部分背包吗?那题里的金币可以随意切分,所以做法是按性价比(价值 ÷ 重量)从高到低贪心拿,拿不下就把最后一样切开装一部分。

那 0/1 背包能不能照抄这个贪心?不能。看一个例子:

物品重量价值性价比
A6366.0(最高)
B5255.0
C5255.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(背包)。
以后看到"可以分割/可以只拿一部分",先想贪心;看到"每种只有一份、要么拿要么不拿",那就是背包。

2从二维写法推到一维写法

先把四步法填一遍(二维版,最好懂)

步骤填什么
① 状态定义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]] 我们也想要旧值。
但一维数组只有一个,怎么保证读到的都是旧的、而不是刚刚被自己改过的?

答案就是下一节:靠枚举顺序。这也是今天唯一一个必须彻底想明白的地方。

3★ 为什么容量一定要倒序枚举

别背结论,我们拿一组最小的数据逐格手推一遍,看正序到底错在哪里。

实验设置

背包容量 W = 6,只有 1 件物品:重量 w = 3,价值 v = 5。

先自己想一想:只有一件物品、它又不能切分,那正确答案显然是 5(拿它或者不拿它,拿到就是 5)。

数组 dp[0..6] 本来全是 0。

情况一:正序枚举(j 从 w[i] 到 W,也就是 3 → 6)

j执行 dp[j] = max(dp[j], dp[j-3] + 5)这一轮之后的 dp[0..6]
3max(dp[3]=0, dp[0]+5 = 0+5) = 50 0 0 5 0 0 0
4max(dp[4]=0, dp[1]+5 = 0+5) = 50 0 0 5 5 0 0
5max(dp[5]=0, dp[2]+5 = 0+5) = 50 0 0 5 5 5 0
6max(dp[6]=0, dp[3]+5 = 5+5) = 100 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 的价值)。

🚫 正序的错误结果:dp[6] = 10

可我们只有 1 件物品,怎么可能拿两次?
正序枚举 = 读到了"本行"刚更新过的新值 = 允许同一件物品重复使用 = 完全背包的语义。

情况二:倒序枚举(j 从 W 到 w[i],也就是 6 → 3)

j执行 dp[j] = max(dp[j], dp[j-3] + 5)这一轮之后的 dp[0..6]
6max(dp[6]=0, dp[3]+5 = 0+5) = 5 ← dp[3] 还是旧值 00 0 0 0 0 0 5
5max(dp[5]=0, dp[2]+5 = 0+5) = 50 0 0 0 0 5 5
4max(dp[4]=0, dp[1]+5 = 0+5) = 50 0 0 0 5 5 5
3max(dp[3]=0, dp[0]+5 = 0+5) = 50 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 的含义换了换。

4背包的四个常见变体(今天全都会遇到)

模板一样,但题目问的东西不一样,初始化和答案的输出位置就不一样。下面这张表是今天的第二重点。

变体含义初始化今天的题
①
不超过容量
求最大价值
容量可以不装满,只要不超就行 dp 全部为 0
int dp[W+5] = {0};
P1048 采药
P1060 开心的金明
②
恰好装满
求最大价值
容量必须刚好用光,用不光就算无解 dp[0] = 0,其余 -INF 今天没有(但要会)
③
价值 = 重量
不求价值,求"能装进去多少体积" dp 全部为 0,转移里 v[i] 换成 w[i] P1049 装箱问题
④
求方案数
(恰好装满)
不数价值,数"有几种选法" dp[0] = 1,其余 0 P1164 小 A 点菜

变体 ①:不超过容量 —— 为什么全初始化成 0

dp[j] = 0 的含义是:「容量为 j 时,我什么也不装,价值就是 0」。这是一个完全合法的方案,所以可以放心地从 0 出发取 max。

题目只要求"不超过",那么多出来的容量闲着不用是允许的,所以任何容量都至少有个"什么都不装"的答案 0。

变体 ②:恰好装满 —— 为什么其余要设成 -INF

如果题目要求容量必须刚好用光,那"什么都不装"在容量 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;
⚠️ 今天四道题都不需要 -INF

P1048、P1049、P1060 都是"不超过",P1164 是"恰好",但它是计数不是求最大值,用的是加法和 dp[0] = 1(见变体 ④)。
不过 -INF 这个套路必须会,它是背包题里非常高频的一个变体,以后一定会遇到。

变体 ③:价值 = 体积(P1049 用的就是这个)

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;                 // 剩余空间 = 总容量 - 装进去的

变体 ④:计数背包(P1164 用的就是这个)

把「max 求最大价值」换成「+= 数方案数」,转移方程的形状完全不变:

求最大价值求方案数
转移dp[j] = max(dp[j], dp[j-w] + v)dp[j] += dp[j-w]
初始化dp 全 0dp[0] = 1,其余 0
枚举容量倒序容量倒序(还是 0/1!)
🎯 为什么计数背包要 dp[0] = 1

问自己:「花 0 元有多少种点法?」答案是 1 种 —— 什么都不点。
如果 dp[0] = 0,那所有方案数都会是 0(因为每个方案最后都要追溯到"花 0 元"这个起点)。
计数类的 DP,起点的方案数永远是 1,不是 0。这是最容易忘的一行。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过(0 warning), 并且跑过官方样例和额外的边界数据,输出是你看到的真实结果。

P1048 [NOIP 2005 普及组] 采药 普及- · 0/1 背包模板

题意:有 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

易错点

  • 输入读反:第一个数是时间 T,第二个才是草药数 M。
  • 内层写成正序 for(j = w[i]; j <= T; j++):那就变成完全背包(每株能采无限次),样例就过不去。
  • dp 数组开太小:值域是时间 T(最大 1000),不是草药数 M(最大 100)。按 M 开 105 会越界。
  • 写成"按性价比贪心":本题物品不可分割,贪心是错的(本文第 1 节给了反例实测)。
P1049 [NOIP 2001 普及组] 装箱问题 普及- · 价值 = 体积的背包

题意:箱子容量为 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],别把最大装入体积直接交上去。
  • 数组按 n 开:值域是容量 V(20000),int dp[35] 会越界。这题要开 dp[20005]。
  • 用 2^n 枚举硬搜:n = 30 时是 10 亿种取法,必超时,要老老实实用 DP。
  • 物品体积可能比 V 还大,此时 j >= v[i] 直接不成立,自动跳过,不用特判。
P1060 [NOIP 2006 普及组] 开心的金明 普及- · 0/1 背包

题意:妈妈给 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

易错点

  • 数组按 m 开(dp[30]):本题值域是钱数 n(最大 3 万),必须开 30005 以上。
  • 把价值写成 p 而不是 v × p:题目要最大化的就是「价格 × 重要度」的总和。
  • 输入顺序搞反:第一行是 n m(钱数、物品数),之后每行是 v p(价格、重要度)。
  • 内层还是倒序。这题每件物品只有一件,属于 0/1 背包。
P1164 小 A 点菜 普及- · 计数背包

题意: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]]
初始化全 0dp[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:本题是数方案,要用 +=。看到"有多少种"就该条件反射地想加法。
  • 内层写成正序:那每道菜就能重复点,方案数会暴涨。
  • 数组按 N 开:值域是钱数 M(最大 10000),要开 10005。
  • 同价菜去重:价格相同的菜是不同的菜,不能合并,否则方案数会少算。
  • 用 long long 反而要小心:题目保证答案在 int 范围内,用 int 就好;如果用 long long 也完全没问题。

✗今日常见错误汇总

错误写法后果正确写法
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

✓自测:你真的会了吗

第 1 题:默写 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]);

三个细节:外层物品、内层容量、容量倒序。少一个都不行。

第 2 题(核心):为什么容量必须倒序枚举?用自己的话说一遍

答:一维写法里,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(正确)。

第 3 题:什么时候 dp 要初始化成 -INF?

答:当题目要求「恰好装满」时。dp[0] = 0(容量 0 恰好装满 = 什么都不装,合法),其余全部设为 -INF,表示"这个容量目前凑不出来"。

如果此时用全 0 初始化,那些"容量凑不出来"的非法状态会带着答案 0 参与 max,最终可能输出一个"没装满"的答案。

今天四道题里 P1048 / P1049 / P1060 都是"不超过"(全 0),P1164 是"恰好"但属于计数,用 dp[0] = 1 即可。

第 4 题:P1049 装箱问题怎么套 0/1 背包?答案是什么?

答:把物品的体积同时当成价值(重量 = 体积,价值 = 体积),转移写 dp[j] = max(dp[j], dp[j-v[i]] + v[i])。

这样 dp[V] 就是"容量不超过 V 时能装进去的最大体积",题目要的"最小剩余空间"= V - dp[V]。

它本质上是在做子集和:判断「体积 s 能不能被凑出来」,再找 ≤ V 的最大的 s。

第 5 题:P1164 的 dp 转移为什么用加法?为什么 dp[0] = 1?

答:因为状态的含义是"方案数"。dp[j] = 刚好花掉 j 元的点法数。花掉 j 元的所有方案可以分成两类:不点第 i 种菜、点第 i 种菜。这两类互不重叠且覆盖全部,所以直接把它们的方案数相加 —— dp[j] += dp[j-a[i]]。

dp[0] = 1 是因为「花 0 元」只有一种做法:什么都不点。它是所有方案的源头,如果设成 0,那么任何方案数都会被推成 0。

第 6 题(手写):把"恰好装满"的 0/1 背包初始化代码写出来
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)

→明天预告 · Day 27

完全背包

今天留了一个尾巴:把内层循环的 >= 改成 <=(容量正序枚举), 背包的语义就从「每件最多一次」变成了「每件可以用无限次」 —— 这就是明天的完全背包。

明天还会遇到几道"看不出是背包"的题(比如 P1466 集合 Subset Sums、P1387 最大正方形), 到时候你会发现:判断一道题是不是背包,关键就是找到"谁是容量、谁是物品、每次决策选或不选"。

今天的四道题已经把背包的四种常见问法(求最大值、求剩余、价值=重量、求方案数)走了一遍, 明天只多一个变化:物品可以重复拿。把今天的「倒序」想透了,明天几乎不用学新东西。