-
- [什么是树形 DP](#什么是树形 DP)
-
- [为什么需要树形 DP?](#为什么需要树形 DP?)
- [树形 DP 的三个核心问题](#树形 DP 的三个核心问题)
- [普通树形 DP](#普通树形 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) 的做法是:
- 第一次 DFS :任选一个根(通常选 1 1 1),算出该根下的答案和必要的辅助信息(如子树大小)。
- 第二次 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) 推导相邻根的答案。