1. 题目解析与问题建模
今天我们来解决LeetCode上经典的二叉树打家劫舍问题(337题)。这个问题将传统的动态规划问题移植到了二叉树结构上,增加了不少趣味性。题目描述是这样的:一个小偷要在一棵二叉树的房屋结构中行窃,如果两个直接相连的房屋(即父子节点)在同一天晚上被打劫,系统就会报警。我们的任务是计算小偷在不触发警报的情况下能够盗取的最大金额。
1.1 问题特点分析
这个问题有几个关键特征需要注意:
- 房屋排列成二叉树结构,每个节点代表一个房屋,节点值代表该房屋的金额
- 不能同时抢劫相邻的父子节点,否则会触发警报
- 需要计算所有可能的抢劫组合,找出金额最大的方案
与线性结构的打家劫舍问题相比,二叉树结构带来了新的挑战。在线性结构中,我们只需要考虑前一个节点是否被抢劫,而在二叉树中,我们需要同时考虑左右子节点的情况。
1.2 示例分析
让我们仔细看看题目给出的两个示例:
示例1:
输入:root = [3,2,3,null,3,null,1]
code复制 3
/ \
2 3
\ \
3 1
最优解:3 + 3 + 1 = 7
解释:选择抢劫根节点(3)、右子节点(3)和右子节点的右子节点(1)
示例2:
输入:root = [3,4,5,1,3,null,1]
code复制 3
/ \
4 5
/ \ \
1 3 1
最优解:4 + 5 = 9
解释:选择抢劫左子节点(4)和右子节点(5)
从这两个例子可以看出,最优解有时需要跳过某些节点,有时又需要连续选择多个节点,关键在于如何找到不违反规则的最大金额组合。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解题思路与算法设计
2.1 动态规划思路
这个问题非常适合用动态规划来解决。我们需要为每个节点定义两种状态:
- 选择当前节点时的最大收益
- 不选择当前节点时的最大收益
对于二叉树中的每个节点,我们可以递归地计算这两种状态的值,然后根据子节点的状态来决定当前节点的最优解。
2.2 状态转移方程
定义:
- dp[0]:不选择当前节点时的最大收益
- dp[1]:选择当前节点时的最大收益
状态转移关系如下:
- 如果选择当前节点,那么不能选择其直接子节点:
dp[1] = 左子节点不被选的最大收益 + 右子节点不被选的最大收益 + 当前节点值 - 如果不选择当前节点,那么子节点可选可不选(取最大值):
dp[0] = max(左子节点被选的最大收益, 左子节点不被选的最大收益)
+ max(右子节点被选的最大收益, 右子节点不被选的最大收益)
2.3 递归实现方案
基于上述思路,我们可以采用后序遍历(DFS)的方式来实现:
- 先递归处理左子树,得到左子节点的两种状态值
- 再递归处理右子树,得到右子节点的两种状态值
- 最后根据左右子树的状态值计算当前节点的两种状态值
这种后序遍历的方式确保了我们在处理父节点时,其子节点的状态已经计算完成。
3. 代码实现与详细解析
3.1 数据结构定义
首先,我们来看二叉树的节点定义:
cpp复制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) {}
};
3.2 核心算法实现
下面是完整的解决方案代码:
cpp复制class Solution {
public:
// 辅助函数:返回数组中较大的值
int max_vector(vector<int>& nums) {
return nums[0] > nums[1] ? nums[0] : nums[1];
}
// 递归函数:返回当前节点的两种状态值
vector<int> dfs(TreeNode* root) {
if (!root) return {0, 0}; // 空节点返回{0,0}
// 递归处理左右子树
vector<int> left = dfs(root->left);
vector<int> right = dfs(root->right);
// 计算当前节点的两种状态值
int selected = left[1] + right[1] + root->val; // 选择当前节点
int not_selected = max(left[0], left[1]) + max(right[0], right[1]); // 不选当前节点
return {selected, not_selected};
}
int rob(TreeNode* root) {
vector<int> result = dfs(root);
return max_vector(result);
}
};
3.3 代码逐行解析
-
max_vector函数:简单的辅助函数,用于比较并返回两个数中的较大值。 -
dfs递归函数:- 参数:当前节点指针
- 返回值:包含两个元素的vector,第一个元素表示选择当前节点的最大收益,第二个表示不选择当前节点的最大收益
- 递归终止条件:当节点为空时,返回{0,0},因为空节点没有价值
- 递归处理左右子树:分别获取左右子树的两种状态值
- 计算当前节点的两种状态:
- 选择当前节点:
left[1] + right[1] + root->val(不能选择子节点) - 不选择当前节点:
max(left[0], left[1]) + max(right[0], right[1])(子节点可选可不选)
- 选择当前节点:
-
rob主函数:- 调用dfs函数获取根节点的两种状态值
- 返回两种状态中的较大值,即为最终结果
4. 算法复杂度分析
4.1 时间复杂度
算法对每个节点只访问一次,因此时间复杂度为O(n),其中n是二叉树的节点数量。这是最优的时间复杂度,因为我们至少需要访问每个节点一次才能计算其价值。
4.2 空间复杂度
空间复杂度主要来自递归调用栈的深度。在最坏情况下(二叉树退化为链表),递归深度为O(n);在平衡二叉树情况下,递归深度为O(logn)。因此空间复杂度为O(h),其中h是二叉树的高度。
5. 边界条件与特殊情况处理
5.1 空树处理
当输入为空树(root=nullptr)时,算法会直接返回0,这是符合预期的,因为空树没有任何价值可偷。
5.2 单节点树
对于只有一个节点的树,算法会正确返回该节点的值,因为这是唯一可能的选择。
5.3 完全二叉树
对于完全二叉树,算法也能正确处理,因为递归会均匀地分布在左右子树中。
5.4 退化的链表树
当二叉树退化为链表时(所有节点都只有左子节点或只有右子节点),算法仍然有效,只是递归深度会达到最大。
6. 算法优化与改进方向
6.1 记忆化优化
虽然当前算法的时间复杂度已经是最优的O(n),但在实际实现中,我们可以考虑使用记忆化技术来避免重复计算。不过在这个问题中,由于每个节点只被访问一次,记忆化的收益不大。
6.2 迭代实现
递归实现虽然直观,但可能会因为递归深度过大导致栈溢出。我们可以考虑使用迭代的方式实现后序遍历,使用显式的栈结构来模拟递归过程。
6.3 空间优化
当前实现中,每次递归调用都会创建新的vector对象,这在C++中可能会带来一定的开销。我们可以考虑使用引用或指针来传递结果,减少内存分配的开销。
7. 实际应用与扩展思考
7.1 实际问题映射
这个问题虽然以"打家劫舍"为背景,但其核心思想可以应用于许多实际问题:
- 资源分配问题:在树形结构中分配有限资源,某些相邻节点不能同时被选中
- 任务调度问题:某些任务有依赖关系,不能同时执行
- 网络路由选择:在某些网络拓扑中选择最优路径
7.2 问题变种思考
我们可以考虑这个问题的几种变种:
- 如果允许抢劫"祖父节点"和"孙子节点",但不允许父子节点和兄弟节点同时被抢劫,该如何修改算法?
- 如果不是二叉树,而是普通的树或多叉树,算法该如何调整?
- 如果每个节点有一个被发现的概率,抢劫路径上所有节点的被发现概率会累积,如何计算最优解?
7.3 与其他算法的对比
这个问题也可以尝试用其他方法解决:
- 贪心算法:每次选择价值最大的节点,跳过其子节点。这种方法通常不能得到最优解。
- 暴力枚举:枚举所有可能的组合,检查是否合法并计算最大值。时间复杂度为O(2^n),不实用。
- 记忆化搜索:与我们的动态规划方法类似,但实现方式略有不同。
相比之下,我们采用的动态规划方法在时间复杂度和空间复杂度上都是最优的。
8. 常见错误与调试技巧
8.1 常见实现错误
在实现这个算法时,容易犯以下几个错误:
- 混淆状态定义:把dp[0]和dp[1]的含义弄反
- 错误的状态转移:在选择当前节点时,错误地考虑了子节点的可选状态
- 忽略空节点处理:忘记处理root为nullptr的情况
- 重复计算:没有利用动态规划的思想,导致重复计算子问题
8.2 调试建议
调试这类递归算法时,可以:
- 打印递归调用树,观察计算顺序
- 在小规模测试用例上手动模拟算法执行过程
- 添加详细的日志输出,记录每个节点的计算结果
- 使用可视化工具观察二叉树的实际结构
8.3 测试用例设计
为了验证算法的正确性,应该设计多种测试用例:
- 空树
- 单节点树
- 完全二叉树
- 退化的链表树
- 随机生成的大型树
- 题目给出的示例
9. 个人实践心得
在实际编码实现这个算法时,我有几点深刻体会:
首先,明确状态定义是解决问题的关键。在这个问题中,为每个节点定义"选"和"不选"两种状态,并清晰地描述它们之间的转移关系,是解题的核心。一开始我尝试用单一状态来表示每个节点的最优解,发现无法处理相邻节点的约束条件。
其次,后序遍历的顺序非常重要。因为我们需要先知道子节点的状态才能计算父节点的状态,所以必须采用后序遍历。我曾经尝试过前序遍历,结果发现无法正确传递子节点的信息。
另外,处理边界条件要小心。特别是空节点的处理,返回{0,0}看似简单,但确保了递归的终止和正确性。在实际编码中,我一开始忘记处理空节点的情况,导致程序崩溃。
最后,这个问题的解法展示了动态规划思想的强大之处。通过将原问题分解为子问题,并存储子问题的解,我们避免了重复计算,大大提高了效率。这种思想在解决许多树形结构的问题时都非常有用。
