打家劫舍 III 图解题解
偷父亲还是偷孩子?得先把最底层的账算清楚再往上汇报。
这棵树就像一栋老式联排别墅:楼上楼下直接打通,父子两层不能同晚都出事。偷不偷这一层,得先看清楚下面两层怎么选才最合算——所以要先把最底层的账算完,一层层往上汇总。每个节点回传两个数:「不偷我能赚多少」和「偷我能赚多少」,上一层根据这两个数各算各的,不再往下重查。
这道题到底在问什么
- 输入
- 根为 3 的 11 节点树
- 输出
- 18
最优解:为什么这么做
一句话答案:LeetCode 337 打家劫舍 III 是经典树形 DP:后序遍历让每个节点向父亲返回一对值——偷它的最大金额和不偷它的最大金额。偷它等于自身金额加左右孩子的不偷值;不偷它等于左右孩子各自两值取大再相加。根节点两值取大即答案,每个节点算一次,时间 O(n)、空间 O(h)。
树上打家劫舍在问什么
房子排成一棵二叉树,每个节点是一间房的金额,规则是不能同时偷有直接父子关系的两间房,求不触发报警能偷到的最大总额。注意约束只发生在相邻的父子之间:祖孙可以同偷,兄弟也可以同偷。它是一维打家劫舍(数组上不能偷相邻两间)从线性搬到树上的版本。
为什么子树只返回一个最大值不够用
自然的想法是写一个函数返回「这棵子树能偷到的最大金额」,父亲直接拿孩子的结果相加。但这个单一数值缺了关键信息:孩子的最优方案里,到底偷没偷孩子自己这个节点。父亲想偷自己时,必须保证两个孩子都没被偷,可孩子只报一个数字,父亲无从判断。硬要绕过去,就得去比较「偷根加上四个孙子的答案」和「不偷根加上两个孩子的答案」,同一棵子树被反复求解,不加记忆化会指数级爆炸。
症结在于孩子的答案依赖父亲的决策。破局的办法不是让父亲追问,而是让孩子把两种情况都算好、一起交上来。
状态怎么定义:每个节点一对值
给每个节点定义两个量:rob 表示偷这个节点时它整棵子树能得的最大金额,skip 表示不偷这个节点时的最大金额。转移只有两行:rob 等于自身金额加左孩子的 skip 加右孩子的 skip——偷了父亲,两个孩子被禁止偷,只能取它们的不偷档;skip 等于左孩子两值取大加右孩子两值取大——父亲不偷,孩子不受任何约束,各自挑对自己最优的档位。注意不偷父亲并不等于孩子必须偷,而是自由选择取较大者。
为什么这样递推是对的,为什么必须后序
正确性的依据是无后效性:父子间的约束只隔一层,一个节点的两个值只由左右孩子的两个值决定,与更上层的祖先怎么选完全无关。所以每棵子树的答案可以独立算好再上交,既不会漏也不会互相干扰。叶子是天然的边界:偷它等于自身金额,不偷它等于 0。
父亲要用到两个孩子的结果,就必须先算完孩子再算父亲——这正是后序遍历(左、右、根)的顺序。整棵树自底向上算到根,根节点 rob 与 skip 的较大者就是答案。
复杂度与常见错误
每个节点在后序中恰好被处理一次,做常数次加法和取大,时间 O(n);递归栈深等于树高 h,平衡树 O(log n),退化成链最坏 O(n)。
高频错误恰好对应两条转移各一个:偷父亲时误用了孩子的偷值——父子相邻不能同偷,必须配孩子的不偷档;不偷父亲时只取孩子的不偷值——孩子明明自由,应取两档中的较大者。第三个坑是函数只返回单个最大值,父亲缺料,整套递推立不起来。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记住这一对转移:偷父就逼孩子不偷,不偷父则孩子放开各取较大。自底向上算到根,取较大者就是答案。
- 4后序遍历:先把左子树彻底算完,再右子树,最后才轮到父亲。每个节点会留下一条记录:它的 [偷它, 不偷它] 两个值。叶子最简单——偷=自身金额,不偷=0。
- 5轮到节点 1(橙色)。它是叶子,没有孩子。偷它就拿自身金额,不偷它拿 0,所以左右孩子贡献都按 [0, 0] 处理。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 6节点 1 算完(转绿)。偷它 = 自身 1 + 左不偷 0 + 右不偷 0 = 1(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 0, 右不偷 0) = 0(孩子各取较大)。返回 [1, 0],单看本节点更划算的是「偷它」。
- 7轮到节点 2(橙色)。它是叶子,没有孩子。偷它就拿自身金额,不偷它拿 0,所以左右孩子贡献都按 [0, 0] 处理。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 8节点 2 算完(转绿)。偷它 = 自身 2 + 左不偷 0 + 右不偷 0 = 2(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 0, 右不偷 0) = 0(孩子各取较大)。返回 [2, 0],单看本节点更划算的是「偷它」。
- 9轮到节点 1(橙色)。它是叶子,没有孩子。偷它就拿自身金额,不偷它拿 0,所以左右孩子贡献都按 [0, 0] 处理。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 10节点 1 算完(转绿)。偷它 = 自身 1 + 左不偷 0 + 右不偷 0 = 1(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 0, 右不偷 0) = 0(孩子各取较大)。返回 [1, 0],单看本节点更划算的是「偷它」。
- 11轮到节点 3(橙色)。先读它两个孩子已经算好的返回值:左孩子 2 返回 [偷=2, 不偷=0];右孩子 1 返回 [偷=1, 不偷=0]。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 12节点 3 算完(转绿)。偷它 = 自身 3 + 左不偷 0 + 右不偷 0 = 3(偷它就逼左右孩子不偷)。不偷它 = max(左偷 2, 左不偷 0) + max(右偷 1, 右不偷 0) = 3(孩子各取较大)。返回 [3, 3],单看本节点更划算的是「偷它」。
- 13轮到节点 4(橙色)。先读它两个孩子已经算好的返回值:左孩子 1 返回 [偷=1, 不偷=0];右孩子 3 返回 [偷=3, 不偷=3]。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 14节点 4 算完(转绿)。偷它 = 自身 4 + 左不偷 0 + 右不偷 3 = 7(偷它就逼左右孩子不偷)。不偷它 = max(左偷 1, 左不偷 0) + max(右偷 3, 右不偷 3) = 4(孩子各取较大)。返回 [7, 4],单看本节点更划算的是「偷它」。
- 15轮到节点 6(橙色)。它是叶子,没有孩子。偷它就拿自身金额,不偷它拿 0,所以左右孩子贡献都按 [0, 0] 处理。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 16节点 6 算完(转绿)。偷它 = 自身 6 + 左不偷 0 + 右不偷 0 = 6(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 0, 右不偷 0) = 0(孩子各取较大)。返回 [6, 0],单看本节点更划算的是「偷它」。
- 17轮到节点 4(橙色)。先读它两个孩子已经算好的返回值:左孩子为空 → [0, 0];右孩子 6 返回 [偷=6, 不偷=0]。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 18节点 4 算完(转绿)。偷它 = 自身 4 + 左不偷 0 + 右不偷 0 = 4(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 6, 右不偷 0) = 6(孩子各取较大)。返回 [4, 6],单看本节点更划算的是「不偷它」。
- 19轮到节点 1(橙色)。先读它两个孩子已经算好的返回值:左孩子为空 → [0, 0];右孩子 4 返回 [偷=4, 不偷=6]。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 20节点 1 算完(转绿)。偷它 = 自身 1 + 左不偷 0 + 右不偷 6 = 7(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 4, 右不偷 6) = 6(孩子各取较大)。返回 [7, 6],单看本节点更划算的是「偷它」。
- 21轮到节点 5(橙色)。先读它两个孩子已经算好的返回值:左孩子为空 → [0, 0];右孩子 1 返回 [偷=7, 不偷=6]。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 22节点 5 算完(转绿)。偷它 = 自身 5 + 左不偷 0 + 右不偷 6 = 11(偷它就逼左右孩子不偷)。不偷它 = max(左偷 0, 左不偷 0) + max(右偷 7, 右不偷 6) = 7(孩子各取较大)。返回 [11, 7],单看本节点更划算的是「偷它」。
- 23轮到节点 3(橙色)。先读它两个孩子已经算好的返回值:左孩子 4 返回 [偷=7, 不偷=4];右孩子 5 返回 [偷=11, 不偷=7]。 这些值都是孩子在「它们自己那一帧」算好返回上来的——后序遍历保证算父亲前孩子一定先算完。
- 24节点 3 算完(转绿)。偷它 = 自身 3 + 左不偷 4 + 右不偷 7 = 14(偷它就逼左右孩子不偷)。不偷它 = max(左偷 7, 左不偷 4) + max(右偷 11, 右不偷 7) = 18(孩子各取较大)。返回 [14, 18],单看本节点更划算的是「不偷它」。
- 25全部算完,回到根节点 3:它返回 [偷=14, 不偷=18]。答案 = max(14, 18) = 18。这一棵树不偷根、把额度让给下面的孩子更划算——这就是逐节点权衡「偷它 vs 不偷它」自底向上汇总的结果。
⚠️ 容易写错的地方
✗ 错:偷父时用了孩子的「偷」值
✓ 对:偷父必须配孩子的「不偷」值 L[1]、R[1]
父子相邻不能同偷,偷了父就只能拿孩子不偷的那一档
✗ 错:不偷父时只取孩子的「不偷」
✓ 对:不偷父,孩子自由 → 各取 max(偷, 不偷)
不偷父对孩子没有约束,该偷就偷哪个大取哪个
✗ 错:只返回一个值(如只返回偷它)
✓ 对:必须返回两个状态 [偷, 不偷]
父亲两种选择都要用到孩子的两种值,缺一个父亲就算不全
完整代码(Python / C++ / Java)
Python
class Solution:
def rob(self, root):
def dfs(node):
if not node: return (0, 0) # (偷, 不偷)
L = dfs(node.left)
R = dfs(node.right)
rob_it = node.val + L[1] + R[1] # 偷它→孩子不偷
skip_it = max(L) + max(R) # 不偷它→孩子各取大
return (rob_it, skip_it)
return max(dfs(root))C++
class Solution {
pair<int,int> dfs(TreeNode* node){
if(!node) return {0, 0}; // {偷, 不偷}
auto L = dfs(node->left);
auto R = dfs(node->right);
int robIt = node->val + L.second + R.second;
int skipIt = max(L.first,L.second) + max(R.first,R.second);
return {robIt, skipIt};
}
public:
int rob(TreeNode* root){
auto p = dfs(root);
return max(p.first, p.second);
}
};Java
class Solution {
public int rob(TreeNode root) {
int[] r = dfs(root);
return Math.max(r[0], r[1]);
}
// 返回长度 2 的数组:r[0]=偷它,r[1]=不偷它
private int[] dfs(TreeNode node) {
if (node == null) return new int[]{0, 0};
int[] L = dfs(node.left);
int[] R = dfs(node.right);
int robIt = node.val + L[1] + R[1]; // 偷它→孩子不偷
int skipIt = Math.max(L[0], L[1]) + Math.max(R[0], R[1]);
return new int[]{robIt, skipIt};
}
}复杂度
时间
O(n)
每个节点后序访问一次,常数次运算
空间
O(h)
递归栈深度 = 树高 h,最坏(退化成链)O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 打家劫舍 III 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么必须返回两个值,而不是只返回一个最大值?+
父亲在「偷自己」时要用孩子的不偷值,在「不偷自己」时要用孩子的较大值——两种情况都要,所以孩子必须同时把「偷」和「不偷」都返回上来。只返一个,父亲就缺料。
它和一维「打家劫舍」(数组版)有什么联系?+
完全同构。数组版每个位置也有「偷/不偷」两态:偷它=不偷前一个+自身,不偷它=max(前一个的偷,不偷)。树版只是把「前一个」换成「左右两个孩子」,相邻约束从线性变成父子。
为什么用后序遍历?+
父亲的两个值都依赖左右孩子的两个值,必须先算完两个孩子才能算父亲,这正是后序(左→右→根)。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 打家劫舍 III 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。