240 lines
9.9 KiB
YAML
240 lines
9.9 KiB
YAML
title: House Robber II
|
|
slug: house-robber-ii
|
|
difficulty: medium
|
|
leetcode_id: 213
|
|
leetcode_url: https://leetcode.com/problems/house-robber-ii/
|
|
categories:
|
|
- arrays
|
|
- dynamic-programming
|
|
patterns:
|
|
- dynamic-programming
|
|
|
|
function_signature: "def rob(nums: list[int]) -> int:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { nums: [2, 3, 2] }
|
|
expected: 3
|
|
- input: { nums: [1, 2, 3, 1] }
|
|
expected: 4
|
|
- input: { nums: [1, 2, 3] }
|
|
expected: 3
|
|
hidden:
|
|
- input: { nums: [1] }
|
|
expected: 1
|
|
- input: { nums: [1, 2] }
|
|
expected: 2
|
|
- input: { nums: [0, 0, 0, 0] }
|
|
expected: 0
|
|
- input: { nums: [200, 3, 140, 20, 10] }
|
|
expected: 340
|
|
- input: { nums: [1, 3, 1, 3, 100] }
|
|
expected: 103
|
|
- input: { nums: [1, 1, 1, 1, 1, 1, 1] }
|
|
expected: 3
|
|
- input: { nums: [100, 1, 1, 100] }
|
|
expected: 101
|
|
|
|
description: |
|
|
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed. All houses at this place are **arranged in a circle**. That means the first house is the neighbour of the last one. Meanwhile, adjacent houses have a security system 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] <= 1000`
|
|
|
|
examples:
|
|
- input: "nums = [2,3,2]"
|
|
output: "3"
|
|
explanation: "You cannot rob house 1 (money = 2) and then rob house 3 (money = 2), because they are adjacent houses."
|
|
- 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 = [1,2,3]"
|
|
output: "3"
|
|
explanation: "Rob house 2 (money = 3) since it's the highest value and not adjacent to itself."
|
|
|
|
explanation:
|
|
intuition: |
|
|
This problem is a clever extension of the classic House Robber problem. The twist? The houses are arranged in a **circle**, meaning the first and last houses are neighbours.
|
|
|
|
Think of it like this: imagine the houses arranged around a cul-de-sac instead of a straight street. If you rob the first house, you can't rob the last one (they share a fence). Conversely, if you rob the last house, you can't rob the first.
|
|
|
|
The key insight is that **you can never rob both the first and last house** — they're mutually exclusive. This transforms the circular problem into two linear problems:
|
|
- **Scenario A**: Rob from houses `0` to `n-2` (exclude the last house)
|
|
- **Scenario B**: Rob from houses `1` to `n-1` (exclude the first house)
|
|
|
|
The answer is simply the maximum of these two scenarios. Each scenario is just the original House Robber problem, which we solve with dynamic programming!
|
|
|
|
approach: |
|
|
We solve this by **reducing the circular problem to two linear problems**:
|
|
|
|
**Step 1: Handle the edge case**
|
|
|
|
- If there's only one house, return `nums[0]` — no circular constraint applies
|
|
|
|
|
|
|
|
**Step 2: Define a helper function for linear House Robber**
|
|
|
|
- This function solves the original problem on a subarray
|
|
- Use two variables (`prev1`, `prev2`) to track the maximum money achievable
|
|
- Recurrence: `current = max(nums[i] + prev2, prev1)`
|
|
|
|
|
|
|
|
**Step 3: Run the helper on two scenarios**
|
|
|
|
- `rob_linear(nums[0:n-1])`: Exclude the last house (can rob the first)
|
|
- `rob_linear(nums[1:n])`: Exclude the first house (can rob the last)
|
|
- These two ranges cover all valid combinations — if we rob both first and last, neither scenario includes it
|
|
|
|
|
|
|
|
**Step 4: Return the maximum**
|
|
|
|
- `max(scenario_a, scenario_b)` gives the optimal answer
|
|
- One of these scenarios will contain the true optimal solution
|
|
|
|
common_pitfalls:
|
|
- title: Treating It Like a Linear Array
|
|
description: |
|
|
A common mistake is to directly apply the House Robber I solution without considering the circular constraint.
|
|
|
|
For `nums = [2, 3, 2]`:
|
|
- Linear approach might yield `2 + 2 = 4` (houses 0 and 2)
|
|
- But houses 0 and 2 are adjacent in a circle!
|
|
- Correct answer is `3` (just house 1)
|
|
|
|
Always remember: in a circle, index `0` and index `n-1` are neighbours.
|
|
wrong_approach: "Apply House Robber I directly"
|
|
correct_approach: "Split into two linear subproblems excluding first or last house"
|
|
|
|
- title: Forgetting the Single House Case
|
|
description: |
|
|
When `nums.length == 1`, both scenarios (`nums[0:0]` and `nums[1:1]`) would be empty arrays, returning 0.
|
|
|
|
But with one house, there are no neighbours — you can simply rob it! Always handle this edge case explicitly by returning `nums[0]` when `n == 1`.
|
|
wrong_approach: "Let the helper function handle all cases"
|
|
correct_approach: "Check n == 1 before splitting into scenarios"
|
|
|
|
- title: Off-by-One in Array Slicing
|
|
description: |
|
|
When excluding the last house, use `nums[0:n-1]` (indices 0 to n-2 inclusive).
|
|
When excluding the first house, use `nums[1:n]` (indices 1 to n-1 inclusive).
|
|
|
|
Python's slice notation is `[start:end)` — end is exclusive. A common error is:
|
|
- `nums[0:n-2]` — misses one house
|
|
- `nums[1:n+1]` — goes out of bounds
|
|
|
|
Double-check your slice boundaries match the scenarios described.
|
|
|
|
key_takeaways:
|
|
- "**Problem reduction**: Convert a harder problem (circular) into simpler subproblems (linear)"
|
|
- "**Mutual exclusion insight**: When constraints create mutually exclusive choices, solve each case separately"
|
|
- "**Reuse existing solutions**: House Robber II builds directly on House Robber I — recognise when you can leverage solved subproblems"
|
|
- "**Pattern for circular arrays**: Many circular array problems can be solved by breaking the cycle and running linear algorithms twice"
|
|
|
|
time_complexity: "O(n). We run the linear House Robber algorithm twice, each taking O(n) time, giving O(2n) = O(n)."
|
|
space_complexity: "O(1). The space-optimised linear algorithm uses only two variables, and we run it twice sequentially."
|
|
|
|
solutions:
|
|
- approach_name: Two-Pass Dynamic Programming
|
|
is_optimal: true
|
|
code: |
|
|
def rob(nums: list[int]) -> int:
|
|
# Edge case: single house has no circular constraint
|
|
if len(nums) == 1:
|
|
return nums[0]
|
|
|
|
def rob_linear(houses: list[int]) -> int:
|
|
"""Solve the linear House Robber problem."""
|
|
prev2 = 0 # Max money from two houses back
|
|
prev1 = 0 # Max money from previous house
|
|
|
|
for money in houses:
|
|
# Rob this house + prev2, or skip and keep prev1
|
|
current = max(money + prev2, prev1)
|
|
prev2 = prev1
|
|
prev1 = current
|
|
|
|
return prev1
|
|
|
|
n = len(nums)
|
|
# Scenario A: exclude last house (can rob first)
|
|
# Scenario B: exclude first house (can rob last)
|
|
return max(rob_linear(nums[:n-1]), rob_linear(nums[1:]))
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Two linear passes through subarrays of size n-1.
|
|
|
|
**Space Complexity:** O(1) — Only uses constant extra space (two variables per pass).
|
|
|
|
By excluding either the first or last house, we break the circular constraint and can apply the standard House Robber DP approach. The maximum of both scenarios gives us the optimal answer because any valid solution must exclude at least one of the endpoints.
|
|
|
|
- approach_name: Two-Pass with Explicit Ranges
|
|
is_optimal: false
|
|
code: |
|
|
def rob(nums: list[int]) -> int:
|
|
n = len(nums)
|
|
|
|
# Edge case: single house
|
|
if n == 1:
|
|
return nums[0]
|
|
|
|
def rob_range(start: int, end: int) -> int:
|
|
"""Rob houses from index start to end (inclusive)."""
|
|
prev2 = 0
|
|
prev1 = 0
|
|
|
|
for i in range(start, end + 1):
|
|
current = max(nums[i] + prev2, prev1)
|
|
prev2 = prev1
|
|
prev1 = current
|
|
|
|
return prev1
|
|
|
|
# Exclude last house OR exclude first house
|
|
return max(rob_range(0, n - 2), rob_range(1, n - 1))
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Two passes through subarrays.
|
|
|
|
**Space Complexity:** O(1) — Constant extra space.
|
|
|
|
This version uses explicit index ranges instead of array slicing. It avoids creating subarray copies (though in practice, Python's slice is efficient). The logic is identical: solve two linear subproblems and take the maximum.
|
|
|
|
- approach_name: DP with Array (Educational)
|
|
is_optimal: false
|
|
code: |
|
|
def rob(nums: list[int]) -> int:
|
|
n = len(nums)
|
|
|
|
if n == 1:
|
|
return nums[0]
|
|
if n == 2:
|
|
return max(nums[0], nums[1])
|
|
|
|
def rob_linear(houses: list[int]) -> int:
|
|
"""Standard House Robber with DP array."""
|
|
m = len(houses)
|
|
if m == 1:
|
|
return houses[0]
|
|
|
|
dp = [0] * m
|
|
dp[0] = houses[0]
|
|
dp[1] = max(houses[0], houses[1])
|
|
|
|
for i in range(2, m):
|
|
dp[i] = max(houses[i] + dp[i - 2], dp[i - 1])
|
|
|
|
return dp[m - 1]
|
|
|
|
# Two scenarios: exclude last or exclude first
|
|
return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))
|
|
explanation: |
|
|
**Time Complexity:** O(n) — Two linear passes.
|
|
|
|
**Space Complexity:** O(n) — DP arrays of size n-1 for each pass.
|
|
|
|
This version explicitly builds the DP table, making the recurrence relation easier to trace. Each `dp[i]` represents the maximum money from houses 0 to i in that subarray. While less space-efficient, this is useful for understanding the DP transition before optimising to O(1) space.
|