树形DP入门到实战:从状态定义到迭代优化
1. 树形DP到底在解决什么问题第一次接触树形DP的人大概率是在刷题时被一道“没有上司的舞会”或者“二叉树中的最大路径和”卡住然后翻题解看到“树形DP”四个字点进去发现代码不长但就是看不懂状态为什么这么定义、转移为什么这么写。我当初也是这样盯着别人的代码看了半小时脑子里只有一个念头这玩意儿凭什么能这么转移后来做多了才明白树形DP本质上就是把线性DP的那套思路搬到树这种数据结构上。线性DP是在一条线上从左往右推树形DP是在一棵树上从叶子往根推。核心思想没变还是“用子问题的最优解推导父问题的最优解”只不过子问题的组织方式从数组下标变成了树的节点关系。它主要解决的是具有层级依赖关系的最优化问题。比如选了一个节点就不能选它的父节点或者反过来比如要在树上选若干个点使得某种代价最小比如要计算树上所有路径中权值和最大的那条。这类问题的共同特征是决策之间存在树形的依赖约束不能简单地用线性顺序来处理。适合谁来学如果你已经掌握了基本的DP思想比如背包、最长上升子序列并且对树的遍历DFS、BFS不陌生那树形DP就是你下一步必须拿下的知识点。它在笔试面试中的出现频率极高尤其是涉及二叉树和一般树的动态规划问题几乎是大厂面试的必考题型。2. 树形DP的核心思路与方案选型2.1 为什么树形DP通常用DFS而不是BFS树形DP的经典实现方式是后序遍历的DFS。原因很直接DP需要先算出子问题的解再推导父问题的解。在树的结构里子节点就是子问题父节点就是更大的问题。后序遍历保证了你访问一个节点时它的所有子节点已经被访问过了也就是说子问题的解已经准备好了。BFS当然也能做但BFS是按层遍历的你访问到某一层的时候下一层的状态还没算出来需要额外记录层级信息再反向推导代码会复杂不少。而DFS递归的天然回溯特性恰好和“先算子树再算自己”的需求完美契合。我个人的习惯是能用递归后序就用递归后序代码简洁、思路清晰。只有在树特别深比如链式树深度达到10^5级别可能爆栈的时候才会考虑手动用栈模拟后序遍历或者用BFS加反向拓扑排序来处理。2.2 状态定义的一般套路树形DP的状态定义有一个非常实用的套路dp[u][0/1]或者dp[u][j]。dp[u][0/1] 适用于二选一的决策场景。比如“选或不选节点u”对应两种状态0表示不选1表示选。dp[u][j] 适用于资源分配或计数场景。比如在以u为根的子树中选j个节点的最优值。状态定义的关键在于dp数组的值必须是只依赖于以u为根的子树的信息不能掺杂子树外的信息。这一点非常重要因为树形DP的推导方向是从叶子到根如果你定义的状态需要用到父节点或兄弟节点的信息那就没法从下往上推了。注意如果你发现状态定义里出现了“从根到u的路径上……”这种描述那大概率需要用换根DP或者树上背包的进阶技巧不是最基础的树形DP能直接处理的。2.3 转移方程的推导逻辑转移方程是树形DP最核心也最容易出错的部分。推导的时候我习惯按以下步骤来明确dp[u][state]的含义枚举u的所有子节点v对于每个子节点考虑它有哪些状态可以转移到u的当前状态把所有子节点的贡献合并起来以最经典的“没有上司的舞会”为例每个员工有一个快乐值选了某个员工就不能选他的直接上司问最大快乐值总和。状态定义dp[u][0]表示不选u时以u为根的子树的最大快乐值dp[u][1]表示选u时的最大快乐值。转移如果选udp[u][1]那么所有子节点都不能选所以 dp[u][1] val[u] sum(dp[v][0])如果不选udp[u][0]那么子节点可以选也可以不选取最大值所以 dp[u][0] sum(max(dp[v][0], dp[v][1]))这个转移的逻辑非常清晰父节点的决策限制了子节点的可选状态子节点在允许的状态中取最优。2.4 边界条件的处理树形DP的边界就是叶子节点。叶子节点没有子节点所以它的dp值可以直接初始化。比如上面的例子中叶子节点u的dp[u][0] 0dp[u][1] val[u]。在实际写代码的时候我通常会在DFS函数开头先初始化dp[u][0]和dp[u][1]然后遍历子节点在遍历过程中累加贡献。这样即使u是叶子节点初始化之后循环体不执行dp值也是正确的。3. 从零实现一个树形DP完整实操过程3.1 问题建模与数据准备我们以一个具体的问题来走完整流程给定一棵n个节点的树每个节点有一个权值可能为负要求选出若干个节点使得选出的节点中任意两个都不相邻即不能有边直接相连求选出节点的权值之和的最大值。这个问题是“最大权独立集”在树上的版本也是树形DP最基础的模型之一。数据准备方面我们需要邻接表存储树的结构一个数组记录每个节点的权值一个二维dp数组n 8 val [0, 3, -1, 4, 2, -5, 6, 1, -2] # 1-indexed adj [[] for _ in range(n 1)] # 假设边为(1,2), (1,3), (2,4), (2,5), (3,6), (3,7), (6,8) edges [(1,2), (1,3), (2,4), (2,5), (3,6), (3,7), (6,8)] for u, v in edges: adj[u].append(v) adj[v].append(u) dp [[0, 0] for _ in range(n 1)] # dp[u][0]: 不选u的最大权值和 # dp[u][1]: 选u的最大权值和3.2 DFS后序遍历的实现细节import sys sys.setrecursionlimit(300000) def dfs(u, parent): dp[u][0] 0 dp[u][1] val[u] for v in adj[u]: if v parent: continue dfs(v, u) dp[u][0] max(dp[v][0], dp[v][1]) dp[u][1] dp[v][0] dfs(1, 0) answer max(dp[1][0], dp[1][1]) print(answer)这段代码看起来很短但每一行都有讲究。为什么传入parent参数因为树是无向图邻接表中父节点也会出现在子节点的邻居列表里。如果不记录parentDFS就会在父子之间来回横跳导致死循环。传入parent并在遍历时跳过它是树形DP中最常用的避免重复访问的手段。为什么先递归再累加这就是后序遍历的体现。必须先拿到子节点的dp值才能计算当前节点的dp值。如果把累加写在递归之前用的就是子节点未初始化的值结果必然错误。sys.setrecursionlimit的作用Python默认递归深度限制是1000左右树的深度超过这个值就会报RecursionError。对于n达到10^5的题目必须手动调大递归深度限制。但即使调大了Python的递归效率也不高极端情况下还是可能超时或爆栈这时候就需要改成迭代写法。3.3 手动模拟执行过程为了让大家真正理解递归的执行顺序我手动模拟一下上面那棵树的执行过程。树的结构1 / \ 2 3 / \ / \ 4 5 6 7 | 8DFS从节点1开始parent0。进入dfs(1, 0)初始化dp[1][0]0, dp[1][1]3遍历1的邻居2和3先进入dfs(2, 1)初始化dp[2][0]0, dp[2][1]-1遍历2的邻居1和4和5跳过1进入dfs(4, 2)初始化dp[4][0]0, dp[4][1]24没有其他邻居返回回到dfs(2,1)dp[2][0] max(0, 2) 2dp[2][1] 0 -1进入dfs(5, 2)初始化dp[5][0]0, dp[5][1]-55没有其他邻居返回回到dfs(2,1)dp[2][0] max(0, -5) 2dp[2][1] 0 -1dfs(2,1)结束返回回到dfs(1,0)dp[1][0] max(2, -1) 2dp[1][1] 2 5进入dfs(3, 1)初始化dp[3][0]0, dp[3][1]4遍历3的邻居1和6和7跳过1进入dfs(6, 3)初始化dp[6][0]0, dp[6][1]6进入dfs(8, 6)初始化dp[8][0]0, dp[8][1]-2返回回到dfs(6,3)dp[6][0] max(0, -2) 0dp[6][1] 0 6进入dfs(7, 3)初始化dp[7][0]0, dp[7][1]1返回回到dfs(3,1)dp[3][0] max(0, 6) max(0, 1) 7dp[3][1] 0 0 4dfs(3,1)结束返回回到dfs(1,0)dp[1][0] max(7, 4) 9dp[1][1] 7 12最终答案max(dp[1][0], dp[1][1]) max(9, 12) 12。选中的节点是1, 4, 5, 7, 8。权值和 3 2 (-5) 1 (-2) -1等等这里算出来不对。让我重新检查。dp[1][1] 12意味着选了节点1。选了1就不能选2和3。dp[1][1] val[1] dp[2][0] dp[3][0] 3 2 7 12。dp[2][0] 2表示不选2选了4权值2不选5权值-5。dp[3][0] 7表示不选3选了6权值6和7权值1不选8权值-2。所以选中的节点是1, 4, 6, 7。权值和 3 2 6 1 12。验证一下1和4不相邻1和6不相邻1和7不相邻4和6不相邻4和7不相邻6和7不相邻。确实没有相邻的节点被同时选中。答案正确。这个手动模拟的过程看起来很繁琐但它是理解树形DP执行流程的最好方式。我建议每个初学者至少手动模拟两到三道题把每一步的dp值变化都写出来做完之后你对树形DP的理解会有一个质的飞跃。3.4 迭代写法避免递归爆栈当树的深度很大时递归写法会爆栈。这时候需要改成迭代写法。迭代写法的核心是用栈模拟后序遍历或者用BFS得到拓扑序再反向处理。from collections import deque def solve_iterative(n, val, adj): # BFS得到遍历顺序和父节点 parent [0] * (n 1) order [] visited [False] * (n 1) q deque([1]) visited[1] True while q: u q.popleft() order.append(u) for v in adj[u]: if not visited[v]: visited[v] True parent[v] u q.append(v) dp [[0, 0] for _ in range(n 1)] # 反向遍历order保证子节点先于父节点处理 for u in reversed(order): dp[u][0] 0 dp[u][1] val[u] for v in adj[u]: if v parent[u]: continue dp[u][0] max(dp[v][0], dp[v][1]) dp[u][1] dp[v][0] return max(dp[1][0], dp[1][1])这个写法的关键在于BFS得到的order数组中父节点一定排在子节点前面。所以反向遍历order时子节点一定先于父节点被处理保证了DP的正确顺序。这种写法不需要递归完全避免了爆栈的风险而且效率通常比递归更高。实操心得在竞赛或面试中如果n的范围超过10^4我一般直接写迭代版本省得担心递归深度的问题。虽然递归版本代码更短但调试爆栈问题花的时间远比多写几行代码要多。4. 树形DP的常见变体与进阶技巧4.1 树上背包问题树上背包是树形DP中最重要也最常考的变体。典型问题是在以u为根的子树中选j个节点使得某种代价最小或收益最大。状态定义dp[u][j]表示在以u为根的子树中选j个节点的最优值。转移的时候需要合并子节点类似于背包的合并过程def dfs(u, parent): size[u] 1 dp[u][0] 0 dp[u][1] val[u] for v in adj[u]: if v parent: continue dfs(v, u) # 合并子节点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]这里有几个关键点为什么j要倒序遍历因为这是0-1背包的合并过程每个子节点只能被选一次。倒序遍历保证不会重复使用同一个子节点的贡献。size数组的作用size[u]记录以u为根的子树大小用来限制j和k的枚举范围。如果不加这个限制复杂度会退化到O(n^3)甚至更高。加了size限制之后总复杂度是O(n^2)的。为什么k从1开始因为dp[v][0]通常没有意义选0个节点或者已经在初始化中考虑过了。具体要看题目定义。树上背包的复杂度分析是一个经典话题。表面上看是三重循环但实际上由于size的限制总复杂度是O(n^2)而不是O(n^3)。这个结论的证明需要用到树上每对节点只在它们的LCA处被合并一次的计数技巧这里不展开但记住结论就好。4.2 换根DP换根DP解决的是“对于每个节点求出以它为根时的某个值”这类问题。最典型的例子是求树上每个节点到其他所有节点的距离之和。如果对每个节点都跑一次DFS复杂度是O(n^2)太慢了。换根DP的思路是先以1为根跑一次DFS求出dp[1]然后利用父子关系用O(1)的时间从父节点的答案推导出子节点的答案。def dfs1(u, parent): size[u] 1 dp[u] 0 for v in adj[u]: if v parent: continue dfs1(v, u) size[u] size[v] dp[u] dp[v] size[v] def dfs2(u, parent): for v in adj[u]: if v parent: continue # 从dp[u]推导dp[v] dp[v] dp[u] - size[v] (n - size[v]) dfs2(v, u)换根DP的关键在于推导公式。从u换到v时v的子树中的节点到v的距离比到u少1而其他节点到v的距离比到u多1。所以dp[v] dp[u] - size[v] (n - size[v])。这个技巧非常实用很多看起来需要O(n^2)的问题用换根DP都能优化到O(n)。4.3 树形DP与状态压缩的结合有时候树形DP的状态不止0和1两种而是需要记录更多信息。比如“每个节点有三种状态未覆盖、被自己覆盖、被子节点覆盖”这时候dp[u][0/1/2]就需要三种状态。更复杂的情况下可能需要记录“当前节点是否被选”、“当前节点的颜色是什么”等多个维度的信息。状态设计的核心原则是状态必须能够完整描述子问题对父问题的影响。如果状态不够转移就会出错如果状态太多复杂度和空间都会爆炸。我的一般做法是先想清楚父节点需要从子节点获取哪些信息然后把这些信息编码到状态里。如果状态数超过4种就要考虑是不是有更优的建模方式了。5. 常见问题与排查技巧实录5.1 树形DP调试速查表问题现象可能原因排查方法答案偏小转移时漏了某个子节点的贡献检查循环是否遍历了所有子节点答案偏大重复计算了某个子节点检查是否跳过了parent节点死循环/爆栈没有记录parent父子来回访问在DFS参数中加入parent并跳过结果不稳定dp数组未初始化或初始化不完整检查每个节点的dp初值是否设置复杂度超限没有用size数组限制枚举范围在树上背包中加入size限制叶子节点结果错误叶子节点的dp初值不对单独检查叶子节点的初始化逻辑5.2 三个我踩过的坑第一个坑忘记跳过parent节点。这是我初学树形DP时最常犯的错误。因为树是用无向图存储的邻接表中父节点也会出现在子节点的邻居列表里。如果不跳过parentDFS就会在父子之间无限循环直到爆栈。后来我养成了一个习惯写DFS函数时第一个参数是当前节点第二个参数一定是parent然后在循环开头就写if (v parent) continue;。第二个坑树上背包的枚举顺序写反。树上背包合并子节点时j必须倒序遍历。我当初不理解为什么就正序写了结果发现同一个子节点被重复选了多次答案偏大。后来想明白了正序遍历时dp[u][j]被更新之后后面的dp[u][jk]可能会用到已经包含了当前子节点贡献的dp[u][j]相当于同一个子节点被选了两次。倒序遍历就避免了这个问题因为dp[u][j]在被更新之前用的还是上一个子节点合并后的值。第三个坑递归深度不够导致段错误。有一次做一道n50000的题递归写法在本地跑小数据没问题提交上去就RE。查了半天才发现是递归深度超过了系统栈的限制。后来改成迭代写法就过了。从那以后只要n超过10000我直接写迭代版本不给自己找麻烦。5.3 复杂度优化的几个实用技巧树形DP的复杂度优化主要集中在减少无效枚举上。除了前面提到的size数组限制还有几个技巧上下界剪枝在树上背包中j的枚举范围可以限制在[max(0, m - (n - size[u])), min(size[u], m)]而不是从0到m。这样可以进一步减少无效枚举。预处理子树大小在DFS过程中顺便计算size数组避免单独再跑一次。滚动数组如果dp数组的第二维只依赖于上一层的状态可以用滚动数组优化空间。不过在树形DP中由于每个节点都需要保留自己的dp数组滚动数组的适用场景有限。记忆化搜索如果状态转移存在重复计算可以用记忆化搜索代替递推。但在树形DP中每个节点只被访问一次记忆化的收益不大。实操心得复杂度优化要建立在正确性的基础上。我见过太多人为了优化常数把代码改得面目全非最后连自己都看不懂了。先把正确版本写出来过了样例和对拍再去考虑优化。优化的时候一次只改一个地方改完立刻对拍验证确保没有引入新的bug。6. 树形DP的实战应用场景6.1 组织架构中的最优决策树形DP在组织管理中有非常直观的应用。比如一个公司的人员结构是一棵树每个员工有一个“绩效贡献值”但选了某个员工就不能选他的直接下属避免利益冲突问如何选择使得总贡献最大。这就是“没有上司的舞会”的现实版本。再比如公司要在一个层级结构中分配预算每个部门有最低预算要求总预算有限问如何分配使得整体效益最大。这是树上背包的典型应用。6.2 文件系统中的空间分配文件系统的目录结构天然是一棵树。每个目录可以分配一定的存储配额子目录的配额不能超过父目录的配额问如何分配使得所有目录的利用率最高。这类问题可以用树形DP来建模。6.3 网络拓扑中的最优部署在网络拓扑中节点之间的连接关系构成一棵树或近似树形结构。要在某些节点上部署服务使得覆盖范围最大或延迟最小同时满足某些约束条件比如相邻节点不能同时部署这也是树形DP的经典应用场景。6.4 游戏AI中的决策树在策略游戏中AI需要在一棵决策树上选择最优的行动序列。每个决策节点有多个分支每个分支有不同的收益和代价问如何选择使得总收益最大。树形DP可以高效地求解这类问题。这些应用场景的共同特征是决策之间存在层级依赖关系且这种依赖关系可以用树来建模。一旦你识别出问题具有这个特征就可以考虑用树形DP来求解。7. 从树形DP延伸出去的学习路径树形DP掌握之后下一步可以往几个方向深入。第一个方向是树上问题的高级技巧包括树链剖分、树上莫队、点分治等。这些技巧处理的是树上路径查询、子树修改等更复杂的问题树形DP是它们的基础。第二个方向是动态规划的更多变体包括状压DP、数位DP、概率DP等。这些DP的思想和树形DP有相通之处但处理的问题类型不同。第三个方向是图论中的DP包括DAG上的DP、基环树DP等。基环树可以看作树形DP的扩展处理的是“树加上一条边”的结构。我个人的学习路径是先把树形DP的几种基本模型最大独立集、树上背包、换根DP练熟然后去做一些综合题把树形DP和其他技巧结合起来。做题的时候不要只追求AC要思考“这个状态为什么这么定义”、“这个转移为什么是对的”、“如果换个定义会怎样”。这种思考比单纯刷题有价值得多。最后分享一个我常用的练习方法每做完一道树形DP的题我会把状态定义和转移方程用自然语言写一遍然后尝试自己出一道类似的题改一改约束条件或者目标函数看看能不能用同样的思路解决。这个方法帮我真正理解了树形DP的本质而不是死记硬背模板。