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)
注意:这种解法虽然直观,但存在大量的重复计算,时间复杂度是指数级的,在LeetCode上会超时。
2.2 记忆化递归优化
为了优化暴力解法,我们可以引入记忆化技术,将已经计算过的节点结果保存下来,避免重复计算。这需要我们对每个节点维护两个状态:
- 偷当前节点时的最大值
- 不偷当前节点时的最大值
改进后的解法:
python复制def rob(root):
memo = {}
def helper(node):
if not node:
return (0, 0)
if node in memo:
return memo[node]
left = helper(node.left)
right = helper(node.right)
# 偷当前节点
rob = node.val + left[1] + right[1]
# 不偷当前节点
not_rob = max(left) + max(right)
memo[node] = (rob, not_rob)
return memo[node]
return max(helper(root))
这种解法的时间复杂度降到了O(n),因为每个节点只会被处理一次。
2.3 动态规划解法
更优雅的解法是直接在递归过程中返回两个状态值,完全消除重复计算。这种解法不需要额外的记忆化存储,空间复杂度更优:
python复制def rob(root):
def helper(node):
if not node:
return (0, 0)
left = helper(node.left)
right = helper(node.right)
# 偷当前节点
rob = node.val + left[1] + right[1]
# 不偷当前节点
not_rob = max(left) + max(right)
return (rob, not_rob)
return max(helper(root))
3. 代码实现细节
3.1 Python完整实现
python复制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)
# 当前节点被偷时的最大值
rob = node.val + left[1] + right[1]
# 当前节点不被偷时的最大值
not_rob = max(left) + max(right)
return (rob, not_rob)
return max(dfs(root))
3.2 关键点解析
-
返回值设计:辅助函数dfs返回一个元组,第一个元素表示偷当前节点时的最大值,第二个元素表示不偷当前节点时的最大值。
-
状态转移:
- 偷当前节点时,不能偷子节点,所以rob = node.val + left[1] + right[1]
- 不偷当前节点时,可以自由选择偷或不偷子节点,所以not_rob = max(left) + max(right)
-
边界条件:当节点为空时,返回(0, 0),表示没有可偷的金额。
4. 复杂度分析与优化
4.1 时间复杂度
所有解法最终都遵循相同的递归模式,每个节点只被访问一次,所以时间复杂度都是O(n),其中n是树中的节点数。
4.2 空间复杂度
- 暴力递归:O(h),h是树的高度,由递归调用栈决定
- 记忆化递归:O(n),需要存储所有节点的结果
- 动态规划:O(h),只使用递归栈空间
4.3 迭代解法
虽然递归解法简洁,但我们可以用后序遍历的迭代方式实现,避免递归栈溢出的风险:
python复制def rob(root):
def helper(node):
if not node:
return (0, 0)
stack = [(node, False)]
result = {}
while stack:
node, visited = stack.pop()
if visited:
left = result.get(node.left, (0, 0))
right = result.get(node.right, (0, 0))
rob = node.val + left[1] + right[1]
not_rob = max(left) + max(right)
result[node] = (rob, not_rob)
else:
stack.append((node, True))
if node.right:
stack.append((node.right, False))
if node.left:
stack.append((node.left, False))
return result.get(root, (0, 0))
return max(helper(root))
5. 常见错误与调试技巧
5.1 典型错误案例
-
错误理解相邻节点:有些同学会误认为只要不是同一个父节点的子节点就可以同时偷,实际上父子节点直接相连就算相邻。
-
重复计算问题:直接使用暴力递归会导致大量重复计算,在较大的测试用例上会超时。
-
状态设计错误:有些同学尝试只用一个返回值,无法同时表示偷与不偷两种状态。
5.2 调试技巧
-
小规模测试:先用简单的二叉树验证代码正确性,例如:
- 单节点树
- 完全左斜树
- 三层满二叉树
-
打印中间结果:在递归函数中加入打印语句,观察每个节点的返回值是否符合预期。
-
可视化思考:画出一棵简单的二叉树,手动计算每个节点偷与不偷的最大值,与程序输出对比。
6. 同类问题扩展
6.1 打家劫舍系列
- 打家劫舍 I:房屋排成一条直线,相邻不能偷
- 打家劫舍 II:房屋围成一个环形
- 打家劫舍 III:本题,房屋排列成二叉树
6.2 树形DP问题
- 二叉树中的最大路径和
- 监控二叉树
- 二叉树中的最长交错路径
这类问题的共同特点是需要在树结构上进行状态转移,通常采用后序遍历的方式自底向上计算。
7. 面试技巧与实战建议
-
问题分析步骤:
- 先确认题目理解正确,特别是"相邻"的定义
- 从暴力解法开始,分析其缺点
- 引入记忆化或动态规划优化
- 讨论时间空间复杂度
-
代码实现要点:
- 明确定义递归函数的返回值含义
- 处理好边界条件
- 状态转移方程要正确
-
沟通技巧:
- 解释清楚每个步骤的思考过程
- 主动提出优化思路
- 讨论可能的变种问题
在实际面试中,遇到这类问题时,建议先花2-3分钟在白板上画出示例树,手动计算几个节点的值,帮助理清思路。然后再开始编码,编码过程中可以继续解释自己的思考过程。