news 2026/7/9 19:18:59

树形动态规划实战:从状态定义到后序遍历解决打家劫舍III

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
树形动态规划实战:从状态定义到后序遍历解决打家劫舍III

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 动态规划的状态定义

动态规划的精髓在于定义状态,并找到状态之间的转移关系。对于树上的每一个节点,我们关心的是“以这个节点为根的子树”所能获得的最大收益。但是,仅仅一个“最大收益”是不够的,因为这会丢失关键信息:当前节点是否被偷窃?这个信息会直接影响其子节点的选择。

因此,我们必须为每个节点定义两个状态

  1. selected(或rob):表示偷窃当前节点的情况下,以该节点为根的子树能获得的最大收益。
  2. not_selected(或not_rob):表示不偷窃当前节点的情况下,以该节点为根的子树能获得的最大收益。

这是一个非常关键的洞见。通过为每个节点维护这两个值,我们在处理父节点时,就可以直接利用子节点的这两个状态值进行决策,而无需关心子节点的子树内部具体是怎么偷的,这完美符合动态规划的“最优子结构”性质。

2.3 状态转移方程的推导

现在,假设我们正在处理节点node。我们已经通过递归计算出了其左孩子left_child和右孩子right_child(selected, not_selected)状态对。

  • 情况一:偷窃节点node(即node.selected如果我决定偷node,那么根据规则,它的直接孩子left_childright_child绝对不能被偷。因此,我能从左右子树获得的收益,仅限于当左右孩子“不被偷”时的最大收益。

    • 公式:node.selected = node.val + left_child.not_selected + right_child.not_selected
  • 情况二:不偷窃节点node(即node.not_selected如果我不偷node,那么对于它的孩子left_childright_child,我就没有限制。我可以自由选择偷或不偷它们,目标当然是让从左右子树获得的总收益最大化。

    • 因此,对于每个孩子,我取其selectednot_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 遍历顺序的选择:为什么是后序遍历?

有了状态和转移方程,我们需要计算整棵树每个节点的这两个状态值。应该以什么顺序遍历树呢?

答案是:后序遍历(左 -> 右 -> 根)

原因在于我们的状态转移方程:要计算节点nodeselectednot_selected,我们必须先知道其左右孩子left_childright_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])

代码要点与避坑指南:

  1. 递归函数返回值dfs函数返回一个长度为2的元组(selected, not_selected)。这是最清晰的方式。有些实现会用字典或列表,但元组在Python中更轻量且意图明确。
  2. 递归基(Base Case):空节点的处理(0, 0)必须要有。这保证了递归能够正确终止。
  3. 后序遍历顺序:注意left = dfs(node.left)right = dfs(node.right)的调用在计算selectednot_selected之前。这是后序遍历的体现。
  4. 状态转移的实现selectednot_selected的计算严格对应我们推导的公式。特别注意not_selected中对max(left[0], left[1])的调用,这里left[0]对应left_selectedleft[1]对应left_not_selected
  5. 最终结果:递归从根节点开始,最终得到根节点的状态对。题目要求整棵树的最大收益,所以我们需要取根节点被偷根节点不被偷两种情况下的最大值,即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++实现特点与注意事项:

  1. 使用std::pair:C++标准库中的pair<int, int>非常适合用来返回两个状态值,语义清晰。也可以自己定义结构体,但pair更简洁。
  2. 使用auto关键字auto left = dfs(node->left);让代码更简洁,编译器会自动推导出left的类型是pair<int, int>
  3. 访问成员:通过firstsecond来访问pair的两个元素,对应selectednot_selected
  4. 递归基:同样,对空指针返回{0, 0}
  5. 性能:这个算法的时间复杂度是 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 空间复杂度:递归深度的代价

空间复杂度分为两部分:

  1. 递归调用栈空间:这取决于树的高度 H。在最坏情况下,树退化成一条链(例如每个节点都只有左孩子),此时树高 H = N,递归深度为 N,空间复杂度为 O(N)。在最好情况下,树是完全平衡的,高度 H ≈ log₂N,空间复杂度为 O(log N)。
  2. 算法本身使用的额外空间:除了递归栈,我们只使用了几个局部变量(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 如果警报条件变化(隔两层才触发)?

原题是偷了节点,其父子节点不能偷。如果规则变成“偷了节点,其父节点和子节点不能偷,但孙子节点可以偷”(即相隔一层即可),问题会变得更复杂。 此时,一个节点的状态不仅受其孩子影响,还受其孙子影响。状态定义可能需要扩展。一种思路是定义三个状态:

  1. selected: 偷当前节点。
  2. not_selected_but_child_selected: 不偷当前节点,但偷了至少一个直接子节点。
  3. not_selected_and_child_not_selected: 不偷当前节点,且所有直接子节点也都没偷。 然后推导这三个状态之间的转移方程。这显然比原题复杂,但核心思想依然是定义足够的状态来捕获决策对后续选择的影响。

5.3 如何输出具体偷窃了哪些节点?

原题只要求返回最大金额。如果面试官追问:“请输出偷窃了哪些节点”,这就是一个经典的“DP方案回溯”问题。 我们的DP过程记录了每个节点在“偷”与“不偷”状态下的最优收益。要回溯方案,我们需要从根节点开始,根据最终的选择(是selected收益大还是not_selected收益大)以及子节点的状态,决定当前节点是否被偷,然后递归地决定子节点的选择。

回溯步骤简述(Python思路)

  1. 修改dfs函数,让它除了返回收益,还返回能达到该收益的节点选择列表(或一个标记)。
  2. 或者,更常见的做法是:先运行一遍DP,计算出所有节点的selectednot_selected值。然后写一个独立的backtrack函数,从根节点开始:
    • 比较root.selectedroot.not_selected
    • 如果root.selected更大,说明最优方案中偷了根节点。将根节点加入结果列表,然后递归地处理root.leftroot.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.valNone.left)。

