🌳 树形DP — 打家劫舍III与树的最大路径和

树上的DP——后序遍历天然就是DP的填表顺序

📖 树形DP的核心思想

树形DP是在树结构上做动态规划。树的后序遍历(先处理子节点再处理当前节点)天然对应DP的"从子问题到原问题"的填表顺序。树形DP的核心是:每个节点返回一个状态,父节点基于子节点的状态做决策。

树形DP两大模型: 模型一: 选/不选 (打家劫舍) +-------------------------------------+ | 每个节点返回 [选的值, 不选的值] | | 选 = 节点值 + 子节点不选的值 | | 不选 = max(子节点选, 子节点不选) | +-------------------------------------+ 模型二: 路径模型 (最大路径和) +-------------------------------------+ | 每个节点返回"向下延伸的最大和" | | 同时更新"经过该节点的最大路径和" | | 路径可以是: 左子树->节点->右子树 | +-------------------------------------+ 关键: 后序遍历 = DP填表顺序 先递归处理子树 -> 拿到子树结果 -> 计算当前节点

1. 树形DP通用框架

def tree_dp(root):
    def dfs(node):
        if not node:
            return base_case
        left = dfs(node.left)   # 先处理左子树
        right = dfs(node.right) # 再处理右子树
        # 基于子树结果计算当前节点
        result = compute(node, left, right)
        return result
    return dfs(root)
树形DP的时间复杂度通常是O(n)(每个节点只访问一次),空间复杂度O(h)(递归栈,h为树高)。这比在树上做暴力搜索高效得多。

🔬 题目一:打家劫舍III(LC 337)

在二叉树上打家劫舍,不能同时偷父子节点,返回能偷到的最大金额。

思路分析

打家劫舍III的选/不选模型: 3 / \ 2 3 \ \ 3 1 dfs(node)返回 [rob, not_rob] rob = node.val + left[1] + right[1] not_rob = max(left) + max(right) 节点2: rob=2+3=5, not_rob=3 --> [5,3] 节点3(右): rob=3+1=4, not_rob=1 --> [4,1] 节点3(根): rob=3+3+1=7, not_rob=5+4=9 --> [7,9] 答案 = max(7, 9) = 9

代码实现

class Solution:
    def rob(self, root: Optional[TreeNode]) -> int:
        def dfs(node):
            if not node:
                return [0, 0]  # [rob, not_rob]
            left = dfs(node.left)
            right = dfs(node.right)
            # 偷当前节点:子节点不能偷
            rob = node.val + left[1] + right[1]
            # 不偷当前节点:子节点可偷可不偷
            not_rob = max(left) + max(right)
            return [rob, not_rob]
        return max(dfs(root))

复杂度分析

时间: O(n) 空间: O(h)

打家劫舍系列:LC 198(线性)-> LC 213(环形)-> LC 337(树形)。核心思想一脉相承:"选/不选"的分叉决策。树形版本只需要把一维DP变成DFS的返回值。

🔬 题目二:二叉树中的最大路径和(LC 124)

路径被定义为任意节点序列,每个节点最多出现一次。求路径上节点值之和的最大值。

思路分析

最大路径和: -10 / \ 9 20 / \ 15 7 dfs返回: 从当前节点"向下延伸"的最大和 同时更新全局max: 经过当前节点的完整路径和 节点9: dfs返回9, 贡献路径=9 节点15: dfs返回15, 贡献路径=15 节点7: dfs返回7, 贡献路径=7 节点20: dfs返回20+15=35 贡献路径=15+20+7=42 <-- 全局最大 节点-10: dfs返回max(-10+35, 0)=25 答案 = 42

代码实现

class Solution:
    def maxPathSum(self, root: Optional[TreeNode]) -> int:
        max_sum = float('-inf')

        def dfs(node):
            nonlocal max_sum
            if not node:
                return 0
            # 负数贡献不要,取max(x, 0)
            left = max(dfs(node.left), 0)
            right = max(dfs(node.right), 0)
            # 经过当前节点的完整路径
            max_sum = max(max_sum, node.val + left + right)
            # 返回向下延伸的最大和(只能选一侧)
            return node.val + max(left, right)

        dfs(root)
        return max_sum

复杂度分析

时间: O(n) 空间: O(h)

🔬 题目三:二叉树的直径(LC 543)

二叉树的直径是任意两个节点间路径长度的最大值(边数)。

思路分析

与LC 124同构:
1. dfs(node) 返回从node向下的最大深度
2. 直径 = 左子树深度 + 右子树深度(经过当前节点的最长路径)
3. 用全局变量维护最大直径

代码实现

class Solution:
    def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
        diameter = 0

        def dfs(node):
            nonlocal diameter
            if not node:
                return 0
            left = dfs(node.left)
            right = dfs(node.right)
            diameter = max(diameter, left + right)
            return 1 + max(left, right)

        dfs(root)
        return diameter

复杂度分析

时间: O(n) 空间: O(h)

LC 543和LC 124是完全相同的框架!区别只在:543求的是边数(深度之和),124求的是节点值之和(加权深度)。掌握一个框架,两个题都会了。这就是树形DP的魅力——框架统一,参数变化。

面试实战技巧

在实际面试中,算法题不仅仅是写代码,更考察沟通、分析和解决问题的能力。以下是一些实战技巧:

1. 解题步骤模板

5分钟分析:理解题意 - 确认边界 - 想暴力解 - 优化 - 写代码

