Facebook Pixel

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 robbed
  • b = 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:

  1. We need to explore the entire tree to find the optimal solution
  2. We need to make decisions at each node based on information from its subtrees
  3. The problem has a recursive structure where the solution for a node depends on the solutions of its children
  4. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. What's the maximum money if we rob this node?
  2. 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 node
  • b = 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 child
  • lb = max money if we don't rob the left child
  • ra = max money if we rob the right child
  • rb = 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 get root.val + lb + rb

    • We take the current node's value
    • We must skip both children (can't rob them), so we add lb and rb
  • If we don't rob this node (b): We get max(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 Evaluator

Example 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:

  1. 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 be O(log n).
  2. 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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of these pictures shows the visit order of a depth-first search?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More