~/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.
Name the subproblem (the state), write how its answer follows from smaller ones (the recurrence), and fill a table from the base cases up.
You want a best value or a count over many choices, and plain recursion keeps solving the same subproblems again.
O(states × work per state), O(n) for House Robber
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 - 1can give. - Rob it. Then house
i - 1is off limits, so you getmoney[i]plus the best from houses 0 toi - 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.
- State. Make an array
dpwith one cell per house.dp[i]will hold the most money you can take from houses 0 toi, ignoring everything to the right. - Why store it. Plain recursion would recompute
best(0)13 times for these 7 houses. The table computes each cell exactly once. - Base case. With only house 0 there’s nothing to decide:
dp[0] = money[0]. - Choice one, skip house
i: then the best isskip = dp[i-1], the answer we already stored for one house fewer. - Choice two, rob house
i: its neighbour is off limits, sotake = dp[i-2] + money[i]. Fori = 1there’s nothing two back, so count it as 0. - Recurrence. Keep the better option:
dp[i] = max(skip, take). Here robbing house 1 wins, 8 against 3. - 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.
- Answer. The last cell,
dp[n-1], covers every house: 23. - Which houses? Walk back from the end. If
dp[i]equalsdp[i-1], skipping houseiwas enough; step left by one. Otherwise houseiwas robbed; jump left by two. Note thatdp[3]robbed house 3, but the final plan doesn’t: a cell’s choice is only final for its own prefix.
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
nsteps taking 1 or 2 at a time isways[n] = ways[n-1] + ways[n-2]: the same shape as House Robber with+instead ofmax. - 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
Step
iof a staircase costscost[i]. From any step you may climb one or two steps. You want the cheapest way to get past the top. Which approach fits?Each step’s best cost depends only on the best costs of the two steps below it, and those subproblems are shared, which is the DP signature. Greedy fails because a cheap next step can lead into an expensive stretch. Plain recursion is correct but makes about 1.6ⁿ calls, since it recomputes the same steps over and over.
-
2
What does this print?
money = [6, 1, 2, 7]prev2 = prev1 = 0for x in money:prev2, prev1 = prev1, max(prev1, prev2 + x)print(prev1)Houses 0 and 3 are not neighbours, so 6 + 7 = 13 is allowed and beats everything else. Taking every other house gives only 6 + 2 = 8 or 1 + 7 = 8, which is why “alternate” is not a strategy. 16 would rob all four, ignoring the rule.
-
3
This O(1)-space version returns
18for[2, 7, 9]instead of11. What’s wrong?def rob(money):prev2 = prev1 = 0for x in money:prev2 = prev1prev1 = max(prev1, prev2 + x)return prev1After
prev2 = prev1, the line computesmax(prev1, prev1 + x), which just adds every house. The newprev1must be computed from the oldprev2: update both at once with a tuple assignment, or save the new value in a temporary first. Returningmax(prev1, prev2)doesn’t help, because the damage is already done inside the loop. -
4
How long does this take for
nhouses?from functools import cachedef rob(money):@cachedef best(i):if i < 0:return 0return max(best(i - 1), best(i - 2) + money[i])return best(len(money) - 1)There are only n + 1 distinct arguments, and each is computed once with O(1) work on top of two lookups. Without
@cachethe call tree does grow exponentially (like Fibonacci numbers), which is where the tempting O(2ⁿ) comes from. -
5
You run the memoized
best(i)from the previous question on 100,000 houses in Python. What typically happens?Caching removes repeated work, not depth.
best(99999)callsbest(99998), and so on, far past Python’s default limit of about 1,000 frames. The bottom-up loop has no recursion at all, which is one reason to prefer it. A cache of 100,000 small integers is tiny.