想象一下,你是一家大型集团的董事长。集团有几十家子公司,每家子公司又管理着自己的部门和团队,形成一棵活生生的“组织树”。每到年底,你需要知道整个集团的利润最大值、哪些子公司值得保留,如何在预算内做最合理的投资。 但你不可能跑遍每个角落去问每个人的情况,那样太慢了。聪明的做法是:让每个部门经理先汇总自己手下的情况,报给子公司总经理;子公司总经理汇总后再报给大区负责人;最终,所有信息沿着树往上流动,汇集到你手里。 这就是树形动态规划的核心思想——先解决小问题(子树的局部最优),再合并成大问题的答案,自底向上地传递信息。 动态规划在树上展开,就像公司里的层层汇报:每个节点只需关注自己的“子树”,把子辈的消息整合,再决定自己的最优状态。无论树多么枝繁叶茂,只要我们遵循“先叶子、后根节点”的汇报顺序(后序遍历),每条边只需被访问一次,就能优雅地求出整个问题的答案。这就是树形DP——一种在树结构上做“智慧决策”的强大工具。
树形DP是算法面试的常客,也是解决公司组织管理、网络拓扑优化、资源分配等真实场景的核心思维模式。掌握它,意味着你能把递归、动态规划和树结构融会贯通,写出的代码既优雅又高效。
可视化演示
交互展示树形动态规划自底向上的状态转移过程,对比最大独立集选/不选策略与换根DP两次DFS方法。
本节配有交互动效(进入视口后自动加载,约 3KB)。
树形DP状态转移 后序遍历自底向上计算 dp[u][0]不选 dp[u][1]选 dp[u][0]=Σmax(dp[v][0],dp[v][1]) dp[u][1]=w[u]+Σdp[v][0]
核心概念
首先,我们要明确一个问题:什么是“树形DP”?如果把动态规划比作“填表”,那树形DP就是在一棵倒长的树上填表——每个节点对应一行“小账本”,记录着以它为根的子树能取得的最优值。
在公司场景里,每个部门经理都有一本账本,记录着自己部门能创造的最大利润。这本账本不是凭空写出来的,而是等所有下属先把账本交上来之后,经理结合自己的业绩和下属们的贡献,最终汇总成自己的一页。这里的关键是:子树的账本一旦算好,就不会再变,可以直接被父节点信赖和使用。 这种“先建立子问题的答案,再层层向上”的特性,正是动态规划“最优子结构”在树上的体现。
定义状态时,我们通常用 f[u] 表示以节点 u 为根的子树的最优值。但仅仅一个值往往不够,因为决策的“选择”不同,会影响父亲的计算。于是我们引入多维状态,比如 f[u][0] 和 f[u][1] 分别表示“不选 u”和“选 u”两种情况。
回到公司的场景:集团董事长的账本,是所有子公司账本的“汇总版”。每个子公司的账本又是由它下面的团队账本汇总而来。这种一层层交账本、逐级汇总的过程,正是树形DP的骨架。理解了“子树独立、逐级合并”,你就已经握住了树形DP的钥匙。
- 状态维度
f[u][state]用两个下标,u表示树的哪个节点,state表示该节点处于什么决策状态。 - 子节点集合
children(u)表示节点 u 的所有直接下属(孩子节点),它们的状态是“账本”的基础原料。 - 合并操作
combine根据不同场景可能是求和、取最大值或更复杂的函数,它把多个互不干扰的子树结果汇成一个值。
后序遍历
在公司例子里,“先收齐下属报表,再汇总自己”这个流程,在计算机科学里对应着树的后序遍历(Post-order Traversal)。后序遍历的规则非常简单:对于任意一个节点,先递归访问它的所有子节点,最后再访问它自己。
为什么必须是后序?假设你是一个部门经理,如果在自己还没搞清楚下属的情况时就先算自己的利润,那一定是瞎猜的——因为你缺少关键信息。信息只能从基层一层层汇报上来。所以,遍历的方向必须是自底向上的。这个方向不是物理空间的方向,而是数据依赖的方向:父节点的答案依赖子节点的答案,因此子节点必须先被计算。
递归实现是后序遍历最自然的写法:
这个递归过程蕴含着典型的分治思想。分治分三步:分解(把整个树拆成若干子树)、解决(递归求解每个子树)、合并(把子树结果组合成父节点的结果)。树形DP就是分治思想在“带最优值计算”场景的延伸——不仅分解,还要记录每个子问题的最优答案。
def dfs(u, parent): # 先处理所有孩子 for v in children[u]: if v != parent: dfs(v, u) # 此时 v 的子树的答案已经算好,可以合并 dp[u] = combine(dp[u], dp[v]) # 最后计算 u 自身(可选,视问题而定)
状态转移方程设计
有了遍历顺序,下一步就是设计状态转移方程。这是树形DP的灵魂。我们还是回到公司场景:假设集团要在子公司中评选“最佳利润贡献奖”,规则是相邻层级不能同时获奖——因为如果经理和下属同时当选,会让人觉得有徇私嫌疑。这个规则下的最大利润和,就是经典的树的最大独立集(Maximum Independent Set on Tree)问题。
定义两个状态:
f[u][0]表示以 u 为根的子树中,不选 u 时能获得的最大利润;
f[u][1]表示以 u 为根的子树中,选 u 时能获得的最大利润。
如何转移?如果选了 u,由于相邻节点不能同选,它的孩子 v 只能选择“不选”状态,即 f[v][0]。反过来,如果不选 u,它的孩子 v 就可以自由选择——选或不选哪个利润大就选哪个,即 max(f[v][0], f[v][1])。这个“自由选择”的本质就是子树的独立性:每个孩子的决策互不影响,只和自己的子树有关。
这个状态转移方程的价值在于它把“相邻互斥”的约束变成了清晰的两条公式。在公司场景里,实施“相邻不能同时获奖”后,董事长只需看两本最终账本——“我上台领奖的利润”和“我不上台领奖的利润”——挑一个大的即可。你可能会想:那要是“父子不能同时选,但可以隔代选”呢?放心,隔代由 f[v][0] 的下一层 f[v][1] 照顾到了,因为 f[v][0] 本身就是孙子们自由选择后的最优值。这就是状态设计的美妙:它把复杂的跨层约束压缩进了状态里。
max(f[root][0], f[root][1])。- 选 u 的收益选中节点 u 自己,获得价值
w(u),但因为“相邻不能同选”,所有孩子都必须处于不选状态f[v][0],把这些加总。 - 不选 u 的收益节点 u 不选,所有孩子独立决策,每个孩子取“选”与“不选”中的较大值,再求和。
- 最终答案根节点同样拥有选与不选的自由,取两者最大值。
经典问题
掌握了基础的选/不选模型,我们看它的两个重要变形。第一个是树形背包问题(Tree Knapsack)。想象公司有一笔培训预算,每个部门有一个培训项目,需要先报上级项目再报下级项目(依赖关系构成树),每个项目有成本和收益,在预算内选项目使收益最大——这就是“树上的01背包”。状态定义类似:f[u][j] 表示以 u 为根的子树,花费恰好为 j 的预算时能获得的最大收益。转移时对每个孩子 v 枚举它分配的预算 k:
第二个变形是最小覆盖集:选择最少的节点,使每条边至少有一个端点被选中——可以理解为给每条“上下级关系”装一个监控(选中的节点就是监控点),目标是监控最少。它的状态也只需要选/不选两个维度:
总结规律:树形DP的题目千变万化,但状态设计的思路高度一致——分析每个节点的决策选项,把每个选项变成一个状态维度,然后后序遍历合并孩子状态。无论是独立集、背包、覆盖还是路径问题,都在这个框架内。
实现方式
树形DP的代码实现主要有两种写法:记忆化递归和迭代(自底向上)。记忆化递归是最直观的写法——递归函数天然对应树的深度优先遍历,加一个 memo 数组记录已经算过的子树状态,避免重复计算。
在实际比赛中,如果树的规模很大(比如 n=10^5),递归的深度可能达到 O(n),在 Python 中容易触发栈溢出(RecursionError)。这时可以考虑迭代实现:先用一次 DFS(或者用栈模拟)收集节点的后序遍历顺序,然后按这个顺序逆序处理——先处理深的节点,再处理浅的节点。这样就不用递归了。
举个例子,迭代处理独立集问题:先用栈模拟后序遍历,得到 order 列表,里面叶子在前、根在后;然后逆序遍历 order,对每个节点 u,将它贡献的状态加入父节点:
复杂度分析:无论是递归还是迭代,每个节点和边都只访问一次,时间复杂度都是 O(n);空间复杂度也是 O(n)(存储 dp 数组和递归栈/显式栈)。数学上,树有 n 个节点、n-1 条边,每次操作 O(1),所以总复杂度 O(n)。相比之下,标准 DP 面对的是二维表(O(n^2) 空间),而树形DP利用树的分治结构,把空间压到了一维数组——这就是树这种独特结构带来的红利。
order = []stack = [(root, 0)]while stack: u, state = stack.pop() if state == 0: stack.append((u, 1)) for v in children[u]: stack.append((v, 0)) else: order.append(u) # 逆序处理:叶子先算for u in reversed(order): if u == root: continue # 把 dp[u] 合并进父节点 dp[parent[u]] ...
换根DP
前面讲的树形DP都固定了一个根。但有些问题需要把每个节点都当作根来求答案——比如“以每个城市为总部,配送成本最小是多少”。如果对每个节点都重新跑一次树形DP,复杂度是 O(n²),当 n=10^5 时无法承受。换根DP(Rerooting DP)用两次 DFS 解决了这个问题,把复杂度降为 O(n)。
核心思路是:第一次 DFS,任选一个根(比如 1),计算 dp[u]——以 u 为根的子树内的答案。第二次 DFS,从根出发,用一个“来自父亲的信息” up[u],把每个节点的答案“换”成以它为根时的全局答案。这就像公司总部从北京搬到上海:不需要重新跑遍所有子公司问一遍数据,只需要知道——原来总部在北京时的总数据,减去上海子公司的局部数据,再加上上海作为总部后其他部分的新贡献。
转移公式(以求到所有节点距离和为例):设 sz[v] 为 v 的子树大小,ans[v] = ans[u] + n - 2 * sz[v]。意思是:根从 u 换到 v 时,v 的子树里的节点距离变近了 1(一共 sz[v] 个),而其他节点距离变远了 1(一共 n - sz[v] 个),所以总距离变化量为:
第一次 DFS 从下往上收集子树信息,第二次 DFS 从上往下推导换根后的答案。这种“先自底向上、再自顶向下”的模式,让每个节点都以 O(1) 的代价成为“新根”。换根DP揭示了一个深刻思想:树的答案可以在节点之间“滚动传播”,而不必每次从头算起。
def dfs1(u, parent): sz[u] = 1 for v in children[u]: if v != parent: dfs1(v, u) sz[u] += sz[v] dp[u] += dp[v] + sz[v] # 到子树所有节点的距离和 def dfs2(u, parent): for v in children[u]: if v != parent: ans[v] = ans[u] + n - 2 * sz[v] dfs2(v, u)
实战案例与应用
最后,我们把视角拉回真实世界。树形DP在工业界和算法竞赛中都有广泛应用:
树的直径(Tree Diameter):找出树上距离最远的一对节点。可以用两次 BFS/DFS,也可以用树形DP。状态设计为 d1[u](从 u 向下走的最长链)和 d2[u](次长链),直径就是 max(d1[u] + d2[u])。这相当于在公司里找“两个相隔最远的基层员工”,策略是经过每个部门经理时,把最长延伸到两棵子树的两条链拼起来试试。
资源分配:在视频CDN网络中,边缘节点组成一棵树,要在一些节点上部署缓存,让每个用户到最近缓存的距离之和最短——这是树形DP的经典落地;在游戏技能树中,玩家要在有限技能点下最大化战力,每项技能有前置技能,这正是树形背包的直接应用。凡是“树形依赖 + 资源有限 + 目标最优”的问题,都可以尝试树形DP建模。
AI 应用场景
场景一:算法面试与竞赛编程
阶段:模型训练前的数据预处理与算法设计
解决的问题:在机器学习的数据管道中,经常需要处理树状结构数据(如解析XML/HTML的DOM树、组织架构树、文件目录树)。传统做法是层层遍历,复杂度高且代码冗长。树形DP提供了一套“后序汇报”式的高效处理模式,单次遍历即可完成整棵树的统计与决策,把O(n²)暴力优化到O(n)。
具体案例:在训练一个网页内容分类模型前,需要统计HTML DOM树中每个标签下文本的总长度作为特征。用树形DP的思想,从叶子节点向上累加,一趟后序遍历就能算出每个节点的聚合特征,效率远超逐节点重复遍历整棵子树。
场景二:神经网络中的层次化决策
阶段:模型推理阶段的层次分类
解决的问题:传统的平面多分类器在类别极多时(如数万种商品分类),训练和推理都开销巨大。借鉴树形DP的“选/不选”状态转移和层次合并思想,Hierarchical Softmax把类别组织成树,每次决策变成一条从根到叶子的路径选择,计算复杂度从O(K)降为O(log K)。
具体案例:在电商搜索的意图识别中,用一棵商品分类树(如“电子 > 手机 > 智能手机”)做层次分类。推理时沿树做二分类决策,每层用一个小分类器,这不仅模拟了树形DP的“逐层转移”,还让预测路径天然可解释——用户可以清楚看到“为什么是智能手机而不是相机”。
场景三:图神经网络中的信息传递
阶段:GNN的消息传递机制
解决的问题:图神经网络每层都在做“邻居信息聚合”,这和树形DP的后序状态合并惊人地相似——节点先聚合邻居(子树)的信息,再更新自己的表示。理解树形DP的“自底向上”范式,能帮助你设计更合理的GNN聚合顺序和层数。
具体案例:在分子性质预测任务中,分子结构是一棵树(不考虑环时),用类似树形DP的递归消息传递,从原子最底层向上聚合化学特征,最后在根原子处读出整个分子的表示。这个流程与树形DP的状态合并完全同构。
Python 演示
# 树的最大独立集(树形DP + 后序遍历)import sys sys.setrecursionlimit(10 ** 6) def solve(n, edges, weights): children = [[] for _ in range(n)] for u, v in edges: children[u].append(v) children[v].append(u) dp = [[0, 0] for _ in range(n)] # dp[u][0] 不选 u, dp[u][1] 选 u def dfs(u, parent): dp[u][1] = weights[u] for v in children[u]: if v == parent: continue dfs(v, u) dp[u][1] += dp[v][0] # 选了 u, 孩子只能不选 dp[u][0] += max(dp[v][0], dp[v][1]) # 不选 u, 孩子自由选择 dfs(0, -1) return max(dp[0][0], dp[0][1]) if __name__ == "__main__": # 测试: 6 个节点的树, 每个节点的权值 n = 6 edges = [(0, 1), (0, 2), (1, 3), (1, 4), (2, 5)] weights = [5, 3, 4, 2, 6, 1] ans = solve(n, edges, weights) print("最大独立集权值和:", ans) # 期望: 选 0, 3, 4, 5? 验证: 0 与 1 相邻不能同选, 0 与 2 相邻不能同选 # 正确解: 选 0(5) + 3(2) + 4(6) + 5(1) = 14
dp = [[0, 0] for _ in range(n)]每个节点开两个状态位,0表示不选该节点,1表示选该节点。def dfs(u, parent):用parent参数记住来路,避免在无向树中走回父节点形成死循环。for v in children[u]:遍历所有邻居,但if v == parent: continue跳过“来时的路”,剩下全是真孩子。dfs(v, u)先递归孩子,这是后序遍历的关键:等孩子的dp算好再处理当前节点。dp[u][1] += dp[v][0]选了u,相邻的孩子v只能不选,所以累加“孩子不选”状态。dp[u][0] += max(dp[v][0], dp[v][1])不选u,孩子可以自由选或弃,取两者较大值。
常见误区
误区“树形DP就是把动态规划直接套在树上嘛,我可以从根开始,从上往下填表,一样能算出结果吧?”
正解树形DP必须自底向上(后序),因为父节点的状态依赖孩子节点的状态。就像公司汇总报表,总经理不可能在部门提交数据前先报出全公司的总利润。如果你试图先算父节点,会发现缺少子节点信息,只能猜或重复计算,复杂度退化为 O(n²) 甚至错误。正确的做法永远是:先递归完所有孩子,再合并状态。
为何常见:大多数人在学线性DP时习惯了从左到右填表,而树没有天然的“左到右”顺序,大家容易忽略数据依赖方向的问题。
误区“我只需要一个 `dp[u]` 记录以 u 为根的子树的最优值就够了,为什么要搞两个状态?”
正解单一状态无法表达“选了 u 之后,孩子被限制不能选”这个约束。举个例子,“相邻不能同时获奖”规则下,子公司 A 的“最佳利润”依赖它的经理是否获奖——经理获奖,孩子全不能获奖;经理不获奖,孩子随意。这两种情况的子树最优值不一样,只存一个数就会丢失关键信息。所以状态必须覆盖所有决策分支,才能保证转移不漏解。
为何常见:初学者在链式结构(线性DP)里只需一个状态惯了一维思考,遇到树的“父子约束”时没有意识到决策会影响后代的选择空间。
误区“我用邻接表存树,直接从根开始 DFS,为什么打印出来发现节点被访问了两次?”
正解树虽然是无环的,但它是无向图——从节点 u 出发既能走到孩子也能走回父节点。如果不传入 parent 参数并在遍历时跳过它,递归就会在父子和祖孙之间来回跳,导致死循环或重复计算。正确姿势是 dfs(v, u) 传递当前父节点,循环里 if v == parent: continue。
为何常见:很多初学者习惯把树当作有向结构理解(只从父到子),却没有意识到代码里的邻接表是无向的,方向和约束都要自己传递。
误区“树形背包就是在树上做01背包,我直接把普通背包的转移方程搬过来不就行了?”
正解树形背包和普通背包有两个关键区别。第一,普通背包的物品没有依赖关系,而树形背包要求“选某节点必须选它的祖先”或类似的前置约束;第二,普通背包用一维数组滚动更新,而树形背包需要按子树合并,枚举孩子分配预算时必须倒序遍历容量(像分组背包那样),才能保证每个孩子只用一次。如果你直接用子节点状态覆盖父节点,会算重或算漏。
为何常见:背包的滚动数组优化在许多读者脑中根深蒂固,忽视树的依赖关系对容量枚举顺序的限制。
面试题
Q1. 树形DP中,状态转移的正确顺序是( )
- A.从根节点开始,自上而下计算
- B.先计算子节点(子树),再计算父节点← 正确答案
- C.按节点编号从小到大计算
- D.任意顺序都可以
答案:B。父节点状态依赖子节点状态,所以必须先算子树再算父节点,即后序遍历。如果从根开始自上而下,子节点还没算出来,父节点的转移无法进行。编号顺序与树的父子关系无关,D 显然不对。就像公司汇总报表,下属必须先交数据。
Q2. 在树的最大独立集问题中,若选节点 u,则其子节点 v 的状态必须是( )
- A.一定选 v
- B.一定不选 v← 正确答案
- C.v 可随意选或不选
- D.与 v 的兄弟节点保持一致
答案:B。“相邻节点不能同时被选”是独立集的定义。u 被选后,所有相邻的孩子都不能被选,所以 v 必须处于不选状态。C 的情况适用于 u 不选时;D 完全不相关。这就像领导获奖时下属自动失去参评资格。
Q3. 树形DP递归实现中,dfs(u, parent) 里参数 parent 的主要作用是( )
- A.记录父节点的权值
- B.防止递归在父子之间来回跳(避免访问父节点)← 正确答案
- C.标记需要重新计算的状态
- D.传递最终答案
答案:B。树用邻接表存储时是无向的,从 u 能走到所有邻居包括父节点。传递 parent 并在遍历时跳过它(if v == parent: continue),能保证 DFS 只沿子节点方向深入,避免死循环。这是树 DFS 的标准做法。
Q4. 树的直径用树形DP求解时,转移的关键操作是( )
- A.对每个节点求子树大小
- B.对每条边取权值之和
- C.维护最长链和次长链,尝试拼接← 正确答案
- D.对每个节点做背包枚举
答案:C。树形DP求直径时,维护 d1[u](从 u 向下的最长链)和 d2[u](次长链),每次用 d1[v] + 1 更新 d1 或 d2,并尝试用 d1 + d2 更新答案。这相当于在子树合并时“把两条最长链在 u 处拼起来”。
Q5. 关于树形DP的时间复杂度,下列说法正确的是( )
- A.后序遍历每个节点一次,总复杂度为 O(n)← 正确答案
- B.每条边只被访问一次,所以复杂度与边数相关,为 O(n-1)
- C.树形背包的时间复杂度可能为 O(n * m),m 为容量上限← 正确答案
- D.换根DP需要两轮DFS,复杂度为 O(n²)
答案:AC。普通树形DP每个节点和边只处理一次,复杂度 O(n),所以 A 和 B 都对。树形背包每个节点合并孩子时枚举容量,所以复杂度含容量因子,C 对。换根DP两轮DFS都是 O(n),不是 O(n²),D 错。
Q6. 换根DP的核心思想是( )
- A.对每个节点各做一次完整的树形DP
- B.第一轮DFS自底向上求出子树信息,第二轮自顶向下利用父信息推导换根后的答案← 正确答案
- C.把所有节点按权值排序后依次当根
- D.用迭代加深搜索替代递归
答案:B。换根DP用两次DFS:第一次以任意根求子树信息(自底向上),第二次DFS时利用“父节点的答案减去/加上子树的贡献”来推导邻居节点作为根时的答案。这样每个节点以 O(1) 代价换根,总复杂度 O(n),而不是对每个点重做一遍(O(n²))。
Q7. 树的最小覆盖集问题中,“覆盖”的含义是( )
- A.选出的节点覆盖所有叶子节点
- B.每条边至少有一个端点被选中← 正确答案
- C.选出的节点权值和最大
- D.选出的节点覆盖所有节点自身
答案:B。最小覆盖集要求的是边被覆盖——每条边两端至少有一个在集合中。状态转移:u 不选时所有孩子必须选(f[u][0] = sum(f[v][1])),u 选时孩子可选可不选。B 是定义,A/C/D 都不是覆盖集的定义。
Q8. 下列哪些问题适合用树形DP解决( )
- A.树上的最大权独立集← 正确答案
- B.树上的最小路径覆盖← 正确答案
- C.数组上的最长递增子序列
- D.树形背包(有依赖的背包)← 正确答案
答案:ABD。A、B、D 都涉及树结构上的最优决策,符合树形DP的应用场景。C 最长递增子序列是序列结构,用线性DP。识别问题的关键是看数据是否组织成树、决策是否逐层依赖。
Q9. 树形背包转移时枚举容量需要倒序遍历的原因( )
- A.避免重复使用同一个子树的状态← 正确答案
- B.提高遍历速度
- C.让父节点先被更新
- D.防止数组越界
答案:A。与普通01背包类似,倒序遍历容量能保证每个子树的状态只被使用一次——因为在正向遍历时,本次更新可能被同一轮后续更新当作“基础”继续叠加,导致一棵子树被重复计入。倒序则保证每次更新都基于上一轮的旧值。
Q10. 后序遍历在树形DP中的本质作用是( )
- A.让每个节点的信息先传给子节点
- B.保证每个节点的状态计算时,其所有子节点的状态已就绪← 正确答案
- C.让树的深度变小
- D.减少状态定义的数量
答案:B。后序顺序“先孩子后自己”从数据依赖上保证了转移的合法性——父节点计算时需要读取全部子节点的 dp 值。只要子节点计算完毕,父节点的合并操作就是确定性的。这个顺序是树形DP正确性和 O(n) 复杂度的根本保证。