1. 从一道“没有思路”的题说起:树形DP到底在解决什么
如果你刷过一定量的算法题,大概率会遇到这样一类场景:给出一棵有根树,每个节点有自己的权值,要求你在树上选一些节点,使得选出的节点满足某种约束(比如父子不能同时选、覆盖所有边、路径和最大等),并求出最优值。第一次碰到这种题,很多人的第一反应是“这怎么用DP?状态怎么定义?转移从哪来?”——我当年也是这样,盯着题目看了半小时,连状态都写不出来。
树形DP,说白了就是把动态规划这件事搬到树这种数据结构上做。它之所以让人一开始摸不着头脑,是因为我们习惯的线性DP是“一条线”上递推,而树是分叉的,一个节点可能有多个孩子,转移的时候要同时考虑所有子树。但一旦你理解了它的核心思想——把每棵子树当成一个独立的子问题,先算子树的最优解,再合并到父节点——这类题就会变得有章可循。
这篇文章适合谁看?如果你已经会写基础的线性DP(比如背包、最长上升子序列),但对树上问题总是无从下手,那这篇就是写给你的。我会从“为什么树形DP要这样设计状态”讲起,把状态定义、转移方程、遍历顺序、边界处理这些关键环节一个个拆开,再配合几道经典模型的完整代码和踩坑记录,让你看完能直接上手写题。关键词里提到的“树形DP”“动态规划”“子树合并”“后序遍历”这些概念,我都会在具体场景里解释清楚,不堆术语。
先说一个我自己的教训。刚学树形DP的时候,我最大的误区是“想一次性把整棵树的状态都想清楚”,结果越想越乱。后来才明白,树形DP的正确打开方式是递归思维:你只需要想清楚“以某个节点为根的子树,它的最优解怎么由它的孩子们的子树最优解推出来”,剩下的交给递归。这个思维转变,是我觉得学树形DP最关键的一步。
2. 树形DP的状态定义:为什么“以u为根的子树”是万能切入点
2.1 树的结构天然适合递归:子问题就是子树
树有一个非常好的性质:任意一个节点u,把它和它的所有后代拿出来,本身又是一棵树。这意味着如果我们定义“以u为根的子树”为一个子问题,那么这个问题可以完全独立地求解,不需要关心u的祖先是谁、兄弟是谁。这就是树形DP能够成立的根本原因。
对比一下线性DP:线性DP里,dp[i]表示“前i个元素的最优解”,子问题是前缀。树上没有“前缀”这种线性顺序,但有“子树”这种嵌套结构。所以树形DP的状态几乎总是长这样:dp[u][...]表示以u为根的子树在某种状态下的最优值。那个“...”是附加的状态维度,取决于题目约束。
举个最经典的例子:没有上司的舞会(也常叫“树的最大独立集”)。题目是:每个节点有一个快乐值,选一些节点,要求选了某个节点就不能选它的父节点,求最大快乐值总和。这里的状态就是dp[u][0]表示“不选u时,以u为根的子树的最大值”,dp[u][1]表示“选u时,以u为根的子树的最大值”。为什么是这两个状态?因为u选不选,直接决定了它的孩子们能不能选,这个信息必须传递给子树。
2.2 状态维度怎么定:看“父节点需要知道子树的什么信息”
很多人卡在状态定义上,不知道该开几维、每维表示什么。我的经验是:问自己一个问题——父节点在合并子树时,需要从子树知道哪些信息?这些信息就是状态维度。
还是拿“没有上司的舞会”举例。父节点u在决定自己选不选的时候,需要知道孩子v“选v时的最优值”和“不选v时的最优值”。所以孩子必须把这两个值都算出来给父节点,状态就是二维的。如果题目再加一个约束,比如“每个节点有一个花费,总花费不能超过K”,那父节点还需要知道“花费为k时的最优值”,状态就变成dp[u][j][0/1],其中j是花费。维度是随着约束增加的。
这里有个容易犯的错误:状态开太多维导致复杂度爆炸。比如有的题你可能会想“我要记录子树里选了几个节点”,于是开dp[u][cnt],结果复杂度变成O(n^2)甚至更高。这时候要判断这个维度是否真的必要,能不能用其他方式优化(比如树上背包的上下界优化)。我见过不少人在树上背包问题上因为没做上下界优化而超时,这个后面会专门讲。
2.3 一个反直觉的点:状态不一定从叶子往上“算”,而是从根往下“需要”
初学者容易有一个误解:树形DP是从叶子节点开始,一层层往上算。这个说法方向是对的,但理解上容易偏。更准确的说法是:我们从根节点开始递归,递归到叶子时自然得到叶子的状态,然后在回溯的过程中合并。也就是说,代码的调用顺序是从根往下,但状态的求解顺序是从叶子往上。这个“调用顺序”和“求解顺序”的区分很重要,因为它决定了你写递归函数时的思路。
我习惯的写法是写一个dfs(u, parent)函数,先递归所有孩子,再处理u自己的状态合并。这样代码结构非常清晰:孩子没算完,我不动u。这也是为什么树形DP几乎都用后序遍历(先孩子后自己)的原因。
3. 转移方程的推导:合并子树时的三种典型模式
3.1 模式一:父子约束型——选或不选的二元决策
这是最简单也最常见的一类。代表题就是“没有上司的舞会”。转移方程长这样:
- dp[u][0] = Σ max(dp[v][0], dp[v][1]),对所有孩子v求和
- dp[u][1] = val[u] + Σ dp[v][0],对所有孩子v求和
为什么dp[u][0]里孩子v可以选也可以不选?因为u没选,对v没有约束,v选不选都行,取最大值。为什么dp[u][1]里孩子v只能不选?因为u选了,v作为孩子不能选,所以只能加dp[v][0]。这个逻辑非常直白,但它是所有父子约束型树形DP的模板。
我刚开始写的时候,总是忘记dp[u][1]要加上val[u]本身,或者忘记dp[u][0]要对孩子取max。这两个点是最容易出错的。建议写完之后手动模拟一棵三层的小树,验证一下转移对不对。
3.2 模式二:背包合并型——子树之间做资源分配
这类题的典型场景是:每个节点代表一个物品,有体积和价值,选了一个节点就必须选它的父节点(依赖背包),总容量有限,求最大价值。这时候状态是dp[u][j]表示“以u为根的子树,在容量j下的最大价值”。转移的时候,u的每个孩子v相当于一组物品,要在u的容量里分配一部分给v的子树。
转移方程的核心是一个类似背包的合并过程:
for each child v of u: for j from size_u down to 0: for k from 0 to j: dp[u][j] = max(dp[u][j], dp[u][j-k] + dp[v][k])这个三重循环看起来和普通背包一样,但复杂度分析很关键。如果直接写,复杂度是O(n * K^2),其中K是容量。但树上背包有一个经典优化:每个子树的大小作为循环上界,这样总复杂度可以降到O(n * K)。这个优化我第一次看到的时候觉得很神奇,后来自己推了一遍才明白:因为每个点对只会在它们的LCA处被合并一次,总的合并次数是O(n)级别的。
这里有个坑:循环顺序必须是倒序,和01背包一样。如果正序,就会重复选同一个孩子子树里的物品。我见过有人写成正序,结果答案偏大,debug了半天。
3.3 模式三:路径统计型——维护子树内的某种信息
这类题不是“选或不选”,而是统计树上满足条件的路径数量,或者求最长路径。典型的是“树的直径”和“树的重心”相关问题。以树的直径为例,状态是dp[u]表示“从u出发往下的最长路径长度”。转移的时候,对于每个孩子v,dp[u] = max(dp[u], dp[v] + 1)。但直径不一定经过根,所以还要在合并的过程中记录“经过u的最长路径”,也就是取孩子中最大的两个dp[v] + 2。
这类题的关键在于:答案不一定在根节点的状态里,而是在合并过程中产生的。所以你需要一个全局变量ans,在每次合并孩子的时候更新。我一开始总是忘记更新ans,只返回了dp[u],结果答案永远是0或者某个不对的值。
4. 遍历顺序与实现细节:递归、迭代、还是拓扑排序
4.1 递归写法:最直观,但要注意栈深度
树形DP最自然的写法就是递归。伪代码大概是这样:
def dfs(u, parent): # 初始化dp[u] for v in children[u]: if v == parent: continue dfs(v, u) # 合并v的dp到u # 处理u的最终状态这个写法清晰易懂,但有一个隐患:递归深度。如果树是一条链,深度达到n,Python默认递归深度是1000,n超过1000就会爆栈。C++一般没问题,但Python需要手动设置sys.setrecursionlimit。我一般会设成n + 10或者1000000,保险起见。
另一个细节是parent参数的传递。因为树是无向的,你需要避免走回父节点。有两种写法:一种是传parent参数,另一种是建图的时候只建有向边(从父到子)。前者更通用,后者需要你先确定根。我一般用前者,因为不需要额外处理。
4.2 迭代写法:手动模拟栈,避免递归限制
如果不想用递归,可以用栈手动模拟后序遍历。思路是:先做一次DFS得到每个节点的父节点和访问顺序,然后按逆序处理。或者用两个栈模拟后序。这个写法代码量大一些,但不受递归深度限制。我在处理n特别大的题目时会用这种写法。
还有一种更简单的迭代方式:先做一次BFS得到拓扑序(从根到叶),然后逆序处理。因为树本身就是一个DAG,逆序处理就能保证孩子先于父节点被处理。这个写法我觉得比手动模拟栈更清晰,推荐大家试试。
4.3 建图方式:邻接表还是链式前向星
对于树形DP,建图方式影响不大,因为每个节点只会被访问一次。邻接表(vector数组)写起来最方便,C++里就是vector<int> g[N]。链式前向星在竞赛里更常见,因为速度快,但写起来麻烦一些。如果是平时练习,我建议用邻接表,代码可读性高。如果是比赛,看个人习惯,两者都能过。
有一个小细节:如果题目给的是有根树(直接告诉你根是谁),你可以只建有向边,省掉parent判断。如果给的是无向树,就必须建双向边。我一般统一建双向边,然后用parent参数控制,这样不容易出错。
5. 经典模型实战:从“没有上司的舞会”到“树上背包”
5.1 没有上司的舞会:树形DP的Hello World
这道题我做过不下十遍,每次复习树形DP都从它开始。题目描述很简单:n个节点,每个节点有权值,选一些节点,父子不能同时选,求最大权值和。
状态定义:dp[u][0]和dp[u][1]。 转移:上面已经写过。 边界:叶子节点的dp[u][0] = 0,dp[u][1] = val[u]。 答案:max(dp[root][0], dp[root][1])。
完整代码(Python):
import sys sys.setrecursionlimit(1000000) def solve(): n = int(input()) val = [0] * (n + 1) for i in range(1, n + 1): val[i] = int(input()) g = [[] for _ in range(n + 1)] has_parent = [False] * (n + 1) for _ in range(n - 1): u, v = map(int, input().split()) g[v].append(u) has_parent[u] = True root = 1 for i in range(1, n + 1): if not has_parent[i]: root = i break dp = [[0, 0] for _ in range(n + 1)] def dfs(u): dp[u][1] = val[u] for v in g[u]: dfs(v) dp[u][0] += max(dp[v][0], dp[v][1]) dp[u][1] += dp[v][0] dfs(root) print(max(dp[root][0], dp[root][1])) solve()这段代码里,我特意用了has_parent数组来找根,因为题目给的是有向边(从父到子),但顺序是乱的。如果题目给的是无向边,就需要用parent参数。这个细节很多人第一次做的时候会忽略,导致根找错。
5.2 树上背包:依赖背包的树形版本
树上背包的经典题是“选课”:n门课,每门课有学分,选一门课必须先选它的先修课,总共选m门,求最大学分。这本质上是一个依赖背包,依赖关系形成一棵树。
状态:dp[u][j]表示以u为根的子树选j门课的最大学分。 初始化:dp[u][1] = 学分[u](选u自己)。 转移:对每个孩子v,做一次背包合并。
def dfs(u): dp[u][1] = score[u] size[u] = 1 for v in g[u]: dfs(v) # 合并v的子树 for j in range(min(size[u], m), 0, -1): for k in range(1, min(size[v], m - j) + 1): dp[u][j + k] = max(dp[u][j + k], dp[u][j] + dp[v][k]) size[u] += size[v]这里的关键是size[u]的维护和循环上界的控制。如果不加size限制,复杂度会退化。我实测过,n=1000、m=1000的数据,不加优化会超时,加了之后秒过。
还有一个坑:虚拟根节点。因为原图可能是一个森林(多棵树),所以需要加一个虚拟根0,把所有树的根连到0上,然后对0做dfs。最后答案是dp[0][m+1](因为0本身不算一门课,但初始化时dp[0][1]=0,所以要多选一个)。这个技巧很常用,值得记住。
5.3 树的直径:路径统计型树形DP
树的直径有两种求法:两次BFS/DFS,或者树形DP。两次BFS更简单,但只能求直径长度,不能处理带权边(带权边也能做,但需要调整)。树形DP更通用,能处理带权边,也能顺便求出直径路径。
树形DP求直径的思路:对每个节点u,维护“从u往下走的最长路径”dp[u]。合并孩子v时,用dp[v] + w(u,v)更新dp[u],同时用“当前最长+次长”更新答案。
ans = 0 def dfs(u, parent): global ans dp[u] = 0 for v, w in g[u]: if v == parent: continue dfs(v, u) ans = max(ans, dp[u] + dp[v] + w) dp[u] = max(dp[u], dp[v] + w)注意ans的更新必须在dp[u]更新之前,否则会用到已经更新的dp[u],导致路径重复。这个顺序问题我踩过坑,当时答案总是偏大,后来发现是顺序反了。
6. 复杂度分析与优化:为什么你的树形DP会超时
6.1 树上背包的上下界优化:从O(nK^2)到O(nK)
前面提到过,树上背包的朴素写法是三重循环,复杂度O(nK^2)。但通过限制循环上界为子树大小,可以降到O(nK)。这个优化的原理是:每一对节点只会在它们的LCA处被合并一次。所以总的合并次数是O(n)级别的,每次合并的代价是O(K),总复杂度O(n*K)。
具体实现就是上面代码里的min(size[u], m)和min(size[v], m-j)。这两个上界看起来简单,但效果非常明显。我做过一个实验:n=2000,m=2000的随机树,不加优化跑了8秒,加了优化0.3秒。差距就是这么大。
6.2 状态压缩:什么时候可以省掉一维
有些题的状态看起来需要三维,但实际上可以省掉一维。比如“每个节点选或不选,且总花费不超过K”,状态是dp[u][j][0/1]。但如果题目只问“最大价值”,不要求输出方案,那么0/1这一维可以在合并时临时处理,不需要一直存着。具体做法是:先算dp[u][j][0]和dp[u][j][1],合并完孩子后,如果父节点只需要知道“选u”和“不选u”的最大值,那就可以把这两组合并成一个数组。
这个技巧需要具体题目具体分析,不是所有题都能省。我的经验是:如果某一维只在当前节点有用,不需要传给父节点,那就可以省。判断标准就是前面说的“父节点需要知道什么”。
6.3 换根DP:当答案不在固定的根上
有一类题是:对于每个节点,求以它为根时的某个值。比如“求每个节点到其他所有节点的距离之和”。如果对每个节点都做一次DFS,复杂度O(n^2),n大一点就超时。这时候需要用换根DP。
换根DP的思路是:先以1为根做一次DFS,求出dp[1]和每个子树的size。然后再做一次DFS,从父节点推到子节点。比如距离之和的换根转移是:dp[v] = dp[u] + n - 2*size[v]。这个公式的推导需要画图理解,但记住之后很好用。
换根DP是树形DP里比较进阶的内容,但面试和比赛中出现频率不低。我建议先把基础的树形DP练熟,再碰换根。
7. 我踩过的那些坑:从RE到WA的完整排查记录
7.1 递归爆栈:Python选手的必修课
第一次用Python写树形DP,n=10000的链,直接RE。报错是RecursionError: maximum recursion depth exceeded。解决办法是sys.setrecursionlimit(1000000)。但设太大也有风险,可能真的把栈撑爆。更稳妥的做法是改用迭代写法,或者用BFS+逆序处理。
我现在的习惯是:n小于5000用递归,大于5000用迭代。这样既保证代码简洁,又不会爆栈。
7.2 忘记初始化:多组数据时的经典错误
有些题是多组测试数据,每次都要重新初始化dp数组和邻接表。我有一次忘记清空邻接表,导致上一组的数据混进来,WA了好几发。后来养成了习惯:每组数据开始前,把所有全局数组重新赋值。Python里可以用[0]*(n+1)重新创建,C++里用fill或者memset。
7.3 循环顺序写反:背包问题的老毛病
树上背包的合并循环必须倒序,和01背包一样。我有一次写成正序,答案偏大,debug了半小时才发现。后来我总结了一个口诀:“背包合并,倒序保平安”。只要是背包类的合并,先检查循环顺序。
7.4 根节点找错:有向边和无向边的区别
如果题目给的是有向边(从父到子),你需要自己找根(入度为0的点)。如果给的是无向边,你需要用parent参数避免走回头路。我有一次把有向边当无向边处理,结果dfs死循环。后来每次读题都先确认边的方向。
8. 从刷题到实战:树形DP在真实场景中的影子
树形DP不只是竞赛题,它在实际工程中也有应用。比如:
- 组织架构中的权限分配:公司组织架构是一棵树,每个部门有预算,选了某个部门就必须选它的上级部门,总预算有限,求最大收益。这就是树上背包。
- 文件系统的空间统计:文件夹是一棵树,每个文件夹有大小,求删除某些文件夹后释放的最大空间,同时满足依赖关系。
- 网络路由的最优路径:网络拓扑是一棵树,求从根到所有叶子的最优路径,或者求任意两点间的最长路径。
这些场景的共同点是:问题天然具有树形结构,且子问题可以独立求解后再合并。一旦你识别出这个结构,树形DP就是最自然的解法。
我个人的体会是:树形DP的难点不在代码,而在状态定义和转移推导。代码写熟了之后,大部分题都是模板化的。真正需要思考的是:这道题的状态该怎么定?父节点需要知道子树的什么信息?合并的时候有没有顺序要求?把这三个问题想清楚,代码就是水到渠成的事。
最后分享一个我练习树形DP的方法:每做一道题,先不写代码,只在纸上画出树的结构,标出每个节点的状态,手动模拟一遍转移过程。这个过程比直接写代码慢,但能帮你真正理解状态之间的关系。我坚持了这个方法一个月,树形DP的正确率从50%提到了90%以上。如果你也在学树形DP,不妨试试。