【LeetCode算法题精讲】DP进阶——背包问题入门

0 阅读15分钟

知识梯度:浅(背包问题概念)→ 浅中(0-1 背包二维 DP)→ 中(滚动数组优化)→ 中深(416 分割等和子集)→ 深(完全背包→322 零钱兑换)→ 升华(背包问题总结)


目录


引言:从五步法到背包问题

在上一篇中,我们用 DP 五步法拆解了 70 爬楼梯和 198 打家劫舍,掌握了"计数型 DP"和"最值型 DP"的基本框架。结尾我们预告了——背包问题。

如果你刷过 LeetCode 上的 DP 题,会发现一个现象:背包问题几乎占据了 DP 中等题的半壁江山。416 分割等和子集、322 零钱兑换、518 零钱兑换 II、474 一和零、494 目标和……这些题看似各不相同,但底层都共享同一个背包模板。

🤔 思考:为什么背包问题如此重要?因为"有限资源下的最优分配"是计算机科学中最基础的问题模型之一——从内存分配到预算规划,本质都是背包问题。

背包问题体系庞大,但入门的核心只有两个:0-1 背包(每个物品最多选一次)和完全背包(每个物品可以选无限次)。掌握了这两个,背包问题的半壁江山就属于你了。

本文将以 DP 五步法为主线,从 0-1 背包理论基础出发,到滚动数组优化,再到 416 分割等和子集(0-1 背包应用),接着是 322 零钱兑换(完全背包应用),最后做背包问题的系统总结。


0-1 背包理论基础

问题描述

有 N 件物品和一个容量为 W 的背包。第 i 件物品的重量为 w[i],价值为 v[i]。每件物品只能用一次(选或不选),问:在不超过背包容量的前提下,装入哪些物品能使总价值最大?

这就是经典的 0-1 背包问题——"0-1"的含义就是每个物品要么选(1),要么不选(0),没有中间选项。

二维 dp 数组定义

Step 1:确定 dp 数组以及下标的含义

dp[i][j] 表示:从下标为 [0, i] 的物品中任意取,放入容量为 j 的背包,能获得的最大价值。

递推公式

Step 2:确定递推公式

dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])
  • 不选物品 i:dp[i][j] = dp[i-1][j]

  • 选物品 i:dp[i][j] = dp[i-1][j - w[i]] + v[i]

🤔 思考:为什么是 dp[i-1][j - w[i]] 而不是 dp[i][j - w[i]]?因为每件物品只能选一次,dp[i-1] 确保在考虑物品 i 之前的状态。如果是 dp[i][j - w[i]],那就意味着物品 i 可能被选了多次——这正是 0-1 背包和完全背包的分水岭。

初始化

Step 3:dp 数组初始化

  • dp[i][0] = 0:背包容量为 0,什么都装不了

  • dp[0][j]:当 j >= w[0] 时,dp[0][j] = v[0];否则 dp[0][j] = 0

  • 其余元素可以初始化为 0

遍历顺序

Step 4:确定遍历顺序

两层 for 循环,先遍历物品,再遍历背包容量:

for i in range(N):          # 遍历物品
    for j in range(W+1):    # 遍历背包容量
        if j < w[i]:
            dp[i][j] = dp[i-1][j]
        else:
            dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])

打印 dp 验证

Step 5:举例推导 dp 数组

假设物品:w = [1, 3, 4],v = [15, 20, 30],背包容量 W = 4。

物品\容量  0   1   2   3   4
-----------------------------
物品0(w=1)  0  15  15  15  15
物品1(w=3)  0  15  15  20  35
物品2(w=4)  0  15  15  20  35

最终 dp[2][4] = 35

三语言代码(二维 DP)

# Python 0-1 背包二维 DP
def knapsack_2d(w, v, W):
    N = len(w)
    dp = [[0] * (W + 1) for _ in range(N)]
    for j in range(w[0], W + 1):
        dp[0][j] = v[0]
    for i in range(1, N):
        for j in range(1, W + 1):
            if j < w[i]:
                dp[i][j] = dp[i - 1][j]
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
    return dp[N - 1][W]