1. 先复述题目,确认理解无误
2. 问清边界条件:空输入?负数?溢出?
3. 先说暴力解法(证明你理解问题)
4. 分析瓶颈,提出优化思路
5. 边写代码边解释思路

2. 常见陷阱

通用陷阱:
1. 索引越界:特别注意循环范围和数组边界
2. 初始化遗漏:DP的初始条件是否正确?
3. 遍历方向:依赖关系决定遍历顺序
4. 整数溢出:Python不溢出但其他语言注意
5. 边界条件:空数组、单元素、全相同

3. 代码模板

# 通用解题模板
class Solution:
    def solve(self, input_data):
        # 1. 边界检查
        if not input_data:
            return default_value
        # 2. 初始化
        result = initial_value
        state = initial_state
        # 3. 主循环
        for item in input_data:
            state = transition(state, item)
            result = max/min/update(result, state)
        # 4. 返回结果
        return result
面试中的代码不是越短越好,而是越清晰越好。适当的注释、合理的变量名、分步骤的实现,比一行式解法更能展示工程素养。
成就解锁:树形DP大师 — 掌握树形DP的核心范式:选/不选模型与路径模型
LeetCode AC验证:LC 337 House Robber III ✅ | LC 124 Binary Tree Maximum Path Sum ✅ | LC 543 Diameter of Binary Tree ✅

📝 课后练习

  1. LC 1245 树的直径(无根树版本)
  2. LC 968 监控二叉树(三种状态)
  3. LC 834 树中距离之和(换根DP)
  4. LC 337 打家劫舍III变形

🔑 本课要点回顾

📚 扩展阅读

思考题:如果树形DP中每个节点有三个状态(如有监控/被监控/无监控),该如何设计状态转移?

⚡ 进阶专题:常见变体与扩展

每道经典题目都有丰富的变体。掌握核心模板后,灵活应对变体才是面试的真正挑战。以下列举本课相关的高频变体和扩展思路。

1. 常见题目变体

条件变化型:在原题基础上增加约束条件,如:数组中有负数、元素有上限、需要考虑溢出等。核心思路:不改变DP框架,只在转移时增加条件判断。

维度扩展型:从一维DP扩展到二维或三维。如:从线性到环形(LC 213)、从单串到双串(LCS)、从无约束到有约束。核心思路:增加状态维度来编码额外信息。

目标变化型:从求最值变为求方案数、求具体方案、判定可行性。核心思路:dp存的值从max/min变为count/boolean,转移从取最值变为累加。

2. 面试中的沟通技巧

# 面试沟通模板
# 1. 先说思路(1-2分钟)
"这道题我想到两种解法,先说更直观的一种..."

# 2. 分析复杂度(30秒)
"时间复杂度O(n^2),空间可以优化到O(n)"

# 3. 边写边说
"这里我定义dp[i]表示...,转移方程是..."

# 4. 主动验证
"我用示例走一遍:dp[0]=..., dp[1]=..."

# 5. 讨论优化
"空间还可以进一步优化,因为dp[i]只依赖..."

3. 代码调试检查清单

检查项常见错误
初始条件dp[0]是否正确?空输入是否处理?
遍历范围循环从0还是1开始?是否越界?
状态转移依赖的状态是否已计算?
返回值是dp[n]还是max(dp)?
边界情况单元素、全相同、空数组
面试中主动验证是最被低估的技巧。用示例手动走一遍DP表,既能发现bug,又能展示你的严谨性。很多面试官就是看你能否主动发现和修正错误。

⚡ 树形DP进阶:换根DP

换根DP(Rerooting DP)是树形DP的高级技巧,适用于"以每个节点为根时求某个值"的问题。暴力做法O(n^2),换根DP可以做到O(n)。

换根DP思想: 问题: 求每个节点到其他所有节点的距离之和 (LC 834 树中距离之和) 暴力: 以每个节点为根做一次DFS --> O(n^2) 换根: 先以0为根算一次,再O(1)转移根 --> O(n) 步骤1: 以0为根,dfs算出size[i]和dist[0] 步骤2: 换根转移: 根从parent->child时: - child子树内节点距离-1 (共size[child]个) - child子树外节点距离+1 (共n-size[child]个) dist[child] = dist[parent] - size[child] + (n - size[child]) 示例(4节点链): 0-1-2-3 dist[0]=0+1+2+3=6 dist[1]=6-1+(4-1)=8 (1子树=2个,外=2个) dist[2]=8-2+(4-2)=8 dist[3]=8-1+(4-1)=10
# LC 834 树中距离之和
def sumOfDistancesInTree(n, edges):
    graph = [[] for _ in range(n)]
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)

    size = [1] * n  # 子树大小
    dist = [0] * n  # 距离和

    # 第一遍DFS:以0为根
    def dfs1(u, parent):
        for v in graph[u]:
            if v != parent:
                dfs1(v, u)
                size[u] += size[v]
                dist[u] += dist[v] + size[v]

    # 第二遍DFS:换根
    def dfs2(u, parent):
        for v in graph[u]:
            if v != parent:
                dist[v] = dist[u] - size[v] + (n - size[v])
                dfs2(v, u)

    dfs1(0, -1)
    dfs2(0, -1)
    return dist
换根DP的核心思想:先算一次根节点的答案,然后O(1)转移相邻节点的答案。两遍DFS:第一遍自底向上算子树信息,第二遍自顶向下传递换根结果。适用于所有"以每个节点为根"的问题。