1. 问题背景与题目解析
LeetCode 337题"打家劫舍 III"是动态规划系列中非常经典的二叉树应用问题。这道题将经典的"打家劫舍"问题扩展到了树形结构上,要求我们在二叉树结构的房屋中寻找最优的偷窃方案。
题目描述:小偷发现了一个新的可行窃的地区,这个地区只有一个入口,我们称之为"根"。除了"根"之外,每栋房子有且只有一个"父"房子与之相连。在偷窃时,如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。给定这样一个二叉树的表示,计算在不触动警报的情况下,小偷能够盗取的最高金额。
这个问题的难点在于:
- 树形结构相比线性结构更复杂
- 相邻节点的定义需要明确(父子节点不能同时被选中)
- 需要找到最优子结构和状态转移方程
2. 解题思路分析
2.1 暴力递归解法
最直观的解法是使用递归遍历二叉树,对于每个节点,我们有两种选择:
- 偷当前节点,那么就不能偷其直接子节点
- 不偷当前节点,那么可以考虑偷其子节点
这种思路可以用以下伪代码表示:
python复制def rob(root):
if not root:
return 0
# 偷当前节点
val1 = root.val
if root.left:
val1 += rob(root.left.left) + rob(root.left.right)
if root.right:
val1 += rob(root.right.left) + rob(root.right.right)
# 不偷当前节点
val2 = rob(root.left) + rob(root.right)
return max(val1, val2)
这种解法虽然直观,但存在大量重复计算,时间复杂度为O(2^n),在LeetCode上会超时。
2.2 记忆化递归优化
为了优化暴力递归解法,我们可以使用记忆化技术,将已经计算过的节点结果保存下来,避免重复计算。这需要引入一个哈希表来存储计算结果:
python复制def rob(root):
memo = {}
def helper(node):
if not node:
return 0
if node in memo:
return memo[node]
# 偷当前节点
val1 = node.val
if node.left:
val1 += helper(node.left.left) + helper(node.left.right)
if node.right:
val1 += helper(node.right.left) + helper(node.right.right)
# 不偷当前节点
val2 = helper(node.left) + helper(node.right)
memo[node] = max(val1, val2)
return memo[node]
return helper(root)
这种解法的时间复杂度降为O(n),空间复杂度为O(n)。
2.3 动态规划解法
更优的解法是使用动态规划。对于树形DP问题,我们通常需要从子节点向父节点传递信息。对于每个节点,我们可以定义一个长度为2的数组,其中:
- dp[0]表示不偷当前节点时的最大金额
- dp[1]表示偷当前节点时的最大金额
状态转移方程为:
- 偷当前节点:当前节点的值 + 不偷左子节点的最大值 + 不偷右子节点的最大值
- 不偷当前节点:左子节点的最大值(偷或不偷) + 右子节点的最大值(偷或不偷)
具体实现如下:
python复制def rob(root):
def helper(node):
if not node:
return [0, 0]
left = helper(node.left)
right = helper(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 = helper(root)
return max(result[0], result[1])
这种解法的时间复杂度为O(n),空间复杂度为O(h),其中h是树的高度。
3. 代码实现与细节分析
3.1 Python完整实现
python复制# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def rob(self, root: TreeNode) -> int:
def dfs(node):
if not node:
return (0, 0)
left = dfs(node.left)
right = dfs(node.right)
# 当前节点不偷:左右子节点偷或不偷的最大值之和
not_rob = max(left) + max(right)
# 当前节点偷:当前值 + 左右子节点不偷的值
rob = node.val + left[0] + right[0]
return (not_rob, rob)
return max(dfs(root))
3.2 关键点解析
- 后序遍历:我们需要先处理子节点再处理父节点,所以采用后序遍历(DFS)的方式
- 状态定义:每个节点返回两个状态值,分别表示偷或不偷当前节点时的最大值
- 状态转移:
- 不偷当前节点时,子节点可以偷或不偷,取最大值
- 偷当前节点时,子节点必须不偷
- 最终结果:根节点的两个状态取最大值
3.3 复杂度分析
- 时间复杂度:O(n),每个节点只访问一次
- 空间复杂度:O(h),递归栈的深度取决于树的高度
4. 测试用例与边界条件
4.1 常规测试用例
python复制# 测试用例1
# 3
# / \
# 2 3
# \ \
# 3 1
# 预期输出:7 (3+3+1)
# 测试用例2
# 3
# / \
# 4 5
# / \ \
# 1 3 1
# 预期输出:9 (4+5)
4.2 边界条件
- 空树:返回0
- 只有根节点:返回根节点的值
- 单边树(所有节点只有左子节点或只有右子节点)
- 完全二叉树
- 所有节点值相同的情况
5. 常见问题与优化技巧
5.1 常见错误
- 错误的状态定义:只返回一个值,无法区分偷或不偷的情况
- 遍历顺序错误:使用前序或中序遍历,无法正确处理子节点的状态
- 重复计算:没有使用记忆化或DP优化,导致超时
- 边界条件处理不当:忘记处理空节点的情况
5.2 优化技巧
- 使用元组返回多个状态:比使用字典或列表更高效
- 非递归实现:可以使用栈模拟递归过程,减少函数调用开销
- 全局变量优化:对于某些语言,使用类成员变量可能比局部变量更快
5.3 实际应用场景
这类树形DP问题在实际中有广泛的应用,例如:
- 公司组织结构中的权限分配
- 网络拓扑中的资源优化
- 游戏AI中的决策树优化
- 文件系统中的权限管理
6. 同类问题扩展
掌握了这道题后,可以尝试解决以下类似问题:
- LeetCode 124. 二叉树中的最大路径和
- LeetCode 543. 二叉树的直径
- LeetCode 687. 最长同值路径
- LeetCode 968. 监控二叉树
这些题目都采用了类似的树形DP思路,通过后序遍历和状态传递来解决问题。
7. 个人刷题心得
在实际刷题过程中,我发现树形DP问题有几个关键点需要注意:
-
明确状态定义:这是解决问题的第一步,也是最重要的一步。必须清楚每个节点需要返回哪些信息。
-
画图分析:对于复杂的树结构,先在纸上画出树形图,手动计算几个简单案例,有助于理解状态转移过程。
-
从暴力解法开始:先写出暴力递归解法,再逐步优化,这样更容易理解问题的本质。
-
测试用例要全面:不仅要考虑常规情况,还要特别注意边界条件,如空树、单节点树、单边树等。
-
对比不同解法:尝试用多种方法解决同一问题,比较它们的优缺点,这有助于深入理解算法思想。
这道题我最初提交时遇到了超时问题,后来通过引入记忆化优化解决了。再后来学习了更优雅的动态规划解法,代码更加简洁高效。这个过程让我深刻体会到算法优化的重要性。