引言
在计算机科学与运筹学中,背包问题(Knapsack Problem)是一类经典的组合优化问题。它不仅具有深厚的理论价值,还广泛应用于资源分配、投资决策、物流调度等多个现实场景。自20世纪中期被正式提出以来,背包问题一直是算法设计与复杂性理论研究的重要对象。本文将系统介绍背包问题的基本形式、分类、求解方法及其实际应用,并探讨其在现代计算中的意义。
一、什么是背包问题?
背包问题的基本设定如下:给定一个容量为 W 的背包和 n 个物品,每个物品 i 有一个重量 wi 和一个价值 vi。目标是在不超过背包容量的前提下,选择若干物品装入背包,使得所选物品的总价值最大。
这一看似简单的问题背后隐藏着复杂的组合爆炸特性——随着物品数量增加,可能的组合呈指数级增长,因此需要高效的算法策略来应对。
二、背包问题的主要类型
1. 0-1 背包问题
这是最基础的形式,每个物品只能选择“放入”或“不放入”,即取值为 0 或 1。数学表达为:
0-1 背包问题是 NP 完全问题,意味着目前尚无已知的多项式时间精确解法(除非 P=NP)。
2. 完全背包问题
在此变种中,每种物品可以无限次选取。即对每个物品 i,可以选择任意非负整数个。约束条件变为:
xi∈Z≥0
虽然仍属 NP-hard,但动态规划可高效处理。
3. 多重背包问题
每种物品有数量上限 ci,即最多可选 ci 个。这是 0-1 与完全背包的中间形态,更具现实意义(如库存有限的商品)。
4. 多维背包问题
物品不仅有重量,还有体积、成本等多个维度的限制,背包也对应多个容量约束。例如:
此类问题更贴近实际工程需求,但求解难度显著提升(本文不作探讨😊)
三、求解方法
1. 动态规划(Dynamic Programming)
对于 0-1 背包问题,经典解法是使用二维 DP 表:
定义 dp[i][w] 表示前 i 个物品在容量为 w 时能获得的最大价值。状态转移方程为:
时间复杂度为 O(nW),空间可通过滚动数组优化至 O(W)。
2. 贪心算法
贪心策略(如按单位重量价值排序)适用于分数背包问题(允许物品分割),此时可得最优解。但在 0-1 背包中,贪心仅能提供近似解,无法保证最优。
3. 分支限界法与回溯
通过系统地枚举所有可能组合,并利用上界剪枝,可在小规模实例中找到精确解。适合用于教学或验证。
4. 近似算法与启发式方法
对于大规模问题,常采用遗传算法、模拟退火、蚁群优化等元启发式方法,在可接受时间内获得高质量近似解。此外,存在多项式时间近似方案(PTAS)用于特定情形。
四、java代码
public class Knapsack {
/**
* 1. 0-1 背包问题
* 每种物品仅有一件,可以选择放或不放。
*/
public static int zeroOneKnapsack(int[] weights, int[] values, int capacity) {
int n = weights.length;
// dp[j] 表示容量为 j 时能获得的最大价值
int[] dp = new int[capacity + 1];
for (int i = 0; i < n; i++) {
// 关键点:容量从大到小遍历(倒序)
// 防止物品被重复放入
for (int j = capacity; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
/**
* 2. 完全背包问题
* 每种物品有无限件,可以重复选择。
*/
public static int completeKnapsack(int[] weights, int[] values, int capacity) {
int n = weights.length;
int[] dp = new int[capacity + 1];
for (int i = 0; i < n; i++) {
// 关键点:容量从小到大遍历(正序)
// 允许在已经放入物品 i 的基础上再次放入
for (int j = weights[i]; j <= capacity; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
/**
* 3. 多重背包问题
* 每种物品有有限件 (数量数组 counts)。
* 这里使用二进制优化,将其转化为 0-1 背包问题。
*/
public static int multipleKnapsack(int[] weights, int[] values, int[] counts, int capacity) {
// 用于存储拆分后的新物品重量和价值
java.util.List<Integer> newWeights = new java.util.ArrayList<>();
java.util.List<Integer> newValues = new java.util.ArrayList<>();
// 1. 二进制拆分优化
for (int i = 0; i < weights.length; i++) {
int count = counts[i];
// 将数量拆分为 1, 2, 4, 8, ... , remainder
for (int k = 1; k <= count; k *= 2) {
newWeights.add(k * weights[i]);
newValues.add(k * values[i]);
count -= k;
}
// 处理余数
if (count > 0) {
newWeights.add(count * weights[i]);
newValues.add(count * values[i]);
}
}
// 2. 对拆分后的新物品进行 0-1 背包求解
int[] dp = new int[capacity + 1];
for (int i = 0; i < newWeights.size(); i++) {
int w = newWeights.get(i);
int v = newValues.get(i);
// 标准 0-1 背包倒序遍历
for (int j = capacity; j >= w; j--) {
dp[j] = Math.max(dp[j], dp[j - w] + v);
}
}
return dp[capacity];
}
// --- 测试主函数 ---
public static void main(String[] args) {
// 共同的测试数据
int[] weights = {2, 3, 4, 5, 6}; // 物品重量
int[] values = {3, 4, 5, 6, 7}; // 物品价值
int capacity = 7; // 背包容量
// 1. 测试 0-1 背包
int result01 = zeroOneKnapsack(weights, values, capacity);
System.out.println("0-1背包最大价值: " + result01);
// 2. 测试 完全背包
int resultComplete = completeKnapsack(weights, values, capacity);
System.out.println("完全背包最大价值: " + resultComplete);
// 3. 测试 多重背包 (假设各物品数量为 )
int[] counts = {3, 1, 2, 1, 2};
int resultMultiple = multipleKnapsack(weights, values, counts, capacity);
System.out.println("多重背包最大价值: " + resultMultiple);
}
}
说明一下为啥01背包中不能正序遍历
🎯 场景设定
假设我们只有一个物品(物品0):
- 重量:1
- 价值:15
- 背包总容量:3
我们的目标是填满 dp 到 dp 这 4 个格子。
🔄 正序遍历(从小到大)的过程
正序就是:先算容量 1,再算容量 2,最后算容量 3。
-
初始化: 所有格子默认是 0。
dp =(下标: 0 1 2 3) -
开始填表(j 从 1 到 3) :
-
第一步:计算容量 j=1
- 现在背包容量是 1,刚好能放下物品0(重量1)。
- 公式:
dp = max(不放, 放) 放的逻辑是:现在的价值 = (容量为 0 时的价值) + 物品0的价值- 此时,
dp是多少?是 0(初始值)。 - 所以:
dp = 0 + 15 = 15 - 含义:容量为1时,我能拿价值15的东西。
-
第二步:计算容量 j=2
- 现在背包容量是 2,也能放下物品0。
- 公式:
dp = (容量为 1 时的价值) + 物品0的价值 - 关键点来了:此时
dp是多少?- 我们刚刚在第一步已经把它改成了 15!
- 所以:
dp = 15 + 15 = 30 - 含义:容量为2时,我拿了价值30的东西。*
- 逻辑漏洞:我是怎么拿到30的?我是基于
dp=15的基础上又拿了一次物品0。但dp=15本身就已经包含了一次物品0了。所以这里我实际上拿了两次物品0。
-
第三步:计算容量 j=3
- 公式:
dp = (容量为 2 时的价值) + 物品0的价值 - 此时
dp是多少?是 30(上一步刚算出来的)。 - 所以:
dp = 30 + 15 = 45
- 公式:
-
❓ 问题出在哪?
你看,在正序填表时,我们是从左往右填的。
- 当我们填右边的格子(比如 j=2)时,我们参考了左边格子(j=1)的值。
- 但左边的格子(j=1)已经被当前这个物品更新过了。
- 这就导致:我们把“已经拿过这个物品的状态”当成了“没拿过这个物品的初始状态”来用。
这就像是在说:“老板,我刚才已经领了一份工资(dp=15),我现在再干一次活,是不是能再领一份(dp=30)?” —— 这就是重复使用。
🆚 对比:倒序(从大到小)会发生什么?
倒序就是:先算容量 3,再算容量 2,最后算容量 1。
-
初始化:
dp =0 -
填表(j 从 3 到 1) :
-
第一步:计算 j=3
dp = dp + 15- 此时
dp是多少?是 0(因为还没来得及算,还是初始值)。 - 所以
dp = 0 + 15 = 15。
-
第二步:计算 j=2
dp = dp + 15- 此时
dp是多少?是 0(还没算)。 - 所以
dp = 0 + 15 = 15。
-
第三步:计算 j=1
dp = dp + 15dp是 0。- 所以
dp = 15。
-
分析: 因为是倒着填,右边的格子算的时候,左边的格子还是“原始状态”(没拿过这个物品)。所以无论你怎么算,你都是在“原始状态”上加一次物品的价值,这就保证了只拿一次。
📌 总结
- 正序:左边的数据会被先更新。右边计算时用了左边“已经被污染(更新过) ”的数据 -> 重复放入。
- 倒序:右边的数据先算,用的是左边“干净的(未更新) ”数据 -> 只放一次。
再说明一下为啥使用集合记忆已经使用的商品也不行
❌ 问题一:破坏了 DP 的“无后效性”原则
动态规划的核心前提之一是 “无后效性”(No Aftereffect) ,意思是:
当前状态只由之前的状态决定,而与“如何到达这个状态”的具体路径无关。
当你引入一个 Set 来记录“哪些物品已经被选过”,你就把路径信息(即选择了哪些物品)编码进了状态里。
- 在传统的 0-1 背包中,状态是
dp[j]—— 只关心“容量为 j 时的最大价值”。 - 如果加上集合,状态就变成了
(j, selected_set)—— 这个状态空间会爆炸!
❌ 问题二:状态无法压缩,空间爆炸
假设你有 N=30 个物品,那么所有可能的“已选物品集合”数量是 230≈109 种!
即使你用位掩码(bitmask)优化(比如用一个 int 表示选了哪些物品),状态数也会变成:
当 W=1000,N=30 时,状态数高达 1000 × 10亿 = 10¹²,根本无法存储或计算。
这比暴力枚举(O(2N))还差!
❌ 问题三:DP 数组无法定义转移方程
在标准 DP 中,我们能写出清晰的转移方程:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
但如果你的状态包含“已选集合 S”,那么转移方程会变成:
dp[j][S ∪ {i}] = max( dp[j][S ∪ {i}], dp[j - w[i]][S] + v[i] )
这要求你对每一个子集 S 都进行遍历和更新——本质上就是暴力搜索所有子集,时间复杂度回到 失去了 DP 的意义。
🆚 对比:什么情况下可以用 Set?
如果你不用动态规划,而是用 DFS + 回溯(暴力搜索) ,那确实可以用一个 Set 或布尔数组 used[] 来记录已选物品:
void dfs(int index, int currentWeight, int currentValue, boolean[] used) {
if (index == n) {
// 更新答案
return;
}
// 不选
dfs(index + 1, currentWeight, currentValue, used);
// 选(如果没选过且不超重)
if (!used[index] && currentWeight + w[index] <= W) {
used[index] = true;
dfs(index + 1, currentWeight + w[index], currentValue + v[index], used);
used[index] = false; // 回溯
}
}
但这只是指数级暴力算法,适用于 N≤20 的小规模问题。对于 N=100、W=1000 的典型背包问题,它会超时。
所以,不要试图在 DP 中用 Set 来防重复——这不是 DP 的设计哲学。DP 的强大之处,恰恰在于它用状态抽象和顺序控制,巧妙地绕开了对具体路径的追踪。