经典动态规划问题 | 背包问题详解
目录
1. 场景引入:打包行李
想象你要去旅行,有一个背包,它的承重有限(比如只能装 4 公斤)。
你面前有几种物品,每个物品都有它的重量和价值(比如重要性、价格)。
目标: 在不超过背包承重的前提下,让包里的物品总价值最大。
2. 0/1 背包问题(最基础版本)
规则: 每种物品只有一个。你要么拿走它(1),要么不拿(0),不能把物品切开,也不能拿多个。
2.1 核心逻辑:选还是不选?
对于每一个物品,我们只有两个选择:
- 不选它: 背包容量不变,价值不变,接着看下一个物品。
- 选它: 背包容量减少(减去该物品重量),价值增加(加上该物品价值),接着看下一个物品。
我们要做的就是:在这两种选择中,挑一个价值更大的。
2.2 动态规划表格(DP Table)
我们用一个表格来记录“中间结果”。
定义 dp[i][w] 表示:面对前 i 个物品,当背包容量为 w 时,能获得的最大价值。
i范围:0 到 物品总数w范围:0 到 背包最大容量
状态转移方程(核心公式):
对于第 i 个物品(重量 wt,价值 val):
- 如果背包放不下它 (
w < wt):- 只能不选。
dp[i][w] = dp[i-1][w]
- 只能不选。
- 如果背包放得下它 (
w >= wt):- 我们要比较“不选”和“选”哪个更好:
- 不选: 价值等于前
i-1个物品在容量w下的最大价值。 ->dp[i-1][w] - 选: 价值等于
当前物品价值+前 i-1 个物品在剩余容量 (w-wt) 下的最大价值。 ->val + dp[i-1][w-wt] - 取最大值:
dp[i][w] = max(不选,选)
2.3 Python 代码实现 (二维数组版)
这是最容易理解的版本,完全对应上面的表格。
def knapsack_01(weights, values, capacity):
n = len(weights)
# 1. 初始化 dp 表格 (n+1 行,capacity+1 列)
# dp[i][w] 表示前 i 个物品,容量为 w 时的最大价值
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
# 2. 开始填表
for i in range(1, n + 1): # 遍历每个物品 (从 1 到 n)
w_i = weights[i-1] # 当前物品重量 (注意索引是 i-1)
v_i = values[i-1] # 当前物品价值
for w in range(capacity + 1): # 遍历每个容量 (从 0 到 capacity)
# 情况 1: 背包容量不够,装不下当前物品
if w < w_i:
dp[i][w] = dp[i-1][w]
# 情况 2: 背包容量够,决策“选”还是“不选”
else:
# 不选:价值等于上一行同容量的值
not_take = dp[i-1][w]
# 选:当前价值 + 剩余容量的最优解
take = v_i + dp[i-1][w - w_i]
# 取最大值
dp[i][w] = max(not_take, take)
# 3. 返回右下角的值,即最终答案
return dp[n][capacity]
# 测试
weights = [1, 3, 4]
values = [15, 20, 30]
cap = 4
print(knapsack_01(weights, values, cap)) # 输出 35
2.4 空间优化 (一维数组版)
注意: 这一步是为了节省内存,逻辑一样,但代码更简洁。
观察上面的公式 dp[i][w] 只依赖于 dp[i-1]... 也就是说,我们只需要上一行的数据。
我们可以把二维数组压缩成一维数组 dp[w]。
关键点: 内层循环必须倒序遍历容量!
- 为什么倒序? 因为如果我们正序遍历,计算
dp[w]时可能会用到已经更新过的dp[w-w_i](这意味着当前物品被用了两次),这违反了 0/1 背包“每个物品只能用一次”的规则。倒序可以保证用到的是“上一轮”的数据。
def knapsack_01_optimized(weights, values, capacity):
n = len(weights)
# 初始化一维数组,dp[w] 表示容量为 w 时的最大价值
dp = [0] * (capacity + 1)
for i in range(n): # 遍历每个物品
w_i = weights[i]
v_i = values[i]
# 【关键】必须倒序遍历容量
# 从 capacity 到 w_i,确保每个物品只被计算一次
for w in range(capacity, w_i - 1, -1):
dp[w] = max(dp[w], dp[w - w_i] + v_i)
return dp[capacity]
3. 完全背包问题(变种):每种物品无限多个
规则变化: 每种物品无限多个。你可以拿 1 个 A,也可以拿 5 个 A,只要背包装得下。
3.1 区别在哪里?
- 0/1 背包: 物品只能选一次。所以内层循环要倒序(防止重复选)。
- 完全背包: 物品可以选多次。所以内层循环要正序(允许重复选)。
为什么正序就可以重复选?
当你计算 dp[w] 时,如果用到了 dp[w - w_i],而 dp[w - w_i] 是在本轮循环中刚刚更新过的(因为正序),那就说明 dp[w - w_i] 里可能已经包含了一个当前物品 i。
那么 dp[w] = dp[w - w_i] + v_i 就相当于放了两个物品 i。这正是完全背包想要的。
3.2 Python 代码 (完全背包)
def knapsack_complete(weights, values, capacity):
n = len(weights)
# 1. 初始化 dp 表格 (n+1 行,capacity+1 列)
# dp[i][w] 表示前 i 个物品,容量为 w 时的最大价值
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
# 2. 开始填表
for i in range(1, n + 1): # 遍历每个物品 (从 1 到 n)
w_i = weights[i-1] # 当前物品重量 (注意索引是 i-1)
v_i = values[i-1] # 当前物品价值
for w in range(capacity + 1): # 遍历每个容量 (从 0 到 capacity)
# 情况 1: 背包容量不够,装不下当前物品
if w < w_i:
dp[i][w] = dp[i-1][w]
# 情况 2: 背包容量够,决策“选”还是“不选”
else:
# 不选:价值等于上一行同容量的值
not_take = dp[i-1][w]
# 选:当前价值 + 剩余容量的最优解
take = v_i + dp[i][w - w_i] # 01 背包问题这里是 dp[i-1][w - w_i]
# 取最大值
dp[i][w] = max(not_take, take)
# 3. 返回右下角的值,即最终答案
return dp[n][capacity]
3.3 空间优化 (一维数组版)
对比上面的 0/1 背包优化代码,只改了一行(循环方向)。
def knapsack_complete(weights, values, capacity):
n = len(weights)
dp = [0] * (capacity + 1)
for i in range(n): # 遍历每个物品
w_i = weights[i]
v_i = values[i]
# 【关键】完全背包需要正序遍历容量
# 允许在同一轮循环中重复使用当前物品
for w in range(w_i, capacity + 1):
dp[w] = max(dp[w], dp[w - w_i] + v_i)
return dp[capacity]
4. 总结与对比
| 特性 | 0/1 背包 | 完全背包 |
|---|---|---|
| 物品数量 | 每个只有 1 个 | 每个有无限个 |
| 核心决策 | 选 或 不选 | 选 0 个、1 个、2 个... |
| 一维 DP 循环方向 | 倒序 (从大到小) | 正序 (从小到大) |
| 原因 | 防止同一物品被重复计算 | 允许同一物品被重复计算 |
| 状态转移 | dp[w] = max(dp[w], dp[w-wt] + val) |
公式一样,但遍历顺序不同 |

浙公网安备 33010602011771号