引言
动态规划(DP)是算法竞赛中最灵活也最强大的工具之一。当DP遇到树这种数据结构时,就诞生了树形DP------在树上做动态规划。
树形DP的基础模型很简单:从叶子节点向上递推,用子节点的信息更新父节点。但实际题目往往不会这么直接------"求树上距离每个节点最远的节点""在树上做背包选课""每个节点都可以作为根,求全局最优"......这些进阶问题需要更精巧的DP设计。
本文将从树形DP的基础出发,重点讲解两个进阶方向:树上背包 (在树上做分组背包)和换根DP(让每个节点都成为根,求全局最优)。
如果把基础树形DP比作"从树叶到树根收集信息",那么树上背包就是 "在收集信息时还要做选择" ,而换根DP则是 "不仅要从下往上收集,还要从上往下传递" 。三种能力合在一起,才能应对树上DP的全部挑战。
前置知识
在学习树形DP进阶之前,你需要掌握以下基础:
-
树的基本概念:节点、边、根、子树、叶子节点。
-
深度优先搜索(DFS) :树形DP的核心遍历方式。
-
基础树形DP:如"没有上司的舞会"------每个节点选或不选,用子节点更新父节点。
-
背包DP:0/1背包、分组背包的基本概念。
-
递归与记忆化:树形DP通常用递归实现。
第一章:树上背包------在树上"选课"
1.1 问题引入:有依赖的背包
普通的背包问题是:有若干个物品,每个物品有重量和价值,在容量限制下选择物品使总价值最大。
树上背包 在此基础上增加了一个约束:物品之间存在依赖关系------要选择某个物品,必须先选择它的父节点。
最经典的例子是"选课":每门课可能有先修课,要选"数据结构"必须先选"程序设计基础"。如果把课程之间的依赖关系画成一棵树,问题就变成了"在树上选择若干节点,必须选父节点才能选子节点"。
1.2 状态设计
设 dp[u][j] 表示:在以节点u为根的子树中,选择j个节点(且必须选择u本身)所能获得的最大价值。
关键约束 :因为必须选u才能选子树中的其他节点,所以 dp[u][j] 中 j 至少为1(至少选u自己)。
1.3 转移方程
对于节点u,我们逐个处理它的每个子节点v,像做分组背包一样,把子树的"选课方案"当作一组物品:
text
初始化 dp[u][1] = val[u](只选u自己)
对于每个子节点 v:
对于 j 从 m 到 1(当前已选的课程数,倒序):
对于 k 从 1 到 size[v](在v的子树中选k门课):
dp[u][j+k] = max(dp[u][j+k], dp[u][j] + dp[v][k])
代码框架:
cpp
void dfs(int u, int fa) {
sz[u] = 1;
dp[u][1] = val[u]; // 只选自己
for (int v : children[u]) {
if (v == fa) continue;
dfs(v, u);
// 背包合并:倒序枚举容量
for (int j = min(m, sz[u]); j >= 1; j--) {
for (int k = 1; k <= sz[v] && j + k <= m; k++) {
dp[u][j+k] = max(dp[u][j+k], dp[u][j] + dp[v][k]);
}
}
sz[u] += sz[v];
}
}
1.4 复杂度分析
树上背包的时间复杂度是 O(n·m²) 在最坏情况下,但实际运行中,由于每次合并两个子树,总复杂度通常为 O(n·m) (每个节点对在容量维度上被考虑一次)。
1.5 经典例题:洛谷 P2014 选课
题目描述:有n门课,每门课有学分,有些课有先修课(最多一门)。在最多选m门课的条件下,如何选课使总学分最大?
解题思路:
-
构建树:每门课是一个节点,先修课是父节点。
-
如果有多棵树(森林),添加一个虚拟根节点0,学分0,将森林变成一棵树。
-
运行树上背包,答案就是
dp[0][m+1](因为虚拟根节点也占了一门课)。
核心代码(来自):
cpp
// dp[x][j]:在以x为根的子树中选j门课的最大学分
// 转移:将子节点y的子树作为一个分组,做分组背包
for (int j = m; j >= 0; j--) {
for (int k = 1; k <= sz[y]; k++) {
if (j + k <= m) {
dp[x][j+k] = max(dp[x][j+k], dp[x][j] + dp[y][k]);
}
}
}
第二章:换根DP------让每个节点都成为"根"
2.1 问题引入:不定根的最优解
很多树形DP问题会问:"对于树上的每个节点,求以该节点为根时的某个答案"。
最朴素的做法是:枚举每个节点作为根,分别做一次树形DP,时间复杂度 O(n²) 。当n达到10⁵时,这显然不可行。
换根DP(也称二次扫描法) 通过两次DFS 将复杂度优化到 O(n) :
-
第一次DFS:任选一个根(如节点1),计算以它为根的答案。
-
第二次DFS:利用父节点的答案推导出子节点作为根时的答案。
如果把基础树形DP比作"从下往上递推",换根DP就是 "先从上往下推一次,再从下往上推一次,两次信息合在一起才是完整答案"。
2.2 换根DP的核心思想
换根DP的关键是理解:当根从父节点u转移到子节点v时,答案会发生什么变化?
对于大多数问题,变化只涉及两部分:
-
v的子树:所有节点的深度都减少1(因为根下移了一层)。
-
v的子树之外:所有节点的深度都增加1(因为离根远了一层)。
如果能快速计算出这两个部分的贡献,就能在O(1)时间内从 ans[u] 推导出 ans[v]。
2.3 经典例题:洛谷 P3478 STA-Station
题目描述 :给定一棵n个节点的树,求一个节点,使得以该节点为根时,所有节点的深度之和最大。
解题思路:
第一次DFS(计算基础信息) :
-
任选1为根。
-
计算每个节点的子树大小
sz[u]。 -
计算以1为根时的深度之和
sum[1]。
第二次DFS(换根转移) :
-
假设已知以u为根的深度之和
sum[u]。 -
当根从u转移到子节点v时:
-
v的子树(大小
sz[v])中所有节点的深度都减少1。 -
其他节点(数量
n - sz[v])的深度都增加1。
-
-
因此转移公式为:
sumv=sumu−szv+(n−szv)sumv=sumu−szv+(n−szv)
化简得:
sumv=sumu+n−2×szvsumv=sumu+n−2×szv
代码框架:
cpp
void dfs1(int u, int fa) {
sz[u] = 1;
for (int v : children[u]) {
if (v == fa) continue;
dfs1(v, u);
sz[u] += sz[v];
sum[1] += sz[v]; // 以1为根时,每条边对深度的贡献
}
}
void dfs2(int u, int fa) {
for (int v : children[u]) {
if (v == fa) continue;
sum[v] = sum[u] + n - 2 * sz[v]; // 换根转移
dfs2(v, u);
}
}
最终,sum[i] 就是以i为根时的深度之和,取最大值对应的节点就是答案。
2.4 换根DP的通用模板
换根DP的通用思路可以总结为:
-
第一次DFS:计算每个节点的子树信息(大小、贡献等)。
-
第二次DFS :从父节点向子节点转移,用
ans[fa]推导ans[child]。
思考换根DP时,问自己两个问题:
-
根节点的答案如何计算?(第一次DFS)
-
怎么将答案从父亲传向儿子?(第二次DFS)
第三章:树形DP进阶的综合应用
3.1 树上背包 + 换根DP
有些题目会同时用到树上背包和换根DP。例如:"对于每个节点,求以该节点为根时,在树上做背包的最优值"。
思路是:
-
先任选一个根,做树上背包,得到
dp[root]。 -
用换根DP的思想,将
dp信息从父节点传递到子节点。 -
对每个节点,合并"来自父节点方向"和"来自子树方向"的信息。
3.2 常见题型速查
| 题型 | 核心方法 | 典型例题 |
|---|---|---|
| 树上选点(选/不选) | 基础树形DP | P1352 没有上司的舞会 |
| 树上背包(选课) | 树上背包 | P2014 选课 |
| 每个节点作为根的最优值 | 换根DP | P3478 STA-Station |
| 树上距离最远点 | 换根DP(维护最长/次长链) | P10962 Computer |
| 树上染色 / 边权贡献 | 树上背包 + 特殊贡献计算 | HAOI2015 树上染色 |
总结
树形DP的进阶之路,可以概括为三个层次:
| 层次 | 能力 | 代表技术 |
|---|---|---|
| 第一层 | 从叶子向根递推 | 基础树形DP(选/不选) |
| 第二层 | 在递推时做选择 | 树上背包(分组背包合并) |
| 第三层 | 让每个节点都能成为根 | 换根DP(两次扫描) |
学习树形DP进阶的三个关键点:
-
理解树上背包的"分组"本质:每个子树是一组物品,在父节点做分组背包合并。
-
掌握换根DP的"两次扫描"套路:第一次从下往上,第二次从上往下。
-
学会分析"换根时的变化量":根从父节点转移到子节点时,哪些部分的贡献变了?变化量是多少?