有依赖的背包

1 问题引入

真实场景里,物品之间常有「要买 A 必须先买 B」的约束:

定义有依赖的背包

有 nn 件物品和一个容量为 CC 的背包。每件物品有重量 wiw_i、价值 viv_i,以及一个依赖 pip_i: pi=0p_i=0 表示它不依赖任何物品(称主件),pi>0p_i>0 表示选了它就必须选 pip_i。 依赖关系保证无环,每件物品最多依赖一件。求最大价值。

样例:

7 10
2 3 0
3 4 1
4 5 1
1 2 0
2 6 4
3 3 4
1 1 0

读作:1 和 4、7 是主件;2、3 依赖 1;5、6 依赖 4。

2 先看简化版:主件 + 至多两个附件

这就是 NOIP2006「金明的预算方案」的模型。每个主件最多带两个附件,且附件不再有附件。

一个主件和它的附件,可能的买法只有几种:

  1. 都不买;
  2. 只买主件;
  3. 主件 + 附件 1;
  4. 主件 + 附件 2;
  5. 主件 + 两个附件。

这五种买法互斥(你只能选一种),所以「一个主件及其附件」恰好是一个物品组——正是分组背包的模型。把每种买法算成一个「物品」,组内至多选一个,用分组背包求解:

// 有依赖的背包(简化版,NOIP2006 金明的预算方案):每个主件最多 2 个附件
//
// 一个主件和它的附件,可能的买法是:不买、只买主件、主件+附件1、主件+附件2、
// 主件+两个附件 —— 至多 5 种,且互斥。所以「主件及其附件」等价于一个物品组,
// 用分组背包求解:容量倒序,组内枚举买法。
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e3 + 5;

int n, C;
int f[maxn];
int w[maxn], v[maxn];

int att1[maxn], att2[maxn];   // 每个主件的第 1、2 个附件的编号,0 表示没有

int main() {
    cin >> n >> C;

    int mainIdx[maxn], mainCnt = 0;
    for (int i = 1; i <= n; ++i) {
        int p;
        cin >> w[i] >> v[i] >> p;
        if (p == 0) mainIdx[++mainCnt] = i;
        else if (att1[p] == 0) att1[p] = i;
        else att2[p] = i;
    }

    for (int t = 1; t <= mainCnt; ++t) {
        int m = mainIdx[t];
        int c1 = att1[m], c2 = att2[m];

        // 先枚举容量(倒序),再枚举这一组的互斥买法
        for (int j = C; j >= 0; --j) {
            int best = f[j];                                   // 不买
            if (j >= w[m])                                     // 只买主件
                best = max(best, f[j - w[m]] + v[m]);
            if (c1 && j >= w[m] + w[c1])                       // 主件 + 附件1
                best = max(best, f[j - w[m] - w[c1]] + v[m] + v[c1]);
            if (c2 && j >= w[m] + w[c2])                       // 主件 + 附件2
                best = max(best, f[j - w[m] - w[c2]] + v[m] + v[c2]);
            if (c1 && c2 && j >= w[m] + w[c1] + w[c2])         // 主件 + 两个附件
                best = max(best, f[j - w[m] - w[c1] - w[c2]] + v[m] + v[c1] + v[c2]);
            f[j] = best;
        }
    }
    cout << f[C] << endl;
    return 0;
}

样例输出:

16
注意这个写法的前提

main_attach.cpp 假设每个主件最多两个附件、且附件没有自己的附件。如果数据不满足这个前提(比如附件还带附件),它会给出错误答案。放宽这个限制要用下面的树形 DP。

3 一般情形:依赖是一棵森林

去掉「最多两个附件」的限制后,依赖关系就是一片森林:主件是根,附件是它的儿子,附件还能有附件。

加一个虚拟根 00,把所有主件挂在它下面,森林就变成一棵树。选了儿子就必须选父亲,于是可以自底向上 DP:

设 f(u,j)f(u,j) 表示「以 uu 为根的子树里,包含 uu、总花费为 jj 时的最大价值」。