// Java 0-1 背包二维 DP
public int knapsack2D(int[] w, int[] v, int W) {
    int N = w.length;
    int[][] dp = new int[N][W + 1];
    for (int j = w[0]; j <= W; j++) {
        dp[0][j] = v[0];
    }
    for (int i = 1; i < N; i++) {
        for (int j = 1; j <= W; j++) {
            if (j < w[i]) {
                dp[i][j] = dp[i - 1][j];
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
            }
        }
    }
    return dp[N - 1][W];
}
// C++ 0-1 背包二维 DP
int knapsack2D(vector<int>& w, vector<int>& v, int W) {
    int N = w.size();
    vector<vector<int>> dp(N, vector<int>(W + 1, 0));
    for (int j = w[0]; j <= W; j++) {
        dp[0][j] = v[0];
    }
    for (int i = 1; i < N; i++) {
        for (int j = 1; j <= W; j++) {
            if (j < w[i]) {
                dp[i][j] = dp[i - 1][j];
            } else {
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
            }
        }
    }
    return dp[N - 1][W];
}

二维 DP 表格填充过程


滚动数组优化

为什么可以优化?

当前行 i 只依赖上一行 i-1,不需要保留所有行的数据,用一维数组滚动更新即可。

一维 dp 数组

递推公式dp[j] = max(dp[j], dp[j - w[i]] + v[i])

关键:背包容量必须倒序遍历!

for i in range(N):
    for j in range(W, w[i] - 1, -1):  # 倒序!
        dp[j] = max(dp[j], dp[j - w[i]] + v[i])

🤔 思考:为什么必须倒序?因为一维 dp 中,dp[j] 依赖 dp[j - w[i]],如果正序遍历,dp[j - w[i]] 可能已经被当前物品 i 更新过了——这就相当于物品 i 被选了多次,违反了 0-1 背包的约束。

三语言代码(一维 DP)

# Python 0-1 背包一维 DP
def knapsack_1d(w, v, W):
    dp = [0] * (W + 1)
    for i in range(len(w)):
        for j in range(W, w[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - w[i]] + v[i])
    return dp[W]
