树形DP教程:从递归视角彻底搞懂树上DP

· · 算法·理论

前言

在阅读本文前,你只需要:

由于本文主要面向初学者,本文代码均使用 vector 存图。

由于树形 DP 题目变化多端,本文采用一点一例的方式——一个核心思想,配一道经典题

自定义的符号和简写:

  • 子树 u:以节点 u 为根节点的子树。

    基础树形 DP

    例题说明:采用 洛谷 P1352 没有上司的舞会、洛谷 P2016 战略游戏 和 洛谷 P1122 最大子树和 作为经典例题讲解。

本文默认你已经了解题意。

洛谷 P1352 没有上司的舞会

题目可以抽象为:
有一棵树,共有节点 n 个,编号为 i\in[1,n],快乐指数为 r_i,要求选择若干个节点,使得:

思路: 可以发现,每个结点有且只有 2 种情况——不选,只需记录此状态即可。因为树形结构决定了通过递归必须从根向下传递信息,所以一般题目给出自己选择选一个根。

:::info[我是如何思考状态的?]{open}

Step 1:分析限制条件

题目说父节点和子节点不能同时选。这意味着:

这是两种不同的状态,需要分别讨论和包含。
常用做法是用 0/1 表示,0 表示不选,1 表示选。

Step 2:判断子问题独立性

在树中,最小问题规模是每个子问题,而每个子问题均是独立的,但子问题的解依赖于父节点是否被选这个外部条件。
在子树中,根节点可以看做这棵子树的父节点,所以子树的解依赖这个根节点选没选——这就是为什么状态里必须记录根节点的选择状态。
因此状态必须记录下根节点是否选择这一重要信息。

Step 3:设计状态

综合 Step 1Step 2,可以得到状态必须包含的两个信息:

故 可得到 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} ![根据样例数据生成的树](https://cdn.luogu.com.cn/upload/image_hosting/rbe0gatw.png) ::: 先指定 $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^6int 会溢出,部分变量需要 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],节点 ic_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) 是节点 iX 的树上唯一路径的距离(边权和)。

这里依旧是题目要什么,状态定义什么

要求:找出一个节点 X,使得 f_X 最小。

在换根 DP 的视角下,我们把集会地点 X 视作整棵树的临时根——所有奶牛到它的距离,就是它们在以 X 为根的树中的带权深度

思路: 回想 P3478 的 \mathrm{size}_u,原来表示节点总数,但在本题中,实际表示这棵树以 1 为总根时,以节点 u 为根的子树的奶牛数量,只不过 P3478 的每个节点的“奶牛数量”是 1 而已。

参考 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新根vv\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 之路一帆风顺!