对每个点 uu:

  1. 先把 uu 自己放进去:j≥wuj\ge w_u 时 f(u,j)=vuf(u,j)=v_u,否则无解;
  2. 再依次把每个儿子 sonson 的 DP 数组合并进来。合并时枚举「分给已合并部分多少容量」:
f(u,j)=max⁡0≤k≤j{ f(u, j−k)+f(son, k) } f(u,j)=\max_{0\le k\le j}\Bigl\{\,f(u,\,j-k)+f(son,\,k)\,\Bigr\}
  1. 合并完所有儿子后,f(u,⋅)f(u,\cdot) 就是 uu 这棵子树的答案。

由于 f(u,⋅)f(u,\cdot) 强制包含 uu,「包含儿子就包含父亲」自动成立。

// 有依赖的背包:依赖关系是一棵森林,加虚拟根 0 变成一棵树
// 选了儿子就必须选父亲,所以对每个点 u:
//   1. 先把所有儿子的 dp 数组合并进来(每个儿子是一个「物品组」,至多选一个方案)
//   2. 再强制把 u 自己放进去(j >= w[u] 时才有值,否则无解)
// 儿子用链式前向星存,不依赖 STL 容器
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e3 + 5;
const int NEG = -1e9;

int n, C;
int w[maxn], v[maxn];
int f[maxn][maxn];      // f[u][j]:以 u 为根的子树,装了 u,花费 j 的最大价值

int head[maxn], nxt[maxn], to[maxn], ecnt;   // 链式前向星存儿子

void add(int p, int u) {                     // p 是父亲,u 是儿子
    to[++ecnt] = u; nxt[ecnt] = head[p]; head[p] = ecnt;
}

void dfs(int u) {
    // 先把 u 自己放进去:花费 w[u] 才有价值 v[u]
    for (int j = 0; j <= C; ++j)
        f[u][j] = (j >= w[u]) ? v[u] : NEG;

    for (int e = head[u]; e; e = nxt[e]) {
        int son = to[e];
        dfs(son);
        // 合并儿子:枚举分给「已合并部分」多少容量
        for (int j = C; j >= 0; --j) {
            for (int k = 0; k <= j; ++k) {
                if (f[u][j - k] == NEG || f[son][k] == NEG) continue;
                f[u][j] = max(f[u][j], f[u][j - k] + f[son][k]);
            }
        }
    }
}

int main() {
    cin >> n >> C;
    for (int i = 1; i <= n; ++i) {
        int p;
        cin >> w[i] >> v[i] >> p;
        add(p, i);                  // p=0 时自然挂到虚拟根 0 下
    }

    dfs(0);

    // 根 0 不占容量、不产生价值,答案就是 f[0][C](可以什么都不选)
    cout << max(0, f[0][C]) << endl;
    return 0;
}

样例输出:

17
说明为什么合并时容量要倒序

合并儿子时,f(u,j−k)f(u,j-k) 必须还是「没合并这个儿子」的旧值。若正序枚举 jj,同一个儿子可能被重复计入。这与 01 背包倒序是同一个道理。

4 和「泛化物品」的联系

把 f(u,⋅)f(u,\cdot) 看成一个数组,它就是一个函数:给它分配费用 jj,能得到价值 f(u,j)f(u,j)。

这正是下一讲要讲的「泛化物品」。依赖关系树的每一棵子树都等价于一件泛化物品,求某个点对应的泛化物品,就等于把所有儿子的泛化物品加上自己,再求和。

所以树形 DP 的合并操作,本质上就是「泛化物品之和」的运算:

f(u,j)=max⁡0≤k≤j{h(k)+l(j−k)} f(u,j)=\max_{0\le k\le j}\bigl\{h(k)+l(j-k)\bigr\}

5 小结

有依赖的背包有两个层次:

  • 浅层:主件 + 少量附件的固定组合 → 枚举所有互斥买法 → 变成物品组 → 分组背包。
  • 深层:任意深度的依赖森林 → 加虚拟根 → 树形 DP,儿子合并进父亲,强制选父亲。

两种做法的共同点是把互斥的选择打包成一个「物品」。区别只是打包的方式:简化版手工枚举,一般版用 DP 算出来。

6 相关题解

暂无相关题解