错误3:最终结果只返回了not_selected

return result[1] # 错误!可能最优解需要偷根节点。

诊断:用一个简单的例子测试,比如树只有一个节点[100]。正确结果应该是100(偷这个节点)。如果你的程序返回0,那就是犯了这个问题。

错误4:C++中未考虑负数节点值?题目描述中node.val是整数,但并未说明是非负。我们的状态转移方程中使用了max函数,它同样适用于负数(选择较大的收益)。但如果题目明确说明财物价值为非负整数,那么我们可以利用这一点进行一些优化(例如,not_selected一定大于等于0),但基本算法不变。

调试技巧:

  1. 画图:拿一张纸,画一棵简单的二叉树(3-5个节点),手动模拟算法的递归过程,为每个节点算出(selected, not_selected)。这是理解算法最有效的方式。
  2. 打印日志:在递归函数入口和返回处打印节点值和返回的状态对,观察计算顺序和结果。
    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)
  3. 使用LeetCode的测试用例:LeetCode提供的示例和测试用例覆盖了各种边界情况(空树、单节点、左斜树、右斜树、普通树)。逐个测试,确保都能通过。

7. 总结与个人心得

“打家劫舍 III”是一道质量极高的题目,它不像一些纯技巧性的题目那样刁钻,而是扎实地考察了对动态规划和树形数据结构的理解。通过这道题,我们可以深刻体会到“状态定义”在DP中的核心地位。定义出(selected, not_selected)这个状态对,问题就解决了一大半。

我个人在反复练习这道题和类似题目(如“二叉树中的最大路径和”)后,总结出一个树形DP的通用思路框架:

  1. 确定状态:站在一个节点的角度,思考解决以它为根的子树问题,需要哪些信息?这些信息往往需要包含“当前节点处于某种情况”下的结果。像本题,就需要知道“偷它”和“不偷它”两种情况下的最优解。
  2. 定义递归函数:设计一个递归函数,输入一个节点,返回我们需要的信息(通常是一个值或一个结构体/元组)。
  3. 推导转移方程:思考如何利用子节点的返回信息,计算出当前节点的信息。这是最核心的一步,需要仔细分析约束条件。
  4. 确定遍历顺序:绝大多数树形DP都采用后序遍历,因为需要先知道子问题的解,才能解决父问题。
  5. 处理递归基:明确空节点或叶子节点的返回结果。
  6. 获取最终答案:从根节点的递归返回值中,提取或计算出整个问题的答案。

最后,再分享一个面试小技巧:当面试官问出这道题时,在写出代码之前,最好先清晰地阐述你的状态定义和状态转移方程,并说明为什么选择后序遍历。这能展示你清晰的思维过程,比直接闷头写代码要加分得多。写完代码后,主动分析时间复杂度和空间复杂度,并提及可能的变种(如输出偷窃路径),更能体现你的技术深度。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/9 19:18:09

元空间性能优化测试:metaSpaceUsage工具深度解析与实战指南

元空间性能优化测试&#xff1a;metaSpaceUsage工具深度解析与实战指南 【免费下载链接】bishengjdk-test BiSheng JDK test scripts and test suite guide - common across all releases/versions 项目地址: https://gitcode.com/openeuler/bishengjdk-test 前往项目官…

作者头像 李华
网站建设 2026/7/9 19:17:43

5分钟掌握iFakeLocation:无需越狱的iOS虚拟定位终极指南

5分钟掌握iFakeLocation&#xff1a;无需越狱的iOS虚拟定位终极指南 【免费下载链接】iFakeLocation Simulate locations on iOS devices on Windows, Mac and Ubuntu. 项目地址: https://gitcode.com/gh_mirrors/if/iFakeLocation iFakeLocation是一款强大的跨平台开源…

作者头像 李华
网站建设 2026/7/9 19:16:34

TS2007FC与PIC18F4458的嵌入式音频处理方案解析

1. 项目背景与核心组件解析在嵌入式音频处理领域&#xff0c;如何选择合适的硬件平台和放大器方案一直是开发者面临的关键挑战。这次我们要探讨的是基于TS2007FC音频放大器与PIC18F4458微控制器的组合方案——这套搭配在消费级音频设备开发中展现出独特的性价比优势。TS2007FC是…

作者头像 李华
网站建设 2026/7/9 19:15:57

Marvell QLogic适配器性能调优:openeuler/qla2xxx驱动参数深度解析

Marvell QLogic适配器性能调优&#xff1a;openeuler/qla2xxx驱动参数深度解析 【免费下载链接】qla2xxx provide Marvell QLogic FC HBA adapter qla2xxx driver for openEuler LTS version. 项目地址: https://gitcode.com/openeuler/qla2xxx 前往项目官网免费下载&am…

作者头像 李华
网站建设 2026/7/9 19:12:38

基于CKKS全同态加密的Transformer模型隐私保护推理实践

1. 项目概述&#xff1a;当Transformer遇见全同态加密最近在折腾一个挺有意思的项目&#xff0c;核心就一句话&#xff1a;让Transformer模型在不解密的情况下&#xff0c;直接对加密数据进行推理。听起来有点科幻&#xff0c;对吧&#xff1f;但这就是全同态加密&#xff08;F…

作者头像 李华