// Java 0-1 背包一维 DP
public int knapsack1D(int[] w, int[] v, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < w.length; i++) {
        for (int j = W; j >= w[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}
// C++ 0-1 背包一维 DP
int knapsack1D(vector<int>& w, vector<int>& v, int W) {
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < w.size(); i++) {
        for (int j = W; j >= w[i]; j--) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}

二维 vs 一维对比表

| 维度 | 二维 DP | 一维 DP | | --- | --- | --- | | 空间复杂度 | O(N×W) | O(W) | | 代码行数 | 较长 | 极简 | | 理解难度 | 直观 | 需要理解倒序原因 | | 适用场景 | 教学/推导过程 | 面试/竞赛 |


416 分割等和子集(Medium)

题目描述

给你一个 只包含正整数非空 数组 nums。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例:nums = [1, 5, 11, 5],输出 true([1, 5, 5] 和 [11])

如何转换为 0-1 背包?

  1. 计算总和 sum,如果 sum 为奇数,直接返回 false

  2. target = sum / 2,问题转化为:能否从数组中选出一些数,使它们的和恰好等于 target?

  3. 这就是一个 0-1 背包问题——每个数选或不选,背包容量为 target,问能否恰好装满

五步法拆解

Step 1:dp[j] 的含义——dp[j] 表示容量为 j 的背包是否能被装满(true / false)

Step 2:递推公式——dp[j] = dp[j] || dp[j - nums[i]]

Step 3:初始化——dp[0] = true,其余为 false

Step 4:遍历顺序——先物品后背包,背包容量倒序(0-1 背包模板)

Step 5:打印 dp 验证——以 nums = [1, 5, 11, 5]target = 11 为例

初始化: dp = [T, F, F, F, F, F, F, F, F, F, F, F]
i=0, nums[0]=1: dp[1] = T
i=1, nums[1]=5: dp[5] = T, dp[6] = T
i=2, nums[2]=11: dp[11] = T
i=3, nums[3]=5: dp[10] = T, dp[11] 已是 T
最终 dp[11] = true

三语言代码

# Python 416 分割等和子集
def canPartition(self, nums: List[int]) -> bool:
    total = sum(nums)
    if total % 2 != 0:
        return False
    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True
    for num in nums:
        for j in range(target, num - 1, -1):
            dp[j] = dp[j] or dp[j - num]
    return dp[target]
// Java 416 分割等和子集
public boolean canPartition(int[] nums) {
    int total = 0;
    for (int num : nums) total += num;
    if (total % 2 != 0) return false;
    int target = total / 2;
    boolean[] dp = new boolean[target + 1];
    dp[0] = true;
    for (int num : nums) {
        for (int j = target; j >= num; j--) {
            dp[j] = dp[j] || dp[j - num];
        }
    }
    return dp[target];
}
// C++ 416 分割等和子集
bool canPartition(vector<int>& nums) {
    int total = 0;
    for (int num : nums) total += num;
    if (total % 2 != 0) return false;
    int target = total / 2;
    vector<bool> dp(target + 1, false);
    dp[0] = true;
    for (int num : nums) {
        for (int j = target; j >= num; j--) {
            dp[j] = dp[j] || dp[j - num];
        }
    }
    return dp[target];
}

复杂度分析

  • 时间复杂度:O(n × target)

  • 空间复杂度:O(target)

易错点

  1. sum 为奇数直接返回 false

  2. dp 是 boolean 类型,递推公式是 || 不是 max

  3. 背包容量从 target 倒序遍历


完全背包理论基础

与 0-1 背包的唯一区别

完全背包和 0-1 背包的 唯一区别 是:每个物品可以无限次选取

递推公式

dp[j] = max(dp[j], dp[j - w[i]] + v[i])

关键区别:背包容量必须正序遍历!

for i in range(N):
    for j in range(w[i], W + 1):  # 正序!
        dp[j] = max(dp[j], dp[j - w[i]] + v[i])

🤔 思考:为什么正序遍历就变成了完全背包?因为正序遍历时,dp[j - w[i]] 可能已经被当前物品 i 更新过了——这意味着物品 i 可以被多次选取。

遍历顺序的深意

| 遍历顺序 | 结果 | 说明 | | --- | --- | --- | | 先物品后背包(正序) | 组合数 | 不考虑顺序 | | 先背包后物品(正序) | 排列数 | 考虑顺序 |

三语言代码

# Python 完全背包
def complete_knapsack(w, v, W):
    dp = [0] * (W + 1)
    for i in range(len(w)):
        for j in range(w[i], W + 1):  # 正序!
            dp[j] = max(dp[j], dp[j - w[i]] + v[i])
    return dp[W]
// Java 完全背包
public int completeKnapsack(int[] w, int[] v, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < w.length; i++) {
        for (int j = w[i]; j <= W; j++) {  // 正序!
            dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}
// C++ 完全背包
int completeKnapsack(vector<int>& w, vector<int>& v, int W) {
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < w.size(); i++) {
        for (int j = w[i]; j <= W; j++) {  // 正序!
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}

0-1 背包 vs 完全背包对比表

| 维度 | 0-1 背包 | 完全背包 | | --- | --- | --- | | 物品选取次数 | 最多 1 次 | 无限次 | | 一维 dp 内层遍历 | 倒序 | 正序 | | 内层循环起始 | j = Ww[i] | j = w[i]W | | 先物品后背包 | 组合数 | 组合数 | | 先背包后物品 | 排列数(较少用) | 排列数 |


322 零钱兑换(Medium)

题目描述

给定不同面额的硬币 coins 和一个总金额 amount,计算可以凑成总金额所需的 最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。每种硬币的数量是无限的。

示例:coins = [1, 2, 5], amount = 11,输出 3(5+5+1)

完全背包变体

  • 物品:硬币(每种无限量)

  • 重量:硬币面额

  • 价值:1(每个硬币计数为 1)

  • 背包容量:amount

  • 目标:装满背包所需的最小物品个数

五步法拆解

Step 1:dp[j] 的含义——dp[j] 表示凑成金额 j 所需的最少硬币个数

Step 2:递推公式——dp[j] = min(dp[j], dp[j - coins[i]] + 1)

Step 3:初始化——dp[0] = 0,其余初始化为 amount + 1

Step 4:遍历顺序——先遍历硬币,再遍历金额(正序,完全背包模板)

Step 5:打印 dp 验证——以 coins = [1, 2, 5], amount = 11 为例

初始化: dp = [0, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12]
i=0, coin=1: dp[1]=1, dp[2]=2, ..., dp[11]=11
i=1, coin=2: dp[2]=1, dp[4]=2, ..., dp[11]=6
i=2, coin=5: dp[5]=1, dp[10]=2, dp[11]=min(6, dp[6]+1)=3
最终 dp[11] = 3 ✅

递推过程 mermaid 图

三语言代码

# Python 322 零钱兑换
def coinChange(self, coins: List[int], amount: int) -> int:
    dp = [amount + 1] * (amount + 1)
    dp[0] = 0
    for coin in coins:
        for j in range(coin, amount + 1):
            dp[j] = min(dp[j], dp[j - coin] + 1)
    return dp[amount] if dp[amount] != amount + 1 else -1
// Java 322 零钱兑换
public int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);
    dp[0] = 0;
    for (int coin : coins) {
        for (int j = coin; j <= amount; j++) {
            dp[j] = Math.min(dp[j], dp[j - coin] + 1);
        }
    }
    return dp[amount] > amount ? -1 : dp[amount];
}
// C++ 322 零钱兑换
int coinChange(vector<int>& coins, int amount) {
    vector<int> dp(amount + 1, amount + 1);
    dp[0] = 0;
    for (int coin : coins) {
        for (int j = coin; j <= amount; j++) {
            dp[j] = min(dp[j], dp[j - coin] + 1);
        }
    }
    return dp[amount] > amount ? -1 : dp[amount];
}

复杂度分析

  • 时间复杂度:O(n × amount)

  • 空间复杂度:O(amount)

易错点

  1. 初始化填最大值dp[0] = 0,其余填 amount + 1(避免 Integer.MAX_VALUE 溢出)

  2. 无解返回 -1:不是返回 0

  3. 正序遍历:完全背包


背包问题总结

0-1 背包 vs 完全背包

| 维度 | 0-1 背包 | 完全背包 | | --- | --- | --- | | 每个物品选取次数 | 最多 1 次 | 无限次 | | 一维 dp 内层遍历 | 倒序 | 正序 | | 典型应用 | 416 分割等和子集 | 322 零钱兑换 | | 求组合数 | 先物品后背包(倒序) | 先物品后背包(正序) | | 求排列数 | 先背包后物品(倒序) | 先背包后物品(正序) |

遍历顺序规律

0-1 背包(一维):
    for 物品:
        for 容量 in 倒序:    ← 防止重复选取

完全背包(一维,求组合数):
    for 物品:
        for 容量 in 正序:    ← 允许重复选取

完全背包(一维,求排列数):
    for 容量 in 正序:
        for 物品:            ← 交换遍历顺序

家族题一览

| 题目 | 类型 | 核心思路 | | --- | --- | --- | | 416 分割等和子集 | 0-1 背包(是否存在) | target = sum/2,dp[j] = dp[j] || dp[j-nums[i]] | | 322 零钱兑换 | 完全背包(最小个数) | dp[j] = min(dp[j], dp[j-coins[i]] + 1) | | 518 零钱兑换 II | 完全背包(组合数) | dp[j] += dp[j - coins[i]] | | 377 组合总和 Ⅳ | 完全背包(排列数) | 先背包后物品 | | 474 一和零 | 0-1 背包(二维容量) | 两个容量维度 | | 494 目标和 | 0-1 背包(方案数) | 转换为 left - right = target |


结语

本期核心收获:

  1. 0-1 背包理论基础——二维 dp 数组、递推公式、五步法拆解

  2. 滚动数组优化——从二维到一维,理解倒序遍历原因

  3. 416 分割等和子集——0-1 背包应用,boolean 类型 dp

  4. 完全背包理论基础——正序遍历,允许重复选取

  5. 322 零钱兑换——完全背包应用,求最小个数

  6. 背包问题总结——遍历顺序规律、家族题一览

背包问题的本质,就是在"选"与"不选"之间做最优决策。

下期预告:DP 进阶第二弹——打家劫舍系列(213 打家劫舍 II + 337 打家劫舍 III)。


参考文献

  1. 力扣官方题解 - 416. 分割等和子集. 416. 分割等和子集 - 力扣(LeetCode)

  2. 力扣官方题解 - 322. 零钱兑换. 322. 零钱兑换 - 力扣(LeetCode)

  3. 代码随想录 - 背包理论基础(二维 DP). 动态规划:01背包理论基础 | 动态规划 | 01背包 | 二维dp数组 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源

  4. 代码随想录 - 背包理论基础(滚动数组). 动态规划:01背包理论基础(滚动数组) | 动态规划 | 滚动数组 | 01背包 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源

  5. 代码随想录 - 完全背包理论基础. 完全背包理论基础-二维DP数组 | 完全背包 | 二维DP数组 | 状态转移 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源

  6. 代码随想录 - 416. 分割等和子集. 416. 分割等和子集 | 01 背包 | 动态规划 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源

  7. 代码随想录 - 322. 零钱兑换. 322. 零钱兑换 | 动态规划 | 完全背包 | 状态转移 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源

  8. labuladong - 背包问题框架. labuladong.github.io/algo/di-lin…