Dynamic programming: 1D, 2D, knapsack, LIS and edit distance
What it is
Dynamic programming is a technique for problems where a solution decomposes into overlapping subproblems and the answer to a subproblem depends only on the subproblem, not on the path taken to reach it. You solve each subproblem once, store the result, and reuse it. That is the whole idea. Everything else is bookkeeping about what the subproblem is and what order to solve them in.
Two properties must hold. Optimal substructure: the optimal solution contains optimal solutions to its subproblems. Overlapping subproblems: the same subproblem is reached many times. Miss the first and DP gives wrong answers; miss the second and DP is just recursion with wasted memory.
Don't be confused: "DP" is not a class of problems, it is a property of your state definition. The same problem is exponential or polynomial depending on what you choose as the state. Backtracking over "which coins have I picked" has no overlap, because every path holds a different multiset. Rewrite the state as "how much target remains" and paths collapse onto each other, and the same problem becomes a polynomial DP. The state definition is the work; the recurrence usually writes itself once the state is right. If you cannot find the DP, you have the wrong state, not the wrong recurrence.
Don't be confused: memoisation and tabulation are the same algorithm. Top-down memoised recursion and bottom-up table filling compute the same values with the same complexity. They differ in three practical ways: top-down only computes the states it actually needs (which can be far fewer), bottom-up avoids recursion-depth limits and function-call overhead, and only bottom-up lets you drop the table to a rolling row for $O(1)$ or $O(n)$ space. Neither is "the real DP".
The problem it solves
Naive recursion for Fibonacci recomputes fib(30) millions of times. That is the toy
version of a real problem: any recursive formulation whose call tree revisits states does
exponential work for a polynomial amount of distinct information. DP converts the tree into
a DAG and evaluates each node once.
More usefully, DP handles optimisation under constraints where greedy fails. coin_change
in the code below is the standard demonstration: with coins ${1, 5, 6, 9}$ and a target of
11, greedy takes 9 first and needs $9+1+1 = 3$ coins, while the optimum is $5+6 = 2$. Greedy
is correct for some coin systems (including most real currencies, which are designed to be
canonical) and wrong for others, and you cannot tell by looking. DP does not care.
Mechanics
"""Dynamic programming: 1D, 2D, knapsack, LIS and edit distance.
Runnable: `python3 dynamic_programming.py`. The five shapes the interview
pattern list names, each with the state definition written out, because the
state definition IS the problem and the recurrence follows from it.
"""
import bisect
from functools import lru_cache
# --- 1D: the state is one index ----------------------------------------------
def house_robber(nums):
"""state: best[i] = the most you can take from the first i houses.
recurrence: best[i] = max(skip house i, take house i + best[i-2])
Rolling two variables instead of an array, because the recurrence only ever
looks back two steps. O(n) time, O(1) space."""
take, skip = 0, 0
for n in nums:
take, skip = skip + n, max(skip, take)
return max(take, skip)
def coin_change(coins, amount):
"""state: fewest[a] = fewest coins summing exactly to a, or infinity.
recurrence: fewest[a] = 1 + min(fewest[a - c] for each coin c <= a)
UNBOUNDED knapsack: coins are reusable, so the inner loop runs FORWARD over
amounts and each coin can be picked up again at a larger amount."""
INF = float("inf")
fewest = [0] + [INF] * amount
for a in range(1, amount + 1):
for c in coins:
if c <= a and fewest[a - c] + 1 < fewest[a]:
fewest[a] = fewest[a - c] + 1
return -1 if fewest[amount] == INF else fewest[amount]
# --- 0/1 knapsack: the one where loop ORDER carries the meaning --------------
def knapsack_01(weights, values, capacity):
"""state: best[c] = best value achievable with capacity c, using the items
considered so far.
The inner loop runs BACKWARD over capacity. That single detail is what makes
it 0/1 rather than unbounded: iterating downward means best[c - w] has not
yet been updated for the current item, so it still refers to a solution
WITHOUT this item. Iterate upward and you silently allow reuse.
"""
best = [0] * (capacity + 1)
for w, v in zip(weights, values):
for c in range(capacity, w - 1, -1): # backward
best[c] = max(best[c], best[c - w] + v)
return best[capacity]
def knapsack_01_with_items(weights, values, capacity):
"""The 2D table version, kept because reconstructing WHICH items were chosen
needs the full table. The 1D version above throws that history away."""
n = len(weights)
table = [[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):
table[i][c] = table[i - 1][c] # skip item i
if w <= c and table[i - 1][c - w] + v > table[i][c]:
table[i][c] = table[i - 1][c - w] + v # take item i
chosen, c = [], capacity
for i in range(n, 0, -1):
if table[i][c] != table[i - 1][c]: # item i was taken
chosen.append(i - 1)
c -= weights[i - 1]
return table[n][capacity], sorted(chosen)
# --- LIS: two algorithms, and the second is not a DP at all ------------------
def lis_quadratic(nums):
"""state: length[i] = length of the longest increasing subsequence ENDING at
index i. The "ending at i" is what makes the recurrence work; "within the
first i" does not, because it loses the information needed to extend."""
if not nums:
return 0, []
length = [1] * len(nums)
prev = [-1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i] and length[j] + 1 > length[i]:
length[i], prev[i] = length[j] + 1, j
best = max(range(len(nums)), key=lambda i: length[i])
seq, k = [], best
while k != -1:
seq.append(nums[k])
k = prev[k]
return length[best], seq[::-1]
def lis_nlogn(nums):
"""O(n log n) via patience sorting. tails[k] is the SMALLEST possible tail
of an increasing subsequence of length k+1. That array is sorted by
construction, so binary search places each element.
Note what this returns and does not: the LENGTH is correct, but `tails`
itself is generally NOT a valid subsequence of the input. Reconstructing the
actual sequence needs a parent array, as in the quadratic version.
"""
tails = []
for n in nums:
i = bisect.bisect_left(tails, n) # bisect_left: strictly increasing
if i == len(tails):
tails.append(n)
else:
tails[i] = n
return len(tails), tails
# --- 2D: edit distance -------------------------------------------------------
def edit_distance(a, b):
"""state: d[i][j] = edits to turn a[:i] into b[:j].
recurrence: if the last characters match, d[i][j] = d[i-1][j-1] (free).
Otherwise 1 + min(delete a[i-1], insert b[j-1], substitute).
The base row and column are not zeros: turning "" into b[:j] takes j
insertions, so d[0][j] = j. Filling them with zeros is the most common bug.
"""
m, n = len(a), len(b)
d = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
d[i][0] = i
for j in range(n + 1):
d[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if a[i - 1] == b[j - 1]:
d[i][j] = d[i - 1][j - 1]
else:
d[i][j] = 1 + min(d[i - 1][j], # delete from a
d[i][j - 1], # insert into a
d[i - 1][j - 1]) # substitute
return d[m][n], d
def edit_ops(a, b, d):
"""Walk the table backwards to recover the actual edit script. Same idea as
the knapsack reconstruction: the table holds the history, so read it."""
ops, i, j = [], len(a), len(b)
while i > 0 or j > 0:
if i > 0 and j > 0 and a[i - 1] == b[j - 1] and d[i][j] == d[i - 1][j - 1]:
i, j = i - 1, j - 1
elif i > 0 and j > 0 and d[i][j] == d[i - 1][j - 1] + 1:
ops.append(f"sub {a[i-1]}->{b[j-1]} at {i-1}")
i, j = i - 1, j - 1
elif i > 0 and d[i][j] == d[i - 1][j] + 1:
ops.append(f"del {a[i-1]} at {i-1}")
i -= 1
else:
ops.append(f"ins {b[j-1]} at {i}")
j -= 1
return ops[::-1]
# --- memoised recursion: the same DP, discovered rather than designed --------
def grid_paths(rows, cols, blocked=()):
"""Top-down is often the easier way to FIND the recurrence, because you write
the question, not the fill order. Convert to bottom-up only if you need the
space saving or Python's recursion limit is a problem."""
blocked = set(blocked)
@lru_cache(maxsize=None)
def ways(r, c):
if r >= rows or c >= cols or (r, c) in blocked:
return 0
if (r, c) == (rows - 1, cols - 1):
return 1
return ways(r + 1, c) + ways(r, c + 1)
n = ways(0, 0)
return n, ways.cache_info()
if __name__ == "__main__":
houses = [2, 7, 9, 3, 1]
# 7 and 9 are adjacent, so the greedy "take the two biggest" is illegal here.
print("house_robber", houses, "->", house_robber(houses), "(2+9+1; 7+9 is adjacent)")
print("coin_change([1,5,6,9], 11) ->", coin_change([1, 5, 6, 9], 11), "(5+6, greedy would say 9+1+1)")
print("coin_change([2], 3) ->", coin_change([2], 3), "(impossible)")
print()
w, v, cap = [3, 4, 5, 9], [4, 5, 6, 10], 12
print("weights", w, "values", v, "capacity", cap)
print("knapsack_01 (1D) ->", knapsack_01(w, v, cap))
total, items = knapsack_01_with_items(w, v, cap)
print("knapsack_01 (2D) ->", total, "using item indices", items,
"weights", [w[i] for i in items])
print()
seq = [10, 9, 2, 5, 3, 7, 101, 18]
n1, actual = lis_quadratic(seq)
n2, tails = lis_nlogn(seq)
print("sequence :", seq)
print("lis_quadratic :", n1, actual)
print("lis_nlogn :", n2, "tails =", tails, "<- length is right, tails is not the subsequence")
print()
a, b = "intention", "execution"
dist, table = edit_distance(a, b)
print(f"edit_distance({a!r}, {b!r}) = {dist}")
for op in edit_ops(a, b, table):
print(" ", op)
print("edit_distance('', 'abc') =", edit_distance("", "abc")[0])
print()
n, info = grid_paths(3, 4)
print("grid_paths(3,4) :", n, "paths;", info)
n, info = grid_paths(3, 4, blocked=[(1, 1)])
print("with (1,1) blocked :", n, "paths;", info)
Five details, each of which is a question you will be asked.
The loop direction in knapsack_01 is the entire difference between 0/1 and unbounded.
Both versions are the same three lines; one iterates capacity backward and one forward.
Going backward, best[c - w] has not yet been touched for the current item, so it refers to
a solution that does not include the item, which is what "use each item at most once" means.
Going forward, best[c - w] may already include the current item, so the item gets reused,
which is exactly the unbounded knapsack that coin_change wants. A one-character change
to a range silently changes the problem being solved, and being able to say why out loud
is one of the strongest signals available on this topic.
The base row and column of the edit distance table are not zeros. d[i][0] = i because
turning a prefix of length $i$ into the empty string takes $i$ deletions, and d[0][j] = j
symmetrically. Filling them with zeros is the most common edit-distance bug, and it produces
answers that are too small in a way that looks plausible on short inputs.
lis_quadratic defines the state as "ending at index i", not "within the first i". This
is the state-definition point made concrete. "Longest increasing subsequence within the
first $i$ elements" is not enough information to extend, because you do not know what value
the best subsequence ended on. "Ending at $i$" carries that, so the recurrence works, and
the final answer is a max over all $i$ rather than the last cell. When a DP will not close,
this is usually the fix: add to the state whatever the recurrence needs to know.
lis_nlogn is not a DP. It maintains tails[k], the smallest possible tail of any
increasing subsequence of length $k+1$. That array is sorted by construction, so each new
element is placed by binary search, giving $O(n \log n)$. It is the binary search pattern
wearing a DP costume, and it is the standard follow-up once you produce the quadratic
version.
grid_paths uses lru_cache and reports the cache statistics, which is the cheapest
possible demonstration that memoisation is doing something. 17 misses and 6 hits on a 3 by 4
grid means 6 recursive calls returned without recomputing a subtree.
Worked example
house_robber [2, 7, 9, 3, 1] -> 12 (2+9+1; 7+9 is adjacent)
coin_change([1,5,6,9], 11) -> 2 (5+6, greedy would say 9+1+1)
coin_change([2], 3) -> -1 (impossible)
weights [3, 4, 5, 9] values [4, 5, 6, 10] capacity 12
knapsack_01 (1D) -> 15
knapsack_01 (2D) -> 15 using item indices [0, 1, 2] weights [3, 4, 5]
sequence : [10, 9, 2, 5, 3, 7, 101, 18]
lis_quadratic : 4 [2, 5, 7, 101]
lis_nlogn : 4 tails = [2, 3, 7, 18] <- length is right, tails is not the subsequence
edit_distance('intention', 'execution') = 5
sub i->e at 0
sub n->x at 1
sub t->e at 2
sub e->c at 3
sub n->u at 4
edit_distance('', 'abc') = 3
grid_paths(3,4) : 10 paths; CacheInfo(hits=6, misses=17, maxsize=None, currsize=17)
with (1,1) blocked : 4 paths; CacheInfo(hits=4, misses=17, maxsize=None, currsize=17)
The LIS output is the most instructive line on this page. Both algorithms report length
4 and they agree, as they must. But the quadratic version returns the actual subsequence
[2, 5, 7, 101], while tails from the fast version is [2, 3, 7, 18], which is not a
subsequence of the input in that order at all: 3 appears before 5 in the input but after
2, and 18 appears after 101. The tails array is a set of best-possible endings, not a
path. Candidates who return tails as "the LIS" are wrong and their answer looks right,
because the length is right. If the problem asks for the sequence and not just its length,
either use the quadratic version or add a parent array to the fast one, and say which you
are doing.
Check the knapsack by hand. Weights [3,4,5,9], values [4,5,6,10], capacity 12. Items
0, 1 and 2 weigh exactly 12 and are worth $4+5+6 = 15$. The tempting alternative is item 3
(weight 9, value 10) plus item 0 (weight 3, value 4), also exactly 12, worth 14. So 15 is
right and the greedy "best value per unit weight" heuristic would have picked item 0 first
(ratio 1.33) and could plausibly land on the wrong answer. This is the input that shows
the fractional-knapsack greedy does not carry over to 0/1.
grid_paths(3,4) is 10, which is $\binom{5}{2}$ as the closed form predicts, and
blocking the single cell $(1,1)$ drops it to 4. The check: paths through $(1,1)$ number
$\binom{2}{1} \times \binom{3}{1} = 2 \times 3 = 6$, and $10 - 6 = 4$. Having a closed
form to check the DP against is a gift when it exists, and constructing one for the
unobstructed case is a good way to validate an implementation before adding the obstacles
that make the closed form impossible.
The edit script for intention to execution is five substitutions, which is worth
inspecting because the textbook presentation of this exact pair usually shows a delete, three
substitutions and an insert. Both are five operations, so both are optimal; the table has
multiple optimal paths and the reconstruction picks whichever branch it tests first.
If an interviewer's expected output differs from yours on a reconstruction problem, check
whether both are optimal before assuming you are wrong.
Production evidence
Query planners use DP for join ordering. The System R optimiser introduced dynamic
programming over subsets of relations: compute the best plan for every subset of tables,
building up by size, because the best plan for a set of tables can be assembled from the
best plans of its subsets. PostgreSQL still does this for queries below
geqo_threshold (12 relations by default) and switches to a genetic algorithm above it,
because the DP is $O(3^n)$ in the number of relations and stops being affordable. That
threshold is a working example of a team choosing where exact DP stops paying.
The Viterbi algorithm is a DP over hidden state sequences, and it decodes convolutional
codes in mobile and satellite communications, does part-of-speech tagging, and underpinned
speech recognition for decades. The state is "most likely path ending in this state at this
time", which is exactly the "ending at $i$" formulation used in lis_quadratic.
diff is an edit distance variant. Myers' algorithm, which is what Git uses by default,
computes the shortest edit script between two sequences of lines. It is a refinement of the
edit distance DP that searches the table diagonally and runs in $O(ND)$ where $D$ is the
size of the edit script, which is fast in the common case where two file versions are
mostly the same.
Sequence alignment in bioinformatics. Needleman-Wunsch (global alignment) and Smith-Waterman (local alignment) are the edit distance DP with a substitution scoring matrix and gap penalties instead of a flat cost of 1. Smith-Waterman is exact and is usually too slow for large database searches, which is why BLAST exists as a heuristic approximation. That pairing (exact DP, then a heuristic when the DP does not scale) is the same shape as the Postgres threshold above.
Levenshtein automata in Lucene. Lucene's fuzzy query builds an automaton accepting all strings within a given edit distance of the query and intersects it with the term dictionary's finite state transducer, rather than computing edit distance against every term. It is the trie-plus-pruning approach from the previous page, and it is the reason fuzzy search over millions of terms is affordable.
The debate
Top-down or bottom-up? Write top-down first. The reason is not preference: top-down
lets you write the recurrence as a question, without deciding the fill order, and deciding
the fill order is where people get stuck. @lru_cache on a clean recursive function is a
correct DP in one line. Convert to bottom-up when you need the space reduction (the rolling
array in house_robber and knapsack_01 is only possible bottom-up), when recursion depth
is a real risk (Python defaults to 1,000 frames, and a 10,000-element 1D DP will hit it), or
when the constant factor matters. State the conversion as a deliberate step, not as a
correction.
Is the $O(n \log n)$ LIS worth writing in an interview? Only if asked, or if the stated
constraints demand it. The quadratic version is six lines, obviously correct, and returns the
actual subsequence; the fast version is four lines but is easy to get subtly wrong
(bisect_left gives strictly increasing, bisect_right gives non-decreasing, and choosing
wrong is a silent off by one) and does not give you the sequence. My position: write the
quadratic version, state that an $O(n \log n)$ patience-sorting version exists and what its
invariant is, and implement it if they want it. Volunteering the existence of the better
algorithm gets most of the credit at a fraction of the risk.
When is DP the wrong tool? Three cases. First, when a greedy is provably correct, in which case DP is slower and more code, and the proof obligation is on you either way. Second, when the state space is too large: knapsack's $O(nW)$ is pseudo-polynomial, polynomial in the numeric value of $W$ rather than in the bits used to write it, so a capacity of $10^9$ makes the table impossible even with only 20 items. In that regime you want meet-in-the-middle, branch and bound, or an approximation scheme, and knowing that "pseudo-polynomial" is the reason is a real distinction. Third, when the problem lacks optimal substructure, in which case DP produces confident wrong answers, which is worse than being slow.
Follow-up Q&A
Is the knapsack DP polynomial or not? The complexity is $O(nW)$, which looks polynomial. It is pseudo-polynomial. The input size is $O(n \log W)$ bits, because $W$ is written in binary, so $O(nW)$ is exponential in the input length. 0/1 knapsack is NP-complete, and the DP does not contradict that. This is the crispest available test of whether a candidate understands complexity as a function of input size rather than of the numbers in the input, and it is asked often.
How do you reduce a 2D DP's space to $O(n)$? If the recurrence for row $i$ only reads
row $i-1$, keep two rows and swap, or one row updated in the correct direction as
knapsack_01 does. The cost is that you lose the ability to reconstruct the solution path,
which is why knapsack_01_with_items keeps the full table. If you need both minimal space
and the path, the technique is Hirschberg's divide-and-conquer, which computes the
alignment in $O(\min(m,n))$ space at the cost of doubling the time. Naming Hirschberg here
is a strong signal because most people know only the two extremes.
Why does the greedy fail for coin_change with [1,5,6,9] but work for real money?
Because a coin system is canonical when greedy is optimal for every amount, and real
currency denominations are deliberately designed that way. ${1,5,6,9}$ is not canonical:
at 11, greedy takes 9 then needs two 1s for a total of 3, while $5+6$ is 2. There is a known
test (Pearson's algorithm) for deciding whether a given coin system is canonical, and it
runs in polynomial time. The interview-relevant point: you cannot tell by inspection, so
do not assert greedy without a proof or a stated assumption.
Your edit_distance treats insert, delete and substitute as cost 1 each. What if they
differ? Change the constants in the min, and the algorithm is otherwise unchanged. This
generalises to a full substitution matrix, which is exactly Needleman-Wunsch. One subtlety
worth knowing: if a substitution costs more than a delete plus an insert, the substitution
branch is never chosen and the metric silently changes character. Also, the Damerau variant
adds transposition as a fourth operation, which needs one more term reading d[i-2][j-2],
and it matters for typo correction because adjacent-character transposition is one of the
most common human typing errors.
When would you memoise on a tuple that includes something other than indices? Whenever
the recurrence's answer depends on it. A common example is "at most $k$ transactions" stock
problems, where the state is (day, transactions_left, holding). The discipline: write
down every variable the recursive call reads, and that set is your state. If the state
includes something you did not put in the cache key, the memoisation is wrong and returns
stale answers, which is a bug that produces plausible numbers rather than a crash.
How do you approach a DP problem you have never seen, live? In this order. One, solve a
tiny instance by hand. Two, write the brute-force recursion, however slow. Three, ask what
the recursive call actually depends on, and make that the cache key. Four, add
@lru_cache and check the answer is unchanged. Five, only if needed, convert to a table and
reduce space. Narrating those five steps is itself the answer to "how do you think about
DP", and it is more convincing than producing a memorised recurrence.
Common misconceptions
"Dynamic programming means filling a table." It means solving each subproblem once. The table is one implementation. Memoised recursion is DP; so is an iterative rolling variable.
"The state is always the index." It is whatever the recurrence reads. Multi-dimensional states are normal, and the reason a problem seems impossible is usually a missing dimension.
"If it has optimal substructure, greedy will work." Optimal substructure is necessary for both greedy and DP but sufficient for neither. Greedy additionally needs the greedy choice property: a locally optimal choice is part of some globally optimal solution. 0/1 knapsack has optimal substructure and fails the greedy choice property, which is precisely why it needs DP while fractional knapsack does not.
"$O(nW)$ for knapsack means it is efficient." Pseudo-polynomial, as above. The gap between the two is a genuine complexity-theory distinction and it has practical consequences.
"lru_cache makes any recursion fast." Only if states repeat. On a recursion whose
arguments are never equal, it adds hashing overhead and unbounded memory growth and helps
nothing. Also note maxsize=None never evicts, so on a large state space it is a memory
leak in a long-running process.
Interview delivery note
Say the state definition as a full English sentence before you write anything: "Let
best[c] be the maximum value achievable with capacity exactly c using the items I have
considered so far." Then the recurrence, then the base case, then the fill order. Four
sentences, thirty seconds, and they make the rest of the problem mechanical. Candidates who
start writing loops before stating the state are the ones who get stuck, and interviewers
can see it happening.
The senior-to-staff separator on DP is knowing where the DP stops being the right answer and saying so. A senior candidate produces a correct $O(nW)$ knapsack. A staff candidate produces the same code and then says, unprompted: "Note this is pseudo-polynomial. It is $O(nW)$ in the value of the capacity, so with 30 items and a capacity of a billion this table does not exist and I would go to meet-in-the-middle or branch and bound. The threshold is not theoretical: Postgres does exactly this, using DP join ordering under 12 relations and a genetic algorithm above it." That answer demonstrates the thing DP questions are actually probing, which is whether you understand the cost model well enough to know when to abandon your own solution.
Further reading
- Cormen, Leiserson, Rivest and Stein. Introduction to Algorithms, 4th ed. Chapter 14 (Dynamic Programming), including the optimal substructure and greedy-choice discussion.
- P. Griffiths Selinger et al. "Access Path Selection in a Relational Database Management System." SIGMOD 1979. The DP join-ordering algorithm every planner still descends from.
- Eugene W. Myers. "An O(ND) Difference Algorithm and Its Variations." Algorithmica, 1986.
The algorithm behind
git diff. - Dan Hirschberg. "A linear space algorithm for computing maximal common subsequences." Communications of the ACM, 1975. Reconstruction without the full table.