背包DP(01背包+完全背包)

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 次 无限次
相关推荐
笑容掩饰我的失落1 小时前
区块链从小白到精通(七):Solidity 语法精讲——数据类型、函数、修饰器与事件
算法·区块链·php·智能合约·哈希算法
旖旎夜光2 小时前
【C++ 基础入门】C++11 新特性(中):右值引用、移动语义与完美转发
开发语言·c++·笔记·学习
ShineWinsu2 小时前
对于Qt:QTabWidget常见属性的解析
开发语言·数据库·c++·qt·浏览器·qtabwidget·标签页
Pniubi2 小时前
力扣24. 两两交换链表中的节点(递归)
算法·leetcode·链表
꧁细听勿语情꧂3 小时前
直接选择排序,堆排序,冒泡排序,递归快速排序、hoare版本、挖坑法、lomuto前后指针,非递归快速排序
c语言·开发语言·数据结构·算法
俊俊几年的3 小时前
动态规划(Dynamic Programming)详解:从入门到 LeetCode Hot 100
linux·c++·算法·leetcode·嵌入式·动态规划·c
小雉系统12 小时前
BcoreOS安全通信架构:FsSession 抗算法后门安全设计
算法·安全·架构
唐墨12317 小时前
linux kernel 比较数据大小的宏定义
linux·算法