1:总览
| 01 背包 | 完全背包 | |
|---|---|---|
| 每种物品数量 | 1 个 | 无限个 |
| 二维转移 | dp[i-1][j-v](上一行) |
dp[i][j-v](本行) |
一维 j 方向 |
倒序(V → v) | 正序(v → V) |
| 一句话 | 只准选一次 | 可以反复选 |
两种背包的全部区别,就是一个下标的方向。
其余(状态定义、边界、两问的处理)完全一样。
2:核心从二维到一维
2.1 01背包题目
有 2 个物品,背包容量 V = 4:
物品 0:体积 2,价值 3
物品 1:体积 3,价值 4
每个物品最多选 1 次,求不超过容量时的最大价值。
手动枚举所有方案(先知道答案,后面用来对表):
选 {} 体积 0,价值 0
选 {0} 体积 2,价值 3
选 {1} 体积 3,价值 4
选 {0,1} 体积 5 > 4 ✗ 装不下
最大价值 = 4(选物品 1)
2.2 二维定义
dp[i][j] = 用【前 i 个物品】(即物品 0 .. i-1),容量不超过 j 时的最大价值
i 的范围:0 .. n (0 表示"一个都没有")
j 的范围:0 .. V (容量)
为什么要有 i 这一维:
因为我需要表达"考虑过哪些物品"。
dp[1][4] = 只有物品 0 时,容量 4 的最大价值
dp[2][4] = 物品 0、1 都考虑过时,容量 4 的最大价值
两者的答案可能不同 -> 必须有这个维度区分。
2.3 二维转移
对于第 i 个物品(下标是 i-1),容量 j:
方案 A:不选它 -> dp[i-1][j]
方案 B:选它 -> dp[i-1][j - v] + w (只有 j >= v 时才可能)
dp[i][j] = max(A, B)
为什么 B 是 dp[i-1] 而不是 dp[i]:
选了第 i 个物品之后,剩下的容量 j-v 只能用【前 i-1 个】物品来装
------ 因为第 i 个已经用掉了,01 背包里它只有 1 个。
所以来源是【上一行】。这就是"01"在代码里的全部体现。
2.4 手填整张表
边界:
dp[0][j] = 0 (一个物品都没有,价值只能是 0)
dp[i][0] = 0 (容量 0,什么都装不下)
i = 1(只考虑物品 0:v=2, w=3)
j=0: 0
j=1: 装不下(1 < 2) -> dp[0][1] = 0
j=2: max( dp[0][2], dp[0][0] + 3 ) = max(0, 3) = 3
j=3: max( dp[0][3], dp[0][1] + 3 ) = max(0, 3) = 3
j=4: max( dp[0][4], dp[0][2] + 3 ) = max(0, 3) = 3
i = 2(再加上物品 1:v=3, w=4)
j=0: 0
j=1: 装不下 -> dp[1][1] = 0
j=2: 装不下(2 < 3)-> dp[1][2] = 3
j=3: max( dp[1][3]=3, dp[1][0]+4=4 ) = 4
j=4: max( dp[1][4]=3, dp[1][1]+4=4 ) = 4
完整的表:
j=0 j=1 j=2 j=3 j=4
i=0 0 0 0 0 0
i=1 (v2w3) 0 0 3 3 3
i=2 (v3w4) 0 0 3 4 4 <- dp[2][4] = 4,和手算一致
看这张表的一个细节
dp[2][4] = 4 的计算过程里,用到的是 dp[1][4](正上方)和 dp[1][1](上一行的左边)。
整个二维 DP 的所有依赖,都是「正上方」和「上一行的左方」。
这句话是下一节"能不能压缩"的全部依据。
2.5 二维代码
二维版本
int knapsack2D(int V, vector<int>& v, vector<int>& w) {
int n = v.size();
// dp[i][j]:用前 i 个物品、容量不超过 j 的最大价值
vector<vector<int>> dp(n + 1, vector<int>(V + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= V; j++) {
dp[i][j] = dp[i - 1][j]; // 1. 不选第 i 个
if (j >= v[i - 1]) {
dp[i][j] = max(dp[i][j],
dp[i - 1][j - v[i - 1]] + w[i - 1]); // 2. 选
}
}
}
return dp[n][V];
}
这段代码有三个特点:
1. i 从 1 开始,物品用 v[i-1] / w[i-1] (因为 dp[0][*] 是"零个物品")
2. 每一行只依赖【上一行】 (dp[i-1][...])
3. j 的顺序无所谓(正序倒序都行) (因为读的是别的行)
2.6 为什么能压成一维
算完第 i 行之后,第 i-1 行就再也用不到了。
验证:
算 i=1 时用 dp[0] -> 之后不会再用 dp[0]
算 i=2 时用 dp[1] -> 之后不会再用 dp[1]
算 i=3 时用 dp[2] -> ...
也就是说:任意时刻,"有用的历史"只有【一行】。
于是可以把两行压成一行,原地反复覆写:
dp[j] 的含义变成:「当前这一行」的 dp[i][j]
每处理一个新物品 i:
用 dp[j](此时里面还是第 i-1 行的值)算出新的 dp[j]
覆写上去 -> dp[j] 变成第 i 行的值
2.7 但覆写有个陷阱:j 必须倒序
这是全篇最重要的一节。
看二维转移的依赖方向:
dp[i][j] = max( dp[i-1][j], dp[i-1][j-v] + w )
^ 正上方 ^ 上一行的左边
两个来源都在【上一行】。所以覆写 dp[j] 时,需要用到的 dp[j-v]
必须还是【上一行】的值。
先试正序(j 从小到大):
物品 0:v=2, w=3,V=4,初始 dp = [0,0,0,0,0]
j=0: j < 2,跳过
j=1: j < 2,跳过
j=2: dp[2] = max( dp[2], dp[0] + 3 ) = max(0, 0+3) = 3
当前 dp = [0,0,3,0,0]
j=3: dp[3] = max( dp[3], dp[1] + 3 ) = max(0, 0+3) = 3
当前 dp = [0,0,3,3,0]
j=4: dp[4] = max( dp[4], dp[2] + 3 ) = max(0, 3+3) = 6
^^^^^
这个 3 是【本轮刚算出来的】!
-> 结果 dp[4] = 6,但正确答案是 3(物品 0 只有 1 个)✗
错在哪:
dp[2] 在本轮已经被更新成了 3(表示"选了物品 0")
再从 dp[2] + 3 转移过去,等于【又选了一次物品 0】
而 01 背包第 i 个物品只有 1 个 ------ 这就是"重复选取"的错误
再试倒序(j 从大到小):
物品 0:v=2, w=3,V=4,初始 dp = [0,0,0,0,0]
j=4: dp[4] = max( dp[4], dp[2] + 3 ) = max(0, 0+3) = 3
^ dp[2]=0,还是初始值 ✓
当前 dp = [0,0,0,0,3]
j=3: dp[3] = max( dp[3], dp[1] + 3 ) = max(0, 0+3) = 3
当前 dp = [0,0,0,3,3]
j=2: dp[2] = max( dp[2], dp[0] + 3 ) = max(0, 0+3) = 3
当前 dp = [0,0,3,3,3]
-> 结果 dp[4] = 3 ✓ 正确
为什么倒序就对了:
j 从大到小遍历时,j-v 一定【比 j 小】-> 还没轮到它被更新
-> 此时 dp[j-v] 还是【上一行】的值 ✓
-> 正好等价于二维的 dp[i-1][j-v]
正序时,j-v 比 j 小 -> 已经先被更新过了
-> dp[j-v] 变成【本行】的值
-> 等价于二维的 dp[i][j-v] <- 这是完全背包,不是 01 背包
一维的"倒序 / 正序",就是在模拟"读到的是哪一行"。
一维数组没有行的概念,只能靠遍历顺序控制"这个格有没有被本轮改过"。
2.8 一维代码
一维版本(01 背包)
int knapsack1D(int V, vector<int>& v, vector<int>& w) {
int n = v.size();
vector<int> dp(V + 1, 0); // dp[j] = 容量不超过 j 的最大价值
for (int i = 0; i < n; i++) { // 外层:物品
for (int j = V; j >= v[i]; j--) { // 内层:容量【倒序】
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
return dp[V];
}
和二维逐行对照:
| 二维 | 一维 | 说明 |
|---|---|---|
for (i = 1; i <= n; i++) |
for (i = 0; i < n; i++) |
起点差 1,所以物品下标从 i-1 变成 i |
for (j = 0; j <= V; j++) |
for (j = V; j >= v[i]; j--) |
方向反过来 ,且起点从 0 变成 v[i] |
dp[i][j] = dp[i-1][j] |
(不用写) | 一维的 dp[j] 本来就存着上一行的值 |
dp[i-1][j-v] |
dp[j-v] |
靠倒序保证它还没被改 |
return dp[n][V] |
return dp[V] |
--- |
两个公式上的差异,都是"压缩"带来的
一、少了 dp[i][j] = dp[i-1][j] 这一行 :一维里 dp[j] 原地不动就等于"不选",不需要显式赋值。
二、j 的起点变成 v[i] :因为 j < v[i] 时转移式和 dp[j] 一样,算了也没变化,跳过即可。
2.9完全背包
完全背包的二维转移:
dp[i][j] = max( dp[i-1][j], dp[i][j-v] + w )
^^^^^^^^^
这里是【本行】,不是上一行
为什么本行 = 可以重复选:
dp[i][j-v] 表示"用前 i 个物品凑出 j-v"
它里面可能【已经装了物品 i】
-> 从它再转移过来,等于又装了一个物品 i
-> 所以物品 i 可以用无数次
那它的一维怎么写:
"本行" = 已经被本轮更新过的值 = 【正序】遍历时读到的 dp[j-v]
所以完全背包只把 j 的方向改成正序。
一维版本(完全背包)
int knapsack1D_complete(int V, vector<int>& v, vector<int>& w) {
int n = v.size();
vector<int> dp(V + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = v[i]; j <= V; j++) { // 正序
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
return dp[V];
}
用同一个例子验证(物品 0:v=2, w=3,V=4,完全背包):
初始 dp = [0,0,0,0,0]
j=2: dp[2] = max(0, dp[0]+3) = 3 dp = [0,0,3,0,0]
j=3: dp[3] = max(0, dp[1]+3) = 3 dp = [0,0,3,3,0]
j=4: dp[4] = max(0, dp[2]+3) = 6 dp = [0,0,3,3,6]
^ 3 是本轮的 -> 装两个物品 0 ✓
完全背包允许重复,所以 6 是正确答案(2 个体积 2 的物品)
2.10 这一章的总表
┌─────────────────────────────────────────────────────────┐
│ 二维 一维 │
│ 01 背包 dp[i-1][j-v] j 倒序 │
│ 完全背包 dp[i][j-v] j 正序 │
│ │
│ 共同点:dp[i][j] = dp[i-1][j](不选)再叠加"选"的部分 │
│ 一维共同点:dp[j] 原地不动就是"不选" │
└─────────────────────────────────────────────────────────┘
想不清一维的时候,回到二维。
一维的每一行代码,都能在二维里找到对应的一行;一维的"倒序/正序",就是二维的"上一行/本行"。
3:01 背包的例题
从这一章开始,每道题的格式统一为:思路(3-5 句)→ 二维代码 → 一维代码。
3.1 牛客 DP41(两问)------ 最标准的样子
思路
给 n 个物品(体积 v、价值 w)和容量 V。
第一问:不要求装满,最大价值。
第二问:恰好装满,最大价值(无解输出 0)。
两问的转移完全一样,只差【边界】:
第一问 dp 全 0 ("不装"也算一种方案)
第二问 dp[0]=0,其余 -1(只有体积 0 可达成)
转移:dp[i][j] = max( 不选, 选 ),选的时候看 j >= v[i]。
二维版本
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, V;
cin >> n >> V;
vector<int> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
// ---------- 第一问:不要求装满 ----------
vector<vector<int>> dp1(n + 1, vector<int>(V + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= V; j++) {
dp1[i][j] = dp1[i - 1][j];
if (j >= v[i]) {
dp1[i][j] = max(dp1[i][j], dp1[i - 1][j - v[i]] + w[i]);
}
}
}
// ---------- 第二问:恰好装满 ----------
vector<vector<int>> dp2(n + 1, vector<int>(V + 1, -1));
dp2[0][0] = 0;
for (int i = 1; i <= n; i++) {
dp2[i][0] = 0;
for (int j = 0; j <= V; j++) {
dp2[i][j] = dp2[i - 1][j];
if (j >= v[i] && dp2[i - 1][j - v[i]] != -1) {
dp2[i][j] = max(dp2[i][j], dp2[i - 1][j - v[i]] + w[i]);
}
}
}
cout << dp1[n][V] << '\n' << (dp2[n][V] == -1 ? 0 : dp2[n][V]);
}
一维版本
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, V;
cin >> n >> V;
vector<int> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
// ---------- 第一问:不要求装满(全 0) ----------
vector<int> dp1(V + 1, 0);
for (int i = 1; i <= n; i++) {
for (int j = V; j >= v[i]; j--) { // 倒序
dp1[j] = max(dp1[j], dp1[j - v[i]] + w[i]);
}
}
// ---------- 第二问:恰好装满(dp[0]=0,其余 -1) ----------
vector<int> dp2(V + 1, -1);
dp2[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = V; j >= v[i]; j--) { // 倒序(端点与第一问相同)
if (dp2[j - v[i]] != -1) {
dp2[j] = max(dp2[j], dp2[j - v[i]] + w[i]);
}
}
}
cout << dp1[V] << '\n' << (dp2[V] == -1 ? 0 : dp2[V]);
}
两问的循环端点完全相同
for (int j = V; j >= v[i]; j--) ------ 第一问和第二问一个字都不差,只有初始化不同。
3.2 LC 416 分割等和子集 ------ 状态从"最大价值"变成"能否"
思路
问能否把数组分成两个和相等的子集。
转化:总和 sum,两个子集和相等 -> 各为 sum/2
1. sum 为奇数 -> 直接 false
2. 否则:能否从 nums 里选一些数,和恰好 = sum/2?
这一步把"分割"变成了"01 背包的可行性问题":
状态:dp[i][j] = 用前 i 个数,能否凑出和 j(bool)
转移:dp[i][j] = dp[i-1][j] | dp[i-1][j-nums[i-1]]
边界:dp[i][0] = true(凑 0:什么都不选)
二维版本
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int x : nums) sum += x;
if (sum % 2 != 0) return false; // 奇数不可能平分
int target = sum / 2;
int n = nums.size();
// dp[i][j]:用前 i 个数能否凑出 j
vector<vector<bool>> dp(n + 1, vector<bool>(target + 1, false));
for (int i = 0; i <= n; i++) dp[i][0] = true; // 凑出 0:一个都不选
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= target; j++) {
dp[i][j] = dp[i - 1][j]; // 不选第 i 个
if (j >= nums[i - 1]) {
dp[i][j] = dp[i][j] | dp[i - 1][j - nums[i - 1]]; // 选
}
}
}
return dp[n][target];
}
};
一维版本
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int x : nums) sum += x;
if (sum % 2 != 0) return false;
int target = sum / 2;
// dp[j]:能否凑出 j
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int i = 0; i < (int)nums.size(); i++) {
for (int j = target; j >= nums[i]; j--) { // 倒序
dp[j] = dp[j] | dp[j - nums[i]];
}
}
return dp[target];
}
};
和求价值的版本比,只换了三处:
数组类型 int -> bool
转移 max -> |
初值 0 -> false,但 dp[0] 必须是 true
3.3 LC 494 目标和 ------ 状态从"能否"变成"方案数"
思路
每个数前加 + 或 -,求表达式等于 target 的方案数。
转化:设正号部分和为 P,负号部分和为 N
P + N = sum
P - N = target
-> P = (sum + target) / 2
问题变成:从 nums 里选一些数,和【恰好】为 P,有多少种选法。
两个前置判断:
sum < |target| -> 0(全部加正号也不够)
(sum + target) 是奇数 -> 0(P 必须是整数)
二维版本
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int x : nums) sum += x;
if (sum < abs(target) || (sum + target) % 2 != 0) return 0;
int P = (sum + target) / 2;
if (P < 0) return 0;
int n = nums.size();
// dp[i][j]:用前 i 个数凑出 j 的方案数
vector<vector<int>> dp(n + 1, vector<int>(P + 1, 0));
dp[0][0] = 1; // 凑 0:空集,1 种
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= P; j++) {
dp[i][j] = dp[i - 1][j]; // 不选
if (j >= nums[i - 1]) {
dp[i][j] += dp[i - 1][j - nums[i - 1]]; // 选,方案数相加
}
}
}
return dp[n][P];
}
};
一维版本
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int x : nums) sum += x;
if (sum < abs(target) || (sum + target) % 2 != 0) return 0;
int P = (sum + target) / 2;
if (P < 0) return 0;
vector<int> dp(P + 1, 0);
dp[0] = 1; // 凑 0:空集,1 种
for (int i = 0; i < (int)nums.size(); i++) {
for (int j = P; j >= nums[i]; j--) { // 倒序
dp[j] += dp[j - nums[i]]; // 累加
}
}
return dp[P];
}
};
这题最容易错的三处
一、
dp[0]必须是 1(不是 0):凑出 0 有 1 种方案(空集)。写成 0 后面全是 0。二、用
+=不是max:问的是"多少种方案",不是"最大价值"。三、
i从 0 开始就要用nums[i]:不能一边i=0一边写nums[i-1](会越界)。
3.4 LC 1049 最后一块石头的重量 II ------ 背包当"子集和"工具
思路
每次选两块石头粉碎,相同则都碎,不同则留差。求最后剩下的最小重量。
转化:每次"抵消"等价于把石头分成两堆 A、B,让差最小
A + B = sum
B - A = sum - 2A
-> 要让差最小,就要让 A 尽量大,但不超过 sum/2
-> A = "容量 sum/2 的背包能装出的最大【重量和】"
-> 答案 = sum - 2 * dp[sum/2]
这题的特殊之处:价值 = 重量(v[i] == w[i] == stones[i])。
二维版本
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for (int x : stones) sum += x;
int cap = sum / 2;
// dp[i][j]:用前 i 块石头,容量 j 能装出的最大重量和
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= cap; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= stones[i - 1]) {
dp[i][j] = max(dp[i][j],
dp[i - 1][j - stones[i - 1]] + stones[i - 1]);
}
}
}
return sum - 2 * dp[n][cap];
}
};
一维版本
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for (int x : stones) sum += x;
int cap = sum / 2;
vector<int> dp(cap + 1, 0);
for (int i = 0; i < (int)stones.size(); i++) {
for (int j = cap; j >= stones[i]; j--) { // 倒序
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - 2 * dp[cap];
}
};
4:完全背包的例题
4.1 牛客 DP42(两问)------ 只有 j 的方向不同
思路
和 DP41 一样的题面,但每种物品可以用【无数次】。
二维:dp[i][j] = max( dp[i-1][j], dp[i][j-v[i]] + w[i] )
^^^^^^^^^
本行(可以再选)
一维:只把 j 改成【正序】,其余一模一样。
两问的差别仍然只有边界。
二维版本
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, V;
cin >> n >> V;
vector<int> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
// ---------- 第一问:不要求装满 ----------
vector<vector<int>> dp1(n + 1, vector<int>(V + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= V; j++) {
dp1[i][j] = dp1[i - 1][j];
if (j >= v[i]) {
dp1[i][j] = max(dp1[i][j], dp1[i][j - v[i]] + w[i]); // 本行
}
}
}
// ---------- 第二问:恰好装满 ----------
vector<vector<int>> dp2(n + 1, vector<int>(V + 1, -1));
dp2[0][0] = 0;
for (int i = 1; i <= n; i++) {
dp2[i][0] = 0;
for (int j = 0; j <= V; j++) {
dp2[i][j] = dp2[i - 1][j];
if (j >= v[i] && dp2[i][j - v[i]] != -1) {
dp2[i][j] = max(dp2[i][j], dp2[i][j - v[i]] + w[i]); // 本行
}
}
}
cout << dp1[n][V] << '\n' << (dp2[n][V] == -1 ? 0 : dp2[n][V]);
}
一维版本
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, V;
cin >> n >> V;
vector<int> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
// ---------- 第一问 ----------
vector<int> dp1(V + 1, 0);
for (int i = 1; i <= n; i++) {
for (int j = v[i]; j <= V; j++) { // 正序
dp1[j] = max(dp1[j], dp1[j - v[i]] + w[i]);
}
}
// ---------- 第二问 ----------
vector<int> dp2(V + 1, -1);
dp2[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = v[i]; j <= V; j++) { // 正序
if (dp2[j - v[i]] != -1) {
dp2[j] = max(dp2[j], dp2[j - v[i]] + w[i]);
}
}
}
cout << dp1[V] << '\n' << (dp2[V] == -1 ? 0 : dp2[V]);
}
4.2 LC 322 零钱兑换 ------ 求最少个数
思路
给硬币面额和目标金额,求凑出金额的最少硬币数;凑不出返回 -1。
状态:dp[i][j] = 用前 i 种硬币凑出 j 的最少数量
转移:dp[i][j] = min( dp[i-1][j], dp[i][j-c] + 1 ) -- 本行(可重复)
边界:dp[i][0] = 0,其余 INF(不可达)
★ 哨兵必须是 INF,不能是 -1:
求 min 时 -1 是最小的,会永远赢;而且 -1+1=0 会假装合法。
二维版本
class Solution {
const int INF = 1e9;
public:
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
vector<vector<int>> dp(n + 1, vector<int>(amount + 1, INF));
dp[0][0] = 0;
for (int i = 1; i <= n; i++) {
dp[i][0] = 0;
for (int j = 0; j <= amount; j++) {
dp[i][j] = dp[i - 1][j]; // 不用第 i 种
if (j >= coins[i - 1]) {
dp[i][j] = min(dp[i][j],
dp[i][j - coins[i - 1]] + 1); // 本行
}
}
}
return dp[n][amount] == INF ? -1 : dp[n][amount];
}
};
一维版本
class Solution {
const int INF = 1e9;
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INF);
dp[0] = 0;
for (int c : coins) {
for (int j = c; j <= amount; j++) { // 正序
if (dp[j - c] != INF) {
dp[j] = min(dp[j], dp[j - c] + 1);
}
}
}
return dp[amount] == INF ? -1 : dp[amount];
}
};
哨兵值这条判据
哨兵要站在"你不想选的那一边":
求 max → 用极小值(-1 或 -INF) | 求 min → 用极大值(+INF)
求 bool → false | 求方案数 → 0
写完问一句:"这个哨兵在比较里会不会赢?" 会赢就是选反了。
4.3 LC 518 零钱兑换 II ------ 求组合数
思路
求凑出 amount 的【组合数】(顺序不同算同一种)。
状态:dp[i][j] = 用前 i 种硬币凑出 j 的组合数
转移:dp[i][j] = dp[i-1][j] + dp[i][j-c] -- 本行
边界:dp[i][0] = 1(凑 0:空集,1 种)
★ 遍历顺序决定"组合"还是"排列":
外层物品 + 内层金额 -> 组合(本题)
外层金额 + 内层物品 -> 排列(那是 LC 377,不是背包)
二维版本
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
vector<vector<unsigned long long>> dp(
n + 1, vector<unsigned long long>(amount + 1, 0));
for (int i = 0; i <= n; i++) dp[i][0] = 1; // 凑 0:1 种
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= amount; j++) {
dp[i][j] = dp[i - 1][j]; // 不用第 i 种
if (j >= coins[i - 1]) {
dp[i][j] += dp[i][j - coins[i - 1]]; // 本行,累加
}
}
}
return dp[n][amount];
}
};
一维版本
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<unsigned long long> dp(amount + 1, 0);
dp[0] = 1;
for (int c : coins) { // 外层:物品
for (int j = c; j <= amount; j++) { // 内层:金额(正序)
dp[j] += dp[j - c];
}
}
return dp[amount];
}
};
LeetCode 的数据坑:long long 都会溢出
题目说"结果保证 ≤ 32 位整数",但某个测试数据的中间值 会冲到 9.2e18,超出 long long。
做法:用 unsigned long long(上限 1.8e19),逻辑一行不改。
教训:「题目保证的结果范围」只保证最终答案,不保证中间过程。
4.4 LC 279 完全平方数 ------ 物品是"自动生成"的
思路
求最少用几个完全平方数(1,4,9,16...)之和等于 n。
它和 LC 322 是同一道题,只是"物品"不是给定的,而是自动生成:
物品 = 1², 2², 3², ... 直到不超过 n
状态:dp[i][j] = 用前 i 个平方数凑出 j 的最少个数
转移:dp[i][j] = min( dp[i-1][j], dp[i][j-sq] + 1 ) -- 本行(可重复)
边界:dp[i][0] = 0,其余 INF
二维版本
class Solution {
public:
int numSquares(int n) {
vector<int> nums;
nums.push_back(0); // 占位,让下标从 1 开始
for (int i = 1; i * i <= n; i++) {
nums.push_back(i * i); // 物品:1, 4, 9, 16, ...
}
int size = nums.size();
vector<vector<int>> dp(size, vector<int>(n + 1, 0x3f3f3f));
for (int i = 1; i < size; i++) {
dp[i][0] = 0;
for (int j = 0; j <= n; j++) {
dp[i][j] = dp[i - 1][j]; // 不用第 i 个平方数
if (j >= nums[i]) {
dp[i][j] = min(dp[i][j], dp[i][j - nums[i]] + 1); // 本行
}
}
}
return dp[size - 1][n];
}
};
一维版本
class Solution {
public:
int numSquares(int n) {
const int INF = 1e9;
vector<int> dp(n + 1, INF);
dp[0] = 0;
for (int i = 1; i * i <= n; i++) {
int sq = i * i;
for (int j = sq; j <= n; j++) { // 正序
dp[j] = min(dp[j], dp[j - sq] + 1);
}
}
return dp[n];
}
};
5:不要求装满 vs 恰好装满
| 不要求装满 | 恰好装满 | |
|---|---|---|
dp 初值 |
全 0 | dp[0] = 0,其余 -1(或 ±INF) |
| 含义 | "不装"也是合法方案 | 只有体积 0 可达成 |
| 转移 | 直接 max / min |
判 dp[j-v] != -1 |
| 二维边界 | dp[0][j] = 0 |
dp[0][0] = 0,dp[0][j>0] = -1 |
**两问的唯一差别是边界。**转移、循环、方向全一样。
判断依据:问自己一句 ------ "『什么都不装』算不算一种合法方案?"
算 → 全 0;不算 → dp[0]=0,其余 -1。
6:三种「问法」决定转移怎么写
| 问什么 | 转移 | 初值 | 例子 |
|---|---|---|---|
| 最大/最小价值 | max / min |
全 0 或 ±INF | DP41 / DP42 / 322 / 279 |
| 能不能 | |= |
dp[0] = true |
416 |
| 有多少种方案 | += |
dp[0] = 1 |
494 / 518 |
骨架、循环、方向全都不变,只换转移里的运算符和初值。
7:总结
7.1 一页速查
┌──────────────────────────────────────────────┐
│ 01 背包 二维 dp[i-1][j-v] 一维 j 倒序 │
│ 完全背包 二维 dp[i][j-v] 一维 j 正序 │
│ │
│ 不要求装满 -> 全 0 │
│ 恰好装满 -> dp[0]=0,其余 -1(求 max) │
│ │
│ 求 max -> 哨兵 -1 | 求 min -> 哨兵 +INF │
│ │
│ 正序 = 可重复 | 倒序 = 只一次 │
└──────────────────────────────────────────────┘
7.2 两种背包的完整对照
| 01 背包 | 完全背包 | |
|---|---|---|
| 二维转移 | dp[i-1][j-v] |
dp[i][j-v] |
一维 j |
倒序 V → v |
正序 v → V |
| 一维代码 | for (j = V; j >= v; j--) |
for (j = v; j <= V; j++) |
| 能选几次 | 1 次 | 无限次 |