树形 DP

状态挂在节点上,答案由子树合并而来——先算儿子,再算自己。

CSP-S

◎学完你会

1树上状态转移:自底向上

树是无环的,所以不存在"状态成环"的问题——这正是树形 DP 好写的原因。以经典题 树的最大权独立集(相邻节点不能同时选)为例:每个节点两个状态,dp0[u]=不选 u 时子树最优,dp1[u]=选 u 时子树最优。

// 后序遍历:先递归儿子,再合并到自己 void dfs(int u, int fa) { dp0[u] = 0; dp1[u] = w[u]; // 初值:自己这个点 for (int v : g[u]) if (v != fa) { dfs(v, u); dp0[u] += max(dp0[v], dp1[v]); // 不选 u,儿子随意 dp1[u] += dp0[v]; // 选 u,儿子都不能选 } } // 答案 = max(dp0[root], dp1[root])
节点子节点dp0dp1
4———
5———
24,5——
6———
36——
12,3——
权值 w = [_,10,5,8,3,4,6],树为 1-{2,3}、2-{4,5}、3-{6}。点「下一步」按后序出值。
顺序是关键:后序(先儿子后自己)才能保证算 dp[u] 时子树全算完。用 dfs(u,fa) 传父节点防止"走回头路"。

2关键命令与换根 DP

要点含义 / 写法
建树邻接表 vector<int> g[N],无向边加两次,递归时用 fa 剪枝
状态设计dp0[u] / dp1[u]:二元状态最常见;也有 dp[u][j](容量/个数)
合并(后序)dp[u] = Σ f(dp[child]),遍历子节点累加
换根 DP第一遍求子树的 down[],第二遍用父亲答案反推 up[],ans[u]=down[u]+up[u]
树的直径两次 DFS/BFS:从任意点找最远点 A,再从 A 找最远点 B,AB 即直径
// 换根 DP 骨架:求"每个点到其他所有点的距离和"(两遍 DFS) void dfs1(int u, int fa) { // 子树内距离和 + 子树大小 sz[u] = 1; down[u] = 0; for (int v : g[u]) if (v != fa) { dfs1(v, u); sz[u] += sz[v]; down[u] += down[v] + sz[v]; // 每条边贡献 sz[v] 次 } } void dfs2(int u, int fa) { // 用父亲结果推自己 for (int v : g[u]) if (v != fa) { up[v] = up[u] + (n - sz[v]) + (down[u] - down[v] - sz[v]); dfs2(v, u); } }

✳跨学科:自底向上的汇总

树形 DP 的"先算下级、再汇总到上级"几乎就是组织管理的算法版本:公司的报销要等部门汇总、部门要等小组汇总;财务报表也是层层向上合并。理解了"后序合并",你也就理解了层级结构里数据是怎么流动的。

⚠易错点

遍历顺序错:用"前序"或者先算 dp[u] 再去递归儿子,会用到来算完的子树值。必须先递归、后合并。
链状树爆栈:N=2e5 且树退化成链时,递归深度 = N,默认栈会 RE。竞赛里常见做法是把大数组开全局(别开在函数里),必要时改迭代或加 -Wl,--stack。
vis 数组误用:树上不需要 vis,用 fa 参数防回走即可;混用 vis 容易漏遍历或多遍历。
换根加减不对称:从父亲推儿子时,要把"这条子树"的贡献先减掉再加上另一端,漏掉 (n - sz[v]) 是经典错误。

✎练一练

用递归写树形 DP 时,正确的求值顺序是?
合并 dp[u] 时要用到子树的完整结果,所以必须先递归儿子(后序遍历)。编号顺序对树的结构没有意义。

题目要求:对树上的每一个点,求"以它为根时的答案"(如到其他所有点距离之和、最小深度和)。应该怎么做?

这类"每个点当根"的题目,最合适的做法是?
换根 DP 用两遍 DFS 把 O(n²) 降到 O(n):第二遍把"父亲作为一个整体"的贡献替换进来。暴力做法多数题会 TLE。

给一棵 N 个点的树(边权为正),求任意两点间距离的最大值(直径)。

求树的直径,最简洁的正确做法是?
两次 DFS/BFS:第一次从任意点出发找最远点 A(A 必为直径端点之一),第二次从 A 找最远点 B,距离即直径,复杂度 O(n)。