给定一棵二叉树,找出其直径——这条路径可能穿过根节点,也可能完全在某个子树中。当你第一次看到 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 的核心要素:

  1. 状态定义maxDepth(node) 表示以 node 为根的子树的最大深度
  2. 状态转移:当前节点的深度 = max(左子树深度, 右子树深度) + 1
  3. 遍历顺序:先计算子节点的状态,再计算当前节点——这正是后序遍历

为什么必须是后序遍历?因为父节点的答案依赖于子节点的答案。你必须先知道左右子树的深度,才能算出当前节点的深度。这种"先子后父"的计算顺序,是所有树形 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 的经典难题。路径定义为从树中任意节点出发,沿着父子连接到达任意节点的序列。每个节点最多只能访问一次。路径和是路径中所有节点值之和。

这道题的难点在于路径的形状:它可能是一条"向上"的路径(从某节点到祖先),也可能是一条"穿过"某节点的路径(从左子树经过根到右子树)。

关键区分两种情况:

  1. 向上贡献的路径:从当前节点出发,只能选择一边延伸(左或右),可以向父节点贡献。这是递归函数的返回值。
  2. 完整的路径:经过当前节点,左右两边都延伸。这是可能的全局答案,但不能向上传递(因为父节点无法再分支)。
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「监控二叉树」要求在树上放置最少的摄像头,使得每个节点都被监控。摄像头可以监控自己、父节点和直接子节点。

这道题的状态设计更加复杂:每个节点相对于监控状态有三种情况:

  1. 被监控且安装了摄像头
  2. 被监控但未安装摄像头(被子节点的摄像头覆盖)
  3. 未被监控(等待父节点安装摄像头)
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:

  1. 第一次 DFS:以 0 号节点为根,计算 distSum[0] 和每个子树的大小 count[node]
  2. 第二次 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 就派上了用场。

掌握这一模式后,你会发现许多看似复杂的树问题,本质上都是在问:如何把子树的信息正确地传递给父节点?答案往往藏在一行简单的递归返回语句中。


参考资料

  1. LeetCode Discuss: Mastering Dynamic Programming - A Comprehensive Guide. https://leetcode.com/discuss/interview-question/5665849/
  2. GeeksforGeeks: DP on Trees for Competitive Programming. https://www.geeksforgeeks.org/competitive-programming/dp-on-trees-for-competitive-programming/
  3. DEV Community: Blog 8 - Dynamic Programming on Trees (Tree DP). https://dev.to/devcorner/blog-8-dynamic-programming-on-trees-tree-dp-1pca
  4. Hello Algo: 动态规划解题思路. https://www.hello-algo.com/chapter_dynamic_programming/dp_solution_pipeline/
  5. Codeforces: DP on Trees Tutorial. https://codeforces.com/blog/entry/20935
  6. AlgoMonster: Binary Tree Maximum Path Sum Solution. https://algo.monster/liteproblems/124
  7. NeetCode: Binary Tree Maximum Path Sum Explanation. https://neetcode.io/solutions/binary-tree-maximum-path-sum
  8. GeeksforGeeks: Introduction to Dynamic Programming on Trees. https://www.geeksforgeeks.org/dsa/introduction-to-dynamic-programming-on-trees/
  9. 知乎: 二叉树中的最大路径和(树形DP经典问题). https://zhuanlan.zhihu.com/p/403823914
  10. 掘金: 树形DP的通用思路. https://juejin.cn/post/7280843131875082303