2动态规划进阶:背包问题详解与实战

1. 背包问题概述

背包问题是动态规划中最经典的问题类型之一,其核心思想是在有限容量的情况下选择物品以获得最大价值。背包问题主要分为以下几类:

  1. 0-1背包:每个物品只能选或不选
  2. 完全背包:每个物品可以选择无限次
  3. 多重背包:每个物品有数量限制
  4. 分组背包:物品分组,每组只能选一个

2. 0-1背包问题详解

2.1 经典0-1背包问题

问题描述:有N个物品和一个容量为W的背包,每个物品有重量weight[i]和价值value[i],求不超过背包容量的最大价值。

状态定义

dp[i][j]:前i个物品,背包容量为j时的最大价值

状态转移方程
复制代码
dp[i][j] = max(
    dp[i-1][j],                      // 不选第i个物品
    dp[i-1][j-weight[i-1]] + value[i-1]  // 选第i个物品
) if j >= weight[i-1]
完整Python实现
python 复制代码
def knapsack_01(N, W, weight, value):
    """
    0-1背包问题基础版本
    :param N: 物品数量
    :param W: 背包容量
    :param weight: 物品重量列表
    :param value: 物品价值列表
    :return: 最大价值
    """
    # 初始化DP数组,dp[i][j]表示前i个物品容量为j时的最大价值
    dp = [[0] * (W + 1) for _ in range(N + 1)]
    
    # 动态规划填表
    for i in range(1, N + 1):
        for j in range(1, W + 1):
            # 不选第i个物品
            dp[i][j] = dp[i-1][j]
            
            # 如果能装下第i个物品,考虑选择它
            if j >= weight[i-1]:
                dp[i][j] = max(dp[i][j], 
                              dp[i-1][j-weight[i-1]] + value[i-1])
    
    return dp[N][W]

