Skip to main content

Dynamic Programming

Overview

Dynamic programming applies when a problem has overlapping subproblems — the same sub-computation arising many times across a recursion. Solving each one once and reusing the answer converts exponential work into polynomial.

The name is historical and unhelpful; Richard Bellman chose it in the 1950s partly because it sounded impressive to a research sponsor. Read it as "careful recursion with a cache".

Core Concepts

Two conditions, both required:

PropertyMeaning
Overlapping subproblemsThe same subproblem recurs. This distinguishes DP from divide and conquer
Optimal substructureAn optimal solution is built from optimal solutions to subproblems

And two ways to implement it:

Top-down (memoisation)Bottom-up (tabulation)
StructureRecursion + a cacheIteration over a table
ComputesOnly reachable subproblemsAll subproblems
OrderImplicit, from the recursionYou must choose a valid order
StackO(depth) — can overflowNone
Easier toWrite, from the recurrenceOptimise for space

Architecture / Mechanism

The same problem, three ways

# 1. Naive recursion — O(2ⁿ). fib(n-1) and fib(n-2) recompute the same values.
def fib_naive(n):
return n if n < 2 else fib_naive(n - 1) + fib_naive(n - 2)

# 2. Top-down: identical logic, plus a cache — O(n)
from functools import lru_cache

@lru_cache(maxsize=None)
def fib_memo(n):
return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2)

# 3. Bottom-up: fill a table in dependency order — O(n) time, O(1) space
def fib_table(n):
prev, cur = 0, 1
for _ in range(n):
prev, cur = cur, prev + cur
return prev

The third version is what the second becomes once you notice only two entries are ever needed. That progression — recurrence, memoise, tabulate, shrink the table — is the standard workflow.

The workflow, on a real problem

0/1 knapsack: choose items with weights and values, maximising value within a capacity, taking each item at most once. Greedy by value-per-weight fails here.

def knapsack(weights, values, capacity):
n = len(weights)
# dp[i][c] = best value using the first i items within capacity c
dp = [[0] * (capacity + 1) for _ in range(n + 1)]

for i in range(1, n + 1):
w, v = weights[i - 1], values[i - 1]
for c in range(capacity + 1):
dp[i][c] = dp[i - 1][c] # skip item i
if w <= c: # or take it, if it fits
dp[i][c] = max(dp[i][c], dp[i - 1][c - w] + v)
return dp[n][capacity]

Each cell depends only on the previous row, so one row suffices — provided you iterate capacity downward, so that each item is used at most once:

def knapsack_1d(weights, values, capacity):
dp = [0] * (capacity + 1)
for w, v in zip(weights, values):
for c in range(capacity, w - 1, -1): # DOWNWARD — reversing this allows reuse
dp[c] = max(dp[c], dp[c - w] + v)
return dp[capacity]
The iteration direction encodes the problem

Iterating capacity downward gives 0/1 knapsack — each item usable once. Iterating upward gives unbounded knapsack — each item usable any number of times, because dp[c - w] may already include the current item.

The two problems differ by the direction of one loop. This is the most common DP bug, and it produces a valid-looking answer to the wrong question.

Defining the state

Most of the difficulty is choosing what the table indexes. Ask:

  1. What does one cell mean? State it as a sentence — "the best value using the first i items within capacity c". If you cannot, the state is wrong.
  2. What is the recurrence? How does a cell follow from smaller ones?
  3. What are the base cases?
  4. In what order can cells be filled so dependencies are ready?

Practical Usage

ProblemStateComplexity
Fibonaccidp[i] = i-th numberO(n)
Climbing stairs, coin change (count ways)dp[i] = ways to reach iO(n·k)
Coin change (fewest coins)dp[i] = fewest coins for amount iO(n·k)
0/1 knapsackdp[i][c]O(n·capacity)
Longest common subsequencedp[i][j] = LCS of prefixesO(n·m)
Edit distance (Levenshtein)dp[i][j] = edits between prefixesO(n·m)
Longest increasing subsequencedp[i] = best ending at iO(n²), or O(n log n) with binary search
Matrix chain multiplicationdp[i][j] = best cost for the rangeO(n³)
# Edit distance — the engine behind spell-checkers and `diff`
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 # delete all of a's prefix
for j in range(n + 1):
dp[0][j] = j # insert all of b's prefix

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] # free match
else:
dp[i][j] = 1 + min(dp[i - 1][j], # delete
dp[i][j - 1], # insert
dp[i - 1][j - 1]) # substitute
return dp[m][n]
Start top-down

Write the naive recursion first and confirm it is correct. Then add @lru_cache — often the entire optimisation. Convert to a table only if you need the space saving or hit a recursion limit. Trying to write the tabulated version directly, before the recurrence is settled, is how most DP attempts stall.

Edge Cases & Pitfalls

  • @lru_cache requires hashable arguments. Lists must become tuples. It also holds references forever with maxsize=None, which leaks on long-running processes.
  • Mutable default arguments as caches (def f(n, memo={})) share state across calls — a real bug when the cache depends on other inputs.
  • Filling the table in the wrong order reads cells that are still zero. Bottom-up requires an order respecting every dependency — effectively a topological sort of the state graph.
  • Reconstructing the solution, not just its value, needs either a parent table or a backward walk through the finished table. Most implementations return only the optimum and then need rewriting.
  • Pseudo-polynomial complexity. Knapsack's O(n·capacity) is polynomial in the value of the capacity but exponential in the number of bits used to write it — which is why knapsack is still NP-hard despite the DP solution.
  • Memory can be the binding constraint. An O(n·m) table for two 100,000-character strings is 10¹⁰ cells. Use the rolling-row trick, or Hirschberg's algorithm for linear space.

Comparisons

DPGreedyDivide & conquerBacktracking
SubproblemsOverlappingIndependentOverlapping or not
Choices consideredAllOneAllAll, pruned
Guarantees optimumYesOnly with a proofYesYes
Typical costPolynomialO(n log n)O(n log n)Exponential

References

  • Cormen, Leiserson, Rivest & Stein, Introduction to Algorithms, Ch. 15 — dynamic programming, with rod-cutting, matrix chains and LCS developed in full.
  • Bellman, R. (1957), Dynamic Programming — the original, and the source of the name.
  • Kleinberg & Tardos, Algorithm Design, Ch. 6 — an unusually clear treatment of choosing the state.

Books & Videos