“Know how to solve every problem that has been solved.” “What I cannot create, I do not understand.” — Richard Feynman

House Robber

Interview Prep

Warm-upDynamic Programmingdprecurrence

The problem

A row of houses, each with some non-negative cash. You can rob any subset, but no two adjacent houses (an alarm triggers if both are robbed). Return the maximum amount obtainable. Equivalently: maximum-weight independent set on a path graph.

Pattern: linear DP, choose-or-skip

At each house, two choices: rob it (add its value, then jump two ahead) or skip it (move one ahead). Let f(i) = best achievable from index i onward. Then f(i) = max(f(i+1), nums[i] + f(i+2)). Base case f(n) = 0. The recurrence is the "Fibonacci with a choice" — the simplest non-trivial DP.

Equivalently, looking left-to-right: dp[i] = max take using houses 0..i, with recurrence dp[i] = max(dp[i−1], dp[i−2] + nums[i]). Since only the last two values are needed, the space collapses to O(1).

Memoized recursion

def rob_memo(nums: list[int]) -> int:
    cache = {}
    def helper(i):
        if i >= len(nums): return 0
        if i in cache: return cache[i]
        cache[i] = max(helper(i + 1), nums[i] + helper(i + 2))
        return cache[i]
    return helper(0)

Bottom-up DP

def rob_dp(nums: list[int]) -> int:
    n = len(nums)
    if n == 0: return 0
    dp = [0] * n
    dp[0] = nums[0]
    if n > 1: 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[n - 1]

Optimal: rolling pair

def rob(nums: list[int]) -> int:
    """O(1) space — only need the previous two states."""
    prev2 = prev1 = 0
    for x in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + x)
    return prev1

Under the Hood

The same way a mechanic opens the hood and points at the radiator, the alternator, the timing belt — here is an annotated tour of what's actually going on in the working code or math above. No questions, no reveals. Just labels on the parts.

  1. prev2 = prev1 = 0
    The initialization. Both variables start at zero, encoding the rule "robbing no houses scores 0." This is what makes the loop's very first iteration produce the right answer: when the algorithm meets the first house and asks "skip (carry forward prev1 = 0) or rob (gain x + prev2 = x + 0)?", both options are computed correctly because prev2 faithfully represents "best score using houses two-or-more ago" — and there weren't any.
  2. for x in nums
    The loop iterates over house values, not indices. The algorithm never tracks which houses were chosen; it only carries forward running maxima. To recover the actual set of robbed houses you'd need to record the per-step rob-vs-skip decision and walk backward through them — the reconstruction probe in Try First spells that out.
  3. prev2, prev1 = prev1, max(prev1, prev2 + x)
    The rolling update. Done as a single tuple assignment so the new values are computed from the old ones before any of them get overwritten — that's Python evaluating the entire right-hand side first. Written as separate statements, you'd need a temporary variable. The advancement: prev1 moves forward to hold the new "best on the prefix including this house," and prev2 inherits what prev1 just was — i.e., "best on the prefix NOT including this house."
  4. max(prev1, prev2 + x)
    The actual choice. prev1 is "best score if we skip this house"; prev2 + x is "best score if we rob this house" — and the use of prev2 (not prev1) is what enforces the no-adjacent constraint, because robbing this house is only allowed when the previous one was skipped. The max picks the better option silently, without ever recording which one.
  5. return prev1
    The final answer is the maximum loot achievable. By construction, prev1 at termination holds the answer on the full array. Note the function returns ONLY the total — not the houses involved. Reconstructing the actual subset requires extra bookkeeping (recorded decisions, plus a backward walk).

Trace

nums = [2, 7, 9, 3, 1]

prev2 = 0, prev1 = 0

i=0, x=2:  new = max(0, 0 + 2) = 2.   prev2=0,  prev1=2
i=1, x=7:  new = max(2, 0 + 7) = 7.   prev2=2,  prev1=7
i=2, x=9:  new = max(7, 2 + 9) = 11.  prev2=7,  prev1=11
i=3, x=3:  new = max(11, 7 + 3) = 11. prev2=11, prev1=11
i=4, x=1:  new = max(11, 11 + 1) = 12. prev2=11, prev1=12

return 12   (rob houses 0, 2, 4: 2 + 9 + 1 = 12)

Complexity

Try First

