给定一棵二叉树,找出其直径——这条路径可能穿过根节点,也可能完全在某个子树中。当你第一次看到 LeetCode 543 这道题时,可能会想:从根节点出发,找到最深的左节点和最深的右节点,把两条路径连起来不就是答案吗?但这个直觉会出错——直径可能根本不经过根节点。
这就是树形动态规划(Tree DP)要解决的核心问题:当答案不取决于根节点,而取决于子树的某种组合时,如何高效地计算?
从一个简单问题说起:二叉树的最大深度
在进入复杂的树形 DP 之前,先看一个"伪装成简单题"的树形 DP 入门题:LeetCode 104「二叉树的最大深度」。
public int maxDepth(TreeNode root) {
if (root == null) return 0;
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
return Math.max(leftDepth, rightDepth) + 1;
}
这段代码看起来只是简单的递归,但它已经包含了树形 DP 的核心要素:
- 状态定义:
maxDepth(node)表示以node为根的子树的最大深度 - 状态转移:当前节点的深度 =
max(左子树深度, 右子树深度) + 1 - 遍历顺序:先计算子节点的状态,再计算当前节点——这正是后序遍历
为什么必须是后序遍历?因为父节点的答案依赖于子节点的答案。你必须先知道左右子树的深度,才能算出当前节点的深度。这种"先子后父"的计算顺序,是所有树形 DP 的共同特征。
树形 DP 与普通 DP 的本质区别
普通 DP 通常在一个线性结构(数组、字符串)上定义状态,状态之间的依赖关系是单向的:dp[i] 依赖于 dp[0...i-1]。而树形 DP 的状态定义在树的节点上,依赖关系由树的边决定。
这种区别带来了两个重要的技术要点:
第一,树形 DP 通常不需要 memoization(记忆化)。
在线性 DP 中,同一个子问题可能被多次访问(如斐波那契数列中 fib(3) 会被 fib(5) 和 fib(4) 分别调用)。但在树上,每个节点只会被其父节点访问一次,递归过程中不会出现重复计算。因此,树形 DP 的递归实现天然就是 $O(n)$ 的,不需要额外的哈希表来存储中间结果。
第二,树形 DP 的"重叠子问题"体现在递归结构本身。
有人会问:如果没有重叠子问题,这还能叫动态规划吗?这个质疑有一定道理。树形 DP 更准确的描述是:在树结构上应用分治思想,每个节点的解由其子节点的解组合而成。之所以仍然归类为 DP,是因为它满足最优子结构——子问题的最优解能够组合出原问题的最优解。
核心模式:返回值与全局答案的分离
树形 DP 最容易让初学者困惑的是:递归函数返回什么?全局答案在哪里更新?
来看 LeetCode 543「二叉树的直径」。直径定义为树中任意两节点间最长路径的边数。关键观察:对于任意节点,经过该节点的最长路径 = 左子树高度 + 右子树高度(这里高度指从节点到叶子节点的最长边数)。
class Solution {
private int maxDiameter = 0;
public int diameterOfBinaryTree(TreeNode root) {
height(root);
return maxDiameter;
}
// 返回以 node 为根的子树的高度(最大深度 - 1,即边数)
private int height(TreeNode node) {
if (node == null) return -1; // 空节点高度为 -1,这样叶子节点高度为 0
int leftHeight = height(node.left);
int rightHeight = height(node.right);
// 更新全局答案:经过当前节点的最长路径
int diameter = leftHeight + rightHeight + 2;
maxDiameter = Math.max(maxDiameter, diameter);
// 返回子树高度,供父节点使用
return Math.max(leftHeight, rightHeight) + 1;
}
}
这里出现了树形 DP 的经典模式:返回值服务于父节点的计算,全局答案在遍历过程中更新。
为什么要这样设计?因为每个节点只能向父节点返回一个值(高度),但父节点需要这个值来计算"经过自己的直径"。同时,直径可能出现在子树内部(不经过当前节点),所以必须用全局变量记录遍历过程中发现的最大值。
时间复杂度:$O(n)$,每个节点访问一次。空间复杂度:$O(h)$,递归栈深度等于树的高度。
进阶:二叉树中的最大路径和
LeetCode 124「二叉树中的最大路径和」是树形 DP 的经典难题。路径定义为从树中任意节点出发,沿着父子连接到达任意节点的序列。每个节点最多只能访问一次。路径和是路径中所有节点值之和。
这道题的难点在于路径的形状:它可能是一条"向上"的路径(从某节点到祖先),也可能是一条"穿过"某节点的路径(从左子树经过根到右子树)。
关键区分两种情况:
- 向上贡献的路径:从当前节点出发,只能选择一边延伸(左或右),可以向父节点贡献。这是递归函数的返回值。
- 完整的路径:经过当前节点,左右两边都延伸。这是可能的全局答案,但不能向上传递(因为父节点无法再分支)。
class Solution {
private int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
maxGain(root);
return maxSum;
}
// 返回以 node 为起点,向下的最大路径和(只能选一边)
private int maxGain(TreeNode node) {
if (node == null) return 0;
// 递归计算左右子树的最大贡献
// 如果贡献为负,不如不选(选择空路径,贡献为0)
int leftGain = Math.max(maxGain(node.left), 0);
int rightGain = Math.max(maxGain(node.right), 0);
// 经过当前节点的最大路径和
int pathSum = node.val + leftGain + rightGain;
maxSum = Math.max(maxSum, pathSum);
// 返回当前节点能向父节点贡献的最大值(只能选一边)
return node.val + Math.max(leftGain, rightGain);
}
}
注意 Math.max(maxGain(child), 0) 这个细节:如果子树的最大贡献是负数,我们选择"不延伸到该子树",相当于贡献为 0。这是一个贪心的选择——任何负数的贡献都会让路径和变小。
时间复杂度:$O(n)$。空间复杂度:$O(h)$,最坏情况(退化为链表)为 $O(n)$。
状态维度扩展:打家劫舍 III
前面的题目,递归函数只返回一个值(高度或路径和)。但有些问题需要返回多个状态。
LeetCode 337「打家劫舍 III」:二叉树的每个节点代表一个房子,相连的房子不能同时被偷。求能偷到的最大金额。
对于每个节点,有两个选择:偷或不偷。如果偷当前节点,子节点不能偷;如果不偷当前节点,子节点可以偷也可以不偷(取最大值)。
这需要返回两个状态:选中当前节点的最大收益 和 不选中当前节点的最大收益。
class Solution {
public int rob(TreeNode root) {
int[] result = dfs(root);
return Math.max(result[0], result[1]);
}
// 返回数组 [选中当前节点的最大收益, 不选中当前节点的最大收益]
private int[] dfs(TreeNode node) {
if (node == null) return new int[]{0, 0};
int[] left = dfs(node.left);
int[] right = dfs(node.right);
// 选中当前节点:子节点必须不选中
int selected = node.val + left[1] + right[1];
// 不选中当前节点:子节点选或不选均可,取最大
int notSelected = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
return new int[]{selected, notSelected};
}
}
这个模式可以推广:当节点存在多种互斥状态时,用数组返回每种状态对应的最优解。
时间复杂度:$O(n)$。空间复杂度:$O(h)$。
三状态问题:监控二叉树
LeetCode 968「监控二叉树」要求在树上放置最少的摄像头,使得每个节点都被监控。摄像头可以监控自己、父节点和直接子节点。
这道题的状态设计更加复杂:每个节点相对于监控状态有三种情况:
- 被监控且安装了摄像头
- 被监控但未安装摄像头(被子节点的摄像头覆盖)
- 未被监控(等待父节点安装摄像头)
class Solution {
private int cameras = 0;
private final int COVERED = 0; // 被监控,无摄像头
private final int HAS_CAMERA = 1; // 有摄像头
private final int NOT_COVERED = 2; // 未被监控
public int minCameraCover(TreeNode root) {
if (dfs(root) == NOT_COVERED) {
cameras++; // 根节点未被监控,需要放置摄像头
}
return cameras;
}
private int dfs(TreeNode node) {
if (node == null) return COVERED; // 空节点视为已覆盖
int left = dfs(node.left);
int right = dfs(node.right);
// 如果任一子节点未被监控,当前节点必须放摄像头
if (left == NOT_COVERED || right == NOT_COVERED) {
cameras++;
return HAS_CAMERA;
}
// 如果任一子节点有摄像头,当前节点被覆盖
if (left == HAS_CAMERA || right == HAS_CAMERA) {
return COVERED;
}
// 两个子节点都被监控但没有摄像头,当前节点未被覆盖
return NOT_COVERED;
}
}
这个解法蕴含一个贪心策略:从叶子节点向上考虑,尽量在父节点放置摄像头。因为叶子节点没有子节点,只能被父节点监控;而父节点放置摄像头可以同时监控自己、父节点和所有子节点,覆盖效率最高。
时间复杂度:$O(n)$。空间复杂度:$O(h)$。
路径类问题的统一模板
树上的路径问题可以归为一类,它们有一个共同的思维框架:路径要么完全在左子树,要么完全在右子树,要么穿过根节点。
以 LeetCode 687「最长同值路径」为例:找出二叉树中最长的路径,路径上所有节点值相同。
class Solution {
private int maxLength = 0;
public int longestUnivaluePath(TreeNode root) {
arrowLength(root);
return maxLength;
}
// 返回从 node 出发,向下的同值路径最大长度(只能走一边)
private int arrowLength(TreeNode node) {
if (node == null) return 0;
int leftArrow = arrowLength(node.left);
int rightArrow = arrowLength(node.right);
// 计算向左的同值路径长度
int leftPath = 0;
if (node.left != null && node.left.val == node.val) {
leftPath = leftArrow + 1;
}
// 计算向右的同值路径长度
int rightPath = 0;
if (node.right != null && node.right.val == node.val) {
rightPath = rightArrow + 1;
}
// 更新全局答案:穿过当前节点的同值路径
maxLength = Math.max(maxLength, leftPath + rightPath);
// 返回单边最大长度
return Math.max(leftPath, rightPath);
}
}
这个模板与最大路径和类似,只是增加了"同值"的约束判断。
进阶:换根 DP
LeetCode 834「树中距离之和」是一个更高级的树形 DP 问题:对于树中的每个节点,计算它到其他所有节点的距离之和。
朴素做法是对每个节点做 BFS,时间复杂度 $O(n^2)$。但利用树形 DP 可以优化到 $O(n)$。
这需要两次 DFS:
- 第一次 DFS:以 0 号节点为根,计算
distSum[0]和每个子树的大小count[node] - 第二次 DFS:从父节点推导子节点的
distSum,利用公式:distSum[child] = distSum[parent] - count[child] + (n - count[child])
推导逻辑:当根从 parent 移到 child 时,child 的子树中所有节点(共 count[child] 个)距离减少 1,其他节点(共 n - count[child] 个)距离增加 1。
class Solution {
private List<List<Integer>> graph;
private int[] count;
private int[] distSum;
private int n;
public int[] sumOfDistancesInTree(int n, int[][] edges) {
this.n = n;
graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
for (int[] edge : edges) {
graph.get(edge[0]).add(edge[1]);
graph.get(edge[1]).add(edge[0]);
}
count = new int[n];
distSum = new int[n];
// 第一次 DFS:计算 count[] 和 distSum[0]
dfs1(0, -1);
// 第二次 DFS:推导其他节点的 distSum
dfs2(0, -1);
return distSum;
}
private void dfs1(int node, int parent) {
count[node] = 1;
for (int child : graph.get(node)) {
if (child == parent) continue;
dfs1(child, node);
count[node] += count[child];
distSum[node] += distSum[child] + count[child];
}
}
private void dfs2(int node, int parent) {
for (int child : graph.get(node)) {
if (child == parent) continue;
distSum[child] = distSum[node] - count[child] + (n - count[child]);
dfs2(child, node);
}
}
}
这种"换根 DP"技巧在竞赛中很常见,核心思想是:先求一个根的答案,再利用子节点与父节点的关系,推导其他节点的答案。
时间复杂度:$O(n)$。空间复杂度:$O(n)$。
树形 DP 的通用解题框架
综合以上题目,可以总结出树形 DP 的通用思考框架:
第一步:定义状态。
状态通常定义为"以当前节点为根的子树"的某种最优值。状态可能是一维的(如高度),也可能是多维的(如选中/不选中)。
第二步:确定遍历顺序。
绝大多数树形 DP 采用后序遍历——先处理子节点,再处理当前节点。因为父节点的答案依赖于子节点的答案。
第三步:推导状态转移方程。
考虑当前节点与子节点之间的关系:
- 当前节点选择某种状态时,子节点有哪些限制?
- 当前节点的答案如何由子节点的答案组合而成?
第四步:区分返回值与全局答案。
- 返回值:当前节点能向父节点贡献的信息
- 全局答案:遍历过程中可能经过当前节点的最优解
第五步:处理边界条件。
空节点如何处理?叶子节点如何处理?这些边界条件决定了递归的终止条件。
LeetCode 树形 DP 题目清单
以下是按难度排序的树形 DP 题目,建议按顺序练习:
基础题(单状态返回):
- LeetCode 104:二叉树的最大深度
- LeetCode 111:二叉树的最小深度
- LeetCode 110:平衡二叉树(判断高度差是否超过 1)
- LeetCode 543:二叉树的直径
进阶题(全局答案更新):
- LeetCode 124:二叉树中的最大路径和
- LeetCode 687:最长同值路径
- LeetCode 437:路径总和 III(结合前缀和)
高级题(多状态返回):
- LeetCode 337:打家劫舍 III
- LeetCode 968:监控二叉树
- LeetCode 834:树中距离之和
结语
树形 DP 的核心不是"动态规划"四个字,而是后序遍历这种计算顺序。当问题的答案需要从叶子节点开始向上累积,当父节点的决策依赖于子节点的状态,树形 DP 就派上了用场。
掌握这一模式后,你会发现许多看似复杂的树问题,本质上都是在问:如何把子树的信息正确地传递给父节点?答案往往藏在一行简单的递归返回语句中。
参考资料
- LeetCode Discuss: Mastering Dynamic Programming - A Comprehensive Guide. https://leetcode.com/discuss/interview-question/5665849/
- GeeksforGeeks: DP on Trees for Competitive Programming. https://www.geeksforgeeks.org/competitive-programming/dp-on-trees-for-competitive-programming/
- DEV Community: Blog 8 - Dynamic Programming on Trees (Tree DP). https://dev.to/devcorner/blog-8-dynamic-programming-on-trees-tree-dp-1pca
- Hello Algo: 动态规划解题思路. https://www.hello-algo.com/chapter_dynamic_programming/dp_solution_pipeline/
- Codeforces: DP on Trees Tutorial. https://codeforces.com/blog/entry/20935
- AlgoMonster: Binary Tree Maximum Path Sum Solution. https://algo.monster/liteproblems/124
- NeetCode: Binary Tree Maximum Path Sum Explanation. https://neetcode.io/solutions/binary-tree-maximum-path-sum
- GeeksforGeeks: Introduction to Dynamic Programming on Trees. https://www.geeksforgeeks.org/dsa/introduction-to-dynamic-programming-on-trees/
- 知乎: 二叉树中的最大路径和(树形DP经典问题). https://zhuanlan.zhihu.com/p/403823914
- 掘金: 树形DP的通用思路. https://juejin.cn/post/7280843131875082303