Introduction to the House Robber Problem
The House Robber problem is one of the most iconic dynamic programming challenges you will encounter in coding interviews and competitive programming. It elegantly demonstrates how a seemingly complex decision-making problem can be broken down into smaller overlapping subproblems. In this tutorial, we will walk through the problem from a naive recursive solution all the way to an optimized constant-space dynamic programming approach in Python.
What Is the House Robber Problem?
Imagine a street lined with houses, each containing a certain amount of money. You are a robber planning to rob houses along this street. However, adjacent houses have connected security systems, and if two adjacent houses are robbed on the same night, the alarm will trigger and alert the police. Given a list of non-negative integers representing the amount of money in each house, your goal is to determine the maximum amount of money you can rob tonight without alerting the authorities.
Formally, given an array nums of length n, find the maximum sum of a subsequence where no two selected elements are adjacent.
Why It Matters
The House Robber problem matters because it teaches the foundational principles of dynamic programming in a digestible way. Mastering it helps you understand:
- How to identify overlapping subproblems.
- How to formulate a recurrence relation from a problem statement.
- How to transition from exponential brute force to polynomial time.
- How to optimize space complexity by recognizing that only a few previous states are needed.
These concepts transfer directly to harder problems such as the Coin Change problem, the Knapsack problem, and various pathfinding challenges on grids.
Understanding the Recurrence Relation
The key insight is that for every house, you have exactly two choices: rob it or skip it. If you rob the current house, you cannot rob the previous one, so your total becomes the current house's value plus the maximum loot from two houses before. If you skip the current house, your total remains the maximum loot up to the previous house.
This leads to the recurrence:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
Where dp[i] represents the maximum amount that can be robbed from the first i+1 houses. The base cases are dp[0] = nums[0] and dp[1] = max(nums[0], nums[1]).
Step 1: Brute Force Recursive Solution
The most intuitive approach is to recursively explore every possible combination of robbing or skipping each house. While correct, this solution has an exponential time complexity of O(2^n) because it recomputes the same subproblems repeatedly.
def rob_recursive(nums):
def helper(i):
if i < 0:
return 0
# Either rob this house and skip the previous, or skip this house
rob_current = nums[i] + helper(i - 2)
skip_current = helper(i - 1)
return max(rob_current, skip_current)
return helper(len(nums) - 1)
# Example usage
houses = [2, 7, 9, 3, 1]
print(rob_recursive(houses)) # Output: 12
For the input [2, 7, 9, 3, 1], the optimal selection is robbing houses at indices 0, 2, and 4, yielding 2 + 9 + 1 = 12.
Step 2: Top-Down Memoization
To eliminate redundant computations, we can cache the results of subproblems using a dictionary or a list. This technique, known as memoization, reduces the time complexity to O(n) while keeping the recursive structure intact.
def rob_memo(nums):
memo = {}
def helper(i):
if i < 0:
return 0
if i in memo:
return memo[i]
rob_current = nums[i] + helper(i - 2)
skip_current = helper(i - 1)
memo[i] = max(rob_current, skip_current)
return memo[i]
return helper(len(nums) - 1)
houses = [2, 7, 9, 3, 1]
print(rob_memo(houses)) # Output: 12
This version is significantly faster for larger inputs, but it still consumes O(n) stack space due to recursion, which can cause a RecursionError on very large inputs.
Step 3: Bottom-Up Tabulation
To avoid recursion entirely, we can build the solution iteratively from the ground up. We create a dp array where each entry is computed using previously stored values. This approach has O(n) time and O(n) space complexity.
def rob_tabulation(nums):
n = len(nums)
if n == 0:
return 0
if n == 1:
return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
return dp[-1]
houses = [2, 7, 9, 3, 1]
print(rob_tabulation(houses)) # Output: 12
Notice how we handle edge cases explicitly: an empty list returns 0, and a single-element list returns that element. These guards prevent index errors and make the function robust.
Step 4: Space-Optimized Solution
Looking closely at the recurrence, we only ever need the previous two values to compute the current one. This means we can replace the entire dp array with two variables, reducing the space complexity to O(1) while maintaining O(n) time complexity.
def rob_optimized(nums):
prev2 = 0 # dp[i-2]
prev1 = 0 # dp[i-1]
for num in nums:
current = max(prev1, prev2 + num)
prev2 = prev1
prev1 = current
return prev1
houses = [2, 7, 9, 3, 1]
print(rob_optimized(houses)) # Output: 12
This is the most efficient version of the solution. It is concise, easy to reason about, and performs well even on large inputs. The variables prev1 and prev2 slide forward through the array, always holding the optimal loot up to the previous house and the house before that.
Handling Edge Cases
A robust solution must account for edge cases that often appear in interviews and automated testing platforms. Consider the following scenarios:
- An empty list
[]should return0. - A single house
[5]should return5. - Two houses
[3, 5]should return5, the larger of the two. - All houses with the same value
[4, 4, 4, 4]should return8. - Strictly increasing values
[1, 2, 3, 4, 5]should return9.
The space-optimized solution handles all of these naturally because prev1 and prev2 start at zero, and the loop gracefully processes any length of input.
Testing Your Solution
Writing tests ensures your implementation behaves correctly across a variety of inputs. Here is a simple test suite using Python's built-in assert statements:
def test_rob():
assert rob_optimized([]) == 0
assert rob_optimized([5]) == 5
assert rob_optimized([3, 5]) == 5
assert rob_optimized([2, 7, 9, 3, 1]) == 12
assert rob_optimized([1, 2, 3, 1]) == 4
assert rob_optimized([4, 4, 4, 4]) == 8
assert rob_optimized([1, 2, 3, 4, 5]) == 9
assert rob_optimized([10, 1, 1, 10]) == 20
print("All tests passed!")
test_rob()
Running this suite confirms that the function handles typical cases, edge cases, and patterns where alternating selections yield the best result.
Best Practices
When solving dynamic programming problems like House Robber, keep the following best practices in mind:
- Start with the brute force approach. Understanding the recursive structure first makes it easier to identify the recurrence relation and optimize later.
- Clearly define your state. Know exactly what
dp[i]represents. A vague definition leads to off-by-one errors and incorrect transitions. - Handle edge cases early. Empty inputs, single-element inputs, and two-element inputs often require special treatment before the main logic kicks in.
- Optimize space when possible. If your recurrence only depends on a fixed number of previous states, you can usually replace an array with a handful of variables.
- Write tests. Dynamic programming bugs are subtle. A comprehensive test suite catches regressions when you refactor from memoization to tabulation to space optimization.
- Document your recurrence. A comment explaining the recurrence relation helps future readers, including your future self, understand the logic quickly.
Variations to Explore
Once you are comfortable with the basic House Robber problem, try these variations to deepen your understanding:
- House Robber II: The houses are arranged in a circle, meaning the first and last houses are also adjacent. Solve by running the linear solution twice, once excluding the first house and once excluding the last.
- House Robber III: The houses are arranged in a binary tree. Use post-order traversal and return a tuple of (rob, skip) values at each node.
- Delete and Earn: A variation where collecting a number earns its value but deletes all occurrences of that number plus or minus one. It reduces to House Robber after aggregation.
Conclusion
The House Robber problem is a perfect gateway into the world of dynamic programming. By progressing from a naive recursive solution to a space-optimized iterative one, you learn how to identify overlapping subproblems, formulate recurrence relations, and incrementally improve both time and space complexity. The patterns you internalize here, sliding state variables, bottom-up tabulation, and careful edge-case handling, will serve you across countless algorithmic challenges. Practice the variations, write thorough tests, and you will be well-equipped to tackle even the most demanding dynamic programming questions in your next coding interview.