1. 背包问题概述:从生活场景到算法模型
背包问题(Knapsack Problem)是计算机科学中最经典的优化问题之一,也是动态规划算法的典型应用场景。想象你即将开始一次长途旅行,行李箱的容量有限,而你有许多想带的物品,每件物品都有不同的体积和价值。如何选择物品组合,才能在不超过行李箱容量的前提下,带走最大总价值的物品?这就是背包问题的现实原型。
在算法竞赛和实际工程中,背包问题有以下主要变种:
- 01背包:每件物品最多选一次(拿或不拿)
- 完全背包:每种物品可以选无限次
- 多重背包:每种物品有数量限制
- 分组背包:物品分组,每组只能选一件
- 二维费用背包:物品消耗两种资源(如体积和重量)
理解这些变种的解法差异,是掌握动态规划思想的重要里程碑。本文将从最基础的01背包开始,逐步深入各类背包问题的解法精髓。
2. 01背包问题详解
2.1 问题定义与状态设计
给定N件物品和一个容量为V的背包,第i件物品的体积为w[i],价值为v[i]。每件物品只能选择拿或不拿(不能分割),求在不超过背包容量的前提下能获得的最大价值。
动态规划的核心在于状态定义和状态转移。对于01背包,我们定义:
dp[i][j]:考虑前i件物品,背包容量为j时能获得的最大价值
这个二维状态的设计思路是:通过逐步考虑更多物品(i从1到N)和更大容量(j从0到V),最终dp[N][V]就是问题的解。
2.2 状态转移方程推导
对于第i件物品,我们有两种选择:
- 不选:则最大价值等于前i-1件物品在容量j时的最大价值,即
dp[i][j] = dp[i-1][j] - 选:需要保证当前容量j ≥ w[i],此时最大价值为前i-1件物品在容量j-w[i]时的最大价值加上v[i],即
dp[i][j] = dp[i-1][j-w[i]] + v[i]
综合两种情况,状态转移方程为:
python复制dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) if j >= w[i]
else dp[i-1][j]
2.3 基础实现代码
python复制N, V = map(int, input().split())
dp = [[0] * (V + 1) for _ in range(N + 1)]
for i in range(1, N + 1):
w, v = map(int, input().split())
for j in range(V + 1):
if w > j:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w] + v)
print(dp[N][V])
这个实现的时间复杂度是O(NV),空间复杂度也是O(NV)。对于大多数编程竞赛题目,当V在1e5以内时这个解法是可行的。
2.4 空间优化:滚动数组
观察状态转移方程可以发现,dp[i][j]只依赖于dp[i-1][...],即前一行的数据。因此我们可以将空间复杂度优化到O(V)。
2.4.1 双数组滚动
python复制N, V = map(int, input().split())
dp = [[0] * (V + 1) for _ in range(2)] # 只需要两行
for i in range(1, N + 1):
w, v = map(int, input().split())
for j in range(V + 1):
if w > j:
dp[i % 2][j] = dp[(i-1) % 2][j]
else:
dp[i % 2][j] = max(dp[(i-1) % 2][j], dp[(i-1) % 2][j-w] + v)
print(dp[N % 2][V])
2.4.2 单数组逆序更新
更极致的优化是使用单个一维数组,关键在于逆序更新:
python复制N, V = map(int, input().split())
dp = [0] * (V + 1)
for i in range(1, N + 1):
w, v = map(int, input().split())
for j in range(V, w - 1, -1): # 从后往前更新
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
为什么必须逆序更新?
正序更新会导致同一物品被多次选择。例如,处理物品(w=2,v=3)时:
- j=2: dp[2] = max(0, dp[0]+3) = 3
- j=4: dp[4] = max(0, dp[2]+3) = 6 → 相当于选了两次
逆序更新保证了每个物品只被考虑一次。
3. 完全背包问题
3.1 问题定义与状态转移
完全背包与01背包的唯一区别是:每种物品可以选无限次。状态定义相同,但转移方程需要考虑选多个的情况:
dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i]) if j >= w[i]
注意第二个项是dp[i][j-w[i]]而非dp[i-1][j-w[i]],这表示可以重复选择当前物品。
3.2 优化实现
使用一维数组时,只需将01背包的逆序改为正序:
python复制N, V = map(int, input().split())
dp = [0] * (V + 1)
for i in range(1, N + 1):
w, v = map(int, input().split())
for j in range(w, V + 1): # 正序更新
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
为什么正序更新可行?
正序更新时,dp[j-w]可能已经包含当前物品的选择,相当于允许重复选取。例如处理物品(w=2,v=3):
- j=2: dp[2] = max(0, dp[0]+3) = 3
- j=4: dp[4] = max(0, dp[2]+3) = 6 → 相当于选了两次
4. 多重背包问题
4.1 问题定义
每种物品有数量限制s[i],即最多选s[i]个。状态转移方程需要考虑选0到s[i]个的所有可能:
dp[i][j] = max(dp[i-1][j-k*w[i]] + k*v[i]) for k in 0..s[i]且k*w[i] <= j
4.2 二进制拆分优化
直接实现的时间复杂度是O(NVS),当s[i]较大时会超时。优化思路是将s[i]个物品拆分成若干组,每组包含1,2,4,...,2^k个物品的组合,然后转化为01背包问题。
python复制N, V = map(int, input().split())
items = []
for _ in range(N):
w, v, s = map(int, input().split())
k = 1
while s >= k:
items.append((w * k, v * k))
s -= k
k *= 2
if s > 0:
items.append((w * s, v * s))
dp = [0] * (V + 1)
for w, v in items:
for j in range(V, w - 1, -1):
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
这种优化将时间复杂度降为O(NV log S)。
5. 二维费用背包问题
5.1 问题定义
物品除了体积w[i],还有重量m[i],背包有体积限制V和重量限制M。状态需要增加一维:
dp[i][j][k]:前i件物品,体积j,重量k时的最大价值
状态转移方程类似01背包:
python复制dp[i][j][k] = max(dp[i-1][j][k], dp[i-1][j-w][k-m] + v)
5.2 空间优化实现
python复制N, V, M = map(int, input().split())
dp = [[0] * (M + 1) for _ in range(V + 1)]
for _ in range(N):
w, m, v = map(int, input().split())
for j in range(V, w - 1, -1):
for k in range(M, m - 1, -1):
dp[j][k] = max(dp[j][k], dp[j - w][k - m] + v)
print(dp[V][M])
6. 分组背包问题
6.1 问题定义
物品被分为若干组,每组物品互斥(每组最多选一件)。状态转移需要考虑每组中的所有物品:
dp[i][j] = max(dp[i-1][j], max(dp[i-1][j-w[k]] + v[k] for k in group i))
6.2 实现代码
python复制N, V = map(int, input().split())
groups = []
for _ in range(N):
s = int(input())
group = [tuple(map(int, input().split())) for _ in range(s)]
groups.append(group)
dp = [0] * (V + 1)
for group in groups:
for j in range(V, -1, -1):
for w, v in group:
if j >= w:
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
7. 实战技巧与常见错误
7.1 初始化技巧
- 求最大价值:初始化为0(表示不选任何物品时价值为0)
- 求恰好装满时的最大价值:初始化
dp[0]=0,其余为-∞(表示非法状态)
7.2 常见错误
-
混淆01背包和完全背包的更新顺序:
- 01背包必须逆序更新
- 完全背包需要正序更新
-
多重背包未优化导致超时:
- 当s[i]很大时,必须使用二进制拆分优化
-
分组背包重复选择:
- 需要将组内物品循环放在容量循环内部
7.3 性能优化建议
- 提前过滤不可能物品:如果w[i] > V,可以直接跳过
- 合并相同物品:对于完全背包,可以合并相同w和v的物品
- 使用位运算加速:在二进制拆分时,用移位代替乘除
8. 典型例题解析
8.1 蓝桥杯真题:小明的背包1(01背包)
python复制def solve():
N, V = map(int, input().split())
dp = [0] * (V + 1)
for _ in range(N):
w, v = map(int, input().split())
for j in range(V, w - 1, -1):
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
solve()
8.2 蓝桥杯真题:小明的背包2(完全背包)
python复制def solve():
N, V = map(int, input().split())
dp = [0] * (V + 1)
for _ in range(N):
w, v = map(int, input().split())
for j in range(w, V + 1):
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
solve()
8.3 蓝桥杯真题:小明的背包3(多重背包)
python复制def solve():
N, V = map(int, input().split())
items = []
for _ in range(N):
w, v, s = map(int, input().split())
k = 1
while s >= k:
items.append((w * k, v * k))
s -= k
k *= 2
if s > 0:
items.append((w * s, v * s))
dp = [0] * (V + 1)
for w, v in items:
for j in range(V, w - 1, -1):
dp[j] = max(dp[j], dp[j - w] + v)
print(dp[V])
solve()
9. 总结与进阶思考
背包问题的核心在于理解状态设计和转移方程。通过本文的系统讲解,你应该已经掌握了:
- 各类背包问题的状态定义和转移方程
- 空间优化技巧(滚动数组、逆序更新)
- 多重背包的二进制拆分优化
- 实际编码中的常见陷阱和优化技巧
在实际工程中,背包问题有许多变种和应用,例如:
- 求方案数而非最大价值
- 物品体积和价值相关(如体积等于价值)
- 背包容量超大但物品数少时的折半搜索
理解这些基础变种的解法后,你可以尝试解决更复杂的背包问题变种,如树形背包、依赖背包等。动态规划的精髓在于"状态设计"和"无后效性",背包问题为此提供了绝佳的训练场。