Dynamic programming (DP) breaks a complex problem into overlapping subproblems, solves each once, and stores the result. Master these patterns and you can solve 80% of DP interview questions.
Quick reference
| Pattern | Classic problem | Key insight |
|---|---|---|
| 1D DP | Climbing stairs, Fibonacci | dp[i] depends on previous 1–2 states |
| 2D DP / grid | Unique paths, min path sum | dp[i][j] from top-left to bottom-right |
| 0/1 Knapsack | Subset sum, partition equal subset | Include or exclude current item |
| Unbounded knapsack | Coin change, rod cutting | Item can be used unlimited times |
| Longest common subsequence | LCS, edit distance, LCS variants | Match or skip characters |
| Longest increasing subsequence | LIS, Russian doll envelopes | Patience sorting / binary search |
| DP on intervals | Matrix chain multiplication, burst balloons | Try every split point |
| DP on trees | Tree diameter, house robber III | Post-order: solve children first |
| Bitmask DP | TSP, assignment problem | State = set of items visited |
| Digit DP | Count numbers with property | Build digit by digit, track tight constraint |
The two approaches
Memoization (top-down)
Recursive + cache. Write the recursion you'd naturally think of, then cache results.
from functools import lru_cache
def fib(n):
@lru_cache(maxsize=None)
def dp(i):
if i <= 1:
return i
return dp(i - 1) + dp(i - 2)
return dp(n)
function fib(n) {
const memo = new Map();
function dp(i) {
if (i <= 1) return i;
if (memo.has(i)) return memo.get(i);
const result = dp(i - 1) + dp(i - 2);
memo.set(i, result);
return result;
}
return dp(n);
}
Tabulation (bottom-up)
Iterative. Fill a table from base cases upward. No recursion overhead.
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]
Space-optimised (only need last two values):
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
| Memoization | Tabulation | |
|---|---|---|
| Style | Recursive | Iterative |
| State space | Only reachable states | All states |
| Overhead | Call stack | None |
| Space optimisation | Harder | Easier |
| Starting point | Natural recursion | Must think bottom-up |
Pattern 1 — 1D DP
Climbing stairs (LC 70)
You can climb 1 or 2 steps. How many ways to reach step n?
def climbStairs(n: int) -> int:
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev1 + prev2
return prev1
func climbStairs(n int) int {
if n <= 2 { return n }
prev2, prev1 := 1, 2
for i := 3; i <= n; i++ {
prev2, prev1 = prev1, prev1+prev2
}
return prev1
}
Pattern 2 — 2D DP / grid
Minimum path sum (LC 64)
Grid of non-negative numbers. Find path from top-left to bottom-right with minimum sum (right or down only).
def minPathSum(grid: list[list[int]]) -> int:
m, n = len(grid), len(grid[0])
dp = [row[:] for row in grid] # copy
for i in range(1, m):
dp[i][0] += dp[i-1][0]
for j in range(1, n):
dp[0][j] += dp[0][j-1]
for i in range(1, m):
for j in range(1, n):
dp[i][j] += min(dp[i-1][j], dp[i][j-1])
return dp[m-1][n-1]
function minPathSum(grid) {
const m = grid.length, n = grid[0].length;
const dp = grid.map(row => [...row]);
for (let i = 1; i < m; i++) dp[i][0] += dp[i-1][0];
for (let j = 1; j < n; j++) dp[0][j] += dp[0][j-1];
for (let i = 1; i < m; i++)
for (let j = 1; j < n; j++)
dp[i][j] += Math.min(dp[i-1][j], dp[i][j-1]);
return dp[m-1][n-1];
}
Pattern 3 — 0/1 Knapsack
Each item can be used at most once. State: dp[i][w] = max value using first i items with capacity w.
def knapsack(weights: list, values: list, capacity: int) -> int:
n = len(weights)
# 1D optimised — iterate weights in reverse
dp = [0] * (capacity + 1)
for i in range(n):
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
func knapsack(weights, values []int, capacity int) int {
dp := make([]int, capacity+1)
for i := range weights {
for w := capacity; w >= weights[i]; w-- {
if dp[w-weights[i]]+values[i] > dp[w] {
dp[w] = dp[w-weights[i]] + values[i]
}
}
}
return dp[capacity]
}
Key: reverse inner loop prevents using the same item twice.
Subset sum (LC 416)
Can we partition array into two equal-sum subsets?
def canPartition(nums: list[int]) -> bool:
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
Pattern 4 — Unbounded Knapsack
Each item can be used unlimited times. Inner loop goes forward.
Coin change (LC 322)
Minimum coins to make amount.
def coinChange(coins: list[int], amount: int) -> int:
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for a in range(coin, amount + 1): # forward = unbounded
dp[a] = min(dp[a], dp[a - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
function coinChange(coins, amount) {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (const coin of coins)
for (let a = coin; a <= amount; a++)
dp[a] = Math.min(dp[a], dp[a - coin] + 1);
return dp[amount] === Infinity ? -1 : dp[amount];
}
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int coin : coins)
for (int a = coin; a <= amount; a++)
dp[a] = Math.min(dp[a], dp[a - coin] + 1);
return dp[amount] > amount ? -1 : dp[amount];
}
Coin change II — number of ways (LC 518)
def change(amount: int, coins: list[int]) -> int:
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for a in range(coin, amount + 1):
dp[a] += dp[a - coin]
return dp[amount]
Pattern 5 — Longest Common Subsequence (LCS)
dp[i][j] = LCS length for s1[:i] and s2[:j].
def longestCommonSubsequence(s1: str, s2: str) -> int:
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[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]
func longestCommonSubsequence(s1, s2 string) int {
m, n := len(s1), len(s2)
dp := make([][]int, m+1)
for i := range dp { dp[i] = make([]int, n+1) }
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if s1[i-1] == s2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] > dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
return dp[m][n]
}
Edit distance (LC 72)
Minimum operations (insert/delete/replace) to convert word1 to word2.
def minDistance(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = list(range(n + 1))
for i in range(1, m + 1):
prev = dp[0]
dp[0] = i
for j in range(1, n + 1):
temp = dp[j]
if word1[i-1] == word2[j-1]:
dp[j] = prev
else:
dp[j] = 1 + min(prev, dp[j], dp[j-1])
prev = temp
return dp[n]
Pattern 6 — Longest Increasing Subsequence (LIS)
O(n²) DP:
def lengthOfLIS(nums: list[int]) -> int:
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)
O(n log n) patience sorting:
import bisect
def lengthOfLIS(nums: list[int]) -> int:
tails = []
for num in nums:
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num)
else:
tails[pos] = num
return len(tails)
function lengthOfLIS(nums) {
const tails = [];
for (const num of nums) {
let lo = 0, hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (tails[mid] < num) lo = mid + 1;
else hi = mid;
}
tails[lo] = num;
}
return tails.length;
}
Pattern 7 — DP on intervals
For problems of the form "what's the optimal way to split/merge a range [i, j]?"
Matrix chain multiplication
Minimum scalar multiplications to multiply a chain of matrices.
def matrixChain(dims: list[int]) -> int:
n = len(dims) - 1 # number of matrices
dp = [[0] * n for _ in range(n)]
# length = number of matrices in subchain
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] + dims[i]*dims[k+1]*dims[j+1]
dp[i][j] = min(dp[i][j], cost)
return dp[0][n-1]
Burst balloons (LC 312)
def maxCoins(nums: list[int]) -> int:
nums = [1] + nums + [1]
n = len(nums)
dp = [[0] * n for _ in range(n)]
for length in range(2, n):
for left in range(0, n - length):
right = left + length
for k in range(left + 1, right):
dp[left][right] = max(
dp[left][right],
nums[left] * nums[k] * nums[right]
+ dp[left][k] + dp[k][right]
)
return dp[0][n-1]
Pattern 8 — DP on trees
Post-order traversal: solve children first, then the current node.
House robber III (LC 337)
Rob houses in a binary tree — no two adjacent nodes.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
def rob(root: TreeNode) -> int:
def dp(node):
if not node:
return 0, 0 # (rob_this, skip_this)
left_rob, left_skip = dp(node.left)
right_rob, right_skip = dp(node.right)
rob_this = node.val + left_skip + right_skip
skip_this = max(left_rob, left_skip) + max(right_rob, right_skip)
return rob_this, skip_this
return max(dp(root))
Pattern 9 — Bitmask DP
State = bitmask of which items have been used. Useful when n ≤ 20.
Travelling Salesman Problem (TSP)
Minimum cost tour visiting all cities exactly once.
def tsp(dist: list[list[int]]) -> int:
n = len(dist)
INF = float('inf')
# dp[mask][i] = min cost to reach city i, having visited cities in mask
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0 # start at city 0 (bit 0 set)
for mask in range(1 << n):
for u in range(n):
if dp[mask][u] == INF:
continue
if not (mask >> u & 1):
continue
for v in range(n):
if mask >> v & 1:
continue # already visited
new_mask = mask | (1 << v)
dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + dist[u][v])
return min(dp[(1 << n) - 1][i] + dist[i][0] for i in range(n))
Classic problems quick reference
| Problem | LeetCode | Pattern | Time | Space |
|---|---|---|---|---|
| Climbing stairs | 70 | 1D DP | O(n) | O(1) |
| House robber | 198 | 1D DP | O(n) | O(1) |
| House robber II (circle) | 213 | 1D DP × 2 | O(n) | O(1) |
| Coin change | 322 | Unbounded knapsack | O(n·W) | O(W) |
| Coin change II | 518 | Unbounded knapsack | O(n·W) | O(W) |
| 0/1 Knapsack | — | 0/1 Knapsack | O(n·W) | O(W) |
| Partition equal subset sum | 416 | 0/1 Knapsack | O(n·S) | O(S) |
| Longest common subsequence | 1143 | LCS | O(m·n) | O(n) |
| Edit distance | 72 | LCS variant | O(m·n) | O(n) |
| Longest palindromic subsequence | 516 | LCS (s, reversed) | O(n²) | O(n) |
| Longest increasing subsequence | 300 | LIS | O(n log n) | O(n) |
| Unique paths | 62 | 2D grid DP | O(m·n) | O(n) |
| Minimum path sum | 64 | 2D grid DP | O(m·n) | O(n) |
| Word break | 139 | 1D DP | O(n²) | O(n) |
| Decode ways | 91 | 1D DP | O(n) | O(1) |
| Jump game II | 45 | Greedy/DP | O(n) | O(1) |
| Maximum subarray | 53 | Kadane's | O(n) | O(1) |
| Burst balloons | 312 | Interval DP | O(n³) | O(n²) |
| Regular expression matching | 10 | 2D DP | O(m·n) | O(m·n) |
| Wildcard matching | 44 | 2D DP | O(m·n) | O(n) |
| Distinct subsequences | 115 | 2D DP | O(m·n) | O(n) |
| House robber III | 337 | Tree DP | O(n) | O(h) |
| Palindrome partitioning II | 132 | Interval DP | O(n²) | O(n) |
How to identify a DP problem
- "Count the number of ways" → often DP
- "Minimum/maximum cost/length" → optimisation DP
- "Can you achieve X?" → feasibility DP (boolean)
- Overlapping subproblems: naïve recursion revisits the same state → cache it
- Optimal substructure: optimal solution to the problem includes optimal solutions to subproblems
Not DP if subproblems are independent (divide-and-conquer like merge sort).
DP problem-solving framework
1. Define the state
- What information do I need to characterise a subproblem?
- dp[i], dp[i][j], dp[mask][i], dp[i][j][k]?
2. Write the recurrence
- How does dp[i] depend on smaller subproblems?
- Draw a small example (n=3 or 4) and trace it.
3. Identify base cases
- dp[0] = ?, dp[0][0] = ?
4. Decide iteration order
- Bottom-up: make sure dp[i-1] is computed before dp[i]
5. Extract the answer
- Often dp[n], dp[m][n], max(dp), or dp[(1<<n)-1][start]
Common mistakes
| Mistake | Fix |
|---|---|
| 0/1 knapsack inner loop forward | Reverse: for w in range(capacity, weight-1, -1) |
| Unbounded knapsack inner loop backward | Forward: for a in range(coin, amount+1) |
| Off-by-one in LCS table size | Use (m+1) × (n+1) with index offset |
Not initialising dp[0] = 0 for coin change |
Base case: empty set achieves sum 0 |
Returning dp[n-1] instead of dp[n] |
Check if 0-indexed or 1-indexed |
| Skipping memoisation in recursion | Every recursive call checks cache first |
| Mutating input array as DP table | Copy first, or use separate dp array |
DP complexity guide
| Array size | Feasible complexity | Typical pattern |
|---|---|---|
| n ≤ 20 | O(2ⁿ), O(n·2ⁿ) | Bitmask DP |
| n ≤ 100 | O(n³) | Interval DP (matrix chain) |
| n ≤ 1,000 | O(n²) | LCS, edit distance |
| n ≤ 10,000 | O(n log n) | LIS with binary search |
| n ≤ 1,000,000 | O(n) | 1D DP (climbing stairs, house robber) |
FAQ
Q: Memoization or tabulation — which to use?
Start with memoization (easier to write). Switch to tabulation if you hit recursion depth limits or need to optimise space.
Q: How do I reduce 2D DP to 1D?
If dp[i][j] only depends on row i-1, you can keep just one row and update it in the right order (forward or backward depending on the pattern).
Q: What's the difference between DP and divide-and-conquer?
D&C subproblems are independent. DP subproblems overlap — the same subproblem appears in multiple branches. Caching that overlap is the whole point.
Q: How do I handle DP with negative indices?
Add an offset. If index range is [-n, n], shift by n so you use dp[i+n].
Q: When should I use bitmask DP?
When the state is a subset of items, typically n ≤ 20 (2²⁰ = 1 million states). Common in assignment problems, TSP, and shortest Hamiltonian path.
Q: What's Kadane's algorithm?
A special O(n) 1D DP for maximum subarray sum: dp[i] = max(nums[i], dp[i-1] + nums[i]). Since you only need the previous state, you keep just one variable: cur_max = max(num, cur_max + num).