树形 DP 从入门到进阶:普通树形DP、树形背包、换根DP

以三道层次递进的经典题目,彻底掌握树形动态规划的三种核心形态。

什么是树形 DP

树形 DP,顾名思义,就是在「树」这种数据结构上做动态规划。

为什么需要树形 DP?

普通的线性 DP 在一维或二维数组上递推,数据之间的依赖关系是线性的。但现实中很多关系天然是层次化、父子化 的------比如公司组织架构、课程先修关系、网络路由等------这些数据需要用来建模。

在树上做 DP,核心思路只有一句话:

后序遍历整棵树,自底向上合并子树信息,最终在根节点得到全局最优解。

复制代码
        root
       /  |  \
      a   b   c     ← 先递归处理子树 a, b, c
     / \     / \
    d   e   f   g   ← 先处理叶子 d, e, f, g,再向上合并

树形 DP 的三个核心问题

问题 核心难点 代表题目
当前节点「选 / 不选」如何影响子节点? 状态设计 P1352 没有上司的舞会
在子树上分配「有限资源」给多个儿子? 背包合并 P2014 选课
改变根节点后如何 O(1) 重新计算答案? 换根公式 P3478 STA-Station

下面我们逐关击破。


普通树形 DP

题目大意

公司有 N N N 名员工,编号 1 ∼ N 1 \sim N 1∼N,构成一棵树(上下级关系)。每名员工有一个「快乐值」 r i r_i ri。

  • 如果某员工参加舞会,他的所有直接下属都不能参加
  • 如果某员工不参加,他的下属可以参加,也可以不参加

求:在满足上述约束的前提下,参会员工快乐值总和的最大值。

状态设计

这是树形 DP 最经典的「选/不选」模型。

定义 :以 u u u 为根的子树中------

  • d p [ u ] [ 0 ] dp[u][0] dp[u][0]:不选 u u u 时,子树的最大快乐值
  • d p [ u ] [ 1 ] dp[u][1] dp[u][1]: u u u 时,子树的最大快乐值

状态转移推导

我们从叶子节点出发,父子之间的转移关系如下:

复制代码
                u
              / | \
             s1 s2 s3 ...

Case 1:选择 u(dp[u][1] = ?)

复制代码
         u ✓ (选了 u)
       / | \
      ✗  ✗  ✗    ← 所有儿子都不能选!

u 被选中后,每个儿子 s s s 必须不选。因此:

d p [ u ] [ 1 ] = r u + ∑ s ∈ s o n ( u ) d p [ s ] [ 0 ] dp[u][1] = r_u + \sum_{s \in son(u)} dp[s][0] dp[u][1]=ru+s∈son(u)∑dp[s][0]

Case 2:不选 u(dp[u][0] = ?)

复制代码
         u ✗ (不选 u)
       / | \
      ?  ?  ?    ← 儿子可选可不选,各自取 max

u 不选时,每个儿子 s s s 既可以选也可以不选,各自取最优:

d p [ u ] [ 0 ] = ∑ s ∈ s o n ( u ) max ⁡ ( d p [ s ] [ 0 ] , d p [ s ] [ 1 ] ) dp[u][0] = \sum_{s \in son(u)} \max(dp[s][0],\ dp[s][1]) dp[u][0]=s∈son(u)∑max(dp[s][0], dp[s][1])

状态转移可视化

以样例 N = 7 N=7 N=7 为例(圆括号内为快乐值):

复制代码
           1(5)
          /    \
       2(3)    6(1)
      /   \    /  \
    3(4) 4(2) 7(2)
         /
       5(1)

DFS 自底向上计算过程:

复制代码
Step 1: 叶子节点初始化
  节点3: dp[3][0]=0, dp[3][1]=4
  节点5: dp[5][0]=0, dp[5][1]=1
  节点7: dp[7][0]=0, dp[7][1]=2

Step 2: 节点4(儿子=5)
  dp[4][1] = 2 + dp[5][0] = 2 + 0 = 2
  dp[4][0] = max(dp[5][0], dp[5][1]) = max(0,1) = 1

Step 3: 节点2(儿子=3,4)
  dp[2][1] = 3 + dp[3][0] + dp[4][0] = 3+0+1 = 4
  dp[2][0] = max(0,4) + max(1,2) = 4+2 = 6

