E ExamMaster

Data Structures & Algorithms · Data Structures & Algorithms

Dynamic Programming

Solving problems with overlapping subproblems using memoization and tabulation.

Eight concepts. Spot when a recursion recomputes the same inputs, name the state, write the recurrence, then fill a tiny table — interviews punish hand-wavy DP more than they punish missing a library trick.

  • Data Structures & Algorithms
  • Hard level
  • 8 concepts
  • 5 practice questions

1When DP applies

Dynamic programming is not a data structure. It is the decision to cache answers to subproblems once you have proved two things: optimal substructure (an optimal answer is built from optimal answers to smaller instances) and overlapping subproblems (the same smaller instance is asked more than once). Brute-force recursion already explores the tree; DP only pays when that tree revisits nodes.

Figure. Naive F(4) asks for F(2) on two different branches. Those two boxes are the same subproblem — that is the overlap DP caches.

The two tests

  1. Optimal substructureAsk whether the best answer for n is assembled from best answers for strictly smaller inputs — not from some other kind of search.
  2. Overlapping subproblemsDraw or imagine the recursion tree. If the same arguments appear on more than one branch, caching those answers changes exponential work into polynomial work.
  3. If either failsNo overlap means memoization buys nothing (divide-and-conquer is enough). No optimal substructure means greedy or search may still work, but a DP table will encode the wrong claim.
A recursive solution explores a tree of calls. When does adding a memo table change the asymptotic cost?
  1. Whenever the problem has a recurrence
  2. Only when the same subproblem arguments recur on more than one branch
  3. Only when the recursion is written top-down
  4. Whenever the answer is an integer

Memoization removes recomputation. If every call has unique arguments, the table never hits and the cost is unchanged.

2Memoization vs tabulation

Top-down memoization keeps the recursive shape and stores each result the first time it is computed. Bottom-up tabulation invents an order that fills a table from the base cases outward so every dependency is already present. Both avoid recomputation; they differ in control flow and in which states get evaluated when some states are unreachable.

Figure. Same recurrence: memo fills on demand from the root; tabulation fills a table in dependency order.

How each runs

  1. Top-downCall the function on the full input. On a cache miss, recurse; on a hit, return the stored value. Only states that are actually asked are filled.
  2. Bottom-upAllocate the table, write the base cases, then iterate in an order where each transition reads only already-written cells.
  3. Same complexity classFor Fibonacci both are O(n) time. Memoization uses O(n) stack plus cache; tabulation can drop the stack, and a rolling pair of variables drops space to O(1).
Same cache, different drivers
MemoizationTabulation
DirectionRecursive, demand-drivenIterative, dependency order
States filledOnly those reachedUsually every cell in range
Stack riskDeep recursion may overflowNo call stack growth
Typical first moveAdd a map to a working recursive solutionWrite recurrence, then loops

Memoized Fibonacci

from functools import lru_cache

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

Bottom-up Fibonacci to F(5)

Compute F(5) with F(0) = 0, F(1) = 1 and F(n) = F(n−1) + F(n−2).

  • F(0), F(1)0, 1
  • F(2) = F(1) + F(0)1
  • F(3) = F(2) + F(1)2
  • F(4) = F(3) + F(2)3
  • F(5) = F(4) + F(3)5

Pro tip. Once the recurrence is right, memoization is often the fastest path from a brute-force recursive solution to a correct O(n) answer; convert to tabulation when you need tight space or to kill recursion depth.

Memoization and tabulation for the same recurrence both avoid recomputation. What is the real difference?
  1. Only memoization is O(n)
  2. Only tabulation can use a recurrence
  3. Control flow: recursive demand-driven fill versus iterative dependency order
  4. Tabulation cannot handle overlapping subproblems

Both cache subproblem answers. Memoization fills on demand through recursion; tabulation fills by looping from the base cases.

3State and transition

A DP solution is a state definition plus a transition. The state is the tuple of parameters that identify one subproblem — index in the array, remaining capacity, characters matched so far. The transition is the recurrence that builds a larger state's answer from smaller ones, together with the base cases that stop the recursion. Writing those three pieces explicitly before coding is the hard part; the loops are transcription.

Figure. Write three pieces before the loops: the state tuple that names one subproblem, the transition from strictly smaller states, and the base cells that need no recurrence.

