背包DP
在C++中,背包动态规划(Knapsack DP) 是解决资源分配类问题的核心算法范式,尤其在处理物品选择与容量限制的组合优化问题时表现优异。以下是针对不同背包类型的详细解析与代码实现:
一、背包DP问题分类
类型 | 特点 | 经典问题场景 |
---|---|---|
0-1背包 | 每件物品 只能选0或1次 | 物品不可分割(如金条) |
完全背包 | 每件物品 可选无限次 | 硬币兑换(面额无限供应) |
多重背包 | 每件物品 最多选k次 | 有限库存的商品采购 |
分组背包 | 物品分组,每组选一件 | 课程选修(多门课选其一) |
二、0-1背包问题
问题描述
给定物品重量 weights[]
和价值 values[]
,背包容量 W
,求能装入的最大总价值。
状态定义
- 基础二维DP:
dp[i][w]
表示前i
个物品在容量w
下的最大价值 - 优化一维DP:
dp[w]
表示容量w
下的最大价值(空间压缩)
状态转移方程
- 不选第
i
件:dp[i][w] = dp[i-1][w]
- 选第
i
件:dp[i][w] = dp[i-1][w - weights[i]] + values[i]
- 综合:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weights[i]] + values[i])
C++实现
// 二维DP(清晰但空间占用大)
int knapsack_01_2D(vector<int>& weights, vector<int>& values, int W) {
int n = weights.size();
vector<vector<int>> dp(n+1, vector<int>(W+1, 0));
for (int i = 1; i <= n; ++i) {
for (int w = 0; w <= W; ++w) {
if (w < weights[i-1]) {
dp[i][w] = dp[i-1][w];
} else {
dp[i][w] = max(dp[i-1][w],
dp[i-1][w - weights[i-1]] + values[i-1]);
}
}
}
return dp[n][W];
}
// 一维DP(空间优化,必须逆序遍历容量)
int knapsack_01_1D(vector<int>& weights, vector<int>& values, int W) {
vector<int> dp(W + 1, 0);
for (int i = 0; i < weights.size(); ++i) {
for (int w = W; w >= weights[i]; --w) { // 逆向更新
dp[w] = max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[W];
}
关键点
- 逆序遍历容量:确保每个物品只被计算一次
- 索引对应:
weights[i-1]
对应二维版的第i
个物品(一维版直接weights[i]
)
三、完全背包问题
问题描述
物品可无限次选取,求装满背包的最大价值。
状态转移方程
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
(与0-1背包的唯一区别是 正序遍历容量)
C++实现
int knapsack_unbounded(vector<int>& weights, vector<int>& values, int W) {
vector<int> dp(W + 1, 0);
for (int i = 0; i < weights.size(); ++i) {
for (int w = weights[i]; w <= W; ++w) { // 正序遍历
dp[w] = max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[W];
}
应用场景
- 零钱兑换问题(LeetCode 322)
- 凑满容量的最小物品数(LeetCode 279)
四、多重背包问题
问题描述
每个物品有数量限制 counts[i]
,最多选 counts[i]
次。
优化方法
将多重背包转换为 0-1背包 + 二进制拆分,减少物品数量:
- 例如:将13个物品拆分为
1, 2, 4, 6
(组合可表示1~13)
C++实现
int knapsack_multiple(vector<int>& weights, vector<int>& values,
vector<int>& counts, int W) {
// 二进制拆分预处理
vector<int> new_weights, new_values;
for (int i = 0; i < weights.size(); ++i) {
int k = 1;
while (counts[i] > 0) {
int amount = min(k, counts[i]);
new_weights.push_back(weights[i] * amount);
new_values.push_back(values[i] * amount);
counts[i] -= amount;
k *= 2;
}
}
// 转换为0-1背包
vector<int> dp(W + 1, 0);
for (int i = 0; i < new_weights.size(); ++i) {
for (int w = W; w >= new_weights[i]; --w) {
dp[w] = max(dp[w], dp[w - new_weights[i]] + new_values[i]);
}
}
return dp[W];
}
五、分组背包问题
问题描述
物品被分为多组,每组只能选一个物品。
状态转移方程
dp[w] = max{ 不选该组 / 选该组第k个物品 }
C++实现
int group_knapsack(vector<vector<pair<int, int>>>& groups, int W) {
vector<int> dp(W + 1, 0);
for (auto& group : groups) { // 遍历每组
for (int w = W; w >= 0; --w) { // 逆序遍历容量
for (auto& item : group) { // 遍历组内物品
int weight = item.first, value = item.second;
if (w >= weight) {
dp[w] = max(dp[w], dp[w - weight] + value);
}
}
}
}
return dp[W];
}
六、常见问题与调试技巧
1. 初始化陷阱
- 要求恰好装满背包时:
dp[0] = 0
,其他初始化为-INF
- 不要求装满时:全部初始化为
0
2. 遍历顺序总结
背包类型 | 容量遍历顺序 | 物品遍历顺序 |
---|---|---|
0-1背包 | 逆序 | 先物品后容量 |
完全背包 | 正序 | 先物品后容量 |
分组背包 | 逆序 | 先组后容量再物品 |
3. 测试用例设计
- 边界测试:容量为0、物品重量为0
- 极端情况:所有物品重量超过容量
- 完全覆盖:验证状态转移的所有分支
七、典型例题与代码
1. 分割等和子集(LeetCode 416)
转化为0-1背包:找是否能凑出 sum/2
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 2 != 0) return false;
int target = sum / 2;
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int num : nums) {
for (int w = target; w >= num; --w) {
dp[w] = dp[w] || dp[w - num];
}
}
return dp[target];
}
2. 零钱兑换 II(LeetCode 518)
完全背包求组合数:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1;
for (int coin : coins) { // 先遍历物品(保证组合数)
for (int w = coin; w <= amount; ++w) {
dp[w] += dp[w - coin];
}
}
return dp[amount];
}
八、性能优化进阶
- 滚动数组:将二维DP压缩为两个一维数组交替使用
- 单调队列优化:适用于特定多重背包问题
- 位运算优化:当价值较小时,用bitset加速状态转移
掌握背包DP的关键在于 理解物品选择逻辑与遍历顺序的关系,并通过大量练习熟悉不同变种的解题模式。建议从标准0-1背包入手,逐步扩展到更复杂的变种。