#### 空间优化版本(一维数组)
def knapsack_01_optimized(N, W, weight, value):
    """
    0-1背包问题空间优化版本
    关键点:需要从右向左遍历,保证每个物品只被选一次
    """
    dp = [0] * (W + 1)
    
    for i in range(N):
        # 必须从后往前遍历,防止重复选择
        for j in range(W, weight[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
    
    return dp[W]

#### Java实现
```java
public class Knapsack01 {
    // 基础二维DP版本
    public int knapsack01(int N, int W, int[] weight, int[] value) {
        int[][] dp = new int[N + 1][W + 1];
        
        for (int i = 1; i <= N; i++) {
            for (int j = 1; j <= W; j++) {
                // 不选当前物品
                dp[i][j] = dp[i-1][j];
                
                // 如果能选当前物品
                if (j >= weight[i-1]) {
                    dp[i][j] = Math.max(dp[i][j], 
                                       dp[i-1][j - weight[i-1]] + value[i-1]);
                }
            }
        }
        
        return dp[N][W];
    }
    
    // 空间优化版本(一维数组)
    public int knapsack01Optimized(int N, int W, int[] weight, int[] value) {
        int[] dp = new int[W + 1];
        
        for (int i = 0; i < N; i++) {
            // 从右向左遍历,保证每个物品只选一次
            for (int j = W; j >= weight[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }
        
        return dp[W];
    }
}

3. 0-1背包问题变种

3.1 分割等和子集 (LeetCode 416)

问题描述:判断数组是否可以被分割成两个子集,使得两个子集的元素和相等。

问题转化

这个问题可以转化为0-1背包问题:

  • 背包容量 = 总和的一半
  • 物品重量 = 物品价值 = 数组元素
  • 目标:是否能恰好装满背包
Python实现
python 复制代码
def canPartition(nums):
    """
    分割等和子集
    思路:转化为0-1背包问题
    """
    total = sum(nums)
    # 如果总和是奇数,不可能分割
    if total % 2 != 0:
        return False
    
    target = total // 2
    n = len(nums)
    
    # dp[j]表示能否凑出和为j
    dp = [False] * (target + 1)
    dp[0] = True  # 和为0总是可以凑出
    
    for num in nums:
        # 从后往前遍历,防止重复使用
        for j in range(target, num - 1, -1):
            dp[j] = dp[j] or dp[j - num]
    
    return dp[target]

# 二维版本(便于理解)
def canPartition_2D(nums):
    total = sum(nums)
    if total % 2 != 0:
        return False
    
    target = total // 2
    n = len(nums)
    
    # dp[i][j]表示前i个元素能否凑出和为j
    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):
            dp[i][j] = dp[i-1][j]  # 不选当前元素
            if j >= nums[i-1]:
                dp[i][j] = dp[i][j] or dp[i-1][j - nums[i-1]]
    
    return dp[n][target]
Java实现
java 复制代码
public class PartitionEqualSubset {
    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];
    }
}

3.2 目标和 (LeetCode 494)

问题描述:给定一个非负整数数组,给每个数添加+或-,使得总和等于目标值S。

数学转化

设加正号的数字和为P,加负号的数字和为N,则有:

复制代码
P + N = sum(nums)
P - N = S

解得:P = (sum + S) / 2

问题转化为:从nums中选若干个数,使得和为P(0-1背包问题)

Python实现
python 复制代码
def findTargetSumWays(nums, S):
    """
    目标和问题
    """
    total = sum(nums)
    # 边界条件检查
    if abs(S) > total or (total + S) % 2 != 0:
        return 0
    
    target = (total + S) // 2
    
    # dp[j]表示凑出和为j的方法数
    dp = [0] * (target + 1)
    dp[0] = 1  # 凑出和为0的方法有1种(什么都不选)
    
    for num in nums:
        for j in range(target, num - 1, -1):
            dp[j] += dp[j - num]
    
    return dp[target]

#### Java实现
```java
public class TargetSum {
    public int findTargetSumWays(int[] nums, int S) {
        int total = 0;
        for (int num : nums) total += num;
        
        if (Math.abs(S) > total || (total + S) % 2 != 0) {
            return 0;
        }
        
        int target = (total + S) / 2;
        int[] dp = new int[target + 1];
        dp[0] = 1;
        
        for (int num : nums) {
            for (int j = target; j >= num; j--) {
                dp[j] += dp[j - num];
            }
        }
        
        return dp[target];
    }
}

3.3 最后一块石头的重量 II (LeetCode 1049)

问题描述:每次选择两块石头碰撞,求最后剩下的最小可能重量。

问题转化

问题等价于:将石头分成两堆,使两堆的重量差最小。

即:在总重量不超过sum/2的情况下,尽可能多地装石头(0-1背包)

Python实现
python 复制代码
def lastStoneWeightII(stones):
    """
    最后一块石头的重量 II
    """
    total = sum(stones)
    target = total // 2
    
    # dp[j]表示能否凑出重量为j
    dp = [False] * (target + 1)
    dp[0] = True
    
    for stone in stones:
        for j in range(target, stone - 1, -1):
            dp[j] = dp[j] or dp[j - stone]
    
    # 找到能凑出的最大重量(不超过target)
    for j in range(target, -1, -1):
        if dp[j]:
            return total - 2 * j
    
    return total
Java实现
java 复制代码
public class LastStoneWeightII {
    public int lastStoneWeightII(int[] stones) {
        int total = 0;
        for (int stone : stones) total += stone;
        
        int target = total / 2;
        boolean[] dp = new boolean[target + 1];
        dp[0] = true;
        
        for (int stone : stones) {
            for (int j = target; j >= stone; j--) {
                dp[j] = dp[j] || dp[j - stone];
            }
        }
        
        for (int j = target; j >= 0; j--) {
            if (dp[j]) {
                return total - 2 * j;
            }
        }
        
        return total;
    }
}

4. 完全背包问题

4.1 经典完全背包问题

问题描述:每种物品有无限个,求不超过背包容量的最大价值。

状态转移方程(二维)
复制代码
dp[i][j] = max(
    dp[i-1][j],                      // 不选第i种物品
    dp[i][j-weight[i-1]] + value[i-1]   // 选第i种物品(关键:用dp[i]而不是dp[i-1])
) if j >= weight[i-1]
Python实现
python 复制代码
def complete_knapsack(N, W, weight, value):
    """
    完全背包问题基础版本
    """
    dp = [[0] * (W + 1) for _ in range(N + 1)]
    
    for i in range(1, N + 1):
        for j in range(1, W + 1):
            dp[i][j] = dp[i-1][j]
            if j >= weight[i-1]:
                # 注意这里用dp[i][j-weight[i-1]],表示可以重复选
                dp[i][j] = max(dp[i][j], 
                              dp[i][j-weight[i-1]] + value[i-1])
    
    return dp[N][W]

#### 空间优化版本
def complete_knapsack_optimized(N, W, weight, value):
    """
    完全背包空间优化版本
    关键点:从左向右遍历,允许重复选择
    """
    dp = [0] * (W + 1)
    
    for i in range(N):
        # 从左向右遍历,允许重复选择
        for j in range(weight[i], W + 1):
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
    
    return dp[W]
Java实现
java 复制代码
public class CompleteKnapsack {
    // 完全背包基础版本
    public int completeKnapsack(int N, int W, int[] weight, int[] value) {
        int[][] dp = new int[N + 1][W + 1];
        
        for (int i = 1; i <= N; i++) {
            for (int j = 1; j <= W; j++) {
                dp[i][j] = dp[i-1][j];
                if (j >= weight[i-1]) {
                    // 关键区别:用dp[i][j-weight[i-1]]而不是dp[i-1][...]
                    dp[i][j] = Math.max(dp[i][j], 
                                       dp[i][j - weight[i-1]] + value[i-1]);
                }
            }
        }
        
        return dp[N][W];
    }
    
    // 空间优化版本
    public int completeKnapsackOptimized(int N, int W, int[] weight, int[] value) {
        int[] dp = new int[W + 1];
        
        for (int i = 0; i < N; i++) {
            // 从左向右遍历
            for (int j = weight[i]; j <= W; j++) {
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
            }
        }
        
        return dp[W];
    }
}

5. 完全背包问题变种

5.1 零钱兑换 I (LeetCode 322)

问题描述:给定不同面额的硬币,计算可以凑成总金额所需的最少硬币个数。

Python实现
python 复制代码
def coinChange(coins, amount):
    """
    零钱兑换 I - 最小硬币数量
    完全背包问题,求最小值
    """
    # 初始化DP数组,dp[j]表示凑出金额j所需的最少硬币数
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0  # 金额为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] != float('inf') else -1

#### 另一种实现(先遍历金额,再遍历硬币)
def coinChange_alternative(coins, amount):
    """
    另一种遍历顺序
    这种方法可以处理硬币无限取的情况
    """
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    
    for i in range(1, amount + 1):
        for coin in coins:
            if i >= coin:
                dp[i] = min(dp[i], dp[i - coin] + 1)
    
    return dp[amount] if dp[amount] != float('inf') else -1
Java实现
java 复制代码
public class CoinChange {
    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];
    }
}

