Course outline · 0% complete

0/30 lessons0%

Course overview →

House robber: choose or skip

lesson 9-2 · ~11 min · 24/30

House robber

A row of houses holds money, say [2, 7, 9, 3, 1]. You may rob any set of houses but never two adjacent ones, and the goal is to maximize the haul.

Apply the lesson 9-1 discipline, starting with the meaning. table[i] is the best haul considering only the first i houses.

Then the recurrence, which comes from the single choice at house i, using 1-indexed positions.

  • Skip it and you keep the best of the first i−1 houses, which is table[i-1].
  • Rob it and you take houses[i-1] plus the best of the first i−2, since the neighbor is off-limits, which is table[i-2] + houses[i-1].

table[i] = max(table[i−1], table[i−2] + houses[i−1])

Those two options cover every possibility, because house i is either robbed or it is not.

Base cases are table[0] = 0 for no houses and table[1] = houses[0] for one house, where the only choice is to take it.

This choose-or-skip recurrence is the backbone of dozens of problems, including stock cooldowns, deleting numbers, and skipping exams. Learn the shape rather than the story.

table[i] = max(skip, rob) 2 7 9 3 1 best 2 7 11 11 12 robbing 9 adds to the best two back, not one back 2 + 9 + 1 = 12, and no two chosen houses touch
The house robber table, where robbing a house adds its money to the best total from two positions back so the no-adjacent rule is built into the index.

Filling the haul table

The printed table is the running best-so-far.

def rob(houses):
    n = len(houses)
    if n == 0:
        return 0
    table = [0] * (n + 1)
    table[1] = houses[0]
    for i in range(2, n + 1):
        table[i] = max(table[i - 1],
                       table[i - 2] + houses[i - 1])
    print(table)
    return table[n]

print(rob([2, 7, 9, 3, 1]))
print(rob([5, 1, 1, 5]))

Output

[0, 2, 7, 11, 11, 12]
12
[0, 5, 5, 6, 10]
10

The i - 1 in houses[i - 1] is the index shift, since table[i] covers the first i houses and the i-th house sits at index i−1. Writing the meaning down first is what keeps that straight.

Entry 4 staying at 11 is the interesting cell. Robbing house 4 for 3 would mean giving up house 3, and 2 + 9 = 11 beats 7 + 3 = 10, so the max picks the skip.

Entry 5 rises to 12 by robbing house 5 for 1 on top of table[3] = 11, giving 2 + 9 + 1.

The table never decreases, which is a useful sanity check. table[i-1] is always one of the two candidates, so more houses can never mean a worse haul.

The second call is the case a naive alternating rule fails. [5, 1, 1, 5] gives 10 by taking the two 5s, which are positions 1 and 4 and not adjacent.

Because robbing house i forbids robbing house i−1, so the best compatible past is the first i−2 houses.

table[i-1] includes plans that rob house i−1, and adding house i to one of those would break the rule. table[i-2] cannot contain house i−1 at all, so it is safe to build on.

The no-adjacent constraint is encoded exactly there, in that one index. Nothing else in the code mentions adjacency.

That is the general check worth carrying: every constraint in a DP problem must show up somewhere in the recurrence, and finding where is how you verify the recurrence is right.

If a constraint appears nowhere, the table is solving a different problem. Using table[i-1] here would compute the sum of all the houses, which is the answer to a question with no adjacency rule at all.

rob_circle

The follow-up interviewers love, where the street is a circle so the first and last houses are adjacent too.

def rob(houses):
    n = len(houses)
    if n == 0:
        return 0
    table = [0] * (n + 1)
    table[1] = houses[0]
    for i in range(2, n + 1):
        table[i] = max(table[i - 1], table[i - 2] + houses[i - 1])
    return table[n]

def rob_circle(houses):
    if len(houses) == 1:
        return houses[0]
    return max(rob(houses[1:]), rob(houses[:-1]))

print(rob_circle([2, 3, 2]))
print(rob_circle([1, 2, 3, 1]))
print(rob_circle([5]))

Output

3
4
5

The insight is a case split. Any valid plan on a circle cannot rob both house 0 and the last house, so it lives entirely inside houses[1:] or entirely inside houses[:-1].

Both of those slices are ordinary straight streets, and rob already solves those, so no new recurrence is needed.

The two cases can overlap, in that a plan robbing neither end appears in both slices, and that is harmless. The max only needs each optimum to be covered by some case, not covered exactly once.

The single-house case needs its own line, because houses[1:] and houses[:-1] are both empty and the answer 5 would be lost.

For [2, 3, 2] the answer is 3, since the two 2s are now neighbors through the wrap, and for [1, 2, 3, 1] it is 4 from the 1 and the 3.

This reduce-to-a-solved-problem move is itself an interview pattern worth naming out loud, and it beats writing a circular recurrence from scratch.

The maximum haul is 10, from robbing house 1 and house 4 for 5 + 5.

Those two houses are not adjacent, with houses 2 and 3 sitting between them, so the plan is legal.

A greedy rob-every-other-house rule gets 6 either way, taking 5 + 1 from the odd positions or 1 + 5 from the even ones. Both miss because the pattern is fixed in advance.

A rob-the-biggest-first greedy gets this one right by accident, taking a 5 and then the other 5. It fails on [4, 5, 4], where it grabs the 5, blocks both neighbors, and returns 5 where 4 + 4 = 8 was available.

This is another reminder after lesson 8-2 that plausible greedy rules lose to DP when choices interact. Here taking a house changes what is available next, which is exactly the coupling greedy cannot see.