~/dynamic-programming/dp-intro

Dynamic programming: the basics

Solve each small subproblem once, store the answer, and build bigger answers from it. Learned here on House Robber.

what

Name the subproblem (the state), write how its answer follows from smaller ones (the recurrence), and fill a table from the base cases up.

use when

You want a best value or a count over many choices, and plain recursion keeps solving the same subproblems again.

time

O(states × work per state), O(n) for House Robber

space

O(n) for the table, often O(1) by keeping only the last few cells

You’ll recognise it when

  • The question asks for the best value (max, min) or how many ways, not for one specific arrangement.
  • You make a sequence of choices (take or skip, one step or two, which coin), and each choice limits the next ones.
  • A recursive solution is easy to write but slow, and printing its calls shows the same arguments again and again.
  • The input is small in one dimension you can index by: a position, an amount, a length.

It’s often confused with greedy, which commits to whatever looks best right now; DP keeps the best answer for every subproblem, so a choice that looks bad now can still win later.

The idea

A street of houses, each with some money inside. You want as much as possible, but robbing two neighbours sets off the alarm. Walk down the street and, at each door, write one number on a notepad: the most you could have taken from the houses so far. At the next door you don’t replan the whole street. You look at the last two numbers on the pad and decide.

That’s dynamic programming: solve each subproblem once, write the answer down, and build bigger answers from smaller ones. It starts from a plain recursive idea. For the last house i there are only two options:

  • Skip it. The best is whatever houses 0 to i - 1 can give.
  • Rob it. Then house i - 1 is off limits, so you get money[i] plus the best from houses 0 to i - 2.
def best(i): # most money from houses 0..i
if i < 0:
return 0
return max(best(i - 1), best(i - 2) + money[i])

That’s correct, but slow. best(9) calls best(8) and best(7); best(8) calls best(7) again, and so on. The number of calls grows like the Fibonacci numbers, about 1.6ⁿ: a street of 60 houses needs over a trillion calls. Yet there are only n different questions. Memoization stores each answer the first time it’s computed, and every later call is a lookup:

from functools import cache
@cache
def best(i):
if i < 0:
return 0
return max(best(i - 1), best(i - 2) + money[i])

Now each best(i) runs once: O(n). The bottom-up version below goes one step further and fills the answers in a loop, in an order where everything a cell needs is already there. Every DP answers the same five questions:

House Robber
State: what one table cell means dp[i] = the most money from houses 0 to i
Choice: what you decide at a state rob house i, or skip it
Recurrence: the answer from smaller states dp[i] = max(dp[i-1], dp[i-2] + money[i])
Base case: states you know directly dp[0] = money[0], and “before house 0” is 0
Order: fill so dependencies come first left to right, i = 0, 1, 2, …

How it works

We fill dp from left to right, and at each house compare the two options.

Scroll through the steps and the graphic follows along. You can also step with the arrow keys, turn on quiz me to decide each house yourself, or edit the amounts.

  1. State. Make an array dp with one cell per house. dp[i] will hold the most money you can take from houses 0 to i, ignoring everything to the right.
  2. Why store it. Plain recursion would recompute best(0) 13 times for these 7 houses. The table computes each cell exactly once.
  3. Base case. With only house 0 there’s nothing to decide: dp[0] = money[0].
  4. Choice one, skip house i: then the best is skip = dp[i-1], the answer we already stored for one house fewer.
  5. Choice two, rob house i: its neighbour is off limits, so take = dp[i-2] + money[i]. For i = 1 there’s nothing two back, so count it as 0.
  6. Recurrence. Keep the better option: dp[i] = max(skip, take). Here robbing house 1 wins, 8 against 3.
  7. Order. Left to right, every cell needs only the two cells before it, which are already filled. At house 2, skipping wins: 8 beats 3 + 4.
  8. Answer. The last cell, dp[n-1], covers every house: 23.
  9. Which houses? Walk back from the end. If dp[i] equals dp[i-1], skipping house i was enough; step left by one. Otherwise house i was robbed; jump left by two. Note that dp[3] robbed house 3, but the final plan doesn’t: a cell’s choice is only final for its own prefix.
loading dp-intro…

Why it’s correct: every legal plan for houses 0 to i either skips house i or robs it. A best plan that skips it is a best plan for houses 0 to i - 1, which is dp[i-1]. A best plan that robs it can’t use house i - 1, and the rest of it may as well be the best plan for houses 0 to i - 2, since swapping in a better one would only help. So max(skip, take) covers every case, and filling left to right means both are known when we need them.

