树形 DP
一句话: DFS 一遍, 每个节点的答案 = 子树聚合 + 自己的贡献。本质是后序遍历 + 状态在节点上传递, 自底向上 return。
直觉 — DFS 后序 + 子树聚合
线性 DP 是”从左往右抄前面的状态”, 树形 DP 是”从叶子往根抄子树的状态”。换句话说, 树根本不需要新算法, 只是把”前一个状态”换成了”所有子节点的返回值”。
核心套路就一句话:
写一个
dfs(node), 后序递归两个孩子, 把孩子的返回值组合一下, 再 return 给父亲。
到节点 u 时, 我手上已经有 dfs(u.left) 和 dfs(u.right) 的结果。我要决定两件事:
- 当前节点贡献什么(更新全局答案 or 累加进自己的状态)
- 往上返回什么(父亲需要知道我子树的哪些信息才能继续算)
这两件事经常不一样。下面的动画用二叉树直径(LC 543)演示得最清楚: 节点要更新的”经过我的路径”是左链+右链, 但能往上返回的”延伸链”只能选一边 —— 因为路径不能在父亲那里分叉。
对照动画看后序顺序: 叶子 4 → 叶子 6 → 节点 5 → 节点 2 → 叶子 3 → 根 1。每访问一个节点, 蓝色 ↑ 是它返回给父亲的深度, 绿色是经过它的最长路径, 红色全局 ans 只在需要时被刷新。看完这段, 后面所有模板都是同一个骨架。
模板 — Python (dfs 返回 tuple)
骨架长这样, 几乎所有树形 DP 题都是它的变体:
1 | |
三件事必须想清楚再下笔:
- 空节点返回什么 —— 决定递归基。一般是 0 或
(0, 0), 但有些题(如最大路径和)是-inf。 - 返回 tuple 里放几个状态 —— 单状态(深度)/ 两个状态(选 vs 不选)/ 更多(监控二叉树有 3 个)。
- 当前节点的贡献是局部还是全局 —— “经过我的”用来更新全局 ans, “延伸到我的”才能往上 return。两者不能混。
两类经典
子树聚合 — 二叉树直径 (LC 543)
直径 = 任意两节点之间的最长路径。关键区分: 路径可以在某个节点”拐弯”, 但拐弯的那一段不能再往上传。
1 | |
同款骨架直接套: LC 124 最大路径和 (空节点返回 0, 负贡献用 max(L, 0) 截断); LC 687 最长同值路径 (儿子值相同才能延伸)。
选/不选 — 打家劫舍 III (LC 337)
每个节点要么偷要么不偷, 偷了的话孩子就不能偷。返回 (偷我的最大值, 不偷我的最大值):
1 | |
注意 skip_me 取的是 max(L), 不是 L[1] —— 不偷自己时, 孩子可偷可不偷, 取大的那个。这条同款骨架的扩展:
- LC 968 监控二叉树 — 每个节点 3 个状态(放摄像头 / 被覆盖 / 没覆盖), 返回 3 元组。
- 树上独立集 / 最小点覆盖 —— 全是这套”选 vs 不选”。
进阶: 换根 DP (两次 DFS)
上面的模板都是”指定根之后的答案”。如果题目要”以每个节点为根时的答案”(如 LC 834 树中距离之和), 暴力对每个根跑一遍 DFS 就是
换根 DP 用两次 DFS:
- 第一次 DFS(后序): 钦定节点 0 为根, 算出
dp[0]。 - 第二次 DFS(前序): 从根往下传, 增量更新
dp[child] = dp[parent] + 偏移量。
关键是想出”父→子换根时, 答案的增量是什么”。一般是
记忆法 / 易错点
一句话口诀: 后序递归两个孩子, 拼成 tuple 往上传, 全局答案在路上更。
3 个反复出错的点:
- 更新全局 vs return 给父亲是两件事 —— 直径 / 最大路径和题一旦把”拐弯路径”往上 return, 父亲那里就分叉, 全错。
- 空节点的零元 —— 求最大值且节点值可能为负时, 别想当然 return 0; 见 LC 124 用负贡献截断。
- 选/不选的
skip—— 不选当前节点时, 孩子能选可以不选, 取max(L)不是L[1]。这个坑写打家劫舍 III 时几乎人人踩。
判题口诀:
- 问”树上某种最优路径/直径” → 子树聚合, return 一条延伸链。
- 问”选一些节点, 父子互斥” → 选/不选, return tuple。
- 问”以每个节点为根时的答案” → 换根 DP, 两次 DFS。
思考题
1. 为什么树形 DP 要用后序遍历? 前序行不行?
后序意味着”先处理子节点, 再处理当前”。当前节点的答案依赖子节点的返回值, 所以必须后序。前序时子节点还没算, 没法组合。唯一例外是换根 DP 的第二次 DFS, 用父节点信息增量更新子节点, 那一趟用前序。
2. "经过当前节点的路径"和"延伸到当前节点的链"为什么不能混?
“经过我的路径” = 左链 + 我 + 右链, 是个倒 V, 可以是全局最优解。”延伸到我的链” = max(左, 右) + 我, 是直线。区别就在于路径可以在我这里拐弯, 但传给父亲的不能拐, 否则父亲那里就出现分叉, 父亲再往上就更乱了。
3. 换根 DP 为什么只要两次 DFS?
第一次 DFS 确定了”以 0 为根”的基准。换根从 parent 到 child, 只发生一个
LeetCode 练习
热身: 104 最大深度 · 543 直径 · 110 平衡二叉树 · 687 最长同值路径
进阶: 124 最大路径和 · 337 打家劫舍 III · 968 监控二叉树 · 834 树中距离之和 · 310 最小高度树