52·动态规划进阶

背包问题

knapsack0-1unboundedsubset

第52课 - 背包问题

学习目标

完成本课学习后,你将能够:

  1. 理解背包问题的基本概念和分类
  2. 掌握0-1背包问题的状态转移方程和解法
  3. 区分0-1背包和完全背包的区别与实现
  4. 运用背包思想解决子集和等问题
  5. 分析背包问题的时间和空间复杂度

核心概念

什么是背包问题?

想象你是一个小偷,面前有一堆物品,每个物品有重量和价值。你有一个承重有限的背包,需要选择一些物品装进背包,使得总价值最大。这就是经典的背包问题

背包问题主要分为两类:

  1. 0-1背包:每个物品只能选或不选(不能重复选)
  2. 完全背包:每个物品可以选无限次

状态转移思想

解决背包问题的核心思想是动态规划。我们需要定义一个二维数组 dp[i][w],表示:

  • i:考虑前i个物品
  • w:当前背包剩余容量
  • dp[i][w]:此时能获得的最大价值

对于每个物品,我们有两种选择:

  1. 不选第i个物品dp[i][w] = dp[i-1][w]
  2. 选第i个物品(前提是能放下):dp[i][w] = dp[i-1][w-weight[i]] + value[i]

最终结果取两者中的最大值。

代码示例

1. 0-1背包问题

def knapsack_01(weights, values, capacity):
    """
    0-1背包问题
    :param weights: 物品重量列表
    :param values: 物品价值列表
    :param capacity: 背包容量
    :return: 最大价值
    """
    n = len(weights)
    
    # 创建DP表,dp[i][w]表示前i个物品、容量为w时的最大价值
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    for i in range(1, n + 1):  # 遍历物品
        for w in range(capacity + 1):  # 遍历容量
            # 情况1:不选第i个物品
            dp[i][w] = dp[i-1][w]
            
            # 情况2:选第i个物品(如果容量足够)
            if w >= weights[i-1]:
                # 注意:values和weights是0-indexed,所以用i-1
                choose = dp[i-1][w - weights[i-1]] + values[i-1]
                dp[i][w] = max(dp[i][w], choose)
    
    return dp[n][capacity]

# 测试
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
print("0-1背包最大价值:", knapsack_01(weights, values, capacity))  # 输出: 10

2. 完全背包问题

def knapsack_unbounded(weights, values, capacity):
    """
    完全背包问题(每个物品可选无限次)
    :param weights: 物品重量列表
    :param values: 物品价值列表  
    :param capacity: 背包容量
    :return: 最大价值
    """
    n = len(weights)
    
    # 优化:只需要一维数组
    dp = [0] * (capacity + 1)
    
    for i in range(n):  # 遍历物品
        for w in range(weights[i], capacity + 1):  # 从小到大遍历容量
            # 状态转移:选第i个物品
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    
    return dp[capacity]

# 测试
print("完全背包最大价值:", knapsack_unbounded(weights, values, capacity))  # 输出: 12

3. 子集和问题(背包问题的变种)

def subset_sum(nums, target):
    """
    判断是否存在子集,使其和等于目标值
    :param nums: 数字列表
    :param target: 目标和
    :return: 是否存在这样的子集
    """
    # dp[i][j]表示:使用前i个数字,能否组成和为j
    n = len(nums)
    dp = [[False] * (target + 1) for _ in range(n + 1)]
    
    # 初始化:不选任何数字时,和为0是可行的
    for i in range(n + 1):
        dp[i][0] = True
    
    for i in range(1, n + 1):
        for j in range(1, target + 1):
            # 不选第i个数字
            dp[i][j] = dp[i-1][j]
            
            # 选第i个数字(如果j >= nums[i-1])
            if j >= nums[i-1] and dp[i-1][j - nums[i-1]]:
                dp[i][j] = True
    
    return dp[n][target]

# 测试
nums = [3, 34, 4, 12, 5, 2]
target = 9
print(f"是否存在和为{target}的子集:", subset_sum(nums, target))  # 输出: True (4+5)

实践练习

练习1:基础0-1背包

问题:有3个物品,重量分别为[1, 2, 3],价值分别为[6, 10, 12],背包容量为5。求最大价值。

预期输出:22

提示:可以直接使用上面的 knapsack_01 函数。

练习2:完全背包问题

问题:有3种物品,重量分别为[2, 3, 5],价值分别为[3, 4, 6],背包容量为10。每种物品可以选无限次,求最大价值。

预期输出:15

要求:实现一个一维DP数组的解法。

练习3:子集划分问题

问题:给定一个只包含正整数的非空数组,判断是否可以将其分割为两个元素和相等的子集。 示例:[1, 5, 11, 5] → True(可以分成[1, 5, 5]和[11])

预期输出:True

提示:这实际上是背包问题,目标是找出和为总和一半的子集。

常见错误

  1. 混淆0-1背包和完全背包的遍历顺序

    • 0-1背包:内层循环从大到小遍历容量(防止重复选择)
    • 完全背包:内层循环从小到大遍历容量(允许重复选择)
  2. DP数组初始化错误

    • 对于最大价值问题,应该初始化为0
    • 对于可行性问题(如子集和),需要正确初始化dp[0][0] = True
  3. 状态转移方程记忆错误

    • 正确:dp[i][w] = max(dp[i-1][w], dp[i-1][w-weight] + value)
    • 错误:使用dp[i][w-weight](这会导致无限背包的效果)
  4. 忽略边界条件

    • w < weight[i]时,只能选择不拿第i个物品
    • i = 0时(没有物品可选),所有dp[0][w]都应该为0
  5. 空间优化时的错误

    • 一维优化时,遍历方向错误会导致结果错误
    • 忘记从weight开始遍历,导致数组越界

小结

本课我们学习了背包问题这一经典的动态规划问题:

  1. 背包问题本质:在有限容量下做出最优选择
  2. 状态定义dp[i][w]表示前i个物品、容量为w时的最优解
  3. 转移方程dp[i][w] = max(不选, 选)
  4. 两类变种
    • 0-1背包:物品只能选一次
    • 完全背包:物品可选无限次
  5. 空间优化:完全背包可以优化为一维数组
  6. 扩展应用:子集和问题、分割等和子集等问题

背包问题是动态规划的重要基础,掌握了它的思想,可以帮助我们解决很多类似的优化问题。下一课我们将学习最长公共子序列,这是另一类经典的动态规划问题。

练习编辑器

rust
Loading...

继续学习

完成本课后,建议继续学习下一课「最长公共子序列」 以巩固所学知识。