DSA House Robber II
House Robber II places houses in a circle, so the first and last houses count as neighbours. The circle breaks into two straight streets, and the better of the two street answers wins.
The problem
House Robber II places houses in a circle. Robbing two neighbouring houses triggers an alarm, and in a circle the first and last houses are neighbours too.
Each house holds a known amount of money. No two robbed houses may be neighbours. The answer is the largest total that a legal plan can take.
The cases used in this article
cases = [
([2, 3, 2], 3),
([1, 2, 3, 1], 4),
([5, 1, 1, 5], 6),
([7], 7),
([9, 5], 9),
]
for houses, expected in cases:
print("circle", houses, "best total:", expected)The last two rows show the circle rule at its smallest. One house is taken alone. Two houses in a circle are neighbours, so only the richer one is taken.
Intuition: the straight-street answer overcounts
The first idea solves the list as a straight street. On a street the two end houses are far apart, so a plan may take both ends. The circle joins those ends and bans exactly that plan.
The street plan that the circle forbids
def rob_line(houses):
best = 0
skip_one = 0
for money in houses:
take = money + skip_one
best, skip_one = max(best, take), best
return best
houses = [5, 1, 1, 5]
print("both ends together:", houses[0] + houses[-1])
print("street answer:", rob_line(houses))
print("circle answer:", 6)The street plan takes both 5 houses for 10. Those two houses stand next to each other in the circle. The plan is illegal here, and the true answer is 6.
Brute force: test every set of houses
The brute force tests every set of houses. It rejects a set when two chosen houses are neighbours, including the wrap-around pair of first and last. It keeps the largest total among the legal sets.
Enumerating the sets of [1, 2, 3, 1]
houses = [1, 2, 3, 1]
n = len(houses)
checked = 0
best = 0
for mask in range(2 ** n):
checked += 1
chosen = [i for i in range(n) if mask & (1 << i)]
legal = True
for i in chosen:
if (i + 1) % n in chosen:
legal = False
if legal:
best = max(best, sum(houses[i] for i in chosen))
print("sets checked:", checked)
print("best total:", best)Four houses give 16 sets. Each added house doubles the set count. Most sets are illegal or poor, and the brute force sums them all anyway.
The optimal solution: solve two straight streets
Every legal circle plan misses the last house or misses the first house, so it is a legal plan on one of two straight streets. That one sentence is the whole method.
Case one drops the last house and solves that street. Case two drops the first house and solves that street. The larger of the two totals is the circle answer. A single house is answered on its own, before any split.
Two street runs on every case
def rob_line(houses):
best = 0
skip_one = 0
for money in houses:
take = money + skip_one
best, skip_one = max(best, take), best
return best
def rob_circle(houses):
if len(houses) == 1:
return houses[0]
return max(rob_line(houses[:-1]), rob_line(houses[1:]))
cases = [
([2, 3, 2], 3),
([1, 2, 3, 1], 4),
([5, 1, 1, 5], 6),
([7], 7),
([9, 5], 9),
]
for houses, expected in cases:
result = rob_circle(houses)
print("circle", houses, "->", result, "expected:", expected, "match:", result == expected)All five cases match. The single house never reaches the split, because dropping either end of a one-house circle drops the house itself.
Walkthrough: the circle [1, 2, 3, 1]
Case one drops the last house and walks the street 1, 2, 3. Each line prints the take option, the skip option, and the best total after that house. The take option adds the house to the best total that skipped its neighbour.
Both street walks, step by step
def rob_line_trace(houses):
best = 0
skip_one = 0
for money in houses:
take = money + skip_one
print("house", money, "- take:", take, "skip:", best, end=" ")
best, skip_one = max(best, take), best
print("best:", best)
return best
print("drop last, street [1, 2, 3]:")
case_one = rob_line_trace([1, 2, 3])
print("drop first, street [2, 3, 1]:")
case_two = rob_line_trace([2, 3, 1])
print("case one:", case_one, "case two:", case_two)
print("circle answer:", max(case_one, case_two))Case one ends at 4, from the houses holding 1 and 3. Case two ends at 3. The circle answer is the larger total, 4.
Complexity
Time is O(n) for n houses. The solver walks each street once, and the two streets together visit each house at most twice.
Space is O(1). Each walk keeps two running totals and nothing else.
Counting the house visits
def rob_line_count(houses):
best = 0
skip_one = 0
visits = 0
for money in houses:
visits += 1
take = money + skip_one
best, skip_one = max(best, take), best
return best, visits
for n in [4, 8, 16]:
houses = [1] * n
_, v1 = rob_line_count(houses[:-1])
_, v2 = rob_line_count(houses[1:])
print("houses", n, "- visits across both cases:", v1 + v2)The visits grow in a straight line with the house count, two short of double. No table is stored, and no set is ever enumerated.
Edge cases and pitfalls
An empty circle answers 0. Two houses answer the larger single amount, because the pair are neighbours twice over in a circle of two.
Edge cases, run
def rob_line(houses):
best = 0
skip_one = 0
for money in houses:
take = money + skip_one
best, skip_one = max(best, take), best
return best
def rob_circle(houses):
if len(houses) == 1:
return houses[0]
return max(rob_line(houses[:-1]), rob_line(houses[1:]))
print("empty circle:", rob_circle([]))
print("one house:", rob_circle([7]))
print("two houses:", rob_circle([9, 5]))
print("two equal houses:", rob_circle([3, 3]))
print("all zeros:", rob_circle([0, 0, 0]))Splitting a single house is the quiet pitfall. Dropping its last house and dropping its first house both leave an empty street. Without the special case, one house worth 7 would answer 0.
The single-house split without its special case, run
def rob_line(houses):
best = 0
skip_one = 0
for money in houses:
take = money + skip_one
best, skip_one = max(best, take), best
return best
houses = [7]
print("drop last street:", houses[:-1], "answer:", rob_line(houses[:-1]))
print("drop first street:", houses[1:], "answer:", rob_line(houses[1:]))
print("correct answer:", houses[0])The both-ends mistake overcounts, and the single-house split undercounts. The case split plus the one-house special case avoids both errors.
Exercise
A circle holds houses worth [4, 1, 4]. Why is the answer 4 and not 8?