Dynamic programming (DP) is an algorithmic paradigm that solves complex problems by breaking them into simpler overlapping subproblems, solving each once, and storing the result for reuse.
An optimal solution to the problem contains optimal solutions to its subproblems. If you can express the answer in terms of answers to smaller instances of the same problem, the structure is present.
Example: shortest path from A to C via B = shortest(A→B) + shortest(B→C)
The same subproblem is encountered many times during recursion. Naive recursion recomputes them exponentially; DP computes each exactly once.
Example: fib(5) calls fib(3) twice, fib(2) three times
When both properties hold — optimal substructure and overlapping subproblems — dynamic programming applies. Without overlap, divide-and-conquer suffices. Without optimal substructure, greedy or DP will not yield correct results.
memo = {}
def fib(n):
if n <= 1: return n
if n in memo: return memo[n]
memo[n] = fib(n-1) + fib(n-2)
return memo[n]
def fib(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]
return dp[n]
Rule of thumb: use top-down when the state space is sparse or hard to enumerate; use bottom-up when you need every subproblem and want to optimise space.
The recursion tree explodes exponentially. fib(40) makes over one billion calls.
def fib(n):
if n <= 1: return n
return fib(n-1) + fib(n-2)
| Approach | Time | Space |
|---|---|---|
| Naive recursion | O(2n) | O(n) stack |
| Memoization | O(n) | O(n) |
| Tabulation | O(n) | O(n) |
| Space-optimised | O(n) | O(1) |
def fib(n):
if n <= 1: return n
prev2, prev1 = 0, 1
for _ in range(2, n + 1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
Key insight: we only ever need the last two values — no need to store the entire table. This pattern generalises to many 1D DP problems.
A systematic approach to solving any DP problem:
dp[i], dp[i][j], dp[mask]
dp[state] in terms of smaller states. This is the transition function.
dp[0] = 0, dp[0][0] = 1, etc.
dp[n], max(dp), or backtrack for the solution path.
You can climb 1 or 2 steps at a time. How many distinct ways to reach step n?
# State: dp[i] = ways to reach step i
# Recurrence: dp[i] = dp[i-1] + dp[i-2]
# Base: dp[0] = 1, dp[1] = 1
# Order: left to right
# Answer: dp[n]
def climb_stairs(n):
if n <= 1: return 1
prev2, prev1 = 1, 1
for i in range(2, n + 1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
Notice: this is exactly the Fibonacci sequence shifted by one index. Many 1D DP problems reduce to similar linear recurrences.
Given coins of denominations coins[] and a target amount, find the minimum number of coins needed.
dp[0] = 0 # base case
dp[amount] = min(dp[amount - coin] + 1)
for coin in coins
if amount - coin >= 0
def coin_change(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
| amount | dp[amount] | best coin |
|---|---|---|
| 0 | 0 | — |
| 1 | 1 | 1 |
| 2 | 2 | 1 |
| 3 | 1 | 3 |
| 4 | 1 | 4 |
| 5 | 2 | 1+4 |
| 6 | 2 | 3+3 |
Complexity: Time O(amount * |coins|), Space O(amount). Greedy fails here — greedy picks 4+1+1 = 3 coins, but DP finds 3+3 = 2 coins.
Given an array nums[], find the length of the longest strictly increasing subsequence (LIS).
# State: dp[i] = length of LIS ending at index i
# Recurrence: dp[i] = max(dp[j] + 1)
# for all j < i where nums[j] < nums[i]
# Base: dp[i] = 1 for all i
def lis(nums):
n = len(nums)
dp = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
import bisect
def lis_fast(nums):
tails = [] # tails[i] = smallest tail of
# all increasing subseqs of length i+1
for x in nums:
pos = bisect.bisect_left(tails, x)
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
return len(tails)
| Step | tails | LIS length |
|---|---|---|
| 10 | [10] | 1 |
| 9 | [9] | 1 |
| 2 | [2] | 1 |
| 5 | [2, 5] | 2 |
| 3 | [2, 3] | 2 |
| 7 | [2, 3, 7] | 3 |
| 101 | [2, 3, 7, 101] | 4 |
| 18 | [2, 3, 7, 18] | 4 |
Minimum insertions, deletions, and substitutions to transform string a into string b.
# State: dp[i][j] = edit distance between
# a[0..i-1] and b[0..j-1]
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1] # match
else:
dp[i][j] = 1 + min(
dp[i-1][j], # delete from a
dp[i][j-1], # insert into a
dp[i-1][j-1] # substitute
)
def edit_distance(a, b):
m, n = len(a), len(b)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(m+1): dp[i][0] = i
for j in range(n+1): dp[0][j] = j
for i in range(1, m+1):
for j in range(1, n+1):
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j],
dp[i][j-1], dp[i-1][j-1])
return dp[m][n]
| s | i | t | t | i | n | g | ||
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
| k | 1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| i | 2 | 2 | 1 | 2 | 3 | 4 | 5 | 6 |
| t | 3 | 3 | 2 | 1 | 2 | 3 | 4 | 5 |
| t | 4 | 4 | 3 | 2 | 1 | 2 | 3 | 4 |
| e | 5 | 5 | 4 | 3 | 2 | 2 | 3 | 4 |
| n | 6 | 6 | 5 | 4 | 3 | 3 | 2 | 3 |
Answer: 3 — substitute k→s, substitute e→i, insert g. Time O(mn), Space O(mn) reducible to O(min(m,n)).
Find the length of the longest subsequence common to two strings a and b. Characters need not be contiguous but must preserve order.
# State: dp[i][j] = LCS length of a[0..i-1], b[0..j-1]
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
def lcs(a, b):
m, n = len(a), len(b)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if a[i-1] == b[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]
def recover_lcs(a, b, dp):
i, j = len(a), len(b)
result = []
while i > 0 and j > 0:
if a[i-1] == b[j-1]:
result.append(a[i-1])
i -= 1; j -= 1
elif dp[i-1][j] > dp[i][j-1]:
i -= 1
else:
j -= 1
return ''.join(reversed(result))
| String a | ABCBDAB | |||||
|---|---|---|---|---|---|---|
| String b | BDCAB | |||||
| LCS | BCAB (length 4) | |||||
Applications: diff utilities, version control, DNA sequence alignment, plagiarism detection.
Given matrices A1...An with dimensions p[0]×p[1], p[1]×p[2], ..., p[n-1]×p[n], find the parenthesisation that minimises total scalar multiplications.
# State: dp[i][j] = min cost to multiply A_i..A_j
# Recurrence:
dp[i][j] = min over k in [i, j):
dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]
# Base: dp[i][i] = 0 (single matrix, no cost)
def matrix_chain(p):
n = len(p) - 1
dp = [[0]*n for _ in range(n)]
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = float('inf')
for k in range(i, j):
cost = (dp[i][k] + dp[k+1][j]
+ p[i]*p[k+1]*p[j+1])
dp[i][j] = min(dp[i][j], cost)
return dp[0][n-1]
Matrices: A1(10×30), A2(30×5), A3(5×60)
| Parenthesisation | Cost |
|---|---|
(A1·A2)·A3 | 10·30·5 + 10·5·60 = 4500 |
A1·(A2·A3) | 30·5·60 + 10·30·60 = 27000 |
6× difference! The wrong parenthesisation can be catastrophically expensive. For n matrices, there are C(n-1) Catalan-number ways to parenthesise.
Complexity: Time O(n3), Space O(n2). This is a classic interval DP — iterate by increasing interval length.
Each item can be taken at most once.
# dp[i][w] = max value using
# items 0..i with capacity w
if w[i] <= j:
dp[i][j] = max(
dp[i-1][j], # skip
dp[i-1][j-w[i]] + v[i] # take
)
# Time: O(nW), Space: O(nW) → O(W)
NP-hard — pseudo-polynomial in W
Each item has unlimited supply.
# dp[w] = max value with capacity w
for w in range(1, W + 1):
for i in range(n):
if wt[i] <= w:
dp[w] = max(dp[w],
dp[w - wt[i]] + val[i])
# Time: O(nW), Space: O(W)
Coin change is a variant of this
Items can be divided — take any fraction.
# Greedy by value/weight ratio
items.sort(
key=lambda x: x.val/x.wt,
reverse=True)
for item in items:
if capacity >= item.wt:
take whole item
else:
take fraction
break
# Time: O(n log n), Space: O(1)
Greedy works — no DP needed
Key distinction: fractional knapsack has the greedy-choice property. 0/1 and unbounded do not — you must consider all combinations via DP. The 0/1 variant's 1D space optimisation requires iterating capacity right to left to avoid using an item twice.
Strings are a rich domain for DP. The state is typically indices into one or two strings.
# dp[i][j] = LPS length of s[i..j]
if s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
# Iterate by increasing length
# Time: O(n^2), Space: O(n^2) → O(n)
# dp[i] = min cuts for s[0..i] to be
# all palindromes
dp[i] = min(dp[j-1] + 1)
for j in [0..i] where s[j..i]
is a palindrome
# Pre-compute is_pal[i][j] with DP
# Time: O(n^2), Space: O(n^2)
Count the number of distinct subsequences of s that equal t.
# dp[i][j] = ways to form t[0..j-1]
# from s[0..i-1]
if s[i-1] == t[j-1]:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
else:
dp[i][j] = dp[i-1][j]
# Time: O(mn), Space: O(mn) → O(n)
# dp[i][j] = does s[0..i-1] match p[0..j-1]?
# Handle '.', '*' with careful transitions
# Time: O(mn), Space: O(mn)
Pattern: string DP almost always uses dp[i][j] indexed by positions in one or two strings. The transition considers match/mismatch at the current character.
Tree DP computes values at each node based on its children, via post-order DFS. The state is typically dp[node] or dp[node][choice].
Select nodes with maximum total weight such that no two adjacent nodes are selected.
# dp[v][0] = max if v is NOT selected
# dp[v][1] = max if v IS selected
dp[v][0] = sum(max(dp[c][0], dp[c][1])
for c in children(v))
dp[v][1] = weight[v] +
sum(dp[c][0]
for c in children(v))
# Answer: max(dp[root][0], dp[root][1])
# dp[v] = longest path starting at v
# going downward
dp[v] = max(dp[c] + 1 for c in children(v))
# At each node, diameter candidate =
# sum of two longest downward paths
diameter = max(top1 + top2)
across all nodes
Some tree DP problems ask for the answer rooted at every node. Naive approach: run DFS from each node — O(n2).
Rerooting: compute DP rooted at node 1, then "shift the root" to each adjacent node in O(1) using precomputed values. Total: O(n).
Key insight: tree DP works because trees have no cycles — each subtree is an independent subproblem. Process leaves first, work upward.
When the state involves a subset of n elements (n ≤ 20–25), represent the subset as a bitmask integer. State: dp[mask] or dp[mask][i].
# dp[mask][i] = min cost to visit cities in
# 'mask' ending at city i
# Base: dp[1 << start][start] = 0
for mask in range(1, 1 << n):
for i in range(n):
if not (mask & (1 << i)): continue
for j in range(n):
if mask & (1 << j): continue
new_mask = mask | (1 << j)
dp[new_mask][j] = min(
dp[new_mask][j],
dp[mask][i] + dist[i][j]
)
# Answer: min(dp[(1<
| Operation | Code |
|---|---|
| Set bit i | mask | (1 << i) |
| Clear bit i | mask & ~(1 << i) |
| Check bit i | mask & (1 << i) |
| Toggle bit i | mask ^ (1 << i) |
| Count set bits | bin(mask).count('1') |
| Lowest set bit | mask & (-mask) |
| Iterate subsets of mask | sub = mask; while sub: ... sub = (sub-1) & mask |
Interval DP solves problems where the state is a contiguous range [i, j] and the answer for a range depends on splitting it into smaller ranges.
# Iterate by interval length
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
for k in range(i, j): # split point
dp[i][j] = combine(
dp[i][k], dp[k+1][j], cost(i,k,j)
)
# dp[i][j] = max coins from bursting
# balloons i..j
dp[i][j] = max over k in [i..j]:
dp[i][k-1] + dp[k+1][j]
+ nums[i-1] * nums[k] * nums[j+1]
# Key trick: k is the LAST balloon burst
# in the range, not the first
Given keys with search frequencies, build a BST minimising expected search cost.
# dp[i][j] = min cost BST for keys i..j
dp[i][j] = min over r in [i..j]:
dp[i][r-1] + dp[r+1][j] + sum(freq[i..j])
# Time: O(n^3), improved to O(n^2)
# with Knuth's optimisation
Classic interval DP problems:
Complexity: typically O(n3) time, O(n2) space. Some can be optimised to O(n2) with Knuth's or divide-and-conquer optimisation.
When dp[i] depends only on dp[i-1], keep just two rows and alternate.
# Edit distance: O(mn) → O(n) space
def edit_distance(a, b):
m, n = len(a), len(b)
prev = list(range(n + 1))
curr = [0] * (n + 1)
for i in range(1, m + 1):
curr[0] = i
for j in range(1, n + 1):
if a[i-1] == b[j-1]:
curr[j] = prev[j-1]
else:
curr[j] = 1 + min(prev[j],
curr[j-1], prev[j-1])
prev, curr = curr, prev
return prev[n]
# When only prev and prev-prev needed:
prev2, prev1 = base_val, base_val
for ...:
prev2, prev1 = prev1, f(prev1, prev2)
For 0/1 knapsack, iterate capacity right to left to avoid counting an item twice.
# 0/1 knapsack in O(W) space
dp = [0] * (W + 1)
for i in range(n):
for w in range(W, wt[i] - 1, -1):
dp[w] = max(dp[w],
dp[w - wt[i]] + val[i])
For unbounded knapsack, iterate left to right — reusing the same item is allowed.
| Problem | Full | Optimised |
|---|---|---|
| Fibonacci | O(n) | O(1) |
| LCS / Edit dist | O(mn) | O(min(m,n)) |
| 0/1 Knapsack | O(nW) | O(W) |
| Grid paths | O(mn) | O(n) |
| LIS | O(n2) | O(n) |
For interval DP where the optimal split point opt[i][j] satisfies opt[i][j-1] ≤ opt[i][j] ≤ opt[i+1][j], reduce from O(n3) to O(n2).
Applies to: optimal BST, certain stone-merging variants
When dp[i][j] = min over k (dp[i-1][k] + C(k,j)) and the optimal k increases with j, use D&C to reduce one layer from O(n2) to O(n log n).
Applies to: many partition-into-k-groups problems
When the recurrence has the form dp[i] = min(dp[j] + b[j]*a[i]) and slopes b[j] are monotone, maintain a convex hull of lines. Query is O(1) amortised.
Li Chao tree generalises to arbitrary slope order
For sliding-window minimum/maximum in DP transitions. Reduces per-transition cost from O(k) to O(1) amortised.
# dp[i] = min(dp[j] + cost)
# for j in [i-k, i-1]
# Use a deque maintaining indices
# in increasing dp order
Compute for every bitmask the sum over all its submasks. Naive is O(3n); SOS DP does it in O(n · 2n).
for bit in range(n):
for mask in range(1 << n):
if mask & (1 << bit):
dp[mask] += dp[mask ^ (1 << bit)]
When to use: these optimisations matter in competitive programming and at scale. In interviews, the standard O(n2) or O(n3) DP is almost always sufficient.
| Phrase | Likely DP type |
|---|---|
| "minimum / maximum" | Optimisation DP |
| "count the number of ways" | Counting DP |
| "is it possible to…" | Boolean DP (feasibility) |
| "longest / shortest" | Sequence DP |
| "partition into groups" | Interval / subset DP |
| "subsequence" | 1D or 2D string DP |
| "grid / matrix traversal" | 2D grid DP |
| "subset / bitmask" | Bitmask DP (n ≤ 20) |
| Paradigm | When to use |
|---|---|
| Greedy | Greedy-choice property holds; locally optimal = globally optimal |
| D&C | Subproblems are independent (no overlap) |
| DP | Overlapping subproblems + optimal substructure |
| Backtracking | Need all solutions, not just optimal; pruning helps |
Off-by-one errors in base cases propagate through the entire table. Always verify: what happens when the input is empty? When it has one element?
If dp[i] depends on dp[i-1] and dp[i+1], a single left-to-right pass won't work. Draw the dependency graph.
Missing a dimension in the state leads to incorrect transitions. Example: forgetting to track "last element chosen" in problems requiring it.
Counting problems can produce astronomically large values. Use modular arithmetic (% 109+7) or big integers as needed.
For small inputs, print the entire table and manually verify each cell against the recurrence.
Write the naive recursive solution first. Verify it works. Then add memoization. This separates correctness from optimisation.
Generate random small inputs, compare brute-force vs DP output. If they diverge, you have a minimal failing case to debug.
| Source | Description |
|---|---|
| CLRS | Introduction to Algorithms — chapters 14–15 cover DP foundations rigorously |
| Kleinberg & Tardos | Algorithm Design — excellent intuition-building DP chapter |
| Competitive Programmer's Handbook | Laaksonen — free PDF, concise DP coverage with contest examples |
| LeetCode | leetcode.com/tag/dynamic-programming — 500+ problems sorted by difficulty |
| Codeforces EDU | DP section — structured problems with editorials |
| MIT 6.006 | Erik Demaine's lectures on DP — free on YouTube, exceptional clarity |
Practice path: Fibonacci → climbing stairs → coin change → LCS → edit distance → knapsack → LIS → interval DP → tree DP → bitmask DP. Master each level before advancing.