背包问题
背包问题是动态规划中的经典问题,它有多种变体,是算法竞赛中的常见题型。本章将详细介绍各类背包问题的解题思路和技巧。
01背包问题
【问题定义】有N件物品和一个容量为V的背包,每件物品有且只有一件。第i件物品的重量是w[i],价值是v[i]。求解将哪些物品装入背包,可使这些物品的总重量不超过背包容量,且总价值最大。
基本思路
使用动态规划求解,设dp[i][j]表示考虑前i件物品,背包容量为j时能达到的最大价值。
状态转移方程
对于第i件物品,有两种选择:不选择该物品或选择该物品。
- 不选择第i件物品:dp[i][j] = dp[i-1][j]
- 选择第i件物品(前提是能装下):dp[i][j] = dp[i-1][j-w[i]] + v[i]
因此,完整的状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) (if j ≥ w[i])
dp[i][j] = dp[i-1][j] (if j < w[i])
代码实现
二维数组实现:
// 01背包问题的二维数组实现
int knapsack01(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
// dp[i][j]: 考虑前i个物品,背包容量j下的最大价值
vector<vector<int>> dp(n + 1, vector<int>(capacity + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= capacity; j++) {
if (j < weights[i-1]) {
// 背包容量不足,无法放入第i个物品
dp[i][j] = dp[i-1][j];
} else {
// 可以选择放或不放第i个物品
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i-1]] + values[i-1]);
}
}
}
return dp[n][capacity];
}
空间优化
由于dp[i][j]只与dp[i-1][j]和dp[i-1][j-w[i]]有关,可以使用一维数组优化空间:
// 01背包问题的一维数组优化
int knapsack01_optimized(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
vector<int> dp(capacity + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = capacity; j >= weights[i]; j--) {
// 注意这里的遍历顺序:从大到小逆序遍历
dp[j] = max(dp[j], dp[j-weights[i]] + values[i]);
}
}
return dp[capacity];
}
【注意】一维数组实现中,必须从大到小逆序遍历容量j,确保每个物品只被考虑一次。
完全背包问题
【问题定义】有N种物品和一个容量为V的背包,每种物品有无限件。第i种物品的重量是w[i],价值是v[i]。求解将哪些物品装入背包,可使这些物品的总重量不超过背包容量,且总价值最大。
状态转移方程
与01背包不同,完全背包问题中每种物品可以选择多次,因此状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i]) (if j ≥ w[i])
dp[i][j] = dp[i-1][j] (if j < w[i])
注意区别:01背包中是dp[i-1][j-w[i]] + v[i]
,而完全背包中是dp[i][j-w[i]] + v[i]
,因为物品i可以被重复选择。
代码实现
// 完全背包问题
int unboundedKnapsack(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
vector<vector<int>> dp(n + 1, vector<int>(capacity + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= capacity; j++) {
if (j < weights[i-1]) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-weights[i-1]] + values[i-1]);
}
}
}
return dp[n][capacity];
}
空间优化
完全背包的一维数组优化与01背包类似,但遍历顺序不同:
// 完全背包问题的一维数组优化
int unboundedKnapsack_optimized(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
vector<int> dp(capacity + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = weights[i]; j <= capacity; j++) {
// 注意这里的遍历顺序:从小到大正序遍历
dp[j] = max(dp[j], dp[j-weights[i]] + values[i]);
}
}
return dp[capacity];
}
【注意】与01背包不同,完全背包需要从小到大正序遍历容量j,确保每个物品可以被多次选择。
多重背包问题
【问题定义】有N种物品和一个容量为V的背包。第i种物品最多有k[i]件,每件重量为w[i],价值为v[i]。求解将哪些物品装入背包,可使这些物品的总重量不超过背包容量,且总价值最大。
基本思路
多重背包可以看作是01背包和完全背包的混合:每种物品有限定数量。
朴素解法
通过枚举每种物品选择的数量来解决:
// 多重背包问题的朴素解法
int multiplePack(vector<int>& weights, vector<int>& values, vector<int>& counts, int capacity) {
int n = weights.size();
vector<int> dp(capacity + 1, 0);
for (int i = 0; i < n; i++) {
for (int j = capacity; j >= 0; j--) {
for (int k = 1; k <= counts[i] && k * weights[i] <= j; k++) {
dp[j] = max(dp[j], dp[j - k * weights[i]] + k * values[i]);
}
}
}
return dp[capacity];
}
二进制优化
当物品数量较大时,可以使用二进制优化来降低时间复杂度:
// 多重背包问题的二进制优化
int multiplePack_binary(vector<int>& weights, vector<int>& values, vector<int>& counts, int capacity) {
int n = weights.size();
vector<int> dp(capacity + 1, 0);
// 将多个相同物品拆分成二进制组
vector<int> new_weights, new_values;
for (int i = 0; i < n; i++) {
int count = counts[i];
for (int k = 1; k <= count; k *= 2) {
new_weights.push_back(weights[i] * k);
new_values.push_back(values[i] * k);
count -= k;
}
if (count > 0) {
new_weights.push_back(weights[i] * count);
new_values.push_back(values[i] * count);
}
}
// 转化为01背包问题
for (int i = 0; i < new_weights.size(); i++) {
for (int j = capacity; j >= new_weights[i]; j--) {
dp[j] = max(dp[j], dp[j - new_weights[i]] + new_values[i]);
}
}
return dp[capacity];
}
混合背包问题
【问题定义】有N种物品和一个容量为V的背包。物品有三类:
- 第一类物品只能用1次(01背包)
- 第二类物品可以用无限次(完全背包)
- 第三类物品最多用k次(多重背包)
解题思路
根据物品的类型分别处理:
// 混合背包问题
int mixedKnapsack(vector<int>& weights, vector<int>& values, vector<int>& types, vector<int>& counts, int capacity) {
int n = weights.size();
vector<int> dp(capacity + 1, 0);
for (int i = 0; i < n; i++) {
if (types[i] == 0) { // 01背包
for (int j = capacity; j >= weights[i]; j--) {
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
} else if (types[i] == 1) { // 完全背包
for (int j = weights[i]; j <= capacity; j++) {
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
} else { // 多重背包
// 二进制优化
int count = counts[i];
for (int k = 1; k <= count; k *= 2) {
int new_weight = weights[i] * k;
int new_value = values[i] * k;
for (int j = capacity; j >= new_weight; j--) {
dp[j] = max(dp[j], dp[j - new_weight] + new_value);
}
count -= k;
}
if (count > 0) {
int new_weight = weights[i] * count;
int new_value = values[i] * count;
for (int j = capacity; j >= new_weight; j--) {
dp[j] = max(dp[j], dp[j - new_weight] + new_value);
}
}
}
}
return dp[capacity];
}
分组背包问题
【问题定义】有N组物品和一个容量为V的背包。每组物品有若干个,同一组内的物品最多只能选一个。每件物品的重量是w[i][j],价值是v[i][j],其中i是组号,j是组内编号。求解将哪些物品装入背包,可使物品总重量不超过背包容量,且总价值最大。
解题思路
针对每一组物品,枚举该组选择哪件物品:
// 分组背包问题
int groupKnapsack(vector<vector<int>>& weights, vector<vector<int>>& values, int capacity) {
int n = weights.size(); // 组数
vector<int> dp(capacity + 1, 0);
for (int i = 0; i < n; i++) { // 枚举每一组
for (int j = capacity; j >= 0; j--) { // 枚举背包容量
for (int k = 0; k < weights[i].size(); k++) { // 枚举该组的每个物品
if (j >= weights[i][k]) {
dp[j] = max(dp[j], dp[j - weights[i][k]] + values[i][k]);
}
}
}
}
return dp[capacity];
}
二维背包问题
【问题定义】有N件物品和一个容量为(V,M)的背包,这里有两个限制维度(如体积和重量)。第i件物品需要消耗背包的空间为(v[i],m[i]),价值是w[i]。求解将哪些物品装入背包,可使物品的总体积不超过V,总重量不超过M,且总价值最大。
解题思路
扩展到三维DP数组,dp[i][j][k]表示考虑前i个物品,体积不超过j,重量不超过k的最大价值:
// 二维费用背包问题
int twoDimensionKnapsack(vector<int>& weights, vector<int>& volumes, vector<int>& values, int capacity_weight, int capacity_volume) {
int n = weights.size();
vector<vector<int>> dp(capacity_weight + 1, vector<int>(capacity_volume + 1, 0));
for (int i = 0; i < n; i++) {
for (int j = capacity_weight; j >= weights[i]; j--) {
for (int k = capacity_volume; k >= volumes[i]; k--) {
dp[j][k] = max(dp[j][k], dp[j - weights[i]][k - volumes[i]] + values[i]);
}
}
}
return dp[capacity_weight][capacity_volume];
}
背包问题求方案数
【问题变形】除了求最大价值外,还可能需要求达到最大价值的方案数量。
解题思路
维护两个DP数组,一个记录最大价值,一个记录达到该价值的方案数:
// 背包问题求方案数
pair<int, int> knapsackWithCount(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
int mod = 1e9 + 7;
vector<int> dp(capacity + 1, 0); // 最大价值
vector<int> count(capacity + 1, 0); // 方案数量
count[0] = 1; // 初始状态有1种方案
for (int i = 0; i < n; i++) {
for (int j = capacity; j >= weights[i]; j--) {
int new_value = dp[j - weights[i]] + values[i];
if (new_value > dp[j]) {
// 找到更大的价值,更新最大价值和方案数
dp[j] = new_value;
count[j] = count[j - weights[i]];
} else if (new_value == dp[j]) {
// 相同价值,方案数累加
count[j] = (count[j] + count[j - weights[i]]) % mod;
}
}
}
return {dp[capacity], count[capacity]};
}
背包问题输出方案
【问题变形】需要输出具体选择了哪些物品。
解题思路
记录每一步的选择,然后回溯:
// 背包问题输出方案
vector<int> knapsackWithSolution(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
vector<vector<int>> dp(n + 1, vector<int>(capacity + 1, 0));
vector<vector<bool>> selected(n + 1, vector<bool>(capacity + 1, false));
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= capacity; j++) {
if (j < weights[i-1]) {
dp[i][j] = dp[i-1][j];
selected[i][j] = false;
} else {
int not_take = dp[i-1][j];
int take = dp[i-1][j-weights[i-1]] + values[i-1];
if (take > not_take) {
dp[i][j] = take;
selected[i][j] = true;
} else {
dp[i][j] = not_take;
selected[i][j] = false;
}
}
}
}
// 回溯找出选择的物品
vector<int> solution;
int i = n, j = capacity;
while (i > 0) {
if (selected[i][j]) {
solution.push_back(i-1); // 选择了第i个物品
j -= weights[i-1];
}
i--;
}
return solution;
}
多维背包、泛化背包问题
背包问题可以扩展到多个维度,或者添加更多的约束条件。解决这类问题的核心思想是理解状态定义和转移方程,然后根据具体问题调整DP数组的维度和转移方式。
背包问题变种
恰好装满背包
当要求恰好装满背包时,初始化方式有所不同:
- dp[0] = 0
- dp[j] = -∞ (j > 0)
// 恰好装满背包的01背包问题
int knapsack01_exact(vector<int>& weights, vector<int>& values, int capacity) {
int n = weights.size();
vector<int> dp(capacity + 1, INT_MIN); // 除了dp[0]=0外,其他初始化为负无穷
dp[0] = 0;
for (int i = 0; i < n; i++) {
for (int j = capacity; j >= weights[i]; j--) {
dp[j] = max(dp[j], dp[j-weights[i]] + values[i]);
}
}
// 如果结果为负无穷,表示无法恰好装满
return dp[capacity] < 0 ? -1 : dp[capacity];
}
最小代价问题
有时背包问题可以反向思考,寻找达到某一目标的最小代价:
// 最小代价背包问题
int minCostKnapsack(vector<int>& weights, vector<int>& costs, int target_weight) {
int n = weights.size();
vector<int> dp(target_weight + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i < n; i++) {
for (int j = target_weight; j >= weights[i]; j--) {
if (dp[j - weights[i]] != INT_MAX) {
dp[j] = min(dp[j], dp[j - weights[i]] + costs[i]);
}
}
}
return dp[target_weight] == INT_MAX ? -1 : dp[target_weight];
}
背包问题解题技巧
- 正确的遍历顺序:
- 01背包:容量逆序遍历
- 完全背包:容量正序遍历
- 空间优化:多数背包问题都可以使用一维DP数组优化空间
- 初始化:根据问题要求(是否需要恰好装满)选择合适的初始化方式
- 转换思路:某些变种问题通过转换思路,可以归约为标准背包问题
练习题目推荐
- POJ 3624: Charm Bracelet (经典01背包)
- POJ 1276: Cash Machine (完全背包)
- POJ 1742: Coins (多重背包)
- POJ 2392: Space Elevator (分组背包变形)
- POJ 1837: Balance (带负重的背包)
- POJ 3260: The Fewest Coins (混合背包)
总结
背包问题是算法竞赛中的经典问题,掌握其基本思路和各种变形对于解决动态规划问题非常有帮助。通过不断练习,你可以熟悉各种背包问题的解题模式,并将这些思想迁移到更复杂的问题中。