Step 4: 节点6(儿子=7)
  dp[6][1] = 1 + dp[7][0] = 1 + 0 = 1
  dp[6][0] = max(dp[7][0], dp[7][1]) = max(0,2) = 2

Step 5: 根节点1(儿子=2,6)
  dp[1][1] = 5 + dp[2][0] + dp[6][0] = 5+6+2 = 13
  dp[1][0] = max(4,6) + max(1,2) = 6+2 = 8

最终答案: max(13, 8) = 13

关键实现细节

如何找根节点? 题目给的边是「下属 → 上司」,但树的边是无向的。我们维护一个 vis[] 数组,标记每个节点是否有父节点(即是否有上司)。那个唯一 vis[i] = false 的节点,就是整棵树的根。

cpp 复制代码
for (int i = 1; i < n; ++i) {
    int l, k;
    cin >> l >> k;          // l 是 k 的下属
    g[k].push_back(l);      // 建有向边:上司 → 下属
    vis[l] = true;           // l 有上司
}
for (int i = 1; i <= n; ++i) {
    if (!vis[i]) {           // 找到 CEO(根节点)
        dfs(i);
        cout << max(dp[i][0], dp[i][1]);
        break;
    }
}

完整代码

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

#define N 6005

vector<int> g[N];
bool vis[N];
int dp[N][2];
int n;

void dfs(int u) {
    dp[u][0] = 0;                       // 不选 u,子树贡献初始为 0
    for (auto e : g[u]) {
        dfs(e);
        dp[u][1] += dp[e][0];           // 选 u → 儿子必不选
        dp[u][0] += max(dp[e][0], dp[e][1]); // 不选 u → 儿子任选
    }
}

int main() {
    cin.sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> dp[i][1];  // dp[i][1] 初始就是 r_i
    for (int i = 1; i < n; ++i) {
        int l, k;
        cin >> l >> k;
        g[k].push_back(l);
        vis[l] = true;
    }
    for (int i = 1; i <= n; ++i) {
        if (!vis[i]) {
            dfs(i);
            cout << max(dp[i][0], dp[i][1]);
            break;
        }
    }
    return 0;
}

复杂度分析

  • 时间复杂度 : O ( N ) O(N) O(N)。每条边被访问一次,每个节点恰被 DFS 一次。
  • 空间复杂度 : O ( N ) O(N) O(N)。邻接表 + dp 数组。

树形背包

题目大意

有 N N N 门课程,每门课有学分。课程之间有先修关系:要选修某门课,必须先修它的先修课(每门课最多一门直接先修)。现在要选恰好 M M M 门课,求最大总学分。

问题转化:森林 → 树

先修关系构成的是一个森林而非一棵树------有些课没有先修课,它们是各自独立的根。

技巧 :添加一个虚拟节点 0 0 0(学分为 0 0 0),让它成为所有无先修课的课程的父节点。这样整个森林就变成了一棵以 0 0 0 为根的树。

复制代码
原始森林:                    添加虚拟根后:

  A      D                  [虚拟根0]
 / \     |                  /       \
B   C    E                A         D
                          / \        |
                         B   C       E

注意:由于多选了虚拟根 0 0 0,最终我们要选的实际课程数变成了 M + 1 M+1 M+1 门。

状态设计

这是「树上做背包」的经典模型。

定义 : d p [ u ] [ j ] dp[u][j] dp[u][j] 表示在以 u u u 为根的子树中,选择 j j j 门课 (且必须选 u u u 本身)的最大总学分。

为什么「必须选 u u u」?因为在树上做背包时,如果连父节点都不选,子树中的课就没有先修基础,根本不能选。所以 u u u 是必选的。

状态转移推导

复制代码
                u(必选,占 1 个名额)
              /  |  \
            s1   s2   s3
          剩余名额要在儿子之间分配

总共要选 j 门课 → u 占 1 门 → 剩余 j-1 门分配给所有儿子

对于节点 u u u,我们要从它的儿子们的子树中选出若干个课程。这等价于:

有 ∣ s o n ( u ) ∣ |son(u)| ∣son(u)∣ 个「物品组」,每个物品组对应一个儿子的子树。第 i i i 个物品组中,「选 k k k 门课」这个物品的价值是 d p [ s i ] [ k ] dp[s_i][k] dp[si][k](体积为 k k k)。