5.2 零钱兑换 II (LeetCode 518)

问题描述:计算可以凑成总金额的硬币组合数(顺序不同的序列视为相同的组合)。

Python实现
python 复制代码
def change(amount, coins):
    """
    零钱兑换 II - 组合数
    完全背包问题,求方案数
    """
    dp = [0] * (amount + 1)
    dp[0] = 1  # 金额为0的组合数为1(不选任何硬币)
    
    # 注意遍历顺序:先遍历硬币,再遍历金额
    # 这样可以保证组合数(不考虑顺序)
    for coin in coins:
        for j in range(coin, amount + 1):
            dp[j] += dp[j - coin]
    
    return dp[amount]

# 对比:如果先遍历金额再遍历硬币,得到的是排列数
def change_permutation(amount, coins):
    """
    计算排列数(顺序不同视为不同)
    """
    dp = [0] * (amount + 1)
    dp[0] = 1
    
    for j in range(1, amount + 1):
        for coin in coins:
            if j >= coin:
                dp[j] += dp[j - coin]
    
    return dp[amount]
Java实现
java 复制代码
public class CoinChangeII {
    public int change(int amount, int[] coins) {
        int[] dp = new int[amount + 1];
        dp[0] = 1;
        
        for (int coin : coins) {
            for (int j = coin; j <= amount; j++) {
                dp[j] += dp[j - coin];
            }
        }
        
        return dp[amount];
    }
}

