core idea
Every DP problem is four decisions, made in order: what’s the state (the minimal set of parameters that pins down a subproblem), what’s the recurrence (how a state’s answer is built from smaller states’ answers), what’s the base case (the states you can answer without recursing), and what’s the order (fill states so every dependency is already known when you need it). Two ways to run the same recurrence once you’ve named all four:
| approach | direction | mechanics |
|---|---|---|
| memoization (top-down) | recursion, cached | write the brute-force recursion first, add a cache keyed by state — done |
| tabulation (bottom-up) | iteration, filled table | flip the recursion into a loop that fills states in dependency order |
DP is backtracking with a memory
Strip the cache out of any DP solution and what’s left is a plain backtracking recursion — same states, same choices, same base case. What turns exponential backtracking into polynomial DP is that the state space is small and the same states get asked for repeatedly: backtracking explores a tree where most branches are genuinely distinct; DP recognizes the tree secretly has repeated nodes and answers each one once.
when to reach for it
- The problem asks to count the ways, or find a minimum/maximum cost or value, over a sequence of choices.
- A brute-force recursion would work but is exponential — and tracing it shows the same arguments being recomputed over and over.
- The problem has optimal substructure: the best answer for the whole input is built from the best answers to smaller versions of the same problem, not just any answer to them.
- You can name the state in a sentence (“the best score up to index
i”, “the fewest coins to make amounta”) and that sentence has few enough distinct fillings to be practical — usually a polynomial count.
universal templates
Top-down (memoization) — write the recursion you’d write anyway, then cache it:
public int TopDown(int state, Dictionary<int, int> memo)
{
if (IsBaseCase(state)) return BaseValue(state);
if (memo.TryGetValue(state, out var cached)) return cached;
int result = Recurrence(state); // combines TopDown(smaller states)
memo[state] = result;
return result;
}
Bottom-up (tabulation) — the same recurrence, run forward through every state in dependency order:
public int BottomUp(int n)
{
var dp = new int[n + 1];
dp[0] = BaseValue; // seed the base case(s)
for (int i = 1; i <= n; i++)
dp[i] = Recurrence(dp, i); // only reads already-filled entries
return dp[n];
}
Every problem below names its state, writes the recurrence, and picks a direction. Most people think top-down first and ship bottom-up — the array is usually smaller than the recursion’s call stack, and there’s no depth limit to worry about.
rolling arrays
If dp[i] only ever reads dp[i-1] (and maybe dp[i-2]), you don’t need the array at all —
two or three variables suffice, and space drops from O(n) to O(1).
Climbing Stairs and
House Robber both compress this way;
Unique Paths shows the same trick one dimension
up, rolling a 2D table down to a single row.
problems
Fibonacci in disguise — the "ways to reach i" recurrence.
Take-or-skip: dp[i] = max(dp[i-1], dp[i-2] + a[i]).
2D grid DP: cell = sum of the two ways in; rows compress to 1D.
Min-coins per amount, built bottom-up — the unbounded knapsack shape.
DP over string prefixes, with a word set doing the lookups.
O(n²) DP first; the O(n log n) patience upgrade as an optional coda.
cheat sheet — dp
recognize it
- "count the ways" / "minimum or maximum cost or value" over a sequence of choices → DP
- a brute-force recursion re-derives the exact same arguments over and over (
ways(n-2)from two branches,dp[a-5]feeding multiple lateras) → overlapping subproblems - optimal substructure: the best answer for the whole input is built from the best answers to strictly smaller versions of the same problem, not just any answer to them
- a grid with only right/down moves, or a string question about splitting or matching → 2D or string DP — the family that also includes LCS (
1143) and Edit Distance
key tricks
- name the state as a sentence first (
dp[i]= ...) — the recurrence falls out once the sentence is precise - write the brute-force recursion, memoize it top-down to prove it's right, then flip to a bottom-up loop once it works
- if
dp[i]only ever readsdp[i-1]/dp[i-2], roll the table down to 2-3 variables — O(n) space becomes O(1) - Kadane (Maximum Subarray) is DP already compressed to one variable — recognize it before reaching for a fancier tool
- fixed predecessors (
i-1,i-2, Climbing Stairs / House Robber) vs. variable predecessors (every coin, every dictionary word, everyj < i) is the fork between the two solution shapes
common bugs
- wrong loop order — reading a
dpentry before it's filled, or accidentally computing the "combinations" variant instead of "minimum" by swapping which loop is outermost (Coin Change vs. Coin Change II) - off-by-one on the base case — indexing
dp[i-1]/dp[i-2]before the array holds anything real - an "impossible" sentinel that can look like a real answer, or that overflows once you add
1to it (int.MaxValue + 1) - returning
dp[n-1]when the true answer ismax(dp)over every state — the optimum doesn't always end at the last index (LIS)