337. House Robber III
Problem Description
This is a house robbing problem where houses are arranged in a binary tree structure. The thief wants to maximize the amount of money stolen, but there's a constraint: if two houses that are directly connected (parent-child relationship) are robbed on the same night, the police will be alerted.
Given the root of a binary tree where each node contains a value representing the amount of money in that house, you need to find the maximum amount of money the thief can rob without triggering the alarm.
The key constraint is that you cannot rob both a parent node and its direct children. For example, if you rob a node, you cannot rob its left child or right child. However, you can rob its grandchildren since they are not directly connected.
The solution uses dynamic programming with a depth-first search (DFS) approach. For each node, it calculates two values:
- The maximum money if the current node is robbed (in which case its children cannot be robbed)
- The maximum money if the current node is not robbed (in which case its children can be robbed or not)
The dfs
function returns a tuple (a, b)
where:
a
= maximum money when the current node is robbedb
= maximum money when the current node is not robbed
For each node:
- If we rob it: we get
root.val + lb + rb
(current node's value plus the money from not robbing left and right children) - If we don't rob it: we get
max(la, lb) + max(ra, rb)
(the best choice from each subtree, whether to rob the children or not)
The final answer is the maximum of these two choices at the root level.
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- Yes: The problem involves a binary tree, which is a special type of graph where each node has at most two children and there are no cycles.
Is it a tree?
- Yes: The problem explicitly states that all houses form a binary tree structure, with one root entrance and each house having exactly one parent (except the root).
DFS
- Conclusion: Since we're dealing with a tree structure, the flowchart leads us directly to DFS (Depth-First Search).
The flowchart confirms that DFS is the appropriate algorithm for this problem. This makes sense because:
- We need to explore the entire tree to find the optimal solution
- We need to make decisions at each node based on information from its subtrees
- The problem has a recursive structure where the solution for a node depends on the solutions of its children
- DFS allows us to process nodes in a bottom-up manner, computing optimal values for children before their parents
In this specific problem, we use DFS with dynamic programming to track two states at each node:
- Maximum money when robbing the current node
- Maximum money when not robbing the current node
This combination of DFS traversal with state tracking allows us to efficiently solve the constrained optimization problem on the tree structure.
Intuition
When faced with this problem, we need to think about the choices available at each house (node) in the tree. For any given house, we have two options: rob it or don't rob it.
If we rob the current house, we get its money but we're forced to skip its direct children (to avoid alerting the police). However, we can still rob the grandchildren and beyond.
If we don't rob the current house, we're free to make the optimal choice for each child - we can rob them or not, whatever gives us more money.
This creates a recursive decision structure. For each node, we need to know:
- What's the maximum money if we rob this node?
- What's the maximum money if we don't rob this node?
The key insight is that we can compute these values bottom-up. Starting from the leaves and working our way to the root, we can calculate both scenarios for each node based on the already-computed values of its children.
For a leaf node, the answer is simple:
- If we rob it: we get its value
- If we don't rob it: we get 0
For any other node with children, we apply the constraint:
- If we rob it:
current_value + money_from_not_robbing_left_child + money_from_not_robbing_right_child
- If we don't rob it:
best_choice_from_left_subtree + best_choice_from_right_subtree
By tracking both possibilities (rob or don't rob) at each node and propagating these values up the tree, we can determine the optimal solution at the root. The final answer is simply the maximum of these two choices at the root level.
This approach naturally fits with DFS because we need to process children before their parents, making it perfect for a post-order traversal where we compute values on the way back up from recursive calls.
Solution Approach
The implementation uses a recursive DFS function that returns a tuple for each node, representing the two scenarios we identified in our intuition.
Let's break down the implementation:
1. Function Signature
def dfs(root: Optional[TreeNode]) -> (int, int):
The DFS function returns a tuple (a, b)
where:
a
= maximum money when robbing the current nodeb
= maximum money when NOT robbing the current node
2. Base Case
if root is None: return 0, 0
For a null node, both scenarios yield 0 money.
3. Recursive Calls
la, lb = dfs(root.left) ra, rb = dfs(root.right)
We recursively compute the values for left and right subtrees:
la
= max money if we rob the left childlb
= max money if we don't rob the left childra
= max money if we rob the right childrb
= max money if we don't rob the right child
4. Computing Current Node's Values
return root.val + lb + rb, max(la, lb) + max(ra, rb)
For the current node, we calculate:
-
If we rob this node (
a
): We getroot.val + lb + rb
- We take the current node's value
- We must skip both children (can't rob them), so we add
lb
andrb
-
If we don't rob this node (
b
): We getmax(la, lb) + max(ra, rb)
- We're free to choose the best option from each child
- For the left subtree: take the maximum of robbing or not robbing the left child
- For the right subtree: take the maximum of robbing or not robbing the right child
5. Final Result
return max(dfs(root))
At the root level, we return the maximum of the two scenarios - rob the root or don't rob the root.
The algorithm has a time complexity of O(n)
where n
is the number of nodes, as we visit each node exactly once. The space complexity is O(h)
where h
is the height of the tree, due to the recursion call stack.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example to illustrate the solution approach.
Consider this binary tree where each node shows the money amount:
3 / \ 2 3 \ \ 3 1
We'll use DFS to process nodes bottom-up, calculating for each node: (rob_it, don't_rob_it).
Step 1: Process the leaf nodes
-
Node with value 3 (bottom left):
- If we rob it: 3
- If we don't rob it: 0
- Returns: (3, 0)
-
Node with value 1 (bottom right):
- If we rob it: 1
- If we don't rob it: 0
- Returns: (1, 0)
Step 2: Process the middle level
-
Node with value 2 (left child of root):
- Left child is null: (0, 0)
- Right child returns: (3, 0)
- If we rob this node: 2 + 0 + 0 = 2
- If we don't rob this node: max(0, 0) + max(3, 0) = 0 + 3 = 3
- Returns: (2, 3)
-
Node with value 3 (right child of root):
- Left child is null: (0, 0)
- Right child returns: (1, 0)
- If we rob this node: 3 + 0 + 0 = 3
- If we don't rob this node: max(0, 0) + max(1, 0) = 0 + 1 = 1
- Returns: (3, 1)
Step 3: Process the root
- Root node with value 3:
- Left child returns: (2, 3)
- Right child returns: (3, 1)
- If we rob the root: 3 + 3 + 1 = 7 (root value + don't rob left + don't rob right)
- If we don't rob the root: max(2, 3) + max(3, 1) = 3 + 3 = 6
- Returns: (7, 6)
Final Answer: max(7, 6) = 7
The optimal strategy is to rob the root (3), skip its children, and rob the grandchildren (3 and 1), giving us 3 + 3 + 1 = 7.
Solution Implementation
1# Definition for a binary tree node.
2# class TreeNode:
3# def __init__(self, val=0, left=None, right=None):
4# self.val = val
5# self.left = left
6# self.right = right
7
8from typing import Optional, Tuple
9
10class Solution:
11 def rob(self, root: Optional[TreeNode]) -> int:
12 """
13 House Robber III problem - find maximum amount that can be robbed
14 from a binary tree where no two directly connected nodes can be robbed.
15
16 Args:
17 root: Root node of the binary tree
18
19 Returns:
20 Maximum amount that can be robbed
21 """
22
23 def dfs(node: Optional[TreeNode]) -> Tuple[int, int]:
24 """
25 Perform depth-first search to calculate maximum robbery amount.
26
27 Args:
28 node: Current node being processed
29
30 Returns:
31 Tuple of (rob_current, skip_current) where:
32 - rob_current: max amount if we rob the current node
33 - skip_current: max amount if we skip the current node
34 """
35 # Base case: empty node contributes nothing
36 if node is None:
37 return 0, 0
38
39 # Recursively process left and right subtrees
40 left_rob, left_skip = dfs(node.left)
41 right_rob, right_skip = dfs(node.right)
42
43 # If we rob current node, we must skip both children
44 rob_current = node.val + left_skip + right_skip
45
46 # If we skip current node, we can choose to rob or skip each child
47 # independently based on which gives more money
48 skip_current = max(left_rob, left_skip) + max(right_rob, right_skip)
49
50 return rob_current, skip_current
51
52 # Return the maximum of robbing or skipping the root
53 return max(dfs(root))
54
1/**
2 * Definition for a binary tree node.
3 * public class TreeNode {
4 * int val;
5 * TreeNode left;
6 * TreeNode right;
7 * TreeNode() {}
8 * TreeNode(int val) { this.val = val; }
9 * TreeNode(int val, TreeNode left, TreeNode right) {
10 * this.val = val;
11 * this.left = left;
12 * this.right = right;
13 * }
14 * }
15 */
16class Solution {
17 /**
18 * House Robber III - Maximum money that can be robbed from binary tree houses
19 * where directly connected houses cannot be robbed together
20 *
21 * @param root The root of the binary tree
22 * @return Maximum amount of money that can be robbed
23 */
24 public int rob(TreeNode root) {
25 // Get the maximum values for both scenarios (rob root vs don't rob root)
26 int[] result = dfs(root);
27
28 // Return the maximum between robbing root (result[0]) and not robbing root (result[1])
29 return Math.max(result[0], result[1]);
30 }
31
32 /**
33 * DFS helper method that returns optimal values for current node
34 *
35 * @param root Current node being processed
36 * @return Array where [0] = max money if current node is robbed,
37 * [1] = max money if current node is not robbed
38 */
39 private int[] dfs(TreeNode root) {
40 // Base case: null node contributes 0 to both scenarios
41 if (root == null) {
42 return new int[2];
43 }
44
45 // Recursively calculate optimal values for left and right subtrees
46 int[] leftSubtree = dfs(root.left);
47 int[] rightSubtree = dfs(root.right);
48
49 // Calculate optimal values for current node
50 // robCurrent: If we rob current node, we cannot rob its children
51 int robCurrent = root.val + leftSubtree[1] + rightSubtree[1];
52
53 // skipCurrent: If we skip current node, we can choose the max from each child
54 int skipCurrent = Math.max(leftSubtree[0], leftSubtree[1]) +
55 Math.max(rightSubtree[0], rightSubtree[1]);
56
57 return new int[] {robCurrent, skipCurrent};
58 }
59}
60
1/**
2 * Definition for a binary tree node.
3 * struct TreeNode {
4 * int val;
5 * TreeNode *left;
6 * TreeNode *right;
7 * TreeNode() : val(0), left(nullptr), right(nullptr) {}
8 * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
9 * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
10 * };
11 */
12class Solution {
13public:
14 int rob(TreeNode* root) {
15 // Use DFS to traverse the tree and calculate maximum value
16 // Returns pair: (max value when robbing current node, max value when not robbing current node)
17 function<pair<int, int>(TreeNode*)> dfs = [&](TreeNode* node) -> pair<int, int> {
18 // Base case: empty node returns (0, 0)
19 if (!node) {
20 return make_pair(0, 0);
21 }
22
23 // Recursively process left and right subtrees
24 auto [leftWithRob, leftWithoutRob] = dfs(node->left);
25 auto [rightWithRob, rightWithoutRob] = dfs(node->right);
26
27 // Calculate two scenarios:
28 // 1. Rob current node: add current value + max from children when they're not robbed
29 int robCurrent = node->val + leftWithoutRob + rightWithoutRob;
30
31 // 2. Don't rob current node: take maximum from children (either robbed or not robbed)
32 int skipCurrent = max(leftWithRob, leftWithoutRob) + max(rightWithRob, rightWithoutRob);
33
34 return make_pair(robCurrent, skipCurrent);
35 };
36
37 // Get the result for root node
38 auto [robRoot, skipRoot] = dfs(root);
39
40 // Return the maximum value between robbing or not robbing the root
41 return max(robRoot, skipRoot);
42 }
43};
44
1/**
2 * Definition for a binary tree node.
3 * class TreeNode {
4 * val: number
5 * left: TreeNode | null
6 * right: TreeNode | null
7 * constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
8 * this.val = (val===undefined ? 0 : val)
9 * this.left = (left===undefined ? null : left)
10 * this.right = (right===undefined ? null : right)
11 * }
12 * }
13 */
14
15/**
16 * House Robber III - Rob houses arranged in a binary tree without robbing adjacent nodes
17 * @param root - Root node of the binary tree
18 * @returns Maximum amount that can be robbed
19 */
20function rob(root: TreeNode | null): number {
21 /**
22 * DFS helper function that returns two values for each node:
23 * [0] - Maximum amount if current node is robbed
24 * [1] - Maximum amount if current node is not robbed
25 */
26 const dfs = (node: TreeNode | null): [number, number] => {
27 // Base case: empty node contributes nothing
28 if (!node) {
29 return [0, 0];
30 }
31
32 // Recursively process left and right subtrees
33 const [leftRobbed, leftNotRobbed] = dfs(node.left);
34 const [rightRobbed, rightNotRobbed] = dfs(node.right);
35
36 // If we rob current node, we cannot rob its children
37 const robCurrent = node.val + leftNotRobbed + rightNotRobbed;
38
39 // If we don't rob current node, we can choose to rob or not rob its children
40 const skipCurrent = Math.max(leftRobbed, leftNotRobbed) + Math.max(rightRobbed, rightNotRobbed);
41
42 return [robCurrent, skipCurrent];
43 };
44
45 // Return the maximum of robbing or not robbing the root
46 const [robRoot, skipRoot] = dfs(root);
47 return Math.max(robRoot, skipRoot);
48}
49
Time and Space Complexity
Time Complexity: O(n)
where n
is the number of nodes in the binary tree.
The algorithm performs a depth-first search (DFS) traversal of the binary tree. Each node is visited exactly once during the traversal. At each node, the algorithm performs constant time operations:
- Recursive calls to left and right children
- Computing
root.val + lb + rb
- Computing
max(la, lb) + max(ra, rb)
- Returning a tuple of two values
Since we visit each of the n
nodes exactly once and perform O(1)
work at each node, the total time complexity is O(n)
.
Space Complexity: O(h)
where h
is the height of the binary tree.
The space complexity is determined by:
- Recursive call stack: The maximum depth of the recursion equals the height of the tree. In the worst case (skewed tree), this could be
O(n)
, while in the best case (balanced tree), it would beO(log n)
. - Auxiliary space: Each recursive call returns a tuple of two integers, which requires
O(1)
space per call.
Therefore, the space complexity is O(h)
, which ranges from O(log n)
for a balanced tree to O(n)
for a completely unbalanced tree.
Common Pitfalls
1. Greedy Approach - Selecting Maximum Values Without Considering Dependencies
A common mistake is trying to use a greedy approach by simply selecting nodes with the highest values, which fails because the constraint creates complex dependencies between nodes.
Incorrect Approach:
def rob_greedy(root):
# WRONG: Just picking high-value nodes greedily
if not root:
return 0
# This might pick root even if children have much higher values
if root.val > (root.left.val if root.left else 0) + (root.right.val if root.right else 0):
return root.val + rob_greedy(root.left.left if root.left else None) + ...
else:
return rob_greedy(root.left) + rob_greedy(root.right)
Why it fails: Consider a tree where root = 1, left child = 100, right child = 100. The greedy approach might pick the children (total: 200), but what if each child has children worth 1000? We can't rob those anymore, missing out on potentially 4000 value.
2. Not Considering Both Scenarios for Each Node
Another pitfall is only tracking one value per node instead of maintaining both "rob" and "skip" scenarios.
Incorrect Approach:
def dfs(node):
if not node:
return 0
# WRONG: Only returns single value, loses information
rob_current = node.val + dfs(node.left.left) + dfs(node.left.right) + ...
skip_current = dfs(node.left) + dfs(node.right)
return max(rob_current, skip_current) # Lost the separate scenarios!
Why it fails: When we return only the maximum, the parent node loses crucial information. The parent needs to know specifically how much we get when robbing vs. not robbing the child to make its own decision correctly.
3. Incorrect Constraint Interpretation
Some might misunderstand the constraint and think you can't rob nodes at the same level or that you must alternate levels.
Incorrect Understanding:
def rob_alternating_levels(root):
# WRONG: Assumes we must rob alternate levels
odd_sum = sum_odd_levels(root)
even_sum = sum_even_levels(root)
return max(odd_sum, even_sum)
Why it fails: The constraint is about direct parent-child connections, not levels. You can rob two siblings (same level) or skip multiple levels if beneficial.
4. Memoization with Incorrect State
When trying to optimize with memoization, using just the node as the key without considering the rob/skip state.
Incorrect Memoization:
memo = {}
def dfs(node):
if node in memo: # WRONG: Need to track rob/skip state too
return memo[node]
# ... calculation
memo[node] = result
return result
Solution: The correct approach already handles this by returning a tuple that inherently tracks both states. If using explicit memoization, you'd need to cache both scenarios separately or use the tuple approach as shown in the original solution.
5. Edge Case: Single Node or Skewed Trees
Not handling edge cases properly, especially when the tree degenerates into a linked list (completely skewed).
The Correct Solution Handles This: By returning a tuple and considering both scenarios at each node, the algorithm automatically handles all tree shapes correctly, whether balanced, skewed, or single nodes.
Which of these pictures shows the visit order of a depth-first search?
Recommended Readings
Everything About Trees A tree is a type of graph data structure composed of nodes and edges Its main properties are It is acyclic doesn't contain any cycles There exists a path from the root to any node Has N 1 edges where N is the number of nodes in the tree and
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Want a Structured Path to Master System Design Too? Donât Miss This!