Each prompt asks a checkable question about the working code or math above — predict an output, derive a sign, state an invariant, find a bug. Commit to an answer before clicking "reveal." That commitment is the whole point: if your answer matched, you understand the piece you were looking at; if it didn't, that's the part worth re-reading.

invariant
Pause the rolling-pair version at the top of iteration i, before the update fires. State precisely what prev1 and prev2 hold in terms of the original problem applied to a prefix of nums. Be specific about which prefix.
predict
Predict rob([5, 5, 5, 5, 5, 5, 5, 5, 5, 5]) — ten houses, each worth 5 — without running the code. Then state the closed form for rob([c]·n) (an array of n copies of a constant c > 0).
why does this work
Run the memoized version rob_memo on an array of n distinct houses. How many entries does the cache dict end up with? The recurrence at each node makes two recursive calls — and 2^n would be plausible. Why isn't it that?
find the bug
Replace prev2 + x with 0 + x in the rolling-pair version. The code still runs, still returns a number. prev2 is now dead code, but ignore that — what problem does the modified code correctly solve? It is the correct algorithm for something.
trace
rob([3, 1, 1, 3]) returns 6 — but the function never tells you which houses that 6 came from. Extend the rolling-pair version to also return the list of indices robbed in some optimal solution. Then explain why this required more care than you might have expected.

Hack This

The code above works. Don't reinvent it — pull it into an editor, run it, and try the modifications below. Each one is small. Each one will change the behavior in a specific way; the question is which way.

trivial
The page opens by calling House Robber "Fibonacci with a choice." Make that statement literal: in the rolling-pair version, replace max(prev1, prev2 + x) with prev1 + prev2, and change the initialization from prev2 = prev1 = 0 to prev2, prev1 = 0, 1. Now run it on an array of length 20 — the values in nums are irrelevant since you've stopped reading them. What do you get back, and what's the relationship between it and nums?
trivial
In the rolling-pair version, change prev2 + x to prev1 + x. You've quietly deleted the no-adjacent constraint — every house is now allowed to be robbed regardless of its neighbors. Run it. What does the algorithm now compute? Then answer the deeper question: where exactly, in the original code, did the adjacency constraint live?
small
Replace the recurrence with the multiplicative analogue: change max(prev1, prev2 + x) to max(prev1, prev2 * x), and initialize prev2 = prev1 = 1 (the multiplicative identity). You've just defined a new problem: "maximum-product subset of non-adjacent houses." Run it on [2, 3, 5, 2, 3] and [5, 5, 5, 5, 5]. Then: under what conditions on the input does this multiplicative version pick a different subset of houses than the additive original? When would a robber prefer one objective to the other?
small
The problem statement says houses contain non-negative cash. What if some entries are negative — entering the house costs you something (alarm fee, broken window, etc.) and you'd only rob it if it's net positive after the cost? Run the unmodified code on [5, -10, 5]. Is the answer right? Now try [-3, -1, -4] — every house is a loss. The code returns 0. Is that the right answer to a real-world version of the problem, where robbing nothing might not be an option?
small
Soften the constraint instead of removing it. Suppose you can rob two adjacent houses, but doing so costs a fixed penalty P (the alarm triggers and you pay a fine). At P = 0 the constraint vanishes; at P = ∞ you get the original problem back. Replace the rolling-pair version with a version that tracks two states per index — "robbed house i" and "didn't rob house i" — and apply the penalty only on the rob-then-rob transition. Sweep P from 0 to infinity on [2, 7, 9, 3, 1] and plot the answer. Where does the optimum's structure change, not just its value?
medium
Tighten the constraint instead. What if up to two consecutive houses can be robbed, but never three in a row? Modify the rolling-pair version. The key question first: how much state do you need now? You'll discover the original "prev1, prev2" pair isn't enough — and figuring out the right state is the whole point of the mod. Test on [10, 10, 10, 10, 10]: the original returns 30 (houses 0, 2, 4); your new version should return 40 (houses 0, 1, 3, 4).
medium
Generalize the adjacency rule completely. Instead of "you can't rob two adjacent houses" (i.e., a one-house gap is required), require k houses between any two robs. The original problem is k = 1. k = 0 is "rob everything." k = ∞ is "rob at most one." Write a parametrized rob_k(nums, k) that works for any non-negative k. What's the recurrence? Can you still get O(1) space, or does k show up in the space bound?

Variations worth knowing