1. 树形DP概述
树形DP是动态规划在树形结构上的应用,通过递归遍历树,在子树上进行动态规划。这类问题通常需要后序遍历(自底向上)来计算每个节点的状态值。
2. 树形DP基本概念
2.1 树形DP特点
- 树形结构:基于树或二叉树结构
- 递归性质:父节点的解依赖于子节点的解
- 无后效性:子树之间相互独立
- 自底向上:通常采用后序遍历
2.2 通用模板
python
def tree_dp(root):
"""
树形DP通用模板
"""
# 空节点返回基础值
if not root:
return base_result
# 递归处理左右子树
left_result = tree_dp(root.left)
right_result = tree_dp(root.right)
# 根据左右子树的结果计算当前节点的结果
result = combine_results(root.val, left_result, right_result)
return result
3. 经典树形DP问题
3.1 二叉树中的最大路径和 (LeetCode 124)
问题描述:路径可以是任意节点到任意节点,不一定经过根节点。
状态定义
max_gain(node):以node为起点的最大路径和- 全局变量
max_sum记录全局最大路径和
思路分析
对于每个节点:
- 计算左子树的最大贡献值(如果为负则取0)
- 计算右子树的最大贡献值(如果为负则取0)
- 当前节点的最大路径和 = 节点值 + 左贡献 + 右贡献
- 更新全局最大值
- 返回以当前节点为起点的最大路径和 = 节点值 + max(左贡献, 右贡献)
Python实现
python
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def maxPathSum(root):
"""
二叉树中的最大路径和
"""
def max_gain(node):
nonlocal max_sum
if not node:
return 0
# 递归计算左右子树的最大贡献值
left_gain = max(max_gain(node.left), 0) # 如果为负,则取0
right_gain = max(max_gain(node.right), 0)
# 当前节点的最大路径和(可能包含左右子树)
price_newpath = node.val + left_gain + right_gain
# 更新全局最大值
max_sum = max(max_sum, price_newpath)
# 返回以当前节点为起点的最大路径和
return node.val + max(left_gain, right_gain)
max_sum = float('-inf')
max_gain(root)
return max_sum
#### 面向对象版本
class Solution:
def maxPathSum(self, root: TreeNode) -> int:
self.max_sum = float('-inf')
self._dfs(root)
return self.max_sum
def _dfs(self, node):
if not node:
return 0
# 计算左右子树的最大贡献
left_max = max(self._dfs(node.left), 0)
right_max = max(self._dfs(node.right), 0)
# 当前节点的最大路径和
curr_sum = node.val + left_max + right_max
self.max_sum = max(self.max_sum, curr_sum)
# 返回以当前节点为起点的最大路径和
return node.val + max(left_max, right_max)
Java实现
java
public class BinaryTreeMaximumPathSum {
int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
maxGain(root);
return maxSum;
}
private int maxGain(TreeNode node) {
if (node == null) {
return 0;
}
// 计算左右子树的最大贡献值
int leftGain = Math.max(maxGain(node.left), 0);
int rightGain = Math.max(maxGain(node.right), 0);
// 当前节点的最大路径和
int priceNewpath = node.val + leftGain + rightGain;
// 更新全局最大值
maxSum = Math.max(maxSum, priceNewpath);
// 返回以当前节点为起点的最大路径和
return node.val + Math.max(leftGain, rightGain);
}
}
3.2 打家劫舍 III (LeetCode 337)
问题描述:房屋形成二叉树,不能抢劫直接相连的房屋。
状态定义
返回一个长度为2的数组:
res[0]:不抢劫当前房屋的最大金额res[1]:抢劫当前房屋的最大金额
Python实现
python
def rob_III(root):
"""
打家劫舍 III - 树形DP
"""
def dfs(node):
"""
返回:[不抢当前节点的最大收益, 抢当前节点的最大收益]
"""
if not node:
return [0, 0]
left = dfs(node.left)
right = dfs(node.right)
# 不抢当前节点:子节点可抢可不抢,取最大值
not_rob = max(left[0], left[1]) + max(right[0], right[1])
# 抢当前节点:子节点不能抢
rob = node.val + left[0] + right[0]
return [not_rob, rob]
result = dfs(root)
return max(result[0], result[1])
#### 使用namedtuple提高可读性
from collections import namedtuple
RobResult = namedtuple('RobResult', ['not_rob', 'rob'])
def rob_III_namedtuple(root):
def dfs(node):
if not node:
return RobResult(0, 0)
left = dfs(node.left)
right = dfs(node.right)
# 不抢当前节点
not_rob = max(left.not_rob, left.rob) + max(right.not_rob, right.rob)
# 抢当前节点
rob = node.val + left.not_rob + right.not_rob
return RobResult(not_rob, rob)
result = dfs(root)
return max(result.not_rob, result.rob)
Java实现
java
public class HouseRobberIII {
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 notRob = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
// 抢当前节点
int rob = node.val + left[0] + right[0];
return new int[]{notRob, rob};
}
}
3.3 二叉树的直径 (LeetCode 543)
问题描述:二叉树中两个节点之间最长路径的长度(可能不经过根节点)。
思路分析
直径 = 左子树深度 + 右子树深度
对于每个节点,计算:
- 左子树深度
- 右子树深度
- 当前节点的直径 = 左深度 + 右深度
- 更新全局最大直径
- 返回以当前节点为根的深度 = 1 + max(左深度, 右深度)
Python实现
python
def diameterOfBinaryTree(root):
"""
二叉树的直径
"""
def depth(node):
nonlocal diameter
if not node:
return 0
left_depth = depth(node.left)
right_depth = depth(node.right)
# 更新直径:当前节点的直径 = 左深度 + 右深度
diameter = max(diameter, left_depth + right_depth)
# 返回当前节点的深度
return 1 + max(left_depth, right_depth)
diameter = 0
depth(root)
return diameter
#### 类方法版本
class DiameterCalculator:
def __init__(self):
self.diameter = 0
def diameterOfBinaryTree(self, root: TreeNode) -> int:
self._depth(root)
return self.diameter
def _depth(self, node):
if not node:
return 0
left_depth = self._depth(node.left)
right_depth = self._depth(node.right)
# 更新直径
self.diameter = max(self.diameter, left_depth + right_depth)
# 返回深度
return 1 + max(left_depth, right_depth)
Java实现
java
public class DiameterOfBinaryTree {
int diameter = 0;
public int diameterOfBinaryTree(TreeNode root) {
depth(root);
return diameter;
}
private int depth(TreeNode node) {
if (node == null) {
return 0;
}
int leftDepth = depth(node.left);
int rightDepth = depth(node.right);
// 更新直径
diameter = Math.max(diameter, leftDepth + rightDepth);
// 返回深度
return 1 + Math.max(leftDepth, rightDepth);
}
}
4. 复杂树形DP问题
4.1 监控二叉树 (LeetCode 968)
问题描述:在二叉树节点上安装摄像头,每个摄像头可以监控自身、父节点和直接子节点,求最少需要多少摄像头。
状态定义
每个节点有三种状态:
- 0: 未被监控
- 1: 有摄像头
- 2: 被监控但无摄像头
状态转移
python
def minCameraCover(root):
"""
监控二叉树
"""
# 全局摄像头数量
cameras = [0]
def dfs(node):
"""
返回节点的状态
0: 未被监控
1: 有摄像头
2: 被监控但无摄像头
"""
if not node:
# 空节点视为被监控(不需要在其上安装摄像头)
return 2
left = dfs(node.left)
right = dfs(node.right)
# 情况1:左右子节点都未被监控
if left == 0 or right == 0:
cameras[0] += 1
return 1 # 当前节点需要安装摄像头
# 情况2:左右子节点至少有一个有摄像头
if left == 1 or right == 1:
return 2 # 当前节点被监控
# 情况3:左右子节点都被监控但无摄像头
return 0 # 当前节点未被监控
# 根节点状态
root_state = dfs(root)
# 如果根节点未被监控,需要安装摄像头
if root_state == 0:
cameras[0] += 1
return cameras[0]
#### 更清晰的实现
class CameraSolution:
def minCameraCover(self, root: TreeNode) -> int:
self.result = 0
# 定义三种状态
UNCOVERED = 0 # 未被监控
CAMERA = 1 # 有摄像头
COVERED = 2 # 被监控但无摄像头
def dfs(node):
if not node:
return COVERED
left = dfs(node.left)
right = dfs(node.right)
# 如果子节点有未被监控的,当前节点必须安装摄像头
if left == UNCOVERED or right == UNCOVERED:
self.result += 1
return CAMERA
# 如果子节点有摄像头,当前节点被监控
if left == CAMERA or right == CAMERA:
return COVERED
# 子节点都被监控但无摄像头
return UNCOVERED
# 检查根节点是否需要摄像头
if dfs(root) == UNCOVERED:
self.result += 1
return self.result
Java实现
java
public class BinaryTreeCameras {
private int cameras = 0;
public int minCameraCover(TreeNode root) {
int rootState = dfs(root);
// 如果根节点未被监控,需要安装摄像头
if (rootState == 0) {
cameras++;
}
return cameras;
}
// 0: 未被监控, 1: 有摄像头, 2: 被监控
private int dfs(TreeNode node) {
if (node == null) {
return 2; // 空节点视为被监控
}
int left = dfs(node.left);
int right = dfs(node.right);
// 子节点有未被监控的
if (left == 0 || right == 0) {
cameras++;
return 1;
}
// 子节点有摄像头
if (left == 1 || right == 1) {
return 2;
}
// 子节点都被监控但无摄像头
return 0;
}
}
4.2 二叉树中最大BST子树 (LeetCode 333)
问题描述:找到二叉树中最大的二叉搜索树(BST)子树,返回节点数。
状态定义
返回一个元组:(是否是BST, 最小值, 最大值, 节点数)
Python实现
python
def largestBSTSubtree(root):
"""
二叉树中最大BST子树
"""
def dfs(node):
"""
返回: (is_bst, min_val, max_val, node_count)
"""
if not node:
return (True, float('inf'), float('-inf'), 0)
left_is_bst, left_min, left_max, left_count = dfs(node.left)
right_is_bst, right_min, right_max, right_count = dfs(node.right)
# 当前节点为根的树是否是BST
if (left_is_bst and right_is_bst and
left_max < node.val < right_min):
# 当前节点是BST
curr_min = min(left_min, node.val)
curr_max = max(right_max, node.val)
curr_count = left_count + right_count + 1
# 更新全局最大BST
nonlocal max_bst_size
max_bst_size = max(max_bst_size, curr_count)
return (True, curr_min, curr_max, curr_count)
else:
# 当前节点不是BST,但为了不影响父节点判断,返回不影响的值
# 注意:不是BST时,min和max的值不重要
return (False, float('-inf'), float('inf'), 0)
max_bst_size = 0
dfs(root)
return max_bst_size
#### 更健壮的版本
class LargestBST:
def largestBSTSubtree(self, root: TreeNode) -> int:
self.max_size = 0
self._dfs(root)
return self.max_size
def _dfs(self, node):
if not node:
# 空节点是BST,节点数为0
return (True, float('inf'), float('-inf'), 0)
left_valid, left_min, left_max, left_size = self._dfs(node.left)
right_valid, right_min, right_max, right_size = self._dfs(node.right)
# 判断以当前节点为根的树是否是BST
if (left_valid and right_valid and
left_max < node.val < right_min):
# 是BST,计算相关信息
curr_min = min(left_min, node.val)
curr_max = max(right_max, node.val)
curr_size = left_size + right_size + 1
# 更新最大BST大小
self.max_size = max(self.max_size, curr_size)
return (True, curr_min, curr_max, curr_size)
else:
# 不是BST,返回一个不会影响父节点判断的值
# 任意值都可以,因为父节点会检查is_bst标志
return (False, 0, 0, 0)
Java实现
java
public class LargestBSTSubtree {
private int maxSize = 0;
public int largestBSTSubtree(TreeNode root) {
dfs(root);
return maxSize;
}
// 返回: {isBST, min, max, size}
private int[] dfs(TreeNode node) {
if (node == null) {
// 空节点是BST,min=MAX, min=MIN, size=0
return new int[]{1, Integer.MAX_VALUE, Integer.MIN_VALUE, 0};
}
int[] left = dfs(node.left);
int[] right = dfs(node.right);
// 如果左右子树都是BST,且满足BST条件
if (left[0] == 1 && right[0] == 1 &&
left[2] < node.val && node.val < right[1]) {
int currMin = Math.min(left[1], node.val);
int currMax = Math.max(right[2], node.val);
int currSize = left[3] + right[3] + 1;
maxSize = Math.max(maxSize, currSize);
return new int[]{1, currMin, currMax, currSize};
} else {
// 不是BST,返回任意值(父节点会检查isBST标志)
return new int[]{0, 0, 0, 0};
}
}
}
5. 多叉树树形DP
5.1 员工的重要性 (LeetCode 690)
问题描述:每个员工有唯一id、重要度和直系下属列表,求某员工及其所有下属的重要度之和。
python
class Employee:
def __init__(self, id: int, importance: int, subordinates: List[int]):
self.id = id
self.importance = importance
self.subordinates = subordinates
def getImportance(employees, id):
"""
员工的重要性 - 多叉树DFS
"""
# 构建id到员工的映射
emp_dict = {emp.id: emp for emp in employees}
def dfs(emp_id):
employee = emp_dict[emp_id]
# 当前员工的重要度
total = employee.importance
# 递归计算所有下属的重要度
for sub_id in employee.subordinates:
total += dfs(sub_id)
return total
return dfs(id)
Java实现
java
public class EmployeeImportance {
public int getImportance(List<Employee> employees, int id) {
Map<Integer, Employee> map = new HashMap<>();
for (Employee emp : employees) {
map.put(emp.id, emp);
}
return dfs(map, id);
}
private int dfs(Map<Integer, Employee> map, int id) {
Employee emp = map.get(id);
int total = emp.importance;
for (int subId : emp.subordinates) {
total += dfs(map, subId);
}
return total;
}
}
5.2 公司派对问题
问题描述:公司要办派对,每个员工有快乐值。如果某个员工参加,则他的直接下属不能参加。求最大快乐值。
python
class EmployeeNode:
def __init__(self, happy=0):
self.happy = happy
self.subordinates = [] # 直接下属列表
def maxHappy(root):
"""
公司派对最大快乐值
返回: [不选当前员工的最大快乐值, 选当前员工的最大快乐值]
"""
def dfs(node):
if not node:
return [0, 0]
# 当前员工不参加:下属可参加可不参加
not_join = 0
# 当前员工参加:下属不能参加
join = node.happy
for sub in node.subordinates:
sub_not_join, sub_join = dfs(sub)
# 当前员工不参加,下属可选可不选
not_join += max(sub_not_join, sub_join)
# 当前员工参加,下属不能参加
join += sub_not_join
return [not_join, join]
result = dfs(root)
return max(result[0], result[1])
6. 树形DP优化技巧
6.1 记忆化搜索
对于有重复计算的树形DP,可以使用记忆化搜索。
python
def tree_dp_with_memo(root):
memo = {}
def dfs(node):
if not node:
return base_value
if node in memo:
return memo[node]
left = dfs(node.left)
right = dfs(node.right)
result = calculate_result(node, left, right)
memo[node] = result
return result
return dfs(root)
6.2 自底向上迭代
对于某些树形DP,可以使用后序遍历的迭代版本。
python
def tree_dp_iterative(root):
if not root:
return 0
stack = []
result_map = {} # 存储每个节点的计算结果
# 后序遍历
node = root
last_visited = None
while stack or node:
if node:
stack.append(node)
node = node.left
else:
peek = stack[-1]
# 如果右子树存在且未被访问
if peek.right and last_visited != peek.right:
node = peek.right
else:
# 处理当前节点
node = stack.pop()
# 计算当前节点的结果
left_result = result_map.get(node.left, base_left)
right_result = result_map.get(node.right, base_right)
result = calculate_result(node, left_result, right_result)
result_map[node] = result
last_visited = node
node = None
return result_map[root]
7. 树形DP解题模板总结
7.1 通用解题步骤
-
定义状态:
- 确定每个节点需要返回什么信息
- 通常返回一个元组或多个值
-
确定递归终止条件:
- 空节点返回基础值
- 叶子节点返回特定值
-
递归处理子树:
- 递归处理左右子树
- 获取子树的结果
-
计算当前节点结果:
- 根据子树结果计算当前节点的结果
- 更新全局变量(如果需要)
-
返回结果:
- 返回当前节点的计算结果
7.2 常见状态定义模式
| 问题类型 | 返回信息 | 示例 |
|---|---|---|
| 最大路径和 | 以节点为起点的最大路径和 | (max_gain) |
| 打家劫舍 | 抢/不抢当前节点的最大收益 | (not_rob, rob) |
| 二叉树直径 | 节点深度 | depth |
| 监控二叉树 | 节点状态(0/1/2) | state |
| 最大BST | (是否是BST, 最小值, 最大值, 节点数) | (is_bst, min, max, size) |
7.3 状态转移方程总结
-
最大路径和:
max_gain(node) = node.val + max(左max_gain, 右max_gain, 0)
全局max_sum = max(全局max_sum, node.val + 左max_gain + 右max_gain) -
打家劫舍:
not_rob = max(左not_rob, 左rob) + max(右not_rob, 右rob)
rob = node.val + 左not_rob + 右not_rob -
二叉树直径:
直径 = max(直径, 左深度 + 右深度)
深度 = 1 + max(左深度, 右深度) -
监控二叉树:
if 左或右未被监控: return CAMERA
if 左或右有摄像头: return COVERED
else: return UNCOVERED
7.4 复杂度分析
| 问题 | 时间复杂度 | 空间复杂度 | 备注 |
|---|---|---|---|
| 最大路径和 | O(n) | O(h) | h为树高 |
| 打家劫舍III | O(n) | O(h) | 递归栈深度 |
| 二叉树直径 | O(n) | O(h) | 后序遍历 |
| 监控二叉树 | O(n) | O(h) | 三种状态 |
| 最大BST子树 | O(n) | O(h) | 每个节点常数时间 |
7.5 常见错误与调试
-
状态定义不清:
- 混淆局部最优和全局最优
- 未考虑所有可能的状态
-
递归终止条件错误:
- 空节点处理不当
- 叶子节点特殊处理遗漏
-
状态转移错误:
- 遗漏某些转移路径
- 条件判断不完整
-
更新全局变量时机错误:
- 在错误的位置更新全局变量
- 多次更新导致错误
7.6 调试技巧
- 打印递归路径:
python
def dfs(node, depth=0):
indent = " " * depth
print(f"{indent}Entering node {node.val if node else 'None'}")
# 递归处理...
print(f"{indent}Exiting node {node.val if node else 'None'}")
return result
- 可视化树结构:
python
def print_tree(node, prefix="", is_left=True):
if not node:
return
print_tree(node.right, prefix + ("│ " if is_left else " "), False)
print(prefix + ("└── " if is_left else "┌── ") + str(node.val))
print_tree(node.left, prefix + (" " if is_left else "│ "), True)
- 小规模测试:
python
# 构建测试树
def build_test_tree():
# 示例:[-10,9,20,null,null,15,7]
root = TreeNode(-10)
root.left = TreeNode(9)
root.right = TreeNode(20)
root.right.left = TreeNode(15)
root.right.right = TreeNode(7)
return root
8. 进阶练习题目
8.1 基础到中等
- 平衡二叉树检查 (LeetCode 110)
- 二叉树的所有路径 (LeetCode 257)
- 左叶子之和 (LeetCode 404)
- 路径总和III (LeetCode 437)
8.2 中等难度
- 二叉树的最近公共祖先 (LeetCode 236)
- 从叶结点开始的最小字符串 (LeetCode 988)
- 在二叉树中分配硬币 (LeetCode 979)
- 二叉树中的伪回文路径 (LeetCode 1457)
8.3 进阶挑战
- 二叉树的垂序遍历 (LeetCode 987)
- 二叉树的序列化与反序列化 (LeetCode 297)
- 恢复二叉搜索树 (LeetCode 99)
- 二叉树中的链表 (LeetCode 1367)
9. 面试准备建议
9.1 必备技能
- 理解树的基本遍历(前序、中序、后序)
- 掌握递归和迭代两种实现方式
- 熟悉常见树形DP问题的状态定义
- 了解时间空间复杂度分析方法
9.2 解题思路
- 分析问题:判断是否适合用树形DP
- 定义状态:确定每个节点需要什么信息
- 设计递归:确定递归函数签名和终止条件
- 状态转移:设计状态转移方程
- 处理结果:确定如何从递归结果得到最终答案
9.3 沟通技巧
- 清晰解释树形DP的思想
- 说明状态定义的含义
- 解释递归过程和状态转移
- 分析时间和空间复杂度
- 讨论可能的优化方案
10. 总结
树形DP是动态规划在树结构上的自然延伸,通过递归和分治的思想,将复杂问题分解为子问题。掌握树形DP需要:
核心要点:
- 理解递归本质:树形DP本质上是递归分治
- 合理定义状态:状态要包含解决问题的足够信息
- 正确处理边界:空节点和叶子节点的处理
- 设计状态转移:如何从子节点状态推导父节点状态
- 管理全局信息:如何处理需要全局比较的信息
学习建议:
- 从简单问题开始,逐步增加难度
- 多画图理解递归过程和状态转移
- 练习不同种类的树形DP问题
- 对比递归和迭代两种实现方式
- 总结常见模式,形成解题模板
树形DP不仅是算法面试的重要考点,在实际工程中也有广泛应用(如文件系统、组织结构等)。通过系统学习和大量练习,可以掌握这一重要技能。