我们需要从这些物品组中每组最多选一个物品 ,使得总体积恰好为 j − 1 j-1 j−1,总价值最大。

这正是分组背包模型!

传统分组背包:

cpp 复制代码
for (每组) {
    for (容量从大到小) {
        for (该组的每个物品) {
            dp[容量] = max(dp[容量], dp[容量 - 物品体积] + 物品价值);
        }
    }
}

在树上做分组背包,每个儿子的子树就是一个「组」:

cpp 复制代码
void dfs(int u) {
    dp[u][1] = credit[u];          // 只选 u 自己
    for (int s : g[u]) {           // 枚举每个儿子(即每组)
        dfs(s);
        for (int j = m + 1; j >= 1; --j) {        // 容量倒序(01背包)
            for (int k = 1; k < j; ++k) {          // 分给儿子 s 的课程数
                dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[s][k]);
            }
        }
    }
}

转移过程可视化

假设 M = 3 M=3 M=3,虚拟根 0 0 0 有两个儿子 A A A 和 B B B:

复制代码
               [0]  dp[0][*]
              /   \
            [A]   [B]
步骤 j k 操作 含义
初始化 1 - dp[0][1] = 0 只选虚拟根
合并儿子A 4 1 dp[0][4] = max(dp[0][4], dp[0][3] + dp[A][1]) 给 A 1门
合并儿子A 4 2 dp[0][4] = max(dp[0][4], dp[0][2] + dp[A][2]) 给 A 2门
合并儿子A 4 3 dp[0][4] = max(dp[0][4], dp[0][1] + dp[A][3]) 给 A 3门
合并儿子A 3 1 dp[0][3] = max(dp[0][3], dp[0][2] + dp[A][1]) 给 A 1门
合并儿子A 3 2 dp[0][3] = max(dp[0][3], dp[0][1] + dp[A][2]) 给 A 2门
合并儿子A 2 1 dp[0][2] = max(dp[0][2], dp[0][1] + dp[A][1]) 给 A 1门
合并儿子B ... ... 类似上面,在 A 基础上累加 B 的贡献 分组背包继续

关键理解:每合并一个儿子,dp[u] 就把该儿子的子树信息「吸收」进来。合并完所有儿子后,dp[u][*] 就是整棵子树上的最优解。

完整代码

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

const int N = 305;

vector<int> g[N];
int dp[N][N];    // dp[u][j]: 以 u 为根的子树中选 j 门课的最大值
int credit[N];
int n, m;

void dfs(int u) {
    dp[u][1] = credit[u];                        // 只选自己
    for (int s : g[u]) {
        dfs(s);
        // 分组背包:倒序遍历容量,枚举分配给儿子 s 的课程数
        for (int j = m + 1; j >= 1; --j) {
            for (int k = 1; k < j; ++k) {
                if (dp[u][j - k] != -1) {        // 确保状态可达(处理非满选情况)
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[s][k]);
                }
            }
        }
    }
}

int main() {
    cin.sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> m;

    // 初始化为一个很小的值,表示"不可达"
    for (int i = 0; i <= n; ++i)
        for (int j = 0; j <= m + 1; ++j)
            dp[i][j] = -1e9;

    for (int i = 1; i <= n; ++i) {
        int pre;
        cin >> pre >> credit[i];
        g[pre].push_back(i);   // pre 为 0 时即连到虚拟根
    }

    dfs(0);
    cout << dp[0][m + 1] << endl;   // m+1 因为包含了虚拟根 0
    return 0;
}

复杂度分析

  • 时间复杂度 : O ( N × M 2 ) O(N \times M^2) O(N×M2)

    每个节点在合并时都做一次三重循环。对于节点 u u u,内两层循环是 O ( M 2 ) O(M^2) O(M2),总共 N N N 个节点,故为 O ( N M 2 ) O(NM^2) O(NM2)。

    实际上由于子树大小限制(已遍历的子树大小 × 当前儿子子树大小),严谨上界是 O ( N M ) O(NM) O(NM),但 O ( N M 2 ) O(NM^2) O(NM2) 的理解更直观。

  • 空间复杂度 : O ( N M ) O(NM) O(NM)


换根 DP

题目大意

给定一棵 N N N 个节点的无根树。定义一个节点 u u u 的「深度和」为:以 u u u 为根时,所有其他节点到 u u u 的深度之和。

