NeFut Logo NeFut
EN 管理员登录

树形背包问题:拓扑依赖与动态规划的完美结合

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #Dynamic Programming #Tree

核心逻辑与数学原理

树形背包(Tree-dependent Knapsack),又称有依赖的背包问题,是树形动态规划中最具威力的经典进阶模型。它的核心本质是将图论中的拓扑树状依赖关系与组合数学中的分组背包状态网络进行高维融合

1. 树状依赖的几何降维

在线性背包问题中,各类物品之间是并列、独立的。而在树形背包中,如果一个节点 $u$ 被选入背包,它的所有子节点 $v \in \text{son}(u)$ 才有资格被放入背包;反之,若父节点 $u$ 未被选择,则其整棵子树将被强制“剪枝”。

这种复杂的拓扑依赖,通过 DFS 的后序遍历可以被局部化解耦:

对于任何一个父节点 $u$,在选定它自身的前提下,它的每一个直接子节点 $v$ 都可以看作是一个独立的分组。分给子树 $v$ 不同的体积 $k$,就会产生不同的最大价值收益。这在本质上就是一个分组背包问题

2. 多项式乘法与状态卷积的物理意义

设父节点 $u$ 分配给其子树 $v$ 的总体积为 $k$。那么子树 $v$ 内部的各种物品组合,在体积为 $k$ 的限制下所能贡献的最大价值,就是一个完整的、已固化的局部最优状态。父节点 $u$ 把总容量 $j$ 分配给旗下各个子树的过程,在数学上等价于对多个多项式进行状态卷积(Convolution)

$$f[u][j] = \max \left( f[u][j], f[u][j - k] + f[v][k] \right)$$

其中 $j$ 倒序循环,确保了在更新当前子树 $v$ 的状态时,使用的是尚未被子树 $v$ 污染的父节点前驱状态(严格遵守分组背包的互斥控制)。


状态设计与算法推导

1. 状态空间定义

设 $f[u][j]$ 表示在以 $u$ 为根的子树中,投入总体积(或耗费总资金)恰好或不超过 $j$ 时所能获得的最大价值。

2. 状态转移方程推导

对于节点 $u$,设其自身体积为 $w[u]$,价值为 $v[u]$。总背包容量为 $V$。

  1. 基础元状态初始化

$$f[u][j] = v[u] \quad (\forall w[u] \le j \le V)$$

这表示在不给任何子树分配容量时,仅选择根节点 $u$ 自身的收益。 2. 三层循环卷积转移: 遍历 $u$ 的每一个子节点 $v$:

$$f[u][j] = \max_{w[u] \le j \le V} \left\{ \max_{0 \le k \le j - w[u]} \{ f[u][j - k] + f[v][k] \} \right\}$$


C++ 标准源码(NOIP风格)

以下源码为标准的树形背包实现,采用高性能无向图建树方式加安全哨兵拦截,完美适配 Linux g++ -O2 环境。

#include <iostream>
#include <vector>
#include <algorithm>
#include <cstring>

using namespace std;

const int MAXN = 305;
const int MAXV = 305;

int n, m; // n 为节点总数,m 为背包总容量
int w[MAXN], v[MAXN];
int f[MAXN][MAXV];
vector<int> edge[MAXN];

// 树形背包核心:利用 DFS 动态交织背包状态
void dfs(int u, int fa) {
    // 1. 强制初始化:只要选了以 u 为根的子树,u 自身必须占据 w[u] 的容量
    for (int j = w[u]; j <= m; ++j) {
        f[u][j] = v[u];
    }

    // 2. 枚举子节点(分组背包中的“组”)
    for (int v_node : edge[u]) {
        if (v_node == fa) continue; // 规避无向边反向死循环

        dfs(v_node, u); // 拓扑沉底,先让子节点把自己的背包网络刷出来

        // 3. 倒序枚举父节点背包容量(分组背包的经典容量轴)
        // 致命踩坑点:必须倒序,且下界是 w[u],必须留足根节点自身的空间
        for (int j = m; j >= w[u]; --j) {
            // 4. 枚举分给当前子树 v_node 的容量 k(分组背包中的“组内物品”)
            for (int k = 0; k <= j - w[u]; ++k) {
                f[u][j] = max(f[u][j], f[u][j - k] + f[v_node][k]);
            }
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    if (!(cin >> n >> m)) return 0;

    // 考场小技巧:由于可能存在多棵独立的树(森林结构),
    // 建立一个虚拟的“0号超级根节点”,其 w[0]=0, v[0]=0,将森林统一转化为以 0 为根的单棵树
    w[0] = 0; v[0] = 0;

    for (int i = 1; i <= n; ++i) {
        int parent;
        cin >> parent >> w[i] >> v[i];

        // 建立双向(或单向)拓扑连接
        edge[parent].push_back(i);
        edge[i].push_back(parent);
    }

    // 从虚拟根节点 0 启动 DFS
    dfs(0, -1);

    // 最终答案即为在虚拟根节点投入总容量 m 的最大价值
    cout << f[0][m] << "\n";

    return 0;
}

NOIP 实战避坑指南

1. 三层循环控制顺序错乱导致状态自我污染

2. 内层循环边界未给父节点预留空间


经典 NOIP/洛谷 真题

1. 洛谷 P2014 [CTSC1997] 选课

2. 洛谷 P1273 有线电视网

$$f[u][j] = \max \left( f[u][j], f[u][j - k] + f[v][k] - \text{cost}(u, v) \right)$$

在 DFS 回溯时,父节点通过组合子树的用户数 $k$ 来刷表。最终,在根节点 $root$ 的状态轴上,从大到小搜寻第一个满足 $f[root][j] \ge 0$ 的下划线 $j$,该 $j$ 即为最多能保留的用户数。此题完美展示了背包问题中“体积与价值维数互换”的高阶技巧。

原文链接: local://16.2

[h] 返回首页