Files
codetutor/backend/data/questions/house-robber.yaml
2025-05-25 11:47:04 +01:00

183 lines
7.8 KiB
YAML

title: House Robber
slug: house-robber
difficulty: medium
leetcode_id: 198
leetcode_url: https://leetcode.com/problems/house-robber/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
description: |
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security systems connected and **it will automatically contact the police if two adjacent houses were broken into on the same night**.
Given an integer array `nums` representing the amount of money of each house, return *the maximum amount of money you can rob tonight without alerting the police*.
constraints: |
- `1 <= nums.length <= 100`
- `0 <= nums[i] <= 400`
examples:
- input: "nums = [1,2,3,1]"
output: "4"
explanation: "Rob house 1 (money = 1) and then rob house 3 (money = 3). Total amount you can rob = 1 + 3 = 4."
- input: "nums = [2,7,9,3,1]"
output: "12"
explanation: "Rob house 1 (money = 2), rob house 3 (money = 9) and rob house 5 (money = 1). Total amount you can rob = 2 + 9 + 1 = 12."
explanation:
intuition: |
Imagine walking down a street, deciding which houses to rob. At each house, you face a simple choice: **rob it or skip it**.
If you rob the current house, you can't rob the previous one (they're adjacent). But if you skip the current house, you keep whatever maximum you could achieve up to the previous house.
Think of it like this: for every house, you're asking *"What's better — taking this house plus the best I could do two houses ago, or skipping this house and keeping the best I could do at the previous house?"*
This is the core insight: the **optimal decision at each house only depends on the optimal decisions for the previous two houses**. This "overlapping subproblems" property makes it a textbook dynamic programming problem.
The key realisation is that you don't need to track *which specific houses* you robbed — you only need to track the **maximum money possible** up to each point.
approach: |
We solve this using **Dynamic Programming with Space Optimisation**:
**Step 1: Define the recurrence relation**
- Let `dp[i]` represent the maximum money we can rob from houses `0` to `i`
- At each house `i`, we have two choices:
- **Rob house `i`**: Take `nums[i]` plus the best from two houses back: `nums[i] + dp[i-2]`
- **Skip house `i`**: Keep the best from the previous house: `dp[i-1]`
- The recurrence: `dp[i] = max(nums[i] + dp[i-2], dp[i-1])`
&nbsp;
**Step 2: Recognise we only need two variables**
- The recurrence only looks back two steps (`dp[i-1]` and `dp[i-2]`)
- Instead of storing an entire array, use two variables:
- `prev1`: Maximum money up to the previous house (i.e., `dp[i-1]`)
- `prev2`: Maximum money up to two houses back (i.e., `dp[i-2]`)
&nbsp;
**Step 3: Iterate through each house**
- For each house, calculate: `current = max(nums[i] + prev2, prev1)`
- Update variables: `prev2 = prev1`, then `prev1 = current`
- This "slides" our window of knowledge forward by one house
&nbsp;
**Step 4: Return the result**
- After processing all houses, `prev1` contains the maximum money achievable
- Return `prev1`
common_pitfalls:
- title: The Greedy Trap (Alternating Houses)
description: |
A common first instinct is to simply take every other house — either all odd-indexed or all even-indexed houses.
This fails for cases like `nums = [2, 1, 1, 2]`:
- Odd indices (0, 2): `2 + 1 = 3`
- Even indices (1, 3): `1 + 2 = 3`
- But optimal is indices (0, 3): `2 + 2 = 4`
The pattern of which houses to rob isn't regular — it depends on the actual values. You might skip two houses in a row if the third house has a high value.
wrong_approach: "Take every other house"
correct_approach: "DP considering all valid combinations"
- title: Off-by-One Errors in Base Cases
description: |
The DP approach requires handling base cases carefully:
- If there's only one house, return `nums[0]`
- If there are two houses, return `max(nums[0], nums[1])`
Forgetting these edge cases leads to index out-of-bounds errors or incorrect results for small inputs.
wrong_approach: "Start iteration at index 0 without base cases"
correct_approach: "Handle n=1 and n=2 explicitly, then iterate from index 2"
- title: Confusing the Variable Updates
description: |
When using the space-optimised approach, the order of updates matters:
```python
# WRONG: prev2 gets the new value before we use it
prev2 = prev1
prev1 = max(nums[i] + prev2, prev1)
# CORRECT: Calculate first, then update in order
current = max(nums[i] + prev2, prev1)
prev2 = prev1
prev1 = current
```
Always calculate the new value *before* updating the variables it depends on.
key_takeaways:
- "**Classic DP pattern**: When optimal solutions depend on previous optimal solutions, think dynamic programming"
- "**Space optimisation**: If recurrence only looks back a fixed number of steps, replace the array with variables"
- "**Greedy doesn't always work**: Problems with non-local dependencies (like adjacency constraints) often need DP"
- "**Foundation for variants**: This logic extends to House Robber II (circular street) and House Robber III (binary tree)"
time_complexity: "O(n). We iterate through the array exactly once, making a constant-time decision at each house."
space_complexity: "O(1). We only use two variables (`prev1` and `prev2`) regardless of input size, thanks to space optimisation."
solutions:
- approach_name: Dynamic Programming (Space Optimised)
is_optimal: true
code: |
def rob(nums: list[int]) -> int:
# Edge case: only one house
if len(nums) == 1:
return nums[0]
# prev2 = max money from two houses back
# prev1 = max money from previous house
prev2 = 0
prev1 = nums[0]
for i in range(1, len(nums)):
# Choice: rob this house + prev2, or skip and keep prev1
current = max(nums[i] + prev2, prev1)
# Slide the window forward
prev2 = prev1
prev1 = current
return prev1
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only two variables used.
We iterate once, at each step choosing the better option: rob current house (add to best from 2 houses back) or skip (keep best from previous house). The space optimisation works because we only ever look back two positions.
- approach_name: Dynamic Programming (Array)
is_optimal: false
code: |
def rob(nums: list[int]) -> int:
n = len(nums)
# Edge cases
if n == 1:
return nums[0]
if n == 2:
return max(nums[0], nums[1])
# dp[i] = max money from houses 0..i
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
# Rob house i (add to best from i-2) or skip (keep best from i-1)
dp[i] = max(nums[i] + dp[i - 2], dp[i - 1])
return dp[n - 1]
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(n) — We store the entire DP array.
This version explicitly builds the DP table, making the recurrence relation clearer. Each `dp[i]` represents the maximum money achievable from houses 0 to i. While correct, it uses more space than necessary since we only need the last two values.