求:深度和最大的那个节点。若有多个,输出编号最小的。

暴力做法的问题

朴素的思路:对每个节点分别做一次 DFS 计算深度和 → O ( N 2 ) O(N^2) O(N2),对于 N ≤ 10 6 N \le 10^6 N≤106 不可行。

换根 DP 的核心思想

换根 DP(Re-rooting DP) 的做法是:

  1. 第一次 DFS :任选一个根(通常选 1 1 1),算出该根下的答案和必要的辅助信息(如子树大小)。
  2. 第二次 DFS :利用父节点的答案, O ( 1 ) O(1) O(1) 推导出子节点为根时的答案。

关键洞察:当你把根从 u u u 换到它的儿子 s s s 时,大部分节点的深度变化是有规律的,不需要重新计算。

换根公式推导

复制代码
以 u 为根:                   以 s 为根(s 是 u 的儿子):

       u (根)                        s (根)
      /|\                           /|\
   ... s ...          →           ... u ...
      /|\                           /|\
   T(s)子节点                    T(s)子节点

   T(s) = s 的子树(不含 u 方向)

当根从 u u u 换到 s s s 时:

复制代码
  T(s) 中的所有节点:离新根近了 1 步  → 深度各 -1
  其余 N - sz[s] 个节点:离新根远了 1 步 → 深度各 +1

因此:

d p [ s ] = d p [ u ] − s z [ s ] + ( N − s z [ s ] ) \boxed{dp[s] = dp[u] - sz[s] + (N - sz[s])} dp[s]=dp[u]−sz[s]+(N−sz[s])

化简:

d p [ s ] = d p [ u ] + N − 2 × s z [ s ] dp[s] = dp[u] + N - 2 \times sz[s] dp[s]=dp[u]+N−2×sz[s]

公式可视化------数值实例

以一棵简单的树为例:

复制代码
        1
       / \
      2   3
     / \
    4   5
步骤 操作 sz[1]=5, dp[1]=? 解释
计算 dp[1] 以 1 为根 0+1+1+2+2 = 6 深度: 1(0), 2(1), 3(1), 4(2), 5(2)
换根 1→2 sz[2]=3 dp[2] = 6 - 3 + (5-3) T(2) 内 3 个点各 -1
= 6 - 3 + 2 = 5 其余 2 个点各 +1

验证:以 2 为根,深度: 2(0), 1(1), 4(1), 5(1), 3(2) → 和为 0+1+1+1+2 = 5

复制代码
换根过程图示:

根=1                  根=2
  1(0)                  2(0)
 / \                   / \
2(1) 3(1)    →       1(1) 4(1)
/ \                       \
4(2) 5(2)                 5(1)

dp[1]=0+1+1+2+2=6      dp[2]=0+1+1+1+2=5

验证公式: dp[2] = dp[1] - sz[2] + (N - sz[2])
                = 6 - 3 + 2 = 5  ✓

完整代码

cpp 复制代码
#include <iostream>
#include <vector>

using namespace std;

const int N = 1e6 + 5;

vector<int> edg[N];
long long dp[N];    // dp[i] = 以 i 为根时的深度和
long long sz[N];    // sz[i] = 以当前根视角下 i 的子树大小
int n;

// 第一次 DFS:以 1 为根,计算 sz[] 和 dp[1]
void dfs1(int u, int p) {
    sz[u] = 1;
    for (int s : edg[u]) {
        if (s == p) continue;
        dfs1(s, u);
        sz[u] += sz[s];
        dp[u] += dp[s] + sz[s];   // 子树的深度贡献 = 子树内部深度和 + 子树每个点到 u 的那一步
    }
}

// 第二次 DFS:换根,推导所有节点的 dp[]
void dfs2(int u, int p) {
    for (int s : edg[u]) {
        if (s == p) continue;
        // 换根公式
        dp[s] = dp[u] - sz[s] + (n - sz[s]);
        dfs2(s, u);
    }
}

int main() {
    cin.sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i < n; ++i) {
        int p, q;
        cin >> p >> q;
        edg[p].push_back(q);
        edg[q].push_back(p);
    }

    dfs1(1, -1);
    dfs2(1, -1);

    long long Max = -1;
    int ans = 1;
    for (int i = 1; i <= n; ++i) {
        if (dp[i] > Max) {
            Max = dp[i];
            ans = i;
        }
    }
    cout << ans;
    return 0;
}

