What Is Dynamic Programming?
That notepad is the entire idea behind Dynamic Programming. Store the answer to each sub-problem once, look it up whenever needed, never recompute.
Dynamic Programming (DP) breaks complex problems into smaller overlapping sub-problems, solving each only once and storing its result — via memoisation (top-down) or tabulation (bottom-up).
Optimal substructure (best solution built from best sub-solutions) AND overlapping sub-problems (same sub-problems solved repeatedly). If either is absent, use greedy or divide-and-conquer.
Top-Down vs Bottom-Up
Pros: Only computes needed sub-problems.
Cons: Stack overflow risk on deep problems.
Pros: No stack overflow. Cache-friendly.
Cons: Must solve all sub-problems.
Interviews: top-down first. Production large inputs: bottom-up (no recursion limit, better memory locality). Both have identical asymptotic complexity.
Watch the DP table fill cell-by-cell. Each cell uses only the two before it.
Example 1 — Fibonacci Numbers
The fix is embarrassingly simple: write the answer for each month on a notepad the first time you compute it. Every future request just reads the notepad. T(n) = T(n−1) + T(n−2) — an exponential disaster — becomes a single linear pass. This one insight is the core of all DP.
The Fibonacci sequence is the traditional first DP problem. Its naive recursive implementation is O(2ⁿ) — fixing it with DP gives O(n).
Without memo, fib(5) calls fib(4) and fib(3).
fib(4) also calls fib(3) — again.
The call tree grows as φⁿ ≅ 1.618ⁿ.
With memo, each of fib(0)…fib(n) is computed exactly once:
n+1 total computations instead of exponentially many.
F(0)=0 | F(1)=1F(n)=F(n−1)+F(n−2)Approach A — Top-Down (Memoisation)
import sys
sys.setrecursionlimit(10000)
# memo dictionary stores already-computed results
memo = {}
def fib_memo(n):
if n <= 1: return n
if n in memo: return memo[n] # cache hit
memo[n] = fib_memo(n-1) + fib_memo(n-2)
return memo[n]
print([fib_memo(i) for i in range(10)])
Approach B — Bottom-Up (Tabulation)
def fib_dp(n):
if n <= 1: return n
dp = [0] * (n+1)
dp[1] = 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2] # recurrence
return dp[n]
Tabulation turns the recurrence into a loop. We allocate an array
dp[0…n], seed the base cases (dp[0]=0, dp[1]=1),
then iterate i from 2 to n, filling dp[i] = dp[i−1] + dp[i−2].
Because we always fill left-to-right, the values we depend on are already computed.
No recursion, no stack, no risk of overflow — just a simple loop.
If dp[i] depends only on dp[i-1] and dp[i-2], replace the full table with two variables: O(n) → O(1) space.
Example 2 — 0/1 Knapsack Problem
dp[i][w] = max value, first i items, capacity wdp[i][w] = dp[i−1][w]dp[i][w] = dp[i−1][w−wt] + valmax(skip, take)The 2-D table captures all possibilities. Row i asks: "What if I could only use items 1…i?" Column w asks: "What if my capacity were exactly w kg?" For each cell (i, w) we face a binary choice — take item i or skip it — and record the better option. Because row i only depends on row i−1, the table can be space-optimised to a single 1-D array filled in reverse.
The “0/1” means you either take an item completely (1) or not at all (0). You cannot take half an artefact. This is what rules out the greedy approach (which works perfectly for the fractional knapsack). The binary choice forces you to consider all item subsets — 2ⁿ combinations — unless you use DP to eliminate redundant sub-problems and bring it down to O(n×W).
def knapsack(weights, values, W):
n = len(weights)
dp = [[0]*(W+1) for _ in range(n+1)]
for i in range(1, n+1):
for w in range(W+1):
skip = dp[i-1][w]
take = 0
if weights[i-1] <= w:
take = dp[i-1][w-weights[i-1]] + values[i-1]
dp[i][w] = max(skip, take)
return dp[n][W]
dp[n][W] gives the optimal value. To find which items,
walk backwards from dp[n][W]: if dp[i][w] == dp[i−1][w],
item i was skipped (move up a row). Otherwise item i was taken —
record it and subtract its weight (w −= weights[i−1]).
Repeat until i=0. This traceback is O(n+W) and works for any 2-D DP table.
■ Blue = traceback path. ■ Green = optimal answer dp[4][8]=10.
2-D table is O(n×W) time and space. If W is enormous (e.g. 10⁵), use branch-and-bound instead.
Example 3 — Longest Common Subsequence (LCS)
git diff computes the LCS of two file versions. Lines in the LCS are “unchanged”; everything else is an insertion or deletion. LCS also powers DNA alignment, spelling correction, and plagiarism detectors.
LCS is solved with a 2-D DP table. dp[i][j] stores the length of the
longest common subsequence of the first i characters of X and the first
j characters of Y. The table is filled row by row; each cell depends on
at most three neighbours (diagonal, above, left), so the fill order is straightforward.
dp[i][j]=dp[i−1][j−1]+1dp[i][j]=max(dp[i−1][j], dp[i][j−1])dp[0][j]=dp[i][0]=0def lcs(X, Y):
m, n = len(X), len(Y)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if X[i-1] == Y[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
X,Y = "ABCB", "BDCB" # LCS = "BCB", length 3
The 2-D table has m×n cells, each computed in O(1) — giving O(m×n) time. Space is also O(m×n) for the full table. Since each row only needs the previous row, this can be reduced to O(min(m,n)) space by keeping only two rows. The traceback step requires the full table, so only do the space optimisation when you only need the length, not the actual LCS string.
Edit Distance, Shortest Common Supersequence, DNA alignment (Smith-Waterman) — all are variations on the LCS DP table. The recurrence changes; the strategy stays identical.
Example 4 — Coin Change (Minimum Coins)
dp[a] = min coins to make amount adp[0] = 0 | dp[a] = ∞ for a > 0 (initially unreachable)dp[a] = min(dp[a], dp[a−c] + 1)dp[amount] (or −1 if still ∞)
The key insight: to make amount a using coin c, we need to already know
how to make amount a−c optimally. By iterating a from 1 to amount
(smallest to largest), every dp[a−c] we reference is already filled.
We try every coin for every amount, keeping the minimum. This is an
unbounded knapsack variant — coins can be reused any number of times.
def coin_change(coins, amount):
INF = float('inf')
dp = [INF] * (amount+1)
dp[0] = 0
for a in range(1, amount+1):
for c in coins:
if c <= a and dp[a-c] != INF:
dp[a] = min(dp[a], dp[a-c]+1)
return dp[amount] if dp[amount]!=INF else -1
Unbounded Coin Change (coins reusable): outer loop over amounts,
inner loop over coins — any coin can extend any sub-amount repeatedly.
0/1 Knapsack (each item once): outer loop over items, inner loop
over capacity in reverse — reversing prevents an item being used twice in one pass.
The recurrence looks similar; the loop order is what makes them different problems.
Example 5 — Longest Increasing Subsequence (LIS)
This is exactly LIS: the box heights are the array; the stack is the increasing subsequence. The same pattern appears in scheduling jobs by deadline, finding the longest chain of events, and the patience card-sorting algorithm used in real card games. O(n log n) LIS is one of the most elegant DP tricks.
Find the longest subsequence where each element is strictly greater than the previous. Elements need not be contiguous.
dp[i] = length of longest increasing subsequence ending at index idp[i] = 1 for all i (every element is a subsequence of length 1)dp[i] = max(dp[i], dp[j] + 1)max(dp) — the best ending position
Keep a tails[] array where tails[k] is the smallest
tail element of any increasing subsequence of length k+1. For each new element,
binary-search for where it fits in tails. This runs in O(log n) per
element — O(n log n) total. The tails array length at the end equals
the LIS length. Note: tails itself is not the LIS; it is a structural
bookkeeping array.
def lis_dp(arr):
n = len(arr)
dp = [1] * n
prev = [-1] * n
for i in range(1, n):
for j in range(i):
if arr[j] < arr[i] and dp[j]+1 > dp[i]:
dp[i] = dp[j]+1; prev[i]=j
return max(dp)
dp[i] tells you the length but not which elements.
To reconstruct, maintain a prev[i] array: whenever dp[i]
is updated via index j, store prev[i] = j.
After filling dp, start at the index with max(dp) and follow
prev pointers backwards until you reach −1.
Reverse the collected indices to get the subsequence in order.
Example 6 — Matrix Chain Multiplication
A(10×30)·B(30×5)·C(5×60): (A·B)·C=4,500 ops vs A·(B·C)=27,000 ops. DP finds the optimal parenthesisation in O(n³).
dp[i][j] = min scalar multiplications to compute Aₐ·A₁·…·Aⱼdp[i][i] = 0 — one matrix needs no multiplicationdp[i][j] = min over all k of (cost)This is interval DP: the state spans a contiguous sub-range [i,j]. We try every possible split point k — meaning every possible place to put the last parenthesis — and take the minimum cost. The key is to fill by increasing chain length: a chain of length l depends only on sub-chains of length < l, which are already computed.
The number of ways to parenthesise n matrices is the (n−1)-th Catalan number Cₙ₋₁. For n=10 that is 4,862. For n=20 it is 1,767,263,190. For n=50 it exceeds 10³³. DP solves all n matrices in O(n³) time and O(n²) space — a transformation of almost unimaginable scale.
def matrix_chain(dims):
n = len(dims)-1
dp = [[0]*n for _ in range(n)]
for l in range(2, n+1):
for i in range(n-l+1):
j = i+l-1; dp[i][j] = float('inf')
for k in range(i,j):
cost = dp[i][k]+dp[k+1][j]+dims[i]*dims[k+1]*dims[j+1]
if cost<dp[i][j]: dp[i][j]=cost
return dp[0][n-1]
Alongside dp[i][j], maintain a split[i][j] table recording
which k achieved the minimum. To print the optimal order, recurse:
print_opt(i,j) calls print_opt(i, split[i][j]) and
print_opt(split[i][j]+1, j), wrapping each in parentheses.
The recursion bottoms out when i==j (a single matrix). This gives the
full parenthesisation in O(n) time after the O(n³) fill.
Time & Space Complexity Reference
Every DP problem has two costs: time (how many states × how much work per state) and space (how big the table is). The table below shows both for each example in this tutorial, plus whether the space can be reduced. The “optimised space” column assumes you only need the final value, not the full traceback.
A general rule: DP time = (number of distinct states) × (work per state transition). For 1-D DP with n states and O(1) transitions: O(n). For 2-D DP with m×n states and O(1) transitions: O(m×n). Matrix chain has O(n²) states and O(n) transitions (trying all k), giving O(n³).
| Problem | Brute Force | DP Time | DP Space | Opt. Space | Approach |
|---|---|---|---|---|---|
| Fibonacci | O(2ⁿ) | O(n) | O(n) | O(1) | 1-D table |
| 0/1 Knapsack | O(2ⁿ) | O(n×W) | O(n×W) | O(W) | 2-D table |
| LCS | O(2ⁿ) | O(m×n) | O(m×n) | O(min(m,n)) | 2-D grid |
| Coin Change | O(Sⁿ) | O(n×A) | O(A) | O(A) | 1-D table |
| LIS | O(2ⁿ) | O(n²) | O(n) | O(n log n) | binary search |
| Matrix Chain | O(4ⁿ) | O(n³) | O(n²) | O(n²) | interval DP |
| Edit Distance | O(3^(m+n)) | O(m×n) | O(m×n) | O(min(m,n)) | 2-D grid |
The 5-Step Framework
Every DP problem, regardless of domain, can be solved by following these five steps in order. Skipping steps — especially step 1 — is the leading cause of bugs in DP code. Work through a small example (3–4 elements) on paper before writing a single line of code.
dp[i] = ... in one plain-English sentence.DP Pattern Taxonomy
Recognising the pattern a problem belongs to is the fastest route to a solution. Most DP problems are disguised instances of one of six canonical patterns. Once you identify the pattern, the state definition, recurrence, and fill order follow almost mechanically.
When you see a problem: (1) Is there a single sequence? → Linear DP. (2) Two sequences or a grid? → 2-D DP. (3) Sub-array / sub-chain? → Interval DP. (4) A tree structure? → Tree DP. (5) n ≤ 20 with subset choices? → Bitmask DP. (6) Count integers in a range with digit constraints? → Digit DP. Most real problems reduce to one of these in under a minute of analysis.
Common Mistakes & How to Avoid Them
Most DP bugs fall into a small number of repeating categories. The table below shows the five most common mistakes, what symptom they produce, and the concrete fix. Recognising these patterns in your own code is worth more than memorising a hundred DP solutions.
| Mistake | Symptom |
|---|---|
| Wrong state definition | Answer always 0 or ∞ |
| Missing base case | Index error / silent wrong |
| Off-by-one table size | IndexError on dp[n] |
| Wrong fill order | Using dp[i+1] before computed |
| Mutating memo mid-loop | Non-deterministic results |
| Fix | How |
|---|---|
| State must fully describe sub-problem | Test with small example by hand |
| Init dp[0] before the loop | Trace what happens when i=1 |
| Use dp=[0]*(n+1) not [0]*n | One extra cell for “zero items” |
| Fill smallest i first | Draw dependency arrows |
| Separate dp table from input | Never modify input in place |
Does your greedy choice ever need to be revised based on future information? If yes, it is not greedy — it is DP.
Golden Rules of Dynamic Programming
These six rules distil hundreds of solved DP problems into actionable habits. They are not tips to memorise — they are habits to build through deliberate practice. Apply them on every new problem until they become automatic.
dp[i] = ... in plain English. If you cannot explain it in one sentence, your state is wrong.dp[i]+=dp[i-k]. Optimisation: dp[i]=min/max(dp[i],…).