Before you code

  1. Name the stateAsk what you must know to answer a subproblem in isolation. Those parameters become the indices of the table (or the arguments of the memoized function).
  2. Write the recurrenceExpress dp[state] using only strictly smaller states — take/skip, match/mismatch, last coin chosen — so evaluation cannot cycle.
  3. Pin the base casesEmpty prefix, zero capacity, amount zero: the cells that need no recurrence. Wrong bases are the usual source of off-by-one interview bugs.
You are about to code a DP solution. Which order avoids the most common interview failure?
  1. Write the nested loops first, then invent what each cell means
  2. Define state, recurrence and base cases explicitly, then implement
  3. Copy a 2D array template and adjust bounds until samples pass
  4. Start with space optimisation to one array

The transition is the hard part. Loops that are not tied to a stated recurrence tend to encode the wrong dependency.

4Coin change (minimum coins)

Given coin denominations and a target amount, find the fewest coins that sum to that amount (unlimited supply of each coin). Let dp[a] be that minimum for amount a. The transition tries every coin c ≤ a and keeps dp[a−c] + 1. Unreachable amounts stay at a sentinel infinity so the final answer can report −1.

Figure. Final dp[a] for coins {1,2,5}: height is fewest coins. Amount 11 stands at 3; amount 8 needs three coins (no two-coin combination hits 8); amount 0 is the base at height 0.

How the table fills

  1. Basedp[0] = 0 — zero amount needs zero coins. Every other cell starts at infinity.
  2. TransitionFor each amount a from 1 to A, for each coin c with c ≤ a: dp[a] = min(dp[a], dp[a−c] + 1).
  3. Read the answerdp[A] is the fewest coins, or −1 if it is still infinity. Time is O(n \times A) for n denominations.

Minimum coins, bottom-up

def coin_change(coins, amount):
    INF = amount + 1
    dp = [0] + [INF] * amount
    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] < INF else -1

coins = [1, 2, 5], amount = 11

Find the fewest coins from {1, 2, 5} that sum to 11.

  • dp[0]0
  • dp[1]…dp[4] via coins 1,21, 1, 2, 2
  • dp[5] = min(dp[4]+1, dp[3]+1, dp[0]+1)1
  • dp[10] (two 5s)2
  • dp[11] = min(dp[10]+1, dp[9]+1, dp[6]+1)3

Pro tip. One optimal combination is 5 + 5 + 1. This bottom-up table runs in O(n \times A); return −1 if dp[amount] stays infinity.

In the minimum-coin recurrence, why must unreachable cells stay at a sentinel larger than any real answer?
  1. So that min(dp[a], dp[a−c] + 1) never treats an impossible prefix as a usable candidate
  2. So the array uses less memory
  3. So tabulation runs faster than memoization
  4. So coins must be sorted first

If an impossible amount were stored as 0, later cells would think they can build on a free solution that does not exist. The sentinel keeps those transitions from winning the min.

50/1 knapsack

Each item may be taken at most once. With n items and capacity W, dp[i][w] is the best total value using the first i items within capacity w. The transition is the max of skipping item i or taking it when weight[i] ≤ w: dp[i−1][w] versus value[i] + dp[i−1][w−weight[i]]. Time and space are O(nW) before the usual 1D compression.

Figure. At one cell the transition is max(skip, take). Here take wins 9 over skip 8 — each item still used at most once.

Take or skip

  1. Skipdp[i][w] can always equal dp[i−1][w] — the best answer that ignores this item.
  2. TakeIf weight[i] ≤ w, compare against value[i] + dp[i−1][w−weight[i]], which spends this item's weight in a subproblem that has not already used it.
  3. Order matters for 1DCompressing to one array requires iterating capacity downward for 0/1 so a cell still holds the previous-item row when you read it. Ascending reuse is the unbounded (complete) knapsack.

0/1 knapsack, 1D compression

def knapsack(weights, values, W):
    dp = [0] * (W + 1)
    for wt, val in zip(weights, values):
        for w in range(W, wt - 1, -1):
            dp[w] = max(dp[w], val + dp[w - wt])
    return dp[W]

weights [1,3,4,5], values [1,4,5,7], W = 7

Maximise total value without exceeding capacity 7; each item at most once.

  • Best with capacity 7 using items of weight 3 and 44 + 5 = 9
  • Best with 5 and 17 + 1 = 8
  • Best with 5 and 3 (weight 8)infeasible
  • dp answer for W = 79

Pro tip. Iterate capacity from high to low when compressing 0/1 knapsack to a 1D array so each item is used at most once.

