树形DP教程:从递归视角彻底搞懂树上DP
ZhangChuan1350
·
2026-04-23 21:25:47
·
算法·理论
前言
在阅读本文前,你只需要:
了解树的各项概念和定义。
会用邻接表存一棵树。
会写 DFS 遍历树(知道怎么避免走回父节点)。
由于本文主要面向初学者,本文代码均使用 vector 存图。
由于树形 DP 题目变化多端,本文采用一点一例的方式——一个核心思想,配一道经典题 。
自定义的符号和简写:
本文默认你已经了解题意。
洛谷 P1352 没有上司的舞会
题目可以抽象为:
有一棵树,共有节点 n 个,编号为 i\in[1,n] ,快乐指数为 r_i ,要求选择若干个节点,使得:
任意一对父节点和子节点最多选一个。
最大化快乐指数。
思路: 可以发现,每个结点有且只有 2 种情况——选 或不选 ,只需记录此状态即可。因为树形结构决定了通过递归必须从根向下传递信息 ,所以一般题目给出 或自己选择 选一个根。
:::info[我是如何思考状态的?]{open}
Step 1:分析限制条件
题目说父节点和子节点不能同时选 。这意味着:
若父节点选了,任意子节点均不能选。
若父节点没选,任意子节点均可选可不选。
这是两种不同的状态,需要分别讨论和包含。
常用做法是用 0/1 表示,0 表示不选,1 表示选。
Step 2:判断子问题独立性
在树中,最小问题规模是每个子问题,而每个子问题均是独立的,但子问题的解依赖于父节点是否被选 这个外部条件。
在子树中,根节点可以看做这棵子树的父节点 ,所以子树的解依赖这个根节点选没选 ——这就是为什么状态里必须记录根节点的选择状态。
因此状态必须记录下根节点是否选择 这一重要信息。
Step 3:设计状态
综合 Step 1 与 Step 2 ,可以得到状态必须包含的两个信息:
当前处理的子树根节点,假设为 u 。
当前子树的根节点是否选择,这个可以用 0/1 表示。
故 可得到 f_{u,0/1} 这一状态。
:::
定义状态 f_{u,0/1} ,表示以编号为 u 的节点为根节点,是否选择节点 u 的最大快乐指数。
则我们可得到状态初始化:
- $f_{u,0}\gets0$,不选节点 $u$,初始快乐指数为 $0$。
- $f_{u,1}\gets r_u$,选择节点 $u$ ,加上结点 $u$ 的快乐指数 $r_u$。
状态转移方程可以这样想:
- 不选节点 $u$,则每个子节点 $v$ 可以取或不取,取其最优值并求和。即
$$f_{u,0}\gets f_{u,0}+\sum_{v\in\operatorname{son}(u)}\max(f_{v,0},~f_{v,1})$$
- 选择节点 $u$,则所有子节点必须不选,因此
$$f_{u,1}\gets f_{u,1}+\sum_{v\in\operatorname{son}(u)}f_{v,0}$$
**注意**:在这题中,数据给出的是有向边,因此需找到没有父节点的结点——**根节点**($\mathrm{root}$),以保证这棵树所有节点都被遍历完成。
因为根节点也可能不选,所以答案不是简单的 $f_{\mathrm{root},0/1}$,而是两者取大,因此答案为
$$\max(f_{\mathrm{root},0},~f_{\mathrm{root},1})$$
把状态转移**翻译**成代码,就是:
```cpp
#include <bits/stdc++.h>
using namespace std;
constexpr int N=6e3+5;
int n,r[N],f[N][2];
int root,father[N]; //记录 u 的父节点,用于寻找根 root
vector<int>tree[N]; //存储 u 的所有子节点
void dp(int u){
f[u][0]=0;f[u][1]=r[u];
for (const int &v:tree[u]){
dp(v);
f[u][1]+=f[v][0];
f[u][0]+=max(f[v][0],f[v][1]);
}
}
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;++i) scanf("%d",&r[i]);
for (int i=1;i<n;++i){
int l,k;
scanf("%d%d",&l,&k);
tree[k].push_back(l);
father[l]=k; //记录 l 的父节点 k
}
for (int i=1;i<=n;++i) if (father[i]==0) root=i; //检查没有父节点的节点
dp(root);
printf("%d",max(f[root][0],f[root][1]));
return 0;
}
```
> **注**:由于在树中,每个节点至多 $1$ 个父节点,一个节点的结果仅会被其父节点所调用 $1$ 次,因此**不可能出现重叠子问题**。所以在这里不需要记忆化搜索。
::::warning[贪心算法为何无法得到最优解]
:::info[反例演示]
考虑有 $3$ 个职员,编号为 $i\in\{1,2,3\}$。
树的结构为:$3$ 是 $1$ 和 $2$ 两者的直接上司(根节点为 $3$),即下图
```text
3
/ \
1 2
```
其中,每个职员的快乐指数分别为 $r_1=r_2=4,r_3=6$。
**典型贪心思路:优先选快乐指数最大的单个职员。**
贪心计算:快乐指数最大的单个职员为 $r_3$。由于 $3$ 是 $1$ 和 $2$ 的直接上司,因此 $1$ 和 $2$ 均不能选择。
故贪心得出的“最优解”为 $6$。
但可以发现,若选择 $1$ 和 $2$,满足题目要求,其结果为 $8$,比贪心得出的解更优。
树形 DP 计算表明:$8$ 确实是该树的最优解。
故 贪心在此样例中失效。
:::
:::info[理论说明]
回顾贪心算法的核心前提:**问题具有贪心选择性质和最优子结构性质**。
此题满足最优子结构性质,可用 DP。
但不满足贪心选择性质(无法使用贪心),原因如下:
- **决策的关联性**:父节点和子节点的选择是**互斥的**,父节点的局部最优选择会直接剥夺所有子节点的选择可能,但子节点的整体收益可能远大于父节点的单个收益,导致可能得不到最优解。
- **树的层级性**:该题的树是多层结构,贪心仅能处理**单层局部**,无法兼顾多层的收益权衡。例如,父节点选或不选,不仅影响子节点,还会影响孙节点,导致**贪心无法预判后续层级的收益总和**。
这种状态的最优子结构需要 DP 计算,但**贪心无法表达 $\text{选/不选}$ 的双状态,只能做单一生硬的局部选择**。
**这便是贪心无法求得最优解的根本原因。**
:::
::::
## [洛谷 P2016 战略游戏](https://www.luogu.com.cn/problem/P2016)
:::epigraph[**例题对比**]
这道题与 [P1352](https://www.luogu.com.cn/problem/P1352) 恰好对称——[P1352](https://www.luogu.com.cn/problem/P1352) 是**相邻节点不能都选**,这里是**相邻节点至少选一个**。这种对称性能帮你更深刻地理解状态设计。
:::
**题目可以抽象为:**
在一棵**无根树**上有 $n$ 个节点,其编号为 $i\in[0,~n-1]$,每个节点与其他 $k$ 个节点有一条**无向边**相连。当士兵站在节点 $u$ 时,所有与 $u$ 相连的**边**都可被瞭望到。
要求放置若干个士兵,使得:
- 所有边均被瞭望到。
- 放置的士兵数目最小。
---
**思路:** 可以发现,当这棵树的其中一条边被瞭望到时,必然有士兵在**父节点放置**或**子节点放置**,那么可认为是**相邻节点至少选 $1$ 个**。同理,每个结点有且只有 $2$ 中情况——**选**或**不选**,且我们通常从根节点开始考虑。因此:
定义状态 $f_{u,0/1}$,表示以编号 $u$ 为根节点,是否在节点 $u$ 放置士兵的**最少士兵数目**。
$0$ 表示不放置,$1$ 表示放置。
则初始化是显然的:
- 当不在节点 $u$ 放置时,$f_{u,0}\gets0$。
- 当在节点 $u$ 放置时,$f_{u,1}\gets1$。
**状态转移方程**可以这样想:
- 不在节点 $u$ 放置,则 $u$ 的子节点必须放置,在初始基础上加上子节点的值,也就是
$$f_{u,0}\gets f_{u,0}+\sum_{v\in\operatorname{son}(u)}f_{v,1}$$
- 在节点 $u$ 放置,则 $u$ 的子节点可放可不放,在初始基础上加上**放**与**不放**的**较小者**,也就是
$$f_{u,1}\gets f_{u,1}+\sum_{v\in\operatorname{son}(u)}\min(f_{v,0},~f_{v,1})$$
同理,根节点也可能不选,因此答案取**较小者**,为
$$\min(f_{\mathrm{root},0},~f_{\mathrm{root},1})$$
> **注**:本题中给的数据是无向边,构成的是无根树,但我们要将其转换为有根树。
把状态转移**翻译**成代码,就是:
```cpp
#include <bits/stdc++.h>
using namespace std;
//我们任意选择一个节点作为根,因为该题中,根的选择不影响最终结果
constexpr int root=0;
int n;
int f[1505][2];
vector<int>g[1505],tree[1505]; //g 是原无根树,tree 是转换后的有根树
void build(int u,int dad){ //把无根树变为有根树
for (const int &v:g[u]){
if (v==dad) continue;
tree[u].push_back(v);
build(v,u);
}
}
void dp(int u){
f[u][0]=0;f[u][1]=1;
for (const int &v:tree[u]){
dp(v);
f[u][1]+=min(f[v][0],f[v][1]);
f[u][0]+=f[v][1];
}
}
int main()
{
scanf("%d",&n);
while (n--){
int i,k;
scanf("%d%d",&i,&k);
while (k--){
int r;
scanf("%d",&r);
g[i].push_back(r);
g[r].push_back(i);
}
}
build(root,-1); //根节点没有父节点,假定一个不存在的父节点
dp(root);
printf("%d",min(f[root][0],f[root][1]));
return 0;
}
```
:::info[为什么可以选择任意根?]{open}
观察 `dp` 函数,它的计算仅依赖于当前节点的子节点,不依赖**谁是整棵树的根**。
换一个根,只是把树**重新悬挂**了一次,每个节点依然会被作为某个子树的根计算一次。
因此,无论选哪个节点作为根,最终答案 $\min(f_{\mathrm{root},0},~f_{\mathrm{root},1})$ 都是相同的。
但在实际编码中,可以不用 `tree` 来存储有根树,只需按同样的逻辑,在状态转移过程中**避开父节点**即可。只需要去掉函数 `build`,同时把 `dp` 函数改为:
```cpp
void dp(int u,int dad){ //标记父节点即可
f[u][0]=0;f[u][1]=1;
for (const int &v:g[u]){
if (v==dad) continue; //只要添加这里
dp(v,u);
f[u][1]+=min(f[v][0],f[v][1]);
f[u][0]+=f[v][1];
}
}
```
这样编写代码,可大大减小代码量和浪费空间,同时不容易出错。
在本文后面介绍的树形 DP 里,均采用直接避开父节点的方法。
:::
::::info[贪心算法是否可行]
明确结论:**可行**!
大致的核心思路如下:
1. 按深度从大到小排序(从叶子往上)。
2. 如果当前节点和它的父节点都没被覆盖,就把父节点放入覆盖集。
3. 标记当前节点和父节点为已覆盖。
4. 统计放入的节点数。
这个贪心策略对树是正确的,理由如下:
- **树没有环**,从叶子向上处理不会产生后效性
- **边覆盖的性质**:覆盖一条边,选父节点和选子节点是**对称**的。
- 贪心策略选父节点,可以一起覆盖当前边和父节点向上的边。
> 贪心做法的代码实现细节较多,建议先掌握树形 DP 的解法。
> 由于本文专讲树形 DP,因此该题的贪心算法不做详细描述。
下面给出几个优秀参考实现,感兴趣可自行查阅:
- 帖子《[证明贪心代码正确性](https://www.luogu.com.cn/discuss/1219900)》。
- 博客《[详解洛谷P2016 战略游戏/BZOJ0495. 树的最小点覆盖之战略游戏](https://www.e-com-net.com/article/1754884682861789184.htm)》。
- 题解《[题解 P2016【战略游戏】](https://www.luogu.com.cn/article/z5xy0boz)》。
贪心算法的复杂度:
- 时间复杂度:$O(n)$,但常数比 DP 小。
- 空间复杂度:$O(n)$。
:::warning[如何改动题面,使得贪心失效]
> 虽然贪心在该题有效,但一旦稍微改动题面,贪心就可能无效了。
> 一般地,改动后,题面将不具有贪心选择性质,但具有无后效性——**树形 DP 可解**。
以下是几种可行方案:
- **引入节点权值**:给每个节点赋予不同的代价,要求最小化总代价而非士兵数量。
**失效原因**:贪心优先选**覆盖更多边**的节点,但若有节点代价极高,选它可能不是最优。
**修改题面**:每个节点 $i$ 上放置士兵需要花费 $w_i$ 的代价,求覆盖所有边的最小总代价。
- **引入约束**:某些节点不能放士兵。
**失效原因**:贪心在决策时会倾向于父节点,但若父节点**禁止放置**,会陷入困境。
**修改题面**:部分节点无法放置士兵,求在满足条件的前提下覆盖所有边的最少士兵数。
- **改变覆盖规则**:士兵只能覆盖到父节点的边,不能覆盖到子节点的边。
**失效原因**:这样边覆盖变成了有向覆盖,贪心的**对称性**被破坏。从叶子向上贪心时,选父节点可能无法覆盖子节点方向的需求。
**修改题面**:士兵只能瞭望到通向父节点的边。求覆盖所有边的最少士兵数。
- **引入收益递减**:每条边需要被至少 $k$ 个士兵覆盖($k\geqslant2$)
**失效原因**:贪心的**选一个节点覆盖多条边**策略不够用,因为一条边需要多次覆盖。
**修改题面**:每条道路需要至少 $k$ 个士兵瞭望才能确保安全,求最少士兵数。
这 $4$ 种方案均不影响树形 DP 的使用。
> **小贴士**:一个题能被树形 DP 解决,当且仅当满足四个条件:**树形结构、无后效性、最优子结构、状态可分解**。
:::
::::
## [洛谷 P1122 最大子树和](https://www.luogu.com.cn/problem/P1122)
:::epigraph[**例题对比**]
这是一种**新的题目**,包含**负数处理**、**贪心思想**等方法。
:::
**题目可以抽象为:**
在一棵无根树上有 $n$ 个节点,其编号为 $i\in[1,n]$,每个节点有一个节点权值 $\mathrm{pretty}_i$。有 $(n-1)$ 条边,要求去掉若干条边。去掉一条边时,必然生成两棵树,要求选择一棵树继续操作,最终使得:
- 在树中找一个连通子图,使得节点权值之和最大。
---
**思路:** 可以发现,这道题目其实要求的是**连通块**中的节点的值。
因此,定义 $f_u$ 表示在子树 $u$ 中,包含 $u$ 的连通块的节点最大和。
那么显然可以有初始化 $f_u\gets \mathrm{pretty}_u$,因为子树肯定首先包含的就是**根节点**。
**状态转移方程**可以这样想:
如果我们对这棵树的其中一条边作决策,有**剪**和**不剪**这两种方案?怎么选择呢?
容易发现一个浅显的贪心思想:
- 若该边连接的子树节点权值和为**负数**,则不能保留。
- 若该子树节点权值和为**非负数**,则可以加上。
于是转移方程出来了:
$$f_u=\mathrm{pretty}_u+\sum_{v\in\operatorname{son}(u)}\max(0,f_v)$$
**注意**:在本题中,$f_u$ 表示:在子树 $u$ 中,必须包含 $u$ 的连通块的最大和。
这个定义保证了我们算出的值只考虑那些**以 $u$ 为根节点**的连通块。
但题目需要整棵树中任意一个连通子树的最大和,因此需要考虑**每个节点当根节点的情况**。
因此,最终答案为
$$\max(f_1,~f_2,~\dots,~f_n)$$
把状态**翻译**成代码,就是:
```cpp
#include <bits/stdc++.h>
using namespace std;
constexpr int root=1;
int n,ans=-2e9,f[16005];
int pre[16005]; //pretty的缩写
vector<int>g[16005];
void dp(int u,int dad){
for (const int &v:g[u]){
if (v==dad) continue;
dp(v,u);
//是正就加,非正不加
if (f[v]>0) f[u]+=f[v];
}
}
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;++i) {scanf("%d",&pre[i]);f[i]=pre[i];}
for (int i=1;i<n;++i){
int a,b;
scanf("%d%d",&a,&b);
g[a].push_back(b);
g[b].push_back(a);
} dp(root,0);
for (int i=1;i<=n;++i) ans=max(ans,f[i]);
printf("%d",ans);
return 0;
}
```
# 树形背包
:::epigraph[**承接**]
在《[进阶背包DP教程:基础背包的综合运用](https://www.luogu.com.cn/article/u1b3q8nf)》中留下的**常见问题解答**中,留下了**依赖背包**的悬念:**依赖背包**和**树形背包**到底是什么关系?
本文将理清树形背包,你会发现,依赖背包——不过是它的一种特例。
:::
**例题说明**:采用 [洛谷 P2015 二叉苹果树](https://www.luogu.com.cn/problem/P2015) 和 [洛谷 P2014 选课](https://www.luogu.com.cn/problem/P2014) 作为经典例题讲解。
本文默认你已经了解题意。
## [洛谷 P2015 二叉苹果树](https://www.luogu.com.cn/problem/P2015)
**题目可以抽象为:**
有一棵无向树,共有结点 $N$ 个,编号为 $i\in[1,N]$;有边 $(N-1)$ 条,编号为 $j\in[1,N-1]$,其边权为 $w_j$,要求保留 $Q$ 条边,使得:
- 保留的这 $Q$ 条边构成一棵连通树。
- 最大化总边权之和。
可以看出,这其实是一个背包问题:选若干条边,同时最大化总边权。只不过这是在**树**上的背包,因此被称为**树形背包**。
---
**思路:** 可以发现,如果**去掉**一条边,则必然会将这棵树分割为两棵不连通的树。
因此,若要在一棵子树中**保留**一条边,则必须先选**它和父节点之间的那条边**(后文称**入场边**),否则此儿子所在的子树无法进来。
定义参数 $w_{u,v}$ 表示该边 $(u,v)$ 的权值。
则定义状态 $f_{u,j}$ 表示在以 $u$ 为根的子树中,选 $j$ 条边,**且 $u$ 必须与父节点相连**的最大边权。
**状态转移方程**可以这样想:
对于 $\forall~v\in\operatorname{son}(u)$,若我们决定保留 $(u,v)$ 这条**入场边**,花费 $1$ 条边的容量,并分配给 $v$ 的子树 $k$ 条边,则总贡献为
$$
f_{u,j}\gets\max_{k\in[0,j)}\begin{cases}
f_{u,j}\\
f_{u,j-k-1}+f_{v,k}+w_{u,v}
\end{cases}
$$
> 这里的 $k<j$ 是为了保证还有容量放进这个**入场券**。
当我们用代码实现这个转移时,**要注意**:$f_{u,j}$ 的更新依赖于未考虑当前子节点的 $f_{u,*}$,这与《[背包DP教程:从DFS暴力到DP的逐步推导](https://www.luogu.com.cn/article/s3dkf9lf)》中 **01 背包降维优化**的要求一致,因此 $j$ 需倒序遍历。
完整代码如下:
```cpp
#include <bits/stdc++.h>
using namespace std;
constexpr int root=1;
struct Edge {int to,w;};
vector<Edge>g[105];
int n,q,f[105][105];
void dp(int u,int dad){
for (const Edge &e:g[u]){
int v=e.to,w=e.w;
if (v==dad) continue;
dp(v,u);
for (int j=q;j>=0;--j)
for (int k=0;k<j;++k)
f[u][j]=max(f[u][j],f[u][j-k-1]+f[v][k]+w);
}
}
int main()
{
scanf("%d%d",&n,&q);
for (int i=1;i<n;++i){
int u,v,w;
scanf("%d%d%d",&u,&v,&w);
g[u].push_back({v,w});
g[v].push_back({u,w});
}
dp(root,-1);
printf("%d",f[root][q]);
return 0;
}
```
## [洛谷 P2014 选课](https://www.luogu.com.cn/problem/P2014)
**题目可以抽象为:**
有若干个有向图,它们总共有 $N$ 个节点和 $N$ 条边,节点编号为 $i\in[1,N]$,每个节点有一个**节点权值** $s_i$,每个节点最多有 $1$ 个前驱。若要选择一个节点,则必须选择其前驱的节点。现要求:
- 选择 $M$ 个节点。
- 最大化**节点权值**。
> 可以看出,这是一个**加强版**依赖背包,有了多层依赖。
> 同时,物品件数为 $N$,背包容量为 $M$,第 $i$ 件物品价值为 $s_i$、重量为 $1$。
---
**思路:** 注意到 `题目保证课程安排无冲突`,表明题目给出的数据**没有环**。
既然题目给出的有向图是**若干个**,为了方便统计,我们可以引入一个**虚根 $0$**,它不占用背包容量,同时它的节点权值为 $0$。虚根 $0$ 与各个树的根节点相连,并且箭头指向各子树的真根节点。
不难发现,此时节点数为 $(N+1)$,边数为 $N$,这样的图是一个**有向树**。
则定义状态 $f_{u,j}$ 表示在以 $u$ 为根的子树中,选 $j$ 门课,**且 $u$ 必须选**。
则初始化是显然的:$f_{u,1}=s_u$,因为若选 $1$ 门课,则必须选择根节点 $u$。
> **提示**:没有 $f_{u,0}=0$。因为状态 $f_{u,j}$ 最重要的是**必须选择根节点 $u$**。$f_{u,0}$ 既然是在这个子树不选任何节点,那怎能选择根节点 $u$ 呢?**因此 $f_{u,0}$ 是非法状态**。
**没有入场券**的状态转移方程:
$$
f_{u,j}\gets\max_{k\in[0,j]}\begin{cases}
f_{u,j}\\
f_{u,j-k}+f_{v,k}
\end{cases}
$$
最终答案:$f_{0,M+1}$,由于有虚根 $0$,因此背包容量是 $(M+1)$。
**注意:**
- 虽然虚根 $0$ 不占用背包容量,但是必须选择它,否则其余节点无法选择。因此背包容量需增加 $1$,实际代码中的背包容量为 $(M+1)$。
- 这里没有**入场边**,因此 $k$ 的取值范围为 $k\in[0,j]$,同时无需减去 $1$。
- 数组 $f$ 需初始化为 $-\infty$,因为需要表明这是**非法状态**。
**参考**:本题中 `选择 M 门课程学习` 说明了本题需要**恰好装满**。我在《[背包DP教程:从DFS暴力到DP的逐步推导](https://www.luogu.com.cn/article/s3dkf9lf)》中的**常见问题解答**中明确指出:若要求**恰好装满**,则应初始化为 $-\infty$。
- 非法状态要用 $-\infty$ 表示。若用 $-1$ 表示非法状态,需要判断 $f_{u,j-k}\ne-1\text{ 且 }f_{v,k}\ne-1$ 才能成功转移,因为若 $f_{u,j-k}$ 和 $f_{v,k}$ 之中有任意一个是 $-1$,另外一个是**正数**,则该状态**一定是非法的**,但 $f_{u,j}$ 却变成了正数,这就变成了合法的。
$-\infty$ 的好处是:不管多少个正数与它相加,它都是负数——**非法状态**。
参考代码如下:
```cpp
#include <bits/stdc++.h>
using namespace std;
int n,m,f[305][305],s[305];
vector<int>g[305];
void dp(int u){
f[u][1]=s[u];
for (const int &v:g[u]){
dp(v);
for (int j=m;j>=0;--j)
for (int k=0;k<=j;++k)
f[u][j]=max(f[u][j],f[u][j-k]+f[v][k]);
}
}
int main()
{
memset(f,0xcf,sizeof f);
scanf("%d%d",&n,&m);
++m; //以放入虚根
for (int i=1;i<=n;++i){
int k;
scanf("%d%d",&k,&s[i]);
g[k].push_back(i);
}
dp(0); //从虚根 0 开始
printf("%d",f[0][m]);
return 0;
}
```
# 换根 DP
**例题说明**:采用 [洛谷 P3478 STA-Station](https://www.luogu.com.cn/problem/P3478) 和 [洛谷 P2986 Great Cow Gathering G](https://www.luogu.com.cn/problem/P2986)。
本文默认你已经了解题意。
## [洛谷 P3478 STA-Station](https://www.luogu.com.cn/problem/P3478)
**题目可以抽象为:**
一棵无根树上有 $n$ 个节点,存在 $(n-1)$ 条无向边。
定义**深度**:一个结点的深度为该节点到根的简单路径上**边**的数量,因此**根节点的深度为 $0$**。
需找出一个节点,使得:**以这个结点为根时,所有结点的深度之和最大。**
**注意:答案可能不唯一,题目要求仅输出 $1$ 个即可。**
---
**思路:** 先看看在一棵无根树上,如果变换根节点,其他节点的深度会怎么跟着变化?
> **注**:后文以 $1$ 为根的整棵树称为**原树**。
:::align{center}

:::
先指定 $1$ 为根节点(指定哪个都行),则其他节点的深度呢?比如:
- **节点 $1$**:深度为 $0$。
- **节点 $4$**:深度为 $1$。
- **节点 $2$**:深度为 $2$。
接着,把根节点交给 $1$ 的子节点 $4$(后续称**新根**)。
那么,在**原树**中以节点 $4$ 为根的子树中,可以发现:其子树中的节点的**新深度**减少了 $1$。
但是,不在原子树的节点,比如节点 $1$,很明显**新深度**增加了 $1$。
以下是各个节点发生的变化:
- 原树中在新根的子树**外**的节点:
- **节点 $1$**:新深度为 $0+1=1$。
- 原树中在新根的子树**内**的节点:
- **节点 $4$**:新深度为 $1-1=0$。
- **节点 $2$**:新深度为 $2-1=1$。
**总结**:当**原根**将根节点换到其子节点作为**新根**时,**原子树**中,以**新根**为根的子树中的所有节点均与**根节点**减小了 $1$ 的距离,子树外的所有节点均与**根节点**增加了 $1$ 的距离。
---
:::info[我是如何思考参数和状态的?]{open}
既然要求深度之和,不妨定义状态 $f_u$,表示当以 $u$ 为整棵树的根节点时,该树的深度。
> 这里的方法是**题目要什么,状态设什么**。
> 大部分树形 DP 题目都可以这样简洁、方便地定义状态。
在**思路**部分中,可以发现:对于每个节点,它们在**原树**的深度对转移是重要的。
因此,定义 $\mathrm{dep}_u$,表示节点 $u$ 在原树的深度。
即 $\mathrm{dep}_v$ 的转移方程为
$$\forall~v\in\operatorname{son}(u),~\mathrm{dep}_v=\mathrm{dep}_u+1$$
**观察**:在每次换根后,树的形态会发生改变——每个节点在原树中,其子树的**节点数量**发生了明确可转移的代数关系。
不妨定义 $\mathrm{size}_u$,表示在原树中,子树 $u$ 的**节点数量**。
> **为什么要定义 $\mathrm{size}_u$?**
> 因为,仅知道深度不够,换根时深度之和的变化量,由子树节点数量决定。
> 因此,定义 $\mathrm{size}_u$ 表示子树 $v$ 中的节点总数。
因为子树必然包含其根节点,故初始化为 $\mathrm{size}_u\gets1$。
又因为子树 $v$ 必然包含于树 $u$。故转移方程为
$$~\mathrm{size}_u\gets\mathrm{size}_u+\sum_{v\in\operatorname{son}(u)}\mathrm{size}_v$$
:::
定义完参数和状态后,可以考虑状态 $f_u$ 的转移方程了,令 $v\in\operatorname{son}(u)$。
因为我们要求树的深度之和,而子树 $v$ 正好将整棵树分割成两个部分:
- **子树内**:$f_u-\mathrm{size}_v$。以 $u$ 为根时,子树 $v$ 中所有节点的深度,比以 $v$ 为根时多 $1$,因此从 $f_u$ 换到 $f_v$ 时,需要先减去 $\mathrm{size}_v$。
- **子树外**:$n-\mathrm{size}_v$,共有 $n$ 个节点,因此 $v$ 子树外的节点共有 $(n-\mathrm{size}_v)$ 个。
因此,其相加之和为 $f_u-\mathrm{size}_v+n-\mathrm{size}_v=f_u+n-2\cdot \mathrm{size}_v
综上,状态转移方程 为
\forall~v\in\operatorname{son}(u),~f_v\gets f_u+n-2\cdot\mathrm{size}_v
注意 n 的数据范围:1\leqslant n\leqslant10^6 。int 会溢出,部分变量需要 long long。
根据状态转移方程直接翻译得出:
#include <bits/stdc++.h>
using namespace std;
constexpr int N=1e6+5;
vector<int>g[N];
int n,sz[N],root=1;
long long f[N],ans;
void build(int u,int dad,int dep){
sz[u]=1; f[1]+=dep; //初始化
for (const int &v:g[u]) {
if (v==dad) continue;
build(v,u,dep+1);
sz[u]+=sz[v];
}
}
void dp(int u,int dad){ //换根
for (const int &v:g[u]){
if (v==dad) continue;
f[v]=f[u]+n-2*sz[v];
dp(v,u);
}
}
int main()
{
scanf("%d",&n);
for (int i=1;i<n;++i){
int u,v;
scanf("%d%d",&u,&v);
g[u].push_back(v);
g[v].push_back(u);
}
build(1,0,0); //根节点的深度为 0
dp(1,0);
for (int i=1;i<=n;++i) if (f[i]>ans) {ans=f[i];root=i;}
printf("%d",root);
return 0;
}
时间复杂度 :两个 O(n) 的 DFS,加上最后处理结果 O(n) ,总时间复杂度为 O(n) 。
洛谷 P2986 Great Cow Gathering G
:::epigraph[例题对比 ]
和 P3478 相比,这里的深度 换成了距离 ,节点数 换成了奶牛数 ——但核心思想完全一样。
我们将复用 P3478 的子树内外 思想,推导出带权的换根公式。
:::
题目可以抽象为:
有一棵 N 个节点的无根树,节点编号为 i\in[1,N] ,节点 i 有 c_i 头奶牛。
树有 (N-1) 条无向边,记 w_{u,v} 为边 (u,v) 的边权。
定义状态 f_X ,表示以 X 为集会地点的不方便程度为
f_X=\sum_{i=1}^Nc_i\cdot\operatorname{dist}(i,X)
其中 \operatorname{dist}(i,X) 是节点 i 到 X 的树上唯一路径的距离(边权和)。
这里依旧是题目要什么,状态定义什么 。
要求 :找出一个节点 X ,使得 f_X 最小。
在换根 DP 的视角下,我们把集会地点 X 视作整棵树的临时根 ——所有奶牛到它的距离,就是它们在以 X 为根的树中的带权深度 。
思路: 回想 P3478 的 \mathrm{size}_u ,原来表示节点总数 ,但在本题中,实际表示这棵树以 1 为总根时,以节点 u 为根的子树的奶牛数量 ,只不过 P3478 的每个节点的“奶牛数量”是 1 而已。
参考 P3478,联系它们的关系,可以得到如下定义:
定义参数 \mathrm{cnt} ,表示整棵树的奶牛数量,也就是
\mathrm{cnt}=\sum_{i=1}^Nc_i
这里的 \mathrm{cnt} 和 P3478 中的 “n ” 功能相同,只不过从节点数量 变成奶牛数量 。
定义参数 \mathrm{size}_u ,在以 1 为总根的情况下,以 u 为根的子树的奶牛数量 。
同理,其初始化为 \mathrm{size}_u\gets c_u ,因为 u 是子树的根节点。
其转移方程同样是
\forall~v\in\operatorname{son}(u),~\mathrm{size}_u\gets\mathrm{size}_u+\mathrm{size}_v
这里的 \mathrm{size}_u 和 P3478 的功能相同,只不过从子树内节点数量 变成子树内奶牛数量 。
由于根节点从 1 开始。由题目抽象 部分容易得到 f_1 的初始化为
f_1=\sum_{i=1}^Nc_i\cdot\operatorname{dist}(i,1)
先来看看样例给的数据,这是通过样例生成的一棵树,节点上的数字代表其编号:
:::align{center}
:::
发现 :当根节点从 1 换到其子节点 3 时,原树中以 3 为根的子树中的所有节点均与 3 的距离减少了 w_{1,3} ,同时其子树中的奶牛数量 \mathrm{size}_3 ,因此不方便程度 减少了 \mathrm{size}_3\cdot w_{1,3} 。
同时,原树中不在以 3 为根的子树中的节点增加了距离 w_{1,3} ,且奶牛数量为总奶牛数量 \mathrm{cnt} 减去子树内的奶牛数量 \mathrm{size}_3 ,即不方便程度 增加了 (\mathrm{cnt}-\mathrm{size}_3)\cdot w_{1,3}
因此,f_3 的值应为 f_1-\mathrm{size}_3\cdot w_{1,3}+(\mathrm{cnt}-\mathrm{size}_3)\cdot w_{1,3} ,即
f_1-w_{1,3}(\mathrm{cnt}-2\cdot\mathrm{size}_3)
归纳: 令该树的原根 为 u ,新根 为 v 且 v\in\operatorname{son}(u) ,则状态 f 的转移方程为
f_v=f_{u}-w_{u,v}(\mathrm{cnt}-2\cdot\mathrm{size}_v)
最后,状态 f_i 存储以 i 为集会地点的不方便程度,题目要求最小化不方便程度 ,因此答案为
\min(f_1,~f_2,~\dots,~f_N)
注 :题目数据可能会超过 int,部分变量和数组需要开 long long。
理解了这个,代码不就是手到擒来吗?
洛谷 P2986 Great Cow Gathering G 的完整代码如下:
#include <bits/stdc++.h>
using namespace std;
constexpr int N=1e5+5;
struct Edge {int to,w;};
vector<Edge>g[N];
int n,c[N];
long long f[N],sz[N],cnt,ans=9e18;
void build(int u,int dad,long long dist){
sz[u]=c[u];f[1]+=c[u]*dist;
for (const Edge &e:g[u]){
int v=e.to,w=e.w;
if (v==dad) continue;
build(v,u,dist+w);
sz[u]+=sz[v];
}
}
void dp(int u,int dad){
for (const Edge &e:g[u]){
int v=e.to,w=e.w;
if (v==dad) continue;
f[v]=f[u]+(cnt-2*sz[v])*w;
dp(v,u);
}
}
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;++i) {scanf("%d",&c[i]);cnt+=c[i];}
for (int i=1;i<n;++i){
int a,b,l;
scanf("%d%d%d",&a,&b,&l);
g[a].push_back({b,l});
g[b].push_back({a,l});
}
build(1,0,0);
dp(1,0);
for (int i=1;i<=n;++i) ans=min(ans,f[i]);
printf("%lld",ans);
return 0;
}
版权声明
:::epigraph[生成式人工智能使用说明 ]
本文的核心思想、推导过程、代码实现均由本人独立完成。保证本人的贡献远大于 DeepSeek 的贡献。
在撰写过程中,使用了 DeepSeek 对全文的语句通顺度 进行润色。
:::
本文采用 知识共享 署名-非商业性使用-相同方式共享 4.0 国际 许可协议 进行许可。
您需遵守下列条件:
署名 :您必须给出适当的署名,提供指向本许可协议的链接,并标明是否对原始作品 作了修改。您可以用任何合理的方式来署名,但不得以任何方式暗示许可人为您或您的使用背书。
非商业性使用 :您不得将本作品用于商业目的 。
相同方式共享 :如果您再混合、转换或者基于本作品进行创作,您必须基于与原先相同的许可协议 分发您贡献的作品。
最后,欢迎各位读者引用本文(需注明文章来源和署名),祝您树形 DP 之路一帆风顺!