树形 DP
树形 DP
一、树形 DP 概述
1.1 什么是树形 DP
树形 DP(Tree DP),即树状动态规划,是在树结构上进行的动态规划。它利用树的递归性质,将大问题分解为子树的子问题,通过自底向上(或自顶向下)的方式合并子问题的解,最终得到原问题的解。
树是一种特殊的图——无环连通图,具有天然的递归结构:以任意节点为根,每个节点的子树都是一棵独立的树。这种递归结构与 DP 的"最优子结构"性质高度契合,因此树上的很多最优化、计数问题都可以用 DP 高效解决。
1.2 树形 DP 的基本特征
- 状态定义:通常以"以 u 为根的子树"为状态的第一维,即
dp[u]表示以 u 为根的子树中的最优解/方案数。 - 转移方式:父节点的答案由其所有子节点的答案合并而来。
- 遍历顺序:绝大多数情况下使用后序遍历(先处理所有子节点,再处理父节点)。
- 时间复杂度:因问题而异,从 \(O(n)\) 到 \(O(nk)\)、\(O(n^2)\) 不等,但通常是线性或近似线性的。
1.3 为什么树适合 DP
| 树的性质 | 对应 DP 的性质 |
|---|---|
| 无环 | 不会重复计算,无后效性 |
| 递归子结构 | 最优子结构 |
| 父子关系明确 | 转移方向清晰 |
| 连通性 | 可以通过 DFS 完整遍历 |
注:树形 DP 的本质是在树的 DFS 序上做 DP。因为树的 DFS 遍历天然保证了"处理完子树再处理父亲"的顺序,这恰好满足了 DP 对计算顺序的要求。
二、基础概念与实现方式
2.1 树的存储
树形 DP 中最常用的存图方式是邻接表:
vector<int> g[MAXN]; // 无向树,每条边存两次
void add_edge(int u, int v) {
g[u].push_back(v);
g[v].push_back(u);
}
有根树 vs 无根树:
- 很多题目给出的是无根树(没有指定根节点),我们需要任选一个根(通常选 1 号节点)将其转化为有根树来处理。
- 根的选择不影响最终答案,但会影响 DP 的实现方式。
2.2 DFS 框架
树形 DP 几乎都基于**深度优先搜索(DFS)**实现。核心框架如下:
void dfs(int u, int fa) { // u 是当前节点,fa 是父节点(防止往回走)
// 1. 初始化 dp[u]
dp[u] = ...;
for (int v : g[u]) {
if (v == fa) continue; // 跳过父节点
dfs(v, u); // 递归处理子树
// 2. 用子节点 v 的 dp 值更新父节点 u 的 dp 值
dp[u] = merge(dp[u], dp[v]);
}
// 3. 后序处理(可选)
// ...
}
关键要点:
- 父节点参数
fa:因为是无向图存边,需要用fa记录父节点,防止 DFS 往回走导致死循环。 - 后序顺序:先递归所有子节点,再处理当前节点。这保证了计算
dp[u]时,所有dp[v](v 是 u 的子节点)都已经算好了。 - 合并操作
merge:这是不同题型的核心差异——如何把子节点的答案合并到父节点上。
2.3 状态设计的一般原则
树形 DP 的状态通常包含以下维度:
| 维度 | 含义 | 示例 |
|---|---|---|
| 第一维 | 节点编号(以 u 为根的子树) | dp[u] |
| 第二维 | u 的状态(选/不选、颜色、度数等) | dp[u][0/1] |
| 第三维 | 子树中选了多少个/体积限制等 | dp[u][k] |
状态设计的核心原则:状态必须包含足够的信息,使得父节点在合并时不需要知道子树内部的具体细节,只需要知道子树根节点的"状态"即可。
三、最大独立集 / 最小点覆盖 / 最小支配集
这三个是树形 DP 中最经典的入门模型,务必熟练掌握。
3.1 最大独立集
问题定义
给定一棵 n 个节点的树,从中选出尽可能多的节点,使得任意两个被选中的节点都不相邻(没有边相连)。求最多选多少个节点。
状态定义
dp[u][0]:在以 u 为根的子树中,不选 u 时的最大独立集大小dp[u][1]:在以 u 为根的子树中,选 u 时的最大独立集大小
转移方程
选 u 的情况(dp[u][1]):
既然选了 u,那么 u 的所有子节点都不能选(因为相邻)。所以:
其中 +1 是选 u 自己。
不选 u 的情况(dp[u][0]):
既然不选 u,那么每个子节点 v 可以选也可以不选,取较大值:
初始化
- 叶子节点:
dp[leaf][0] = 0,dp[leaf][1] = 1 - (其实不需要专门初始化,DFS 后序遍历自然会处理好)
最终答案
code
int dp[MAXN][2];
void dfs(int u, int fa) {
dp[u][1] = 1; // 选 u,先加 1
dp[u][0] = 0; // 不选 u
for (int v : g[u]) {
if (v == fa) continue;
dfs(v, u);
dp[u][1] += dp[v][0]; // 选 u,则子节点必不选
dp[u][0] += max(dp[v][0], dp[v][1]); // 不选 u,子节点可选可不选
}
}
时间复杂度:\(O(n)\)。每个节点恰好被处理一次。
3.2 最小点覆盖
问题定义
给定一棵 n 个节点的树,从中选出尽可能少的节点,使得树中每条边至少有一个端点被选中。求最少选多少个节点。
状态定义
dp[u][0]:在以 u 为根的子树中,不选 u 时的最小点覆盖大小dp[u][1]:在以 u 为根的子树中,选 u 时的最小点覆盖大小
转移方程
选 u 的情况(dp[u][1]):
选了 u,则 u 与子节点的所有边都被覆盖了。子节点 v 可选可不选,取较小值:
不选 u 的情况(dp[u][0]):
不选 u,则为了覆盖 u 与子节点 v 之间的边,每个子节点 v 必须选:
最终答案
对比最大独立集
| 问题 | 选 u 时子节点 | 不选 u 时子节点 | 优化目标 |
|---|---|---|---|
| 最大独立集 | 必须不选 | 可选可不选(取 max) | 最大化 |
| 最小点覆盖 | 可选可不选(取 min) | 必须选 | 最小化 |
注:两者的转移不是简单的"取反"关系,不要混淆。
3.3 最小支配集
问题定义
给定一棵 n 个节点的树,从中选出尽可能少的节点,使得树中每个节点要么被选中,要么与至少一个被选中的节点相邻。求最少选多少个节点。
与点覆盖的区别:点覆盖要求"每条边被覆盖",支配集要求"每个点被覆盖"。支配集更难。
状态定义
需要 3 种状态(比前两个问题多一种):
dp[u][0]:u 被选中(u 支配自己)dp[u][1]:u 没被选中,但被至少一个子节点支配dp[u][2]:u 没被选中,且没被子节点支配(因此必须被父节点支配)
转移方程
状态 0:选 u(dp[u][0])
选了 u,则 u 可以支配所有子节点。子节点 v 可以是任意状态,取最小:
状态 2:u 不选,且不被子节点支配(dp[u][2])
u 不选,也不被子节点支配 → 每个子节点 v 都不能选(否则 v 会支配 u),且 v 必须被它们自己的子节点支配:
状态 1:u 不选,但被子节点支配(dp[u][1])
这是最复杂的转移。u 不选,但至少有一个子节点 v 选了(v 支配 u),其余子节点可以是状态 0 或 1。
设子节点集合为 \(V\),则:
但这样直接枚举是 \(O(n^2)\) 的。可以优化为:
其中 \(\Delta\) 是"修正项"——如果所有子节点都选了 \(\min\) 是 \(dp[v][1]\)(即没有子节点选了),那么需要选一个代价最小的子节点改为选它(状态 0),增量为 \(dp[v][0] - dp[v][1]\)。
即:
- 如果存在至少一个 v 使得 \(dp[v][0] \le dp[v][1]\),则 \(\Delta = 0\)(已经有子节点选了)
- 否则,需要选一个代价增量最小的子节点改为选它
最终答案
(根节点没有父节点,所以不能是状态 2)
三种经典模型对比
| 模型 | 状态数 | 转移复杂度 | 核心思想 |
|---|---|---|---|
| 最大独立集 | 2 | O(n) | 选了父就不能选子 |
| 最小点覆盖 | 2 | O(n) | 不选父就必须选子 |
| 最小支配集 | 3 | O(n) | 需考虑被谁支配 |
四、树的直径
详见:
图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点
4.1 问题定义
树的直径(Diameter)是树中最长的简单路径的长度(路径上边数或点数,视题目而定)。
4.2 性质
- 树的直径可能有多条,但它们的中点(或中间边)相同。
- 从任意节点出发,DFS/BFS 找到最远点 u,再从 u 出发 DFS/BFS 找到最远点 v,则 u-v 就是一条直径。(这是两遍 DFS 求直径的方法)
- 树上任意一点的最远点一定是直径的某个端点。
4.3 DP 求直径
状态定义
dp[u]:以 u 为根的子树中,从 u 出发向下走的最长路径长度
转移
对于 u 的每个子节点 v:
其中 \(w(u, v)\) 是边 u-v 的长度(如果是无权树,w = 1)。
如何求直径
在计算 dp[u] 的过程中,维护 u 的前两大子树路径长度,它们的和就是经过 u 的最长路径(即 u 的"向下最长" + "向下次长")。对所有节点取最大值,就是树的直径。
int dp[MAXN]; // 从 u 向下的最长路径
int diameter; // 直径
void dfs(int u, int fa) {
dp[u] = 0;
int max1 = 0, max2 = 0; // 最长和次长
for (int v : g[u]) {
if (v == fa) continue;
dfs(v, u);
int val = dp[v] + 1; // +1 是边权(无权树)
if (val > max1) {
max2 = max1;
max1 = val;
} else if (val > max2) {
max2 = val;
}
dp[u] = max(dp[u], val);
}
diameter = max(diameter, max1 + max2); // 经过 u 的最长路径
}
时间复杂度
\(O(n)\),每个节点恰好处理一次。
4.4 两遍 DFS 法(补充)
虽然这不是 DP,但也是求树直径的经典方法,一并介绍:
- 从任意节点出发,DFS 找到最远点 u
- 从 u 出发,DFS 找到最远点 v
- u 到 v 的距离就是直径
证明思路:利用直径的性质——任意点的最远点是直径端点。
这种方法只能求无权树的直径(或边权非负的树),而 DP 法更通用。
五、树的重心
5.1 问题定义
树的重心(Centroid)是一个节点,将其删除后,剩余的各连通块中,最大的那个连通块的大小最小。
换句话说,重心是树的"最中心"的节点,删掉它后树被拆得最均匀。
5.2 性质
- 一棵树最多有两个重心,且它们相邻。
- 以重心为根,每个子树的大小都不超过总节点数的一半。
- 树中所有点到某个点的距离和中,到重心的距离和最小。
- 把两棵树通过一条边相连,新树的重心在原来两棵树重心的连线上。
5.3 DP 求重心
状态定义
sz[u]:以 u 为根的子树的大小(节点数)
转移
求重心
对于每个节点 u,删除 u 后,各连通块的大小为:
- 每个子树 v 的大小:
sz[v] - 父节点那一侧的大小:
n - sz[u]
取最大值,记为 max_part[u]。使 max_part[u] 最小的 u 就是重心。
int sz[MAXN];
int max_part[MAXN];
int centroid; // 重心
int n;
void dfs(int u, int fa) {
sz[u] = 1;
max_part[u] = 0;
for (int v : g[u]) {
if (v == fa) continue;
dfs(v, u);
sz[u] += sz[v];
max_part[u] = max(max_part[u], sz[v]);
}
max_part[u] = max(max_part[u], n - sz[u]); // 父节点那一侧
if (max_part[u] < max_part[centroid]) {
centroid = u;
}
}
时间复杂度
\(O(n)\)。
5.4 重心的应用
- 点分治(Centroid Decomposition):这是重心最重要的应用。通过不断找重心、删重心、分治处理各子树,可以在 \(O(n \log n)\) 时间内解决很多树上路径问题。
- 求树的最小距离和
- 平衡树结构的保证
六、树上背包(有依赖的背包)
6.1 问题模型
树上的每个节点代表一个物品,具有体积 \(w_u\) 和价值 \(v_u\)。选一个节点就必须选它的父节点(有依赖关系)。背包容量为 m,求最大价值。
这就是有依赖的背包问题,在树上就是树形背包。
6.2 状态定义
dp[u][k]:在以 u 为根的子树中选若干节点(必须包含 u),总体积恰好为 k 时的最大价值
注意"必须包含 u",这是有依赖背包的关键——选子树内的东西就必须选根。
6.3 朴素转移(分组背包思想)
初始时,dp[u][w_u] = v_u(只选 u 自己)。
然后依次考虑每个子节点 v。对于每个子节点,相当于一个物品组:可以选 v 的子树中的某些节点(但必须包含 v)。这就是一个分组背包问题。
转移方程:
更简洁的写法(标准分组背包):
for 每个子节点 v:
for i from m down to w_u:
for j from 0 to i - w_u:
dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[v][j])
时间复杂度
- 朴素实现:\(O(n \cdot m^2)\),对每个节点做 m² 的分组背包
- 这通常过不了较大的数据
6.4 优化:DFS 序上做背包
核心思想
利用树的 DFS 序,将树形背包转化为线性 DP。
对树进行 DFS,记录每个节点的进入时间 in[u] 和子树大小 sz[u]。在 DFS 序数组上做 DP:
dp[i][k]:考虑前 i 个节点(DFS 序中的前 i 个),体积为 k 时的最大价值
转移:
- 选第 i 个节点(设为 u):则必须选它,且它的子树都在 i+1 到 i+sz[u]-1 范围内。转移到
dp[i+1][k-w_u] + v_u - 不选第 i 个节点:则它的整个子树都不能选(因为有依赖)。直接跳到
i + sz[u],转移到dp[i + sz[u]][k]
时间复杂度
\(O(n \cdot m)\),比朴素的 \(O(nm^2)\) 快很多。
注:这种优化适用于"选父必须选子"的依赖关系。但实际中,依赖关系是"选子必须选父",DFS 序法需要仔细处理顺序。
6.5 另一种常用写法:子树合并
void dfs(int u, int fa) {
dp[u][w[u]] = v[u]; // 初始:只选 u
for (int v : g[u]) {
if (v == fa) continue;
dfs(v, u);
// 类似 01 背包,倒序枚举
for (int j = m; j >= w[u]; j--) {
for (int k = 0; k <= j - w[u]; k++) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
}
}
这个写法直观但复杂度是 \(O(n \cdot m^2)\),需要结合优化才能过大数据。
6.6 进一步优化的条件
如果题目中的体积都是 1(即每个物品体积为 1,求选 k 个物品的最大价值),则有更优的 \(O(nk)\) 做法,因为子树大小限制了枚举范围,实际效率很高。
七、换根 DP(二次扫描与换根法)
7.1 适用场景
有一类树形 DP 问题,需要求出以每个节点为根时的答案。如果对每个节点都跑一遍 DP,复杂度是 \(O(n^2)\),可能超时。
换根 DP(也叫二次扫描与换根法、rerooting technique)通过两次 DFS,将复杂度降为 \(O(n)\)。
典型问题:求树上每个节点到其他所有节点的距离之和(树的距离和问题)。
7.2 基本思想
- 第一次 DFS(后序):任选一个根(比如 1 号节点),计算以该根为基准的子树 DP 值。
- 第二次 DFS(前序):从根出发,利用父节点的答案推导子节点的答案(即"换根"操作)。
核心是找到一个换根公式:已知以 u 为根时的答案 ans[u],如何快速求出以 u 的子节点 v 为根时的答案 ans[v]。
7.3 经典例题:树的距离和
问题
求树上每个节点 u,到其他所有节点的距离之和 ans[u]。
第一次 DFS(后序,以 1 为根)
sz[u]:以 u 为根的子树大小dp[u]:u 到其子树内所有节点的距离之和
转移:
(每个子节点 v 的子树内的点到 u 的距离 = 到 v 的距离 + 1,共 sz[v] 个点,所以加 sz[v])
第二次 DFS(前序,换根)
已知 ans[u],求 ans[v](v 是 u 的子节点)。
当根从 u 换到 v 时:
- v 的子树内的所有节点:距离都减少 1(共 sz[v] 个,减少 sz[v])
- 其余节点(不在 v 的子树内):距离都增加 1(共 n - sz[v] 个,增加 n - sz[v])
因此:
这就是换根公式。
代码框架
int sz[MAXN];
long long dp[MAXN]; // 子树内距离和
long long ans[MAXN]; // 全局距离和
int n;
void dfs1(int u, int fa) { // 后序:算 sz 和 dp
sz[u] = 1;
dp[u] = 0;
for (int v : g[u]) {
if (v == fa) continue;
dfs1(v, u);
sz[u] += sz[v];
dp[u] += dp[v] + sz[v];
}
}
void dfs2(int u, int fa) { // 前序:换根算 ans
for (int v : g[u]) {
if (v == fa) continue;
ans[v] = ans[u] + n - 2 * sz[v]; // 换根公式
dfs2(v, u);
}
}
// 调用:
dfs1(1, 0);
ans[1] = dp[1];
dfs2(1, 0);
时间复杂度
\(O(n)\),两次 DFS。
7.4 换根 DP 的一般步骤
- 选定初始根:通常选 1 号节点
- 后序 DFS:计算子树 DP(以当前节点为根的子树内部的答案)
- 推导换根公式:思考根从父换到子,答案如何变化
- 前序 DFS:利用父节点的答案计算子节点的答案
7.5 常见换根 DP 题型
| 问题 | 状态设计 |
|---|---|
| 每个节点的距离和 | sz[u] + dp[u](子树内距离和) |
| 每个节点的最大距离(树的半径) | 维护子树最长/次长路径 + 父节点方向的最长路径 |
| 树上最长路径(直径)经过的节点数 | 记录直径端点 + 前缀后缀最大值 |
| 每个节点为根时的最大独立集 | 维护子树 dp + 向上的贡献 |
注:换根 DP 的关键在于"信息的可维护性"——需要保证父节点的信息足以推导出子节点的信息。如果状态过于复杂,换根公式可能非常难写。
八、基环树 DP
8.1 什么是基环树
基环树(Pseudotree / Unicyclic Graph):恰好含有一个环的连通图。也叫单环图。
基环树可以看作"一棵树 + 一条额外的边",也可以看作"一个环,环上每个节点挂着一棵树"(外向树)。
树 树
\ /
---环---
/ \
树 树
n 个节点 n 条边的连通图就是基环树。
8.2 基环树 DP 的基本思想
基环树比普通树多了一个环,因此不满足树的无环性质,不能直接树形 DP。
核心思路:
- 找到环
- 破环为链(或"断边"):枚举环上的约束条件,把环断开,转化为普通的树/链做 DP
- 合并结果
8.3 找环
找基环树的环有多种方法:
方法一:拓扑排序(剥洋葱)
不断删除度数为 1 的节点(叶子),最后剩下的度数 ≥ 2 的节点就是环上的节点。
int deg[MAXN];
bool on_cycle[MAXN];
void find_cycle() {
queue<int> q;
for (int i = 1; i <= n; i++) {
if (deg[i] == 1) q.push(i);
}
while (!q.empty()) {
int u = q.front(); q.pop();
on_cycle[u] = false;
for (int v : g[u]) {
if (on_cycle[v]) { // 还在环上
deg[v]--;
if (deg[v] == 1) q.push(v);
}
}
}
}
方法二:DFS 栈
用 DFS 遍历,记录访问栈,遇到回边时回溯得到环。
拓扑排序法最简单,推荐优先使用。
8.4 基环树最大独立集
这是基环树 DP 的经典入门题。
思路
找到环,任选环上一条边 (u, v) 断开:
- 情况 1:不选 u,则 v 可选可不选 → 以 v 为根做树的最大独立集
- 情况 2:不选 v,则 u 可选可不选 → 以 u 为根做树的最大独立集
两种情况取最大值。
为什么这样做?因为环上只有一条额外的边造成了约束,枚举这条边两端的选择情况(不能同时选),就把环的约束消除了。
一般化方法
对于基环树 DP,通用做法是:
- 找到环,设环上节点依次为 \(c_1, c_2, \dots, c_k\)
- 对每个环上节点 \(c_i\),先对它挂的外向树做一次树形 DP,得到 \(c_i\) 的各种状态值
- 在环上做环形 DP(破环为链,枚举起点状态):
- 枚举 \(c_1\) 的状态,然后按链 DP
- 保证 \(c_k\) 的状态与 \(c_1\) 不冲突
- 对所有合法情况取最优
8.5 基环树直径
基环树的直径有两种可能:
- 完全在某棵外向树中(不经过环)
- 经过环(从一棵外向树进环,走一段环,再进另一棵外向树)
对于情况 2,需要在环上用单调队列或前缀和优化来求最大值。
九、树形 DP 的优化
9.1 长链剖分(长链剖分优化 DP)
适用场景
状态中带有"深度"维度的树形 DP,例如:
dp[u][k]:u 的子树中,距离 u 恰好为 k 的节点数/最大权值- 与深度有关的计数/最优化问题
核心思想
- 长链:从节点 u 到其最深叶子的路径
- 每个点属于恰好一条长链
- 合并子节点时,重儿子(长链的延续)的信息直接继承(指针移动,O(1)),轻儿子的信息暴力合并
- 每个点作为轻儿子被合并一次,总复杂度 \(O(n)\)
实现方式
通常用指针数组或动态分配内存来实现"重儿子直接继承"的效果。
9.2 单调队列优化
适用场景
树上背包中,如果转移是"滑动窗口取 max"的形式(比如有距离限制的背包),可以用单调队列将一维复杂度从 m 降到 1。
9.3 点分治 + DP
对于"统计树上满足条件的路径数目/最大权值"类问题,如果直接树形 DP 难以处理,可以用点分治:
- 找重心
- 统计经过重心的路径的答案
- 删掉重心,递归处理各子树
时间复杂度通常为 \(O(n \log n)\) 或 \(O(n \log^2 n)\)。
9.4 状态压缩优化
某些问题中子节点的状态可以用二进制位表示(如"颜色不超过 10 种"),可以用状态压缩将多个状态合并为一个整数,加速转移。
附录:常见误区与注意事项
1. 根节点的选择
- 无根树问题通常选 1 号节点为根
- 注意根节点是否有特殊约束(如支配集问题中根不能是状态 2)
- 换根 DP 中第一次 DFS 的根选哪个不影响正确性,但选 1 最方便
2. DFS 栈溢出
- n 较大(如 1e5)时,递归 DFS 可能栈溢出
- 解决方案:用 BFS + 后序遍历、手写栈(非递归 DFS)、或在 C++ 中扩栈
- 实际竞赛中,n ≤ 1e5 时递归通常没问题(深度不会达到 1e5),但链状树要小心
3. 状态定义不清
- 做树形 DP 前一定要明确状态的含义
- 特别是"是否包含 u"、"u 的状态"等关键信息必须在状态定义中说清楚
- 建议先在纸上写出状态的文字定义,再写转移方程
4. 转移顺序错误
- 后序遍历:先子后父(绝大多数情况)
- 前序遍历:先父后子(换根 DP 的第二次 DFS)
- 不要搞反遍历顺序
5. 初始值错误
- 求最大值:初始化为 -INF(注意边界状态要初始化为 0 或正确值)
- 求最小值:初始化为 INF
- 计数问题:初始化为 0,基准情况为 1
- 不注意初始化会导致 WA 或溢出
6. 树上背包的合并顺序
- 做树上背包时,一定要用类似 01 背包的倒序枚举
- 初始时
dp[u]只包含 u 自己,然后逐个合并子树 - 如果正序枚举会导致重复选(完全背包效果)
7. 基环树漏情况
- 基环树问题一定要考虑"完全在子树内"和"经过环"两种情况
- 破环为链时,枚举的情况要覆盖所有合法情形,且不重复不遗漏
8. 数据范围
- 树形 DP 的答案可能很大(如计数问题),注意开
long long - 取模问题注意模运算的正确性
- 多组数据注意清空数组
总结:树形 DP 的核心在于状态设计和转移方程。做题时,先分析问题在树上的递归结构,设计出以"子树"为核心的状态,再考虑如何从子节点合并到父节点。入门从最大独立集、树直径、树重心三个基础模型开始,进阶掌握树上背包、换根 DP、基环树 DP,就能覆盖绝大多数题型。
参考资料:李煜东《算法竞赛进阶指南》、OI-Wiki
部分内容由 AI 润色
原创·转载请注明出处
✦
浙公网安备 33010602011771号