When a 0/1 knapsack is compressed to one array dp[w], which capacity loop direction keeps each item usable at most once?
  1. Ascending from 0 to W
  2. Descending from W down to the item's weight
  3. Either direction — they are equivalent for 0/1
  4. Random order per item

Descending reads dp[w−wt] from the previous item's row. Ascending would let the same item update a cell and then be reused — that is unbounded knapsack.

6Longest common subsequence

A subsequence keeps relative order but need not be contiguous. For strings X and Y of lengths m and n, dp[i][j] is the LCS length of the prefixes X[:i] and Y[:j]. Matching last characters gives 1 + dp[i−1][j−1]; a mismatch takes the max of dropping a character from either side. The whole table is O(mn) time and space.

Figure. dp table for X = ABC (rows) against Y = AC (columns), including the empty-prefix row and column of zeros. The bottom-right cell is 2.

Cell by cell

  1. Basedp[0][*] = dp[*][0] = 0 — an empty prefix shares nothing.
  2. Equal lettersIf X[i−1] = Y[j−1], set dp[i][j] = dp[i−1][j−1] + 1.
  3. MismatchOtherwise dp[i][j] = max(dp[i−1][j], dp[i][j−1]).

LCS length

def lcs_length(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]

LCS of ABC and AC

Compute the LCS length of X = ABC and Y = AC.

  • dp[1][1] (A vs A)1
  • dp[2][1] (AB vs A)1
  • dp[2][2] (AB vs AC)1
  • dp[3][2] (ABC vs AC, final C matches)2

Pro tip. One LCS is AC. Reconstructing the string itself walks the table backward from dp[m][n], following equal-letter diagonals and mismatch max choices.

For LCS, when the current characters differ, why is the transition a max of two skips rather than a diagonal + 1?
  1. Because a mismatch can still extend the LCS by one
  2. Because you must discard at least one of the two characters and keep the better leftover prefix
  3. Because LCS requires contiguous matches
  4. Because the table is always square

Unequal characters cannot both end the LCS. You drop X's last char, or Y's, and take whichever prefix already had the longer LCS.

7Edit distance

The Levenshtein distance is the fewest insertions, deletions and substitutions that turn string S into string T. With dp[i][j] as the distance between prefixes of lengths i and j, equal characters copy dp[i−1][j−1]; otherwise the cell is 1 plus the min of substitute, delete or insert — the three classical edits. Cost is again O(mn).

Same string×string cell lattice as Longest common subsequence, but each cell stores min insert / delete / substitute cost instead of a shared-subsequence length.

Three edits

  1. SubstituteReplace S[i−1] with T[j−1]: cost 1 + dp[i−1][j−1].
  2. DeleteDrop S[i−1]: cost 1 + dp[i−1][j].
  3. InsertInsert T[j−1] into S: cost 1 + dp[i][j−1].

Levenshtein distance

