Dynamic Programming — theory and understanding
Build the core intuition for DP: it is just recursion that remembers. Learn the two conditions that make DP apply, how to define a state and a transition, and the three ways to implement the same recurrence — plain recursion, top-down memoization, and bottom-up tabulation.
What is dynamic programming?
Dynamic programming (DP) is a technique for solving a problem by breaking it into smaller subproblems, solving each subproblem once, and reusing the stored answer instead of recomputing it.
The one-sentence definition worth memorising:
DP = recursion + remembering the answers to subproblems you've already solved.
That's it. If you can write a correct recursive solution and the recursion keeps solving the same subproblems over and over, DP turns that exponential recursion into something fast — usually polynomial — just by caching.
The name is historical and misleading: it has nothing to do with "dynamic" memory or programming a computer. Richard Bellman coined it in the 1950s partly because it sounded impressive. Don't let the name intimidate you — think "smart recursion with a memory."
Why it matters in contests: an enormous fraction of medium-difficulty problems are DP. Counting problems ("in how many ways…"), optimisation problems ("maximum/minimum…"), and feasibility problems ("is it possible to…") are very often DP once you spot the right state.
The two conditions that signal DP
A problem is solvable by DP when it has both of these properties:
1. Optimal substructure — the optimal answer to the whole problem can be built from optimal answers to its subproblems.
Example: the shortest path from A to C through B uses the shortest path from A to B and the shortest path from B to C. The big answer is composed of smaller optimal answers.
2. Overlapping subproblems — the recursion solves the same subproblem multiple times.
Example: naïve
fib(n)computesfib(n-2)twice,fib(n-3)three times, and so on — the same calls recur all over the recursion tree.
The contrast that clarifies it:
- Divide and conquer (merge sort) has optimal substructure but the subproblems don't overlap — each half is sorted once. No caching needed → not DP.
- DP has optimal substructure and overlap → caching is the whole point.
When you see both conditions, reach for DP. In practice you spot overlap by writing the recursion and noticing it would call itself with the same arguments more than once.
State and transition — the heart of DP
Every DP boils down to answering two questions. Getting these right is solving the problem; the code is mechanical afterwards.
1. What is the state?
The state is the minimal set of variables that uniquely describes a subproblem. Write it as dp[...]. Ask: "What do I need to know to compute the answer for this subproblem, and nothing more?"
dp[i]= answer considering the firstielements (or ending at indexi).dp[i][c]= answer using items0..iwith capacityc(knapsack).dp[i][j]= answer for the cell at rowi, columnj(grids).
2. What is the transition?
The transition (a.k.a. the recurrence) expresses dp[state] in terms of smaller states. It encodes the choices available at this step.
Example — climbing stairs (reach step
ntaking 1 or 2 steps at a time):
dp[i] = dp[i-1] + dp[i-2]State:
dp[i]= number of ways to reach stepi. Transition: the last move was either a 1-step (fromi-1) or a 2-step (fromi-2).
3. Base cases — the smallest states you can fill in directly, without a transition. For climbing stairs: dp[0] = 1 (one way to stand still), dp[1] = 1.
The DP recipe (memorise this):
Three ways to implement the same DP
Once you have the recurrence, there are three standard implementations. They compute the exact same thing — choose based on convenience and constraints.
Style 1 — plain recursion (slow, for understanding only). Just translate the recurrence. Correct but exponential when subproblems overlap.
long long fib(int n) {
if (n < 2) return n; // base case
return fib(n - 1) + fib(n - 2); // transition — but recomputes everything
}Style 2 — top-down memoization (recursion + cache). Same recursion, but store each answer the first time and return it instantly afterwards.
long long memo[100005];
bool seen[100005];
long long fib(int n) {
if (n < 2) return n;
if (seen[n]) return memo[n]; // already computed → reuse
seen[n] = true;
return memo[n] = fib(n - 1) + fib(n - 2);
}Style 3 — bottom-up tabulation (iterative table fill). Drop recursion entirely; fill an array in an order that guarantees dependencies are ready.
long long dp[100005];
long long fib(int n) {
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}All three turn O(2ⁿ) into O(n). The first is a teaching tool; the last two are what you submit.
Top-down vs. bottom-up — which to use?
Both are standard; pick per problem. Here is the honest contest comparison.
| Top-down (memoization) | Bottom-up (tabulation) | |
|---|---|---|
| How you write it | Write the recursion, add a cache | Write a loop that fills a table |
| Easier to derive? | Usually yes — mirrors how you think | Needs you to nail the fill order |
| Computes only needed states? | Yes — lazy, only reachable states | No — fills the whole table |
| Risk | Stack overflow on deep recursion | None (no recursion) |
| Speed | Slight overhead (recursion + cache lookups) | Fastest — tight loops, cache-friendly |
| Space optimisation | Harder | Easy — rolling arrays |
Practical advice for contests:
- Start top-down. It's the fastest path from "I understand the recurrence" to "I have working code," because it follows your recursive reasoning directly.
- Switch to bottom-up when (a) recursion depth might overflow the stack, (b) you need the last drop of speed, or (c) you want to space-optimise with a rolling array.
Many strong competitors write top-down by default and only convert when forced. Both are correct — use whichever gets you to AC faster.
Why DP is fast — counting the work
The speed of a DP is captured by one simple formula:
Total time ≈ (number of distinct states) × (work per transition).
That's the single most useful thing to internalise. To estimate whether a DP will pass:
- Count the states — the size of your
dptable (e.g.nfordp[i],n·Wfordp[i][w]). - Multiply by the cost of one transition — O(1) if it's a couple of lookups, O(k) if you loop over
kchoices.
Examples:
- Fibonacci:
nstates × O(1) = O(n). - 0/1 knapsack:
n·Wstates × O(1) = O(n·W). - Coin change (min coins, amount
A,mcoins):Astates × O(m) per state = O(A·m). - Longest increasing subsequence (basic):
nstates × O(n) scan = O(n²).
Memoization guarantees each state is computed once; every later request is a cache hit. That "compute once, reuse forever" is exactly why exponential recursion collapses to polynomial DP.
If the state count alone already exceeds your time budget (say, 10⁹ states), the DP formulation is too coarse — you need a smaller state, a smarter transition, or a different technique.