5.3 完全平方数 (LeetCode 279)

问题描述:找到和为n的完全平方数的最少数量。

问题转化

完全背包问题:

  • 物品:完全平方数 1, 4, 9, 16, ...
  • 背包容量:n
  • 目标:求最少物品数量
Python实现
python 复制代码
def numSquares(n):
    """
    完全平方数
    """
    # 生成所有完全平方数
    squares = [i*i for i in range(1, int(n**0.5) + 1)]
    
    dp = [float('inf')] * (n + 1)
    dp[0] = 0
    
    for square in squares:
        for j in range(square, n + 1):
            dp[j] = min(dp[j], dp[j - square] + 1)
    
    return dp[n]
Java实现
java 复制代码
public class PerfectSquares {
    public int numSquares(int n) {
        int[] dp = new int[n + 1];
        Arrays.fill(dp, n + 1);
        dp[0] = 0;
        
        for (int i = 1; i * i <= n; i++) {
            int square = i * i;
            for (int j = square; j <= n; j++) {
                dp[j] = Math.min(dp[j], dp[j - square] + 1);
            }
        }
        
        return dp[n];
    }
}

5.4 单词拆分 (LeetCode 139)

问题描述:判断字符串是否可以被拆分为字典中的单词。

问题转化

完全背包问题:

  • 物品:字典中的单词
  • 背包容量:字符串长度
  • 目标:是否能恰好装满背包
Python实现
python 复制代码
def wordBreak(s, wordDict):
    """
    单词拆分
    """
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True  # 空字符串总是可以拆分
    
    for i in range(1, n + 1):
        for word in wordDict:
            word_len = len(word)
            if i >= word_len and dp[i - word_len]:
                if s[i - word_len:i] == word:
                    dp[i] = True
                    break  # 找到一个匹配就可以跳出
    
    return dp[n]

# 优化版本:先判断单词长度
def wordBreak_optimized(s, wordDict):
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    
    # 将字典转换为集合,方便查找
    word_set = set(wordDict)
    max_len = max(len(word) for word in wordDict) if wordDict else 0
    
    for i in range(1, n + 1):
        # 只检查可能的单词长度
        for j in range(max(0, i - max_len), i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break
    
    return dp[n]
Java实现
java 复制代码
public class WordBreak {
    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();
        boolean[] dp = new boolean[n + 1];
        dp[0] = true;
        
        Set<String> wordSet = new HashSet<>(wordDict);
        
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j < i; j++) {
                if (dp[j] && wordSet.contains(s.substring(j, i))) {
                    dp[i] = true;
                    break;
                }
            }
        }
        
        return dp[n];
    }
}

6. 多重背包问题

问题描述:每种物品有数量限制,既不是0-1也不是无限。

解法:转化为0-1背包

可以通过二进制拆分将多重背包转化为0-1背包:

  • 例如:物品有7个,可以拆分为1+2+4(二进制表示)
Python实现
python 复制代码
def multiple_knapsack(N, W, weight, value, count):
    """
    多重背包问题
    :param count: 每种物品的数量限制
    """
    # 二进制拆分
    new_weight = []
    new_value = []
    
    for i in range(N):
        k = 1
        remaining = count[i]
        
        # 二进制拆分
        while remaining >= k:
            new_weight.append(weight[i] * k)
            new_value.append(value[i] * k)
            remaining -= k
            k *= 2
        
        # 剩下的部分
        if remaining > 0:
            new_weight.append(weight[i] * remaining)
            new_value.append(value[i] * remaining)
    
    # 转化为0-1背包问题
    M = len(new_weight)
    dp = [0] * (W + 1)
    
    for i in range(M):
        for j in range(W, new_weight[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - new_weight[i]] + new_value[i])
    
    return dp[W]