def rob(money):
"""Most money you can take without robbing two neighbouring houses."""
n = len(money)
if n == 0:
return 0
# dp[i] = the best total using houses 0..i only
dp = [0] * n
dp[0] = money[0]
for i in range(1, n):
skip = dp[i - 1]
take = (dp[i - 2] if i >= 2 else 0) + money[i]
dp[i] = max(skip, take)
return dp[n - 1]
#include <vector>
#include <algorithm>
using namespace std;
// Most money you can take without robbing two neighbouring houses.
long long rob(const vector<long long>& money) {
int n = money.size();
if (n == 0) return 0;
// dp[i] = the best total using houses 0..i only
vector<long long> dp(n);
dp[0] = money[0];
for (int i = 1; i < n; i++) {
long long skip = dp[i - 1];
long long take = (i >= 2 ? dp[i - 2] : 0) + money[i];
dp[i] = max(skip, take);
}
return dp[n - 1];
}
class HouseRobber {
// Most money you can take without robbing two neighbouring houses.
static long rob(long[] money) {
int n = money.length;
if (n == 0) return 0;
// dp[i] = the best total using houses 0..i only
long[] dp = new long[n];
dp[0] = money[0];
for (int i = 1; i < n; i++) {
long skip = dp[i - 1];
long take = (i >= 2 ? dp[i - 2] : 0) + money[i];
dp[i] = Math.max(skip, take);
}
return dp[n - 1];
}
}

Each cell looks back only two places, so you can keep just those two numbers instead of the whole array. This is the O(1)-space version; the tuple assignment matters, because the new value must be computed from the old pair:

def rob(money):
prev2 = prev1 = 0 # dp[i-2] and dp[i-1]
for x in money:
prev2, prev1 = prev1, max(prev1, prev2 + x)
return prev1

Why it’s O(n)

There are n states, and each is filled with one comparison of two numbers already in the table: O(n) time. The table takes O(n) space; the rolling version keeps two numbers, O(1).

The general rule for DP is number of states × work per state. Plain recursion pays for every path through the choices instead of every state, which is what makes it exponential:

Version Time Extra space
Plain recursion O(1.6ⁿ) O(n) call stack
Memoized (top-down) O(n) O(n) cache + O(n) call stack
Table (bottom-up) O(n) O(n)
Two rolling variables O(n) O(1)

Common mistakes

Taking every other house

Alternating (houses 0, 2, 4… or 1, 3, 5…) looks like it respects the rule, but the best plan can skip two houses in a row. On [6, 1, 2, 7] alternating gives 8, while houses 0 and 3 give 13.

max(sum(money[0::2]), sum(money[1::2])) # ✗ 8 on [6, 1, 2, 7]
max(dp[i-1], dp[i-2] + money[i]) # ✓ 13

Base case for two houses

For i = 1, dp[i-2] would be dp[-1]. In Python that silently reads the last cell; in C++ and Java it’s out of bounds. “Nothing before house 0” is worth 0.

take = dp[i - 2] + money[i] # ✗ dp[-1] when i == 1
take = (dp[i - 2] if i >= 2 else 0) + money[i] # ✓

Updating the rolling variables in the wrong order

In the O(1)-space version, overwriting prev2 first makes the next line add x to the new value, so every house gets robbed.

prev2 = prev1; prev1 = max(prev1, prev2 + x) # ✗ uses the new prev2
prev2, prev1 = prev1, max(prev1, prev2 + x) # ✓ both from the old pair

Deep recursion in the memoized version

@cache removes repeated work but not depth: best(n-1) still recurses n levels before anything is cached. Python stops at about 1,000 frames, so for large n use the loop.

Variations

  • Top-down (memoized). Write the recursion, add a cache, done. It’s the fastest to get right and only computes the states it actually reaches; the price is recursion depth and a little overhead per call.
  • Climbing stairs and Fibonacci. The number of ways to climb n steps taking 1 or 2 at a time is ways[n] = ways[n-1] + ways[n-2]: the same shape as House Robber with + instead of max.
  • Circular street. If the first and last houses are neighbours, they can’t both be robbed. Solve twice, once without the first house and once without the last, and take the larger.
  • Reconstructing the choice. The table answers “how much”; to answer “which houses”, walk back from the end as in the last step, or store the winning choice for each cell while filling it.
  • Spotting DP in general. Ask the five questions: what’s the state, what’s the choice, how does the answer follow from smaller states, what are the base cases, and in what order can you fill them. If plain recursion repeats arguments, the answers to those questions are your solution.

Check yourself

5 quick questions. Pick an answer to see why it's right or wrong.

  1. 1

    Step i of a staircase costs cost[i]. From any step you may climb one or two steps. You want the cheapest way to get past the top. Which approach fits?

  2. 2

    What does this print?

    money = [6, 1, 2, 7]
    prev2 = prev1 = 0
    for x in money:
    prev2, prev1 = prev1, max(prev1, prev2 + x)
    print(prev1)
  3. 3

    This O(1)-space version returns 18 for [2, 7, 9] instead of 11. What’s wrong?

    def rob(money):
    prev2 = prev1 = 0
    for x in money:
    prev2 = prev1
    prev1 = max(prev1, prev2 + x)
    return prev1
  4. 4

    How long does this take for n houses?

    from functools import cache
    def rob(money):
    @cache
    def best(i):
    if i < 0:
    return 0
    return max(best(i - 1), best(i - 2) + money[i])
    return best(len(money) - 1)
  5. 5

    You run the memoized best(i) from the previous question on 100,000 houses in Python. What typically happens?

Practice problems

Further reading

esc