1. 项目概述:从“打家劫舍”到树形动态规划
看到“打家劫舍 III”这个标题,很多人的第一反应可能是“这又是个什么脑筋急转弯?”。其实,这是LeetCode上一个非常经典的动态规划问题,也是“打家劫舍”系列的第三道题。如果说前两道题(打家劫舍 I 和 II)是在一维数组(街道)上玩“隔空取物”的游戏,那么这道题直接把战场搬到了数据结构里的“二叉树”上。想象一下,你是一个技术高超的“侠盗”,现在要潜入一个由二叉树结构组织的社区,每个节点(房子)里都有一定价值的财物。但社区安保系统很智能,如果你偷了某个房子,那么与它直接相连的父子节点房子(即它的父节点和子节点)的警报就会立刻被触发。你的目标是在不触发警报的前提下,偷到总价值最大的财物组合。
这个问题之所以经典,是因为它将动态规划的核心思想——“状态定义”和“状态转移”——与树形结构的递归遍历完美结合。它考察的不仅仅是你能不能写出状态转移方程,更考验你能否将树这种非线性的数据结构,通过递归分解成子问题,并高效地自底向上或自顶向下计算出最优解。对于准备面试尤其是国内外大厂技术面的同学来说,这道题是必须啃下的硬骨头,因为它综合了算法(动态规划)、数据结构(二叉树)和编程实现(递归/迭代)多个考点。
我自己在最初刷这道题时,也卡了很久。最直观的暴力搜索(枚举所有可能的偷窃组合)时间复杂度是指数级的,完全不可行。后来理解了“后序遍历”配合“状态记录”的思路,才豁然开朗。今天,我就把自己踩过的坑、总结的心得,以及完整的Python和C++实现代码,毫无保留地分享出来。无论你是刚开始接触动态规划的新手,还是想巩固树形DP的老手,相信这篇详细的拆解都能让你有所收获。
2. 核心思路拆解:为什么是树形DP?
要解决这个问题,我们首先要彻底理解题目加在我们身上的“约束条件”:不能同时偷窃相邻的节点。在二叉树中,“相邻”意味着父子关系。也就是说,如果你决定偷窃节点node,那么你就绝对不能偷它的直接左孩子node.left、直接右孩子node.right,以及它的父节点(在递归中,父节点的约束通过返回值向上传递来体现)。
2.1 暴力搜索的不可行性
最朴素的想法是:对于每个节点,我们有两种选择:偷,或者不偷。那么,我们可以尝试枚举整棵树所有可能的“偷窃状态组合”,然后检查每种组合是否满足“不相邻”约束,最后从所有合法组合中找出总价值最大的那个。
这听起来简单,但实现起来复杂度爆炸。一棵有N个节点的二叉树,每个节点有2种状态,那么理论上的状态组合就有 2^N 种。即使N只有20,也有超过100万种组合需要检查,这显然是不可接受的。我们需要一个更聪明的办法,避免重复计算子问题——这正是动态规划(DP)大显身手的地方。
2.2 动态规划的状态定义
动态规划的精髓在于定义状态,并找到状态之间的转移关系。对于树上的每一个节点,我们关心的是“以这个节点为根的子树”所能获得的最大收益。但是,仅仅一个“最大收益”是不够的,因为这会丢失关键信息:当前节点是否被偷窃?这个信息会直接影响其子节点的选择。
因此,我们必须为每个节点定义两个状态:
selected(或rob):表示偷窃当前节点的情况下,以该节点为根的子树能获得的最大收益。not_selected(或not_rob):表示不偷窃当前节点的情况下,以该节点为根的子树能获得的最大收益。
这是一个非常关键的洞见。通过为每个节点维护这两个值,我们在处理父节点时,就可以直接利用子节点的这两个状态值进行决策,而无需关心子节点的子树内部具体是怎么偷的,这完美符合动态规划的“最优子结构”性质。
2.3 状态转移方程的推导
现在,假设我们正在处理节点node。我们已经通过递归计算出了其左孩子left_child和右孩子right_child的(selected, not_selected)状态对。
情况一:偷窃节点
node(即node.selected)如果我决定偷node,那么根据规则,它的直接孩子left_child和right_child绝对不能被偷。因此,我能从左右子树获得的收益,仅限于当左右孩子“不被偷”时的最大收益。- 公式:
node.selected = node.val + left_child.not_selected + right_child.not_selected
- 公式:
情况二:不偷窃节点
node(即node.not_selected)如果我不偷node,那么对于它的孩子left_child和right_child,我就没有限制。我可以自由选择偷或不偷它们,目标当然是让从左右子树获得的总收益最大化。- 因此,对于每个孩子,我取其
selected和not_selected中的较大值。 - 公式:
node.not_selected = max(left_child.selected, left_child.not_selected) + max(right_child.selected, right_child.not_selected)
- 因此,对于每个孩子,我取其
注意:这里
node.not_selected的计算是很多初学者容易混淆的地方。不偷当前节点,不代表一定要偷它的子节点。最优策略是:对于每个子节点,我都独立地选择“偷它”或“不偷它”中能带来更大收益的那个方案,然后把左右子树的最优收益加起来。子节点的选择是独立的,互不影响。
2.4 遍历顺序的选择:为什么是后序遍历?
有了状态和转移方程,我们需要计算整棵树每个节点的这两个状态值。应该以什么顺序遍历树呢?
答案是:后序遍历(左 -> 右 -> 根)。
原因在于我们的状态转移方程:要计算节点node的selected和not_selected,我们必须先知道其左右孩子left_child和right_child的对应状态值。这正是一种“自底向上”的计算过程:先解决子问题(子树),再利用子问题的解来解决父问题(当前树)。
后序遍历天然符合这个需求。我们递归地深入左子树,计算出左子树根节点的状态对;再递归深入右子树,计算出右子树根节点的状态对;最后回到当前节点,利用左右孩子的状态对,计算出当前节点的状态对。
3. 代码实现详解与避坑指南
理论清晰了,我们来看代码实现。我会分别给出Python和C++的版本,并详细解释其中的关键点和容易出错的地方。
3.1 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: Optional[TreeNode]) -> int: # 定义递归函数,返回一个元组 (selected, not_selected) def dfs(node): # 递归基:如果节点为空,偷或不偷的收益都是0 if not node: return (0, 0) # 后序遍历:先递归处理左右子树 left = dfs(node.left) right = dfs(node.right) # 情况1:偷当前节点 # 收益 = 当前节点价值 + 不偷左子树的收益 + 不偷右子树的收益 selected = node.val + left[1] + right[1] # 情况2:不偷当前节点 # 收益 = max(偷左子树, 不偷左子树) + max(偷右子树, 不偷右子树) not_selected = max(left[0], left[1]) + max(right[0], right[1]) # 返回当前节点的状态对 return (selected, not_selected) # 从根节点开始递归,最终结果是偷或不偷根节点的最大收益 result = dfs(root) # 根节点也有偷或不偷两种选择,取最大值 return max(result[0], result[1])代码要点与避坑指南:
- 递归函数返回值:
dfs函数返回一个长度为2的元组(selected, not_selected)。这是最清晰的方式。有些实现会用字典或列表,但元组在Python中更轻量且意图明确。 - 递归基(Base Case):空节点的处理
(0, 0)必须要有。这保证了递归能够正确终止。 - 后序遍历顺序:注意
left = dfs(node.left)和right = dfs(node.right)的调用在计算selected和not_selected之前。这是后序遍历的体现。 - 状态转移的实现:
selected和not_selected的计算严格对应我们推导的公式。特别注意not_selected中对max(left[0], left[1])的调用,这里left[0]对应left_selected,left[1]对应left_not_selected。 - 最终结果:递归从根节点开始,最终得到根节点的状态对。题目要求整棵树的最大收益,所以我们需要取
根节点被偷和根节点不被偷两种情况下的最大值,即max(result[0], result[1])。
一个常见的错误:有人可能会想,既然根节点不一定偷,那是不是最终结果就是result[1](不偷根节点的收益)?不对。因为最优解完全有可能包含偷窃根节点的情况(例如根节点价值极高,而子节点价值很低)。所以必须取两者最大值。
3.2 C++ 实现与性能考量
/** * Definition for a binary tree node. * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode() : val(0), left(nullptr), right(nullptr) {} * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} * }; */ class Solution { public: int rob(TreeNode* root) { // 调用递归函数,返回一个pair<int, int> auto result = dfs(root); // 取偷或不偷根节点的最大值 return max(result.first, result.second); } private: // 返回 pair<偷当前节点的最大收益, 不偷当前节点的最大收益> pair<int, int> dfs(TreeNode* node) { if (node == nullptr) { return {0, 0}; // 空节点,收益均为0 } // 后序遍历 auto left = dfs(node->left); auto right = dfs(node->right); // 偷当前节点 int selected = node->val + left.second + right.second; // 不偷当前节点 int not_selected = max(left.first, left.second) + max(right.first, right.second); return {selected, not_selected}; } };C++实现特点与注意事项:
- 使用
std::pair:C++标准库中的pair<int, int>非常适合用来返回两个状态值,语义清晰。也可以自己定义结构体,但pair更简洁。 - 使用
auto关键字:auto left = dfs(node->left);让代码更简洁,编译器会自动推导出left的类型是pair<int, int>。 - 访问成员:通过
first和second来访问pair的两个元素,对应selected和not_selected。 - 递归基:同样,对空指针返回
{0, 0}。 - 性能:这个算法的时间复杂度是 O(N),其中 N 是树中的节点数,因为每个节点只被访问一次。空间复杂度主要取决于递归调用栈的深度,在最坏情况(树退化成链表)下是 O(N),平均情况(平衡树)下是 O(log N)。对于LeetCode的约束,这个复杂度是完全可接受的。
实操心得:在C++中,注意递归深度。虽然题目中树的节点数范围通常不会导致栈溢出,但在处理特别深或不平衡的树时,心里要有这根弦。理论上,可以将递归改为显式栈的迭代后序遍历来实现,以避免潜在的栈溢出风险,但代码会复杂很多。在面试中,先给出清晰正确的递归解法通常是更优选择。
4. 算法深入分析与复杂度证明
4.1 时间复杂度:O(N) 的必然性
我们声称算法的时间复杂度是 O(N),N 为节点数。为什么? 我们的dfs函数对树进行了一次后序遍历。在遍历过程中,每个节点恰好被访问一次。在访问每个节点时,我们只进行了常数时间的操作:从左右子节点的返回值中取出两个值,进行两次加法、两次max比较,以及一次构造返回值的操作。 因此,总时间与节点数 N 成线性关系,即 O(N)。
4.2 空间复杂度:递归深度的代价
空间复杂度分为两部分:
- 递归调用栈空间:这取决于树的高度 H。在最坏情况下,树退化成一条链(例如每个节点都只有左孩子),此时树高 H = N,递归深度为 N,空间复杂度为 O(N)。在最好情况下,树是完全平衡的,高度 H ≈ log₂N,空间复杂度为 O(log N)。
- 算法本身使用的额外空间:除了递归栈,我们只使用了几个局部变量(
left,right,selected,not_selected),这些是常数空间 O(1)。
因此,总的空间复杂度是 O(H),在最坏情况下为 O(N)。
4.3 与记忆化搜索(Memoization)的对比
有同学可能会问:这看起来像递归,为什么说是动态规划?它和“记忆化搜索”有什么区别? 实际上,我们采用的正是记忆化搜索形式的动态规划,也称为“自顶向下的DP”。
- 记忆化搜索:在递归过程中,如果遇到重复子问题,就将结果保存下来(记忆),下次直接查表返回,避免重复计算。
- 我们这里的实现:仔细看,对于树中的每个节点,我们的
dfs函数只会被调用一次。为什么?因为每个节点作为其自身子树的根,这个“子问题”是唯一的。我们从根节点开始,递归地分解问题,每个子问题(子树)只解决一次,其结果通过返回值传递给父问题。这本质上已经避免了重复计算,虽然没有显式地用一个memo字典来存储,但递归函数的调用栈和返回值传递机制隐式地保证了这一点。所以,这是一种非常高效且自然的树形DP实现方式。
如果要显式写成记忆化搜索,可以这样(Python示例):
class Solution: def rob(self, root): memo = {} def dfs(node): if not node: return (0, 0) if node in memo: # 如果该节点的状态已计算过,直接返回 return memo[node] left = dfs(node.left) right = dfs(node.right) selected = node.val + left[1] + right[1] not_selected = max(left[0], left[1]) + max(right[0], right[1]) memo[node] = (selected, not_selected) return memo[node] result = dfs(root) return max(result[0], result[1])对于树结构,显式记忆化的收益不大,因为每个节点天然只被访问一次。但在图等可能重复访问同一状态的问题中,记忆化就至关重要。
5. 变种问题与扩展思考
理解了“打家劫舍 III”的核心,我们可以看看它的几个变种和扩展,这能帮你更好地掌握这类树形DP问题的精髓。
5.1 如果房子不是二叉树,而是多叉树?
假设社区结构是一棵普通的树(每个节点可能有多个孩子),规则不变。我们的解法只需要做微调。 状态定义不变:每个节点仍然需要(selected, not_selected)两个状态。 状态转移方程需要稍作修改:
node.selected = node.val + sum(child.not_selected for child in node.children)node.not_selected = sum(max(dfs(child)[0], dfs(child)[1]) for child in node.children)遍历顺序依然是后序遍历(先处理所有孩子,再处理当前节点)。代码实现上,将处理左右孩子的循环改为处理所有孩子的循环即可。
5.2 如果警报条件变化(隔两层才触发)?
原题是偷了节点,其父子节点不能偷。如果规则变成“偷了节点,其父节点和子节点不能偷,但孙子节点可以偷”(即相隔一层即可),问题会变得更复杂。 此时,一个节点的状态不仅受其孩子影响,还受其孙子影响。状态定义可能需要扩展。一种思路是定义三个状态:
selected: 偷当前节点。not_selected_but_child_selected: 不偷当前节点,但偷了至少一个直接子节点。not_selected_and_child_not_selected: 不偷当前节点,且所有直接子节点也都没偷。 然后推导这三个状态之间的转移方程。这显然比原题复杂,但核心思想依然是定义足够的状态来捕获决策对后续选择的影响。
5.3 如何输出具体偷窃了哪些节点?
原题只要求返回最大金额。如果面试官追问:“请输出偷窃了哪些节点”,这就是一个经典的“DP方案回溯”问题。 我们的DP过程记录了每个节点在“偷”与“不偷”状态下的最优收益。要回溯方案,我们需要从根节点开始,根据最终的选择(是selected收益大还是not_selected收益大)以及子节点的状态,决定当前节点是否被偷,然后递归地决定子节点的选择。
回溯步骤简述(Python思路):
- 修改
dfs函数,让它除了返回收益,还返回能达到该收益的节点选择列表(或一个标记)。 - 或者,更常见的做法是:先运行一遍DP,计算出所有节点的
selected和not_selected值。然后写一个独立的backtrack函数,从根节点开始:- 比较
root.selected和root.not_selected。 - 如果
root.selected更大,说明最优方案中偷了根节点。将根节点加入结果列表,然后递归地处理root.left和root.right,但必须强制它们处于“不被偷”的状态(即接下来只看它们的not_selected分支)。 - 如果
root.not_selected更大,说明最优方案中没偷根节点。那么对于每个孩子,我们选择能带来更大收益的那个状态(可能是偷也可能是不偷),然后递归处理。 - 注意,可能存在多个最优方案(收益相同但节点组合不同),回溯时需要处理。
- 比较
6. 常见错误与调试技巧
在实际编写和调试代码时,以下几个错误非常常见:
错误1:状态转移方程写反
# 错误示例 selected = node.val + max(left[0], left[1]) + max(right[0], right[1]) # 错!偷了node就不能偷孩子。 not_selected = left[1] + right[1] # 错!不偷node,孩子可以偷也可以不偷。诊断:用一个小树(如只有两三层)手动模拟,或者用LeetCode的测试用例(例如[3,2,3,null,3,null,1])跑一下,结果肯定不对。仔细对照我们推导的公式检查。
错误2:忘记处理空节点递归函数没有if not node: return (0, 0)这一句,那么在遍历到叶子节点的空孩子时就会报错(尝试访问None.val或None.left)。
错误3:最终结果只返回了not_selected
return result[1] # 错误!可能最优解需要偷根节点。诊断:用一个简单的例子测试,比如树只有一个节点[100]。正确结果应该是100(偷这个节点)。如果你的程序返回0,那就是犯了这个问题。
错误4:C++中未考虑负数节点值?题目描述中node.val是整数,但并未说明是非负。我们的状态转移方程中使用了max函数,它同样适用于负数(选择较大的收益)。但如果题目明确说明财物价值为非负整数,那么我们可以利用这一点进行一些优化(例如,not_selected一定大于等于0),但基本算法不变。
调试技巧:
- 画图:拿一张纸,画一棵简单的二叉树(3-5个节点),手动模拟算法的递归过程,为每个节点算出
(selected, not_selected)。这是理解算法最有效的方式。 - 打印日志:在递归函数入口和返回处打印节点值和返回的状态对,观察计算顺序和结果。
def dfs(node): if not node: print(f"Node: None -> (0, 0)") return (0, 0) print(f"Entering node: {node.val}") left = dfs(node.left) right = dfs(node.right) selected = node.val + left[1] + right[1] not_selected = max(left[0], left[1]) + max(right[0], right[1]) print(f"Leaving node: {node.val} -> ({selected}, {not_selected})") return (selected, not_selected) - 使用LeetCode的测试用例:LeetCode提供的示例和测试用例覆盖了各种边界情况(空树、单节点、左斜树、右斜树、普通树)。逐个测试,确保都能通过。
7. 总结与个人心得
“打家劫舍 III”是一道质量极高的题目,它不像一些纯技巧性的题目那样刁钻,而是扎实地考察了对动态规划和树形数据结构的理解。通过这道题,我们可以深刻体会到“状态定义”在DP中的核心地位。定义出(selected, not_selected)这个状态对,问题就解决了一大半。
我个人在反复练习这道题和类似题目(如“二叉树中的最大路径和”)后,总结出一个树形DP的通用思路框架:
- 确定状态:站在一个节点的角度,思考解决以它为根的子树问题,需要哪些信息?这些信息往往需要包含“当前节点处于某种情况”下的结果。像本题,就需要知道“偷它”和“不偷它”两种情况下的最优解。
- 定义递归函数:设计一个递归函数,输入一个节点,返回我们需要的信息(通常是一个值或一个结构体/元组)。
- 推导转移方程:思考如何利用子节点的返回信息,计算出当前节点的信息。这是最核心的一步,需要仔细分析约束条件。
- 确定遍历顺序:绝大多数树形DP都采用后序遍历,因为需要先知道子问题的解,才能解决父问题。
- 处理递归基:明确空节点或叶子节点的返回结果。
- 获取最终答案:从根节点的递归返回值中,提取或计算出整个问题的答案。
最后,再分享一个面试小技巧:当面试官问出这道题时,在写出代码之前,最好先清晰地阐述你的状态定义和状态转移方程,并说明为什么选择后序遍历。这能展示你清晰的思维过程,比直接闷头写代码要加分得多。写完代码后,主动分析时间复杂度和空间复杂度,并提及可能的变种(如输出偷窃路径),更能体现你的技术深度。