1. 背包问题概述
背包问题是动态规划中最经典的问题类型之一,其核心思想是在有限容量的情况下选择物品以获得最大价值。背包问题主要分为以下几类:
- 0-1背包:每个物品只能选或不选
- 完全背包:每个物品可以选择无限次
- 多重背包:每个物品有数量限制
- 分组背包:物品分组,每组只能选一个
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 常见错误与调试技巧
- 遍历顺序错误:0-1背包倒序,完全背包正序
- 初始化错误:根据问题类型正确初始化dp[0]
- 数组越界:确保j - weight[i] >= 0
- 数据类型:方案数可能很大,使用long类型
7.5 练习建议
- 按顺序练习:先掌握0-1背包,再学完全背包
- 对比学习:对比不同问题的状态定义和转移方程
- 一题多解:尝试二维和一维两种解法
- 举一反三:思考问题如何转化为背包问题
8. 进阶思考
- 分组背包:物品分组,每组只能选一个
- 依赖背包:物品间有依赖关系(树形DP)
- 背包求具体方案:记录选择路径
- 背包可行性:判断是否能恰好装满
- 多重背包的单调队列优化:进一步优化时间复杂度
掌握背包问题后,你将能够解决一大类动态规划问题,并为学习更复杂的DP类型打下坚实基础。