1. 问题描述
有依赖的背包问题是一种在01背包问题的基础上增加了物品之间的依赖关系的背包问题。也就是说,某些物品必须放在另一些物品之后才能放入背包,或者某些物品只有在另一些物品被放入背包时才能放入背包。
例如,假设有5件物品,每件物品有重量和价值两个属性,还有一个承重为10的背包。物品之间的依赖关系如下:
- 物品1没有依赖
- 物品2只有在物品1被放入背包时才能放入
- 物品3只有在物品1被放入背包时才能放入
- 物品4没有依赖
- 物品5只有在物品4被放入背包时才能放入
物品的重量和价值如下:
物品 | 重量 | 价值 |
---|---|---|
1 | 2 | 3 |
2 | 4 | 4 |
3 | 3 | 5 |
4 | 5 | 6 |
5 | 6 | 8 |
在总重量不超过背包承载上限的情况下,能够装入背包的最大价值是多少?
2. 算法
这个问题由NOIP2006金明的预算方案一题扩展而来。遵从该题的提法,
将不依赖于别的物品的物品称为“主件”,
依赖于某主件的物品称为“附件”。
由这个问题的简化条件可知所有的物品由若干主件和依赖于每个主件的一个附件集合组成。
按照背包问题的一般思路,仅考虑一个主件和它的附件集合。
可是,可用的策略非常多,包括:一个也不选,仅选择主件,选择主件后再选择一个附件,选择主件后再选择两个附件……无法用状态转移方程来表示如此多的策略。(事实上,设有n个附件,则策略有2^n+1个,为指数级。)
考虑到所有这些策略都是互斥的(也就是说,你只能选择一种策略),所以一个主件和它的附件集合实际上对应于P06中的一个物品组,每个选择了主件又选择了若干个附件的策略对应于这个物品组中的一个物品,其费用和价值都是这个策略中的物品的值的和。但仅仅是这一步转化并不能给出一个好的算法,因为物品组中的物品还是像原问题的策略一样多。
再考虑P06中的一句话: 可以对每组中的物品应用P02中“一个简单有效的优化”。 这提示我们,对于一个物品组中的物品,所有费用相同的物品只留一个价值最大的,不影响结果。所以,我们可以对主件i的“附件集合”先进行一次01背包,得到费用依次为0..V-c[i]所有这些值时相应的最大价值f'[0..V-c[i]]。那么这个主件及它的附件集合相当于V-c[i]+1个物品的物品组,其中费用为c[i]+k的物品的价值为f'[k]+w[i]。也就是说原来指数级的策略中有很多策略都是冗余的,通过一次01背包后,将主件i转化为V-c[i]+1个物品的物品组,就可以直接应用P06的算法解决问题了。
3. 解答过程
3.1 状态定义
我们可以借鉴01背包问题的思路,定义状态dp[i][j]表示考虑前i件物品,且所选物品总重量不超过j时获得的最大价值。
但是由于存在依赖关系,我们不能简单地对每件物品进行选择或不选择的判断。我们需要先将物品按照依赖关系分组,然后对每组物品进行选择或不选择的判断。
具体来说,我们可以将没有依赖的物品作为主件,将只能跟随主件放入背包的物品作为附件。例如,在上面的例子中,我们可以将物品分为两组:
- 组1:主件为物品1,附件为物品2和3
- 组2:主件为物品4,附件为物品5
注意,如果一个主件没有附件,那么它就是一个单独的组。如果一个附件没有主件,那么它就不能被放入背包。
3.2 状态转移
对于每一组物品,我们有以下几种选择:
- 不选择这一组
- 只选择主件
- 主件+附件1
- 主件+附件2
- 主件+附件1+附件2
注意,这里假设每组最多有两个附件,如果有更多的附件,可以类似地进行扩展。
那么状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]]+v[i], dp[i-1][j-w[i]-w[a1]]+v[i]+v[a1], dp[i-1][j-w[i]-w[a2]]+v[i]+v[a2], dp[i-1][j-w[i]-w[a1]-w[a2]]+v[i]+v[a1]+v[a2])
其中,i表示第i组物品,j表示背包剩余容量,w[i]表示第i组主件的重量,v[i]表示第i组主件的价值,a1和a2分别表示第i组的两个附件,w[a1]和w[a2]分别表示附件的重量,v[a1]和v[a2]分别表示附件的价值。
3.3 初始状态
我们可以将dp[0][0…C]初始化为0,表示没有物品时获得的最大价值为0。
3.4 最终结果
我们可以从dp[N][C]中得到最终结果,即考虑所有物品且背包容量为C时获得的最大价值。
4. 具体实现
4.1 二维数组实现
public class Solution {
public int solveKnapsack(int[] weights, int[] values, int[][] dependencies, int capacity) {
// 物品数量
int n = weights.length;
// 物品分组
List<List<Integer>> groups = new ArrayList<>();
// 主件索引
Map<Integer, Integer> mainIndex = new HashMap<>();
// 分组编号
int groupId = 0;
// 遍历所有物品
for (int i = 0; i < n; i++) {
// 如果没有依赖,作为主件
if (dependencies[i][0] == -1) {
List<Integer> group = new ArrayList<>();
group.add(i);
groups.add(group);
mainIndex.put(i, groupId);
groupId++;
}
}
// 遍历所有物品
for (int i = 0; i < n; i++) {
// 如果有依赖,作为附件
if (dependencies[i][0] != -1) {
int main = dependencies[i][0];
int index = mainIndex.get(main);
groups.get(index).add(i);
}
}
// 分组数量
int m = groups.size();
// 定义状态数组
int[][] dp = new int[m + 1][capacity + 1];
// 初始化状态数组
for (int j = 0; j <= capacity; j++) {
dp[0][j] = 0;
}
// 状态转移
for (int i = 1; i <= m; i++) {
List<Integer> group = groups.get(i - 1);
int main = group.get(0);
for (int j = 0; j <= capacity; j++) {
// 不选择这一组
dp[i][j] = dp[i - 1][j];
// 只选择主件
if (j >= weights[main]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - weights[main]] + values[main]);
}
// 主件+附件1
if (group.size() > 1) {
int a1 = group.get(1);
if (j >= weights[main] + weights[a1]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - weights[main] - weights[a1]] + values[main] + values[a1]);
}
}
// 主件+附件2
if (group.size() > 2) {
int a2 = group.get(2);
if (j >= weights[main] + weights[a2]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - weights[main] - weights[a2]] + values[main] + values[a2]);
}
}
// 主件+附件1+附件2
if (group.size() > 2) {
int a1 = group.get(1);
int a2 = group.get(2);
if (j >= weights[main] + weights[a1] + weights[a2]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - weights[main] - weights[a1] - weights[a2]] + values[main] + values[a1] + values[a2]);
}
}
}
}
// 返回最终结果
return dp[m][capacity];
}
}
有依赖的背包问题的代码优化方案可能有以下几种:
-
使用树形DP的思路,将每个物品的子树看作一个物品组,然后用分组背包的方法求解。这样可以将时间复杂度降低到O (NW),空间复杂度也可以优化到O (W)。具体可以参考动态规划之背包问题系列 - 知乎中的有依赖的背包问题一节。
-
使用二进制优化的思路,将每个物品和其附件看作一个新的物品,然后用01背包的方法求解。这样可以将时间复杂度降低到O (NW \log N),空间复杂度也可以优化到O (W)。具体可以参考第一章 动态规划 背包问题之有依赖的背包问题 - CSDN博客中的金明的预算方案一题。
-
使用01背包的思路,将每个物品拆分成若干件只能取0或1件的物品,然后用01背包的方法求解。这样可以将时间复杂度降低到O (NW \frac W {\bar {w}}),空间复杂度也可以优化到O (W)。具体可以参考动态规划之背包问题系列 - 知乎中的完全背包问题一节。
-
使用滚动数组或者滑动窗口的思路,将dp数组压缩成一维或者二维,然后根据状态转移方程正向或逆向枚举。这样可以将空间复杂度降低到O (W)或者O (2W)。具体可以参考【动态规划】01背包及其优化详解_SWEENEY_HE的博客-CSDN博客中的01背包及其优化详解一节。
4.2 一维数组的实现
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class Main {
// 定义一个物品类,包含重量、价值和依赖的物品编号
static class Item {
int weight;
int value;
int depend;
public Item(int weight, int value, int depend) {
this.weight = weight;
this.value = value;
this.depend = depend;
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
// 输入物品数量和背包容量
int n = sc.nextInt();
int W = sc.nextInt();
// 创建一个物品数组,下标从1开始
Item[] items = new Item[n + 1];
// 输入每个物品的重量、价值和依赖
for (int i = 1; i <= n; i++) {
int weight = sc.nextInt();
int value = sc.nextInt();
int depend = sc.nextInt();
items[i] = new Item(weight, value, depend);
}
sc.close();
// 调用solve方法求解最大价值
System.out.println(solve(items, n, W));
}
// 定义一个solve方法,接收物品数组、物品数量和背包容量,返回最大价值
public static int solve(Item[] items, int n, int W) {
// 创建一个dp数组,表示背包容量为j时的最大价值
int[] dp = new int[W + 1];
// 创建一个二维列表,表示每个物品的子树(即物品组)
List<List<Integer>> groups = new ArrayList<>();
// 遍历每个物品
for (int i = 1; i <= n; i++) {
// 如果物品没有依赖,说明是主件,创建一个新的物品组
if (items[i].depend == 0) {
List<Integer> group = new ArrayList<>();
group.add(i);
groups.add(group);
} else {
// 如果物品有依赖,说明是附件,加入到对应的物品组中
groups.get(items[i].depend - 1).add(i);
}
}
// 遍历每个物品组
for (List<Integer> group : groups) {
// 逆向枚举背包容量
for (int j = W; j >= 0; j--) {
// 遍历每种取法(二进制表示)
for (int k = 0; k < (1 << group.size()); k++) {
// 计算当前取法的总重量和总价值
int weightSum = 0;
int valueSum = 0;
for (int l = 0; l < group.size(); l++) {
if ((k & (1 << l)) != 0) { // 如果第l位为1,表示取第l个物品
weightSum += items[group.get(l)].weight;
valueSum += items[group.get(l)].value;
}
}
// 如果总重量不超过背包容量,更新dp数组
if (weightSum <= j) {
dp[j] = Math.max(dp[j], dp[j - weightSum] + valueSum);
}
}
}
}
// 返回最大价值
return dp[W];
}
}
好的,我可以给你一个测试用例,但是你最好自己检查一下结果是否正确。😊
以下是一个测试用例,来自动态规划之背包问题系列 - 知乎中的有依赖的背包问题一节:
输入:
5 10
2 1 0
2 2 1
6 5 0
4 4 3
4 6 3
输出:
10
5. 较一般的问题
更一般的问题是:依赖关系以图论中“森林”的形式给出(森林即多叉树的集合),也就是说,主件的附件仍然可以具有自己的附件集合,限制只是每个物品最多只依赖于一个物品(只有一个主件)且不出现循环依赖。
解决这个问题仍然可以用将每个主件及其附件集合转化为物品组的方式。唯一不同的是,由于附件可能还有附件,就不能将每个附件都看作一个一般的01背包中的物品了。若这个附件也有附件集合,则它必定要被先转化为物品组,然后用分组的背包问题解出主件及其附件集合所对应的附件组中各个费用的附件所对应的价值。
事实上,这是一种树形DP,其特点是每个父节点都需要对它的各个儿子的属性进行一次DP以求得自己的相关属性。这已经触及到了“泛化物品”的思想。看完P08后,你会发现这个“依赖关系树”每一个子树都等价于一件泛化物品,求某节点为根的子树对应的泛化物品相当于求其所有儿子的对应的泛化物品之和。
6. 小结
NOIP2006的那道背包问题我做得很失败,写了上百行的代码,却一分未得。后来我通过思考发现通过引入“物品组”和“依赖”的概念可以加深对这题的理解,还可以解决它的推广问题。用物品组的思想考虑那题中极其特殊的依赖关系:物品不能既作主件又作附件,每个主件最多有两个附件,可以发现一个主件和它的两个附件等价于一个由四个物品组成的物品组,这便揭示了问题的某种本质。
我想说:失败不是什么丢人的事情,从失败中全无收获才是。
7. 参考资料
这篇博客介绍了有依赖的背包问题的概念和两个例题,分别是金明的预算方案和有依赖的背包问题。它给出了状态定义,状态转移和代码实现,并且用图示和表格来辅助理解。
这篇博客是一个背包问题的专题,其中有一节是关于有依赖的背包问题的。它也给出了一个例题和代码实现,并且用树形DP的思路来分析状态转移。
这篇博客也是一个背包问题的专题,其中有一节是关于有依赖的背包问题的。它也给出了一个例题和代码实现,并且用分组背包的思路来分析状态转移。
这篇博客也是一个背包问题的专题,其中有一节是关于有依赖的背包问题的。它也给出了一个例题和代码实现,并且用二进制优化的思路来分析状态转移。