def edit_distance(S, T):
    m, n = len(S), len(T)
    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 S[i - 1] == T[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = 1 + min(
                    dp[i - 1][j - 1],
                    dp[i - 1][j],
                    dp[i][j - 1],
                )
    return dp[m][n]

cat → cut

Compute the edit distance from S = cat to T = cut.

  • Prefixes "c" and "c" matchdp = 0
  • "ca" → "cu": substitute a→u1
  • "cat" → "cut": final t matches1

Pro tip. Aligning equal letters for free is what separates edit distance from a blunt length difference |m−n|, which only counts inserts and deletes.

Coding lab. Edit distance cat to cut runs in the app, with checks on your output.

In the edit-distance recurrence, what does the diagonal transition dp[i−1][j−1] mean when the current characters differ?
  1. Insert a character
  2. Delete a character
  3. Substitute one character for the other
  4. Declare the strings already equal

Coming from the diagonal consumes one character on each side. If they differed, that consumption is a substitution (cost 1); if they matched, the copy is free.

8Space optimisation and iteration direction

Many 2D tables depend only on the previous row (or previous two Fibonacci values). Keeping one row — or two rolling scalars — drops O(mn) or O(n) memory without changing the recurrence. The sharp interview trap is direction: 0/1 knapsack must scan capacity downward in the 1D array; unbounded knapsack and coin change scan upward so a coin can be reused.

Figure. When a cell needs only the previous row (or a suffix of it), drop the full table to rolling or 1D storage.

What you may drop

  1. Row dependencyIf dp[i][j] reads only dp[i−1][*] (and maybe the current row to the left), store one row of length O(n) or two rows.
  2. Rolling scalarsFibonacci only needs the previous two answers — O(1) space after the O(n) time pass.
  3. Direction encodes reuseDescending capacity: each item once. Ascending capacity: an update can feed a later cell in the same pass, which is intentional reuse.
1D scan direction
PatternCapacity / amount loopWhy
0/1 knapsackHigh → lowdp[w−wt] must still be the previous item
Unbounded knapsackLow → highSame item may update w more than once
Coin change (min coins)Low → high per amountUnlimited supply of each coin
Fibonacci rollingSingle pass i = 2…nOnly F(i−1), F(i−2) survive

Fibonacci in O(1) space

Compute F(5) keeping only two rolling variables a = F(i−2), b = F(i−1).

  • start a, b0, 1
  • i = 2: a,b ← b, a+b1, 1
  • i = 31, 2
  • i = 42, 3
  • i = 53, 5

Pro tip. Spot DP when brute-force recursion recomputes the same inputs — add memoization first, then convert to tabulation and trim space once the recurrence is trusted.

You compress unbounded knapsack (items reusable) to one array. Which capacity iteration is required?
  1. Descending from W to 0
  2. Ascending from 0 to W
  3. Only even capacities
  4. Two nested capacity loops in opposite directions

Ascending lets a newly updated dp[w] feed dp[w+wt] in the same item pass — that is the reuse. Descending would freeze each item at one use.

Notes

  • When DP Applies: A problem needs DP when it has optimal substructure (optimal solution built from optimal subsolutions) and overlapping subproblems (the same subproblem recurs).
  • Memoization vs Tabulation: Top-down memoization caches recursive results; bottom-up tabulation fills a table iteratively - both avoid recomputation.
  • State and Transition: Define the state (what parameters identify a subproblem) and the recurrence that combines smaller states into larger ones.
  • Classic Patterns: 0/1 knapsack, longest common subsequence, edit distance, coin change, and longest increasing subsequence recur constantly in interviews.
  • Space Optimization: Many 2D DP tables can be reduced to 1D (or two rows) when each state depends only on the previous row.

Formulas

  • Fibonacci with memoization: O(n) time, O(n) space (or O(1) with rolling variables).
  • 0/1 Knapsack: O(nW) time, O(nW) space (reducible to O(W)), for n items and capacity W.
  • Longest Common Subsequence: O(mn) time and space for lengths m and n.
  • Coin Change (min coins): O(n \times A) time for n coin types and amount A.
  • Edit Distance: O(mn) time and space via the Levenshtein recurrence.

Exam traps & shortcuts

  • Spot DP when brute-force recursion recomputes the same inputs - add memoization first, then convert to tabulation if needed.
  • Always write the recurrence and base cases explicitly before coding; the transition is the hard part, not the loop.
  • Reduce space by noticing a 2D table often depends only on the previous row - keep one or two rows.
  • Distinguish 0/1 knapsack (each item once, iterate capacity descending) from unbounded (reuse allowed, iterate ascending).

Reference tables

Interview staples from the roadmap. Lengths m, n; n items; capacity or amount W or A.

Classic patterns — cost sheet
PatternTimeSpace (naive)Compressible to
Fibonacci (memo / tab)O(n)O(n)O(1) rolling
0/1 knapsackO(nW)O(nW)O(W)
Coin change (min coins)O(nA)O(A)already 1D
LCSO(mn)O(mn)O(\min(m,n))
Edit distanceO(mn)O(mn)O(\min(m,n))
LIS (patience / DP)O(n^2) DP / O(n\log n)O(n)

Recap

Carry these into a timed coding round.

When DP
Needs optimal substructure and overlapping subproblems — cache only when the recursion tree revisits inputs.
Before code
Write state, recurrence and base cases before loops; memoization is the usual bridge from brute force.
Coin change
dp[a] = min over coins of dp[a−c] + 1; unreachable stays infinity.
0/1 vs unbounded
0/1: take or skip; 1D scan descends. Unbounded / coin change: scan ascends.
LCS / edit
LCS copies on match and max-skips on mismatch; edit distance adds substitute, delete and insert.

Practise Dynamic Programming

Reading is free and needs no account. Practice, mocks and progress live in the app.

  • 5 exam-style questions on this topic, with explanations
  • A 6-question practice set that ends the chapter
  • Timed mocks scored with the real marking scheme
  • Readiness tracked per topic, kept on your device
Continue with Google — freeNo card, no trial. Works offline once installed.