dfs1 中 dp 计算公式详解

很多初学者困惑于 dfs1 中这行代码:

cpp 复制代码
dp[u] += dp[s] + sz[s];

它的含义:

复制代码
以 u 为根,考虑子树 s 中的所有节点:

       u
       |
       s
      /|\
     ...(子树内部共 sz[s] 个节点)

这些节点到 u 的距离 = (它们到 s 的距离) + 1
                      = dp[s](子树 s 内部的深度和)
                      + sz[s](每个节点都要多走 s→u 这一步)

所以 d p [ u ] = ∑ s ∈ s o n ( u ) ( d p [ s ] + s z [ s ] ) dp[u] = \sum_{s \in son(u)} (dp[s] + sz[s]) dp[u]=∑s∈son(u)(dp[s]+sz[s])。

复杂度分析

  • 时间复杂度 : O ( N ) O(N) O(N)。两次 DFS,每条边各被访问两次。
  • 空间复杂度 : O ( N ) O(N) O(N)。邻接表 + dp + sz 数组。

总结:三种形态的对比与选择

复制代码
树形 DP 三形态递进关系:

  ┌──────────────────────────────────────────────┐
  │                                              │
  │   ① 普通树形 DP                               │
  │   「选/不选」,儿子向父亲汇集                   │
  │   dp[u][状态] = f(∑ dp[儿子][状态])            │
  │                      │                       │
  │                      │ 儿子间存在               │
  │                      │ 资源竞争关系             │
  │                      ▼                       │
  │   ② 树形背包 DP                               │
  │   在①的基础上,加入容量维度                     │
  │   dp[u][j] 需要在儿子间做分组背包               │
  │                      │                       │
  │                      │ 需要以每个节点           │
  │                      │ 为根计算答案             │
  │                      ▼                       │
  │   ③ 换根 DP                                   │
  │   先定一根算答案,再用公式 O(1) 推导其余根       │
  │                                              │
  └──────────────────────────────────────────────┘
维度 普通树形 DP 树形背包 DP 换根 DP
状态维度 dp[u][0/1] dp[u][j] dp[u] + sz[u]
转移方式 儿子直接求和 分组背包合并 O(1) 公式推导
时间复杂度 O ( N ) O(N) O(N) O ( N M 2 ) O(NM^2) O(NM2) O ( N ) O(N) O(N)
关键技巧 找根 + 后序遍历 虚拟根 + 容量倒序 两次 DFS + 换根公式
典型特征 子树独立,无资源限制 有限资源在子树间分配 需要每个节点作为根的结果

一句话总结

  • 普通树形 DP:子树信息简单累加,关注「选或不选」怎么约束儿子。
  • 树形背包 DP:子树合并时做分组背包,容量在外层倒序,儿子分配量在内层枚举。
  • 换根 DP :先算一个根的答案,再利用「深度变化的规律性」, O ( 1 ) O(1) O(1) 推导相邻根的答案。
相关推荐
Hua-Jay2 小时前
OpenCV联合C++/Qt 学习笔记(二十三)----图像校正及单目位姿估计
c++·笔记·qt·opencv·学习·计算机视觉
呃呃本2 小时前
算法题(动态规划)
算法·动态规划
charlie1145141913 小时前
现代C++特性指南(4)——完美转发与移动语义实战
开发语言·c++·现代c++
小白|3 小时前
cann-learning-hub:昇腾CANN社区学习中心完全指南
java·c++·算法
mirror_zAI3 小时前
C++ 仿 QQ 聊天室项目:Qt 客户端 + epoll 服务端 + Reactor 架构(含源码)
c++·qt·架构
我不是懒洋洋3 小时前
大语言模型(LLM)入门:从Transformer到ChatGPT
c语言·开发语言·c++
金创想3 小时前
积木移动题目分析及解题思路——木块问题(1)
c++·算法·字符串·c·刷题·信息学奥赛·积木
BestOrNothing_20153 小时前
C++零基础到工程实战(5.2.4):指针与引用在函数传参、返回值与效率优化中的应用
c++·指针·引用·const·函数参数
L_09073 小时前
【C++】面向对象三大特性之多态
开发语言·c++