7. 背包问题总结与对比

7.1 遍历顺序对比

问题类型 物品遍历 容量遍历 特点
0-1背包 正序 倒序 防止重复选择
完全背包 正序 正序 允许重复选择
多重背包 二进制拆分后按0-1背包 转化为0-1背包

7.2 问题类型总结

问题 背包类型 目标 初始化 状态转移
经典0-1背包 0-1背包 最大价值 dp[0]=0 max
分割等和子集 0-1背包 能否恰好装满 dp[0]=True or
目标和 0-1背包 方案数 dp[0]=1 +
最后一块石头 0-1背包 最大不超过一半 dp[0]=True or
零钱兑换I 完全背包 最小数量 dp[0]=0, 其他inf min
零钱兑换II 完全背包 组合数 dp[0]=1 +
完全平方数 完全背包 最小数量 dp[0]=0, 其他inf min
单词拆分 完全背包 能否拆分 dp[0]=True or

7.3 解题模板

python 复制代码
def backpack_template(weights, values, capacity, problem_type):
    """
    背包问题通用模板
    problem_type: '01' 或 'complete'
    """
    n = len(weights)
    dp = [0] * (capacity + 1)
    
    if problem_type == '01':
        # 0-1背包:倒序遍历容量
        for i in range(n):
            for j in range(capacity, weights[i] - 1, -1):
                dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    else:
        # 完全背包:正序遍历容量
        for i in range(n):
            for j in range(weights[i], capacity + 1):
                dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    
    return dp[capacity]

7.4 常见错误与调试技巧

  1. 遍历顺序错误:0-1背包倒序,完全背包正序
  2. 初始化错误:根据问题类型正确初始化dp[0]
  3. 数组越界:确保j - weight[i] >= 0
  4. 数据类型:方案数可能很大,使用long类型

7.5 练习建议

  1. 按顺序练习:先掌握0-1背包,再学完全背包
  2. 对比学习:对比不同问题的状态定义和转移方程
  3. 一题多解:尝试二维和一维两种解法
  4. 举一反三:思考问题如何转化为背包问题

8. 进阶思考

  1. 分组背包:物品分组,每组只能选一个
  2. 依赖背包:物品间有依赖关系(树形DP)
  3. 背包求具体方案:记录选择路径
  4. 背包可行性:判断是否能恰好装满
  5. 多重背包的单调队列优化:进一步优化时间复杂度

掌握背包问题后,你将能够解决一大类动态规划问题,并为学习更复杂的DP类型打下坚实基础。

相关推荐
YH12312359h2 小时前
战斗机目标检测与跟踪:YOLOv26算法详解与应用
算法·yolo·目标检测
芒克芒克2 小时前
LeetCode 134. 加油站(O(n)时间+O(1)空间最优解)
java·算法·leetcode·职场和发展
TracyCoder1233 小时前
LeetCode Hot100(4/100)——283. 移动零
算法·leetcode
啊阿狸不会拉杆3 小时前
《计算机操作系统》第七章 - 文件管理
开发语言·c++·算法·计算机组成原理·os·计算机操作系统
黎阳之光3 小时前
打破视域孤岛,智追目标全程 —— 公安视频追踪技术革新来袭
人工智能·算法·安全·视频孪生·黎阳之光
jiaguangqingpanda3 小时前
Day28-20260124
java·数据结构·算法
TracyCoder1233 小时前
LeetCode Hot100(2/100)——49. 字母异位词分组 (Group Anagrams)。
算法·leetcode
lixinnnn.3 小时前
字符串拼接:Cities and States S
开发语言·c++·算法
AI街潜水的八角3 小时前
医学图像算法之基于MK_UNet的肾小球分割系统3:含训练测试代码、数据集和GUI交互界面
算法