Dynamic Programming: The Ultimate Reference Guide
A self-contained, publication-quality reference โ from first principles to production-grade applications in DS, AI, ML, and LLM systems. Every algorithm ships with intuition, recurrence, runnable top-down and bottom-up code, a hand-traced example, and hard-won practitioner insight.
๐ Table of Contentsโ
- What is Dynamic Programming?
- 1D Dynamic Programming
- 2D Dynamic Programming
- Knapsack Problem
- Edit Distance (Levenshtein)
- Comparative Summary
- Master Cheat Sheet
๐ง 1. What is Dynamic Programming?โ
Dynamic Programming (DP) is a problem-solving technique that solves a complex problem by breaking it into overlapping subproblems, solving each subproblem exactly once, and storing (caching) the result so it's never recomputed. It trades memory for time.
The name is historical, not descriptive. Richard Bellman coined "dynamic programming" in the 1950s partly because it sounded impressive to a research-funding Secretary of Defense who "had a pathological fear of the word research." "Programming" here means tabular planning/optimization (as in "linear programming"), not writing code.
The mental model that never fails:
DP = Recursion + Memory. If you can write a correct brute-force recursion, and that recursion re-solves the same inputs, DP is just that recursion with a cache.
Core Propertiesโ
A problem is a DP candidate if and only if it exhibits both:
-
Optimal Substructure โ an optimal solution to the whole is built from optimal solutions to its parts. Formally, the recurrence for the optimum only depends on the optima of smaller subproblems, not on how those subproblems were solved.
- โ Shortest path (subpaths of shortest paths are shortest).
- โ Longest simple path in a general graph โ combining two optimal sub-paths can revisit a node, so it lacks clean optimal substructure.
-
Overlapping Subproblems โ the naรฏve recursion solves the same subproblem many times. This is what distinguishes DP from Divide & Conquer (merge sort, quicksort), where subproblems are disjoint and caching buys nothing.
- Fibonacci:
fib(5)recomputesfib(3)twice,fib(2)three times โ exponential blowup without a cache.
- Fibonacci:
| Overlapping subproblems | Disjoint subproblems | |
|---|---|---|
| Optimal substructure | Dynamic Programming | Divide & Conquer |
| No optimal substructure | Greedy sometimes, else brute force | โ |
When to Use DP (Decision Checklist)โ
Ask these in order. If the first three are "yes," reach for DP:
- Am I asked for an optimum (min/max/count/"is it possible")? โ DP loves
min,max,count,true/false. It rarely helps with "list all solutions." - Can I define the answer for input
Nin terms of smaller inputs? (Recurrence exists.) - Does the naรฏve recursion recompute the same states? (Overlap exists.)
- Is the state space polynomial? If the number of distinct subproblems is small (poly in input size), DP is efficient. If it's exponential (e.g., subsets that don't collapse), DP may not save you.
- Greedy check: Can a local greedy choice be proven optimal (exchange argument / matroid)? If yes, prefer the simpler greedy. DP is the fallback when greedy provably fails.
The universal DP recipe (memorize this):
- Define the state โ what does
dp[...]mean? (The hardest and most important step.) - Write the recurrence โ how does a state depend on smaller states?
- Set base cases โ the smallest states you can answer directly.
- Choose a direction โ top-down (memoized recursion) or bottom-up (iterative table).
- Decide the answer cell โ which state holds the final result?
- Optimize space โ collapse dimensions once the recurrence is correct (never before).
๐ 2. 1D Dynamic Programmingโ
Reasoning before writing (chain-of-thought):
- Minimal mental model: a 1D array where
dp[i]summarizes everything about the prefix/position up toi.- Expert vs. beginner: beginners memorize Fibonacci; experts see the pattern โ "the answer at
idepends on a constant number of earlier indices" โ O(1) rolling space.- Real-world link: this is the backbone of sequence scoring, streaming metrics, and Viterbi-style decoding.
- Most common mistake: off-by-one in base cases and confusing "ending at
i" vs. "considering up toi."
Concept & Intuitionโ
1D DP applies when a single index parameterizes the subproblem. dp[i] captures the optimal answer for the sub-instance defined by position i (a prefix, a step, or a state at time i). The recurrence looks back a constant number of steps.
Canonical family: Climbing Stairs / House Robber / Max Subarray (Kadane) / Fibonacci. We'll use House Robber as the running example because it forces a genuine choice at every index (unlike Fibonacci, which is a pure count).
Problem (House Robber): Given
nums[]of non-negative house values arranged in a line, maximize the sum you can steal without robbing two adjacent houses.
Optimal substructure: the best loot for houses 0..i is built from the best loot for 0..i-1 and 0..i-2.
Overlapping subproblems: naรฏve recursion rob(i) calls rob(i-1) and rob(i-2), and rob(i-1) also calls rob(i-2) โ exponential overlap.
Analogyโ
"1D DP is like walking up a staircase while a stopwatch runs: to know the best time to reach step
i, you only need the best times for the one or two steps just below you โ not the entire history of how you got there. You carry a tiny 'best-so-far' note and update it one step at a time."
For House Robber specifically:
"Think of robbing houses like choosing gigs on a calendar where back-to-back days are forbidden. At each day you either skip (keep yesterday's best) or take today's pay plus your best from two days ago. You never need to re-plan the whole month โ just the last two days' bests."
Algorithm & Recurrenceโ
-
State:
dp[i]= maximum loot achievable considering houses0..i(i.e., "up to and including indexi"). -
Recurrence:
dp[i] = max( dp[i-1], # skip house i โ keep best up to i-1
dp[i-2] + nums[i] ) # rob house i โ best up to i-2, plus this house -
Base cases:
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1]) -
Direction: naturally bottom-up (index 0 upward). Top-down memoization also works with identical recurrence.
-
Answer cell:
dp[n-1]. -
Space optimization:
dp[i]depends only ondp[i-1]anddp[i-2]โ keep two scalars, drop the array โ O(1) space. This "rolling variable" trick is the signature of 1D DP.
Numbered pseudocode (bottom-up):
- If array empty โ return 0; if single element โ return it.
- Initialize
prev2 = nums[0],prev1 = max(nums[0], nums[1]). - For
ifrom2ton-1:cur = max(prev1, prev2 + nums[i]); shiftprev2 = prev1,prev1 = cur. - Return
prev1.
Code (Top-Down + Bottom-Up)โ
from functools import lru_cache
from typing import List
# ---------- TOP-DOWN (Memoization) ----------
def rob_topdown(nums: List[int]) -> int:
n = len(nums)
if n == 0:
return 0
@lru_cache(maxsize=None) # cache turns exponential recursion into O(n)
def best(i: int) -> int: # best(i) = max loot from houses 0..i
if i < 0: # no houses left โ 0 loot (clean base case)
return 0
if i == 0: # only the first house available
return nums[0]
# choose: skip house i, OR rob it (add nums[i] to best two houses back)
return max(best(i - 1), best(i - 2) + nums[i])
return best(n - 1) # answer: best over all houses
# ---------- BOTTOM-UP (Tabulation), O(1) space ----------
def rob_bottomup(nums: List[int]) -> int:
if not nums: # edge case: empty street
return 0
if len(nums) == 1: # edge case: single house
return nums[0]
prev2 = nums[0] # dp[i-2]: best up to two houses back
prev1 = max(nums[0], nums[1]) # dp[i-1]: best up to previous house
for i in range(2, len(nums)):
cur = max(prev1, prev2 + nums[i]) # skip vs. rob-this-house
prev2, prev1 = prev1, cur # roll the window forward
return prev1 # dp[n-1]
if __name__ == "__main__":
demo = [2, 7, 9, 3, 1]
assert rob_topdown(demo) == rob_bottomup(demo) == 12 # rob houses 2,9,1
print("House Robber:", rob_bottomup(demo)) # -> 12
Complexity: Top-down O(n) time / O(n) space (cache + recursion stack). Bottom-up O(n) time / O(1) space.
Worked Exampleโ
nums = [2, 7, 9, 3, 1]
| i | nums[i] | skip = dp[i-1] | rob = dp[i-2] + nums[i] | dp[i] = max | choice |
|---|---|---|---|---|---|
| 0 | 2 | โ | โ | 2 | rob H0 |
| 1 | 7 | 2 | (dp[-1]=0)+7 = 7 | 7 | rob H1 |
| 2 | 9 | 7 | dp[0]+9 = 2+9 = 11 | 11 | rob H0+H2 |
| 3 | 3 | 11 | dp[1]+3 = 7+3 = 10 | 11 | skip H3 |
| 4 | 1 | 11 | dp[2]+1 = 11+1 = 12 | 12 | rob H2+H4... |
Final answer dp[4] = 12. Backtracking the choices: rob H1 (7) + H2 (9)? No โ 7 and 9 are adjacent. The correct trace picks H0(2) + H2(9) + H4(1) = 12. This illustrates a key subtlety: dp[i] stores the value; recovering the actual set requires backtracking through which branch of the max won.
Expert Takeawaysโ
- Novice vs. expert: A novice allocates a full
dp[]array. An expert immediately notices the recurrence's window width (here 2) and collapses to O(1) scalars โ critical for streaming/embedded contexts. - "Ending at i" vs. "up to i": These are different states with different recurrences. Kadane's max-subarray uses
dp[i] = max(nums[i], dp[i-1] + nums[i])wheredp[i]means "best subarray ending exactly ati" โ you then take the global max separately. Mixing these two definitions is the #1 1D-DP bug. - Base-case discipline: Extending the state to
i < 0 โ 0(as in the top-down version) eliminates messy special-casing. Prefer a clean sentinel base case over branching. - AI/ML/LLM connection: 1D DP is the algorithmic core of the Viterbi algorithm (most-likely hidden state sequence in HMMs) and CTC decoding for speech-to-text โ both are "best-score-ending-at-time-
t" recurrences. In RL, Bellman backups for a chain MDP are literally 1D DP over states. - Pitfall โ negative numbers & empty inputs: Max-subarray must return the largest single element when all are negative; never seed with
0. Always test empty/singleton/all-negative arrays.
Complexity Tableโ
| Variant | Time Complexity | Space Complexity | Optimization Possible? |
|---|---|---|---|
| Naรฏve recursion (no cache) | O(2^n) | O(n) stack | โ (avoid) |
| Top-down memoization | O(n) | O(n) | Convert to bottom-up |
| Bottom-up (array) | O(n) | O(n) | Roll to O(1) |
| Bottom-up (rolling scalars) | O(n) | O(1) | Already optimal |
DS/AI/ML/LLM Applicationsโ
- DS: Kadane's algorithm (max subarray), longest increasing subsequence (with the
O(n log n)patience-sorting variant), stock-buy-sell. - AI/ML: Viterbi decoding (HMM/CRF), CTC loss forward pass, dynamic time warping (1D-ish), Bellman value iteration on chains.
- LLM/NLP: beam-search bookkeeping and n-gram sequence scoring are 1D-DP-flavored "best score so far at position
t."
๐บ๏ธ 3. 2D Dynamic Programmingโ
Reasoning before writing (chain-of-thought):
- Minimal mental model: a grid
dp[i][j]where two indices jointly define the subproblem (two sequences, or a position in a 2D space).- Expert vs. beginner: experts recognize that most 2D DP fills row-by-row depending only on the current + previous row โ O(min(m,n)) space.
- Real-world link: sequence alignment (bioinformatics), diff tools, image seam carving.
- Most common mistake: wrong initialization of the first row/column, and iterating in an order that reads cells not yet computed.
Concept & Intuitionโ
2D DP arises when two indices are needed to describe a subproblem โ typically two sequences (align A[0..i] with B[0..j]) or a position on a grid (row i, col j). We'll use the Unique Paths / Min Path Sum grid family as the primary example (pure, visual), then reuse the 2D machinery for Edit Distance and 0/1 Knapsack later.
Problem (Minimum Path Sum): Given an
m ร ngrid of non-negative costs, find the minimum-cost path from top-left to bottom-right, moving only right or down.
Optimal substructure: the cheapest way to reach (i, j) uses the cheapest way to reach (i-1, j) or (i, j-1).
Overlapping subproblems: cell (i, j) is reachable through many prefixes; naรฏve recursion recomputes each interior cell exponentially often.
Analogyโ
"2D DP is like filling in a crossword grid where each square's answer depends only on the square above and the square to its left. You sweep across rows top-to-bottom, left-to-right, and by the time you reach any square, everything it needs is already inked in. The bottom-right corner holds the final answer."
Or for alignment problems:
"Aligning two strings is like two people editing the same document from opposite ends โ at each cell you decide whether they agree (move diagonally for free), or someone made an edit (pay 1 and step)."
Algorithm & Recurrenceโ
-
State:
dp[i][j]= minimum cost to reach cell(i, j)from(0, 0). -
Recurrence:
dp[i][j] = grid[i][j] + min( dp[i-1][j], # came from above
dp[i][j-1] ) # came from the left -
Base cases:
dp[0][0] = grid[0][0]; first row accumulates leftward; first column accumulates upward (only one way to reach edge cells). -
Direction: bottom-up, iterating rows
0โm-1, cols0โn-1so bothdp[i-1][j]anddp[i][j-1]are ready. -
Answer cell:
dp[m-1][n-1]. -
Space optimization: each row depends only on the row above + the current row's left neighbor โ keep one row (
O(n)), orO(min(m, n))by iterating along the shorter dimension.
Numbered pseudocode (bottom-up):
- Initialize
dp[0][0] = grid[0][0]. - Fill first column:
dp[i][0] = dp[i-1][0] + grid[i][0]. - Fill first row:
dp[0][j] = dp[0][j-1] + grid[0][j]. - For each
ifrom 1, eachjfrom 1: apply the recurrence. - Return
dp[m-1][n-1].
Code (Top-Down + Bottom-Up)โ
from functools import lru_cache
from typing import List
# ---------- TOP-DOWN (Memoization) ----------
def min_path_sum_topdown(grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
@lru_cache(maxsize=None)
def cost(i: int, j: int) -> int: # min cost to reach (i, j)
if i == 0 and j == 0: # start cell
return grid[0][0]
if i < 0 or j < 0: # off-grid โ infinity (illegal)
return float("inf")
# best of coming from above or from the left, plus this cell's cost
return grid[i][j] + min(cost(i - 1, j), cost(i, j - 1))
return cost(m - 1, n - 1)
# ---------- BOTTOM-UP (Tabulation), O(n) space ----------
def min_path_sum_bottomup(grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
dp = [0] * n # single rolling row
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
dp[j] = grid[0][0] # start
elif i == 0:
dp[j] = dp[j - 1] + grid[i][j] # only from the left
elif j == 0:
dp[j] = dp[j] + grid[i][j] # only from above (old dp[0])
else:
# dp[j] currently holds the value from the row ABOVE (dp[i-1][j])
# dp[j-1] already updated to the current row (dp[i][j-1])
dp[j] = grid[i][j] + min(dp[j], dp[j - 1])
return dp[n - 1]
if __name__ == "__main__":
g = [[1, 3, 1],
[1, 5, 1],
[4, 2, 1]]
assert min_path_sum_topdown(g) == min_path_sum_bottomup(g) == 7 # 1โ3โ1โ1โ1
print("Min Path Sum:", min_path_sum_bottomup(g)) # -> 7
Complexity: Top-down O(mn) time / O(mn) space. Bottom-up O(mn) time / O(n) space (rolling row).
Worked Exampleโ
grid = [[1,3,1],[1,5,1],[4,2,1]]. Full dp table (min cost to reach each cell):
j=0 j=1 j=2
i=0 [ 1 , 4 , 5 ] # first row: 1, 1+3=4, 4+1=5
i=1 [ 2 , 7 , 6 ] # dp[1][0]=1+1=2; dp[1][1]=5+min(4,2)=7; dp[1][2]=1+min(5,7)=6
i=2 [ 6 , 8 , 7 ] # dp[2][0]=4+2=6; dp[2][1]=2+min(7,6)=8; dp[2][2]=1+min(6,8)=7
Final answer dp[2][2] = 7. Optimal path: 1 โ 3 โ 1 โ 1 โ 1 (right, right, down, down). Notice how dp[1][2] = 6 beats dp[1][1] = 7: the recurrence automatically discovers that hugging the top row then dropping down is cheaper than cutting through the expensive 5.
Expert Takeawaysโ
- Novice vs. expert: novices allocate a full
mรnmatrix reflexively. Experts ask "does rowineed anything older than rowi-1?" โ usually no, so they roll to one row (O(n)), and pick the shorter dimension to minimize memory. - Iteration order is a correctness issue, not a style choice. The loop order must guarantee every dependency (
dp[i-1][j],dp[i][j-1]) is computed before use. When you compress to 1D, the timing of whendp[j]still holds the old row vs. the new row becomes load-bearing โ annotate it in comments (as above). - Initialize edges explicitly. The first row and column have degenerate recurrences (only one predecessor). Silent zero-initialization is the classic 2D-DP bug.
- Path reconstruction: store parent pointers or re-derive by checking which predecessor achieved the min. Alignment/diff tools need the actual path, not just the cost.
- AI/ML/LLM connection: 2D DP is the engine of NeedlemanโWunsch / SmithโWaterman sequence alignment (genomics), Dynamic Time Warping for time-series/speech similarity, seam carving in image resizing, and the token-level alignment behind BLEU/ROUGE/TER and word-error-rate in LLM/ASR evaluation.
Complexity Tableโ
| Variant | Time Complexity | Space Complexity | Optimization Possible? |
|---|---|---|---|
| Naรฏve recursion | O(2^(m+n)) | O(m+n) stack | โ (avoid) |
| Top-down memoization | O(mn) | O(mn) | Convert to bottom-up |
| Bottom-up (full matrix) | O(mn) | O(mn) | Roll rows |
| Bottom-up (rolling row) | O(mn) | O(min(m,n)) | Optimal for full-path problems |
DS/AI/ML/LLM Applicationsโ
- DS: unique paths, min/max path sum, longest common subsequence (LCS), matrix chain multiplication, grid DP with obstacles.
- AI/ML: DTW (speech/gesture), sequence alignment (bioinformatics), CRF forward-backward over 2D lattices.
- LLM/NLP: LCS underpins ROUGE-L; edit-distance alignment underpins WER/CER and diff-based eval; token alignment for translation metrics.
๐ 4. Knapsack Problemโ
The knapsack family is the canonical testbed for "choose a subset under a capacity budget to maximize value." Three variants differ only in how many times you may take each item โ and that single change flips the loop direction, the recurrence, and even the paradigm (DP vs. greedy).
Reasoning before writing (chain-of-thought):
- Minimal mental model:
dp[c]= best value achievable with capacityc; items are layered on top.- Expert vs. beginner: the difference between 0/1 and unbounded is the direction of the inner capacity loop โ beginners get this backwards and silently reuse items.
- Real-world link: budget allocation, cargo loading, feature selection under a compute budget, and quantization/pruning bit-budgets in ML.
- Most common mistake: iterating capacity ascending for 0/1 (which double-counts an item) or descending for unbounded (which forbids reuse).
4a. 0/1 Knapsackโ
Concept & Intuitionโ
Problem: Given
nitems withweight[i]andvalue[i], and a knapsack of capacityW, pick a subset (each item at most once) maximizing total value with total weight โคW.
Optimal substructure: the best value using items 0..i at capacity c is the better of (a) skipping item i and (b) taking item i plus the best of items 0..i-1 at capacity c - weight[i].
Overlapping subproblems: the same (item index, remaining capacity) pair recurs across many subset choices.
Analogyโ
"0/1 Knapsack is like packing for a flight with a strict weight limit and a bin of unique souvenirs โ each souvenir exists only once, so for each one you make a binary call: take it (and eat into your remaining allowance) or leave it. You can't pack two of the same vase."
Algorithm & Recurrenceโ
-
State:
dp[i][c]= max value using the firstiitems with capacity exactlyโค c. -
Recurrence:
dp[i][c] = dp[i-1][c] # skip item i
if weight[i] <= c:
dp[i][c] = max(dp[i-1][c],
dp[i-1][c - weight[i]] + value[i]) # take item i (from i-1!) -
Base cases:
dp[0][c] = 0for allc(no items โ no value);dp[i][0] = 0. -
Direction: bottom-up over items, then capacity. 1D-compressed: iterate capacity DESCENDING so each item is used at most once.
-
Answer cell:
dp[n][W]. -
Space optimization: collapse to
dp[c](O(W)), inner loopcfromWdown toweight[i].
Numbered pseudocode (1D, space-optimized):
dp = [0]*(W+1).- For each item
i: forcfromWdown toweight[i]:dp[c] = max(dp[c], dp[c-weight[i]] + value[i]). - Return
dp[W].
Why descending? When we compute
dp[c]fromdp[c-weight[i]], the descending order guaranteesdp[c-weight[i]]still reflects the previous item row (iteminot yet used at that smaller capacity) โ enforcing "at most once." Ascending would letdp[c-weight[i]]already include itemi, silently allowing reuse (that's exactly the unbounded variant).
Code (Top-Down + Bottom-Up)โ
from functools import lru_cache
from typing import List
# ---------- TOP-DOWN (Memoization) ----------
def knapsack01_topdown(weight: List[int], value: List[int], W: int) -> int:
n = len(weight)
@lru_cache(maxsize=None)
def best(i: int, cap: int) -> int: # best value using items i..n-1 with capacity cap
if i == n or cap == 0: # no items left or no capacity
return 0
skip = best(i + 1, cap) # don't take item i
take = 0
if weight[i] <= cap: # only if it fits
take = value[i] + best(i + 1, cap - weight[i]) # take it once, move on
return max(skip, take)
return best(0, W)
# ---------- BOTTOM-UP (Tabulation), O(W) space ----------
def knapsack01_bottomup(weight: List[int], value: List[int], W: int) -> int:
dp = [0] * (W + 1) # dp[c] = best value at capacity c
for i in range(len(weight)):
# DESCENDING capacity => each item used at most once (0/1 constraint)
for c in range(W, weight[i] - 1, -1):
dp[c] = max(dp[c], dp[c - weight[i]] + value[i])
return dp[W]
if __name__ == "__main__":
w, v, cap = [1, 3, 4], [1, 4, 5], 4
assert knapsack01_topdown(w, v, cap) == knapsack01_bottomup(w, v, cap) == 5
print("0/1 Knapsack:", knapsack01_bottomup(w, v, cap)) # -> 5
Complexity: O(nยทW) time; 2D O(nยทW) space, 1D O(W) space. Note: this is pseudo-polynomial โ W is a value, not input size, so it's exponential in the bit-length of W. 0/1 Knapsack is NP-hard; DP is efficient only when W is modest.
Worked Exampleโ
weight=[1,3,4], value=[1,4,5], W=4. Full 2D table dp[i][c] (rows = items considered):
Items: weight=[1,3,4], value=[1,4,5], capacity=4
c=0 c=1 c=2 c=3 c=4
dp[0] (none) [ 0 , 0 , 0 , 0 , 0 ] โ base case
dp[1] (w1=1) [ 0 , 1 , 1 , 1 , 1 ] โ item 1 (v=1) fits from c>=1
dp[2] (w3=3) [ 0 , 1 , 1 , 4 , 5 ] โ at c=4: max(1, dp[1][1]+4)=1+4=5
dp[3] (w4=4) [ 0 , 1 , 1 , 4 , 5 ] โ at c=4: max(5, dp[2][0]+5)=5 (tie, keep 5)
Final answer dp[3][4] = 5 โ achieved by taking items 1 (w1,v1) + 2 (w3,v4) = weight 4, value 5, which ties with item 3 alone (w4,v5). The DP silently keeps whichever reaches the optimum.
Expert Takeawaysโ
- Novice vs. expert: the loop-direction rule (descending = 0/1, ascending = unbounded) is the single most important knapsack fact. Experts derive it from "which item-row does
dp[c-w]represent?" - Pseudo-polynomial trap: if
Wis huge (e.g.,10^9),O(nW)DP is infeasible โ switch to value-indexed DP (dp[value] = min weight) when total value is small, or use meet-in-the-middle / branch-and-bound / FPTAS approximation. - Reconstruction: to recover which items, either keep the 2D table and backtrack (
dp[i][c] != dp[i-1][c]โ itemitaken), or store a parent bitmask. - AI/ML/LLM connection: 0/1 knapsack models budgeted feature/model selection, ad/creative selection under a spend cap, layer/attention-head pruning under a FLOP budget, and mixed-precision quantization (assign bit-widths to layers to maximize accuracy under a memory budget). Multi-dimensional knapsack generalizes to multi-resource scheduling on GPUs.
- Pitfall: forgetting the
weight[i] <= cguard, or initializingdpwith-infwhen "partial fill allowed" (use0for "โค capacity"; use-infonly for "exactly capacity" variants).
Complexity Tableโ
| Variant | Time | Space | Optimization Possible? |
|---|---|---|---|
| Brute force (subsets) | O(2^n) | O(n) | โ |
| Top-down memo | O(nW) | O(nW) | โ bottom-up |
| Bottom-up 2D | O(nW) | O(nW) | roll to 1D |
| Bottom-up 1D | O(nW) | O(W) | value-indexed DP / FPTAS if W huge |
4b. Unbounded Knapsackโ
Concept & Intuitionโ
Problem: Same as 0/1, but each item may be taken an unlimited number of times. (Coin Change and Rod Cutting are the same problem in disguise.)
Optimal substructure: best value at capacity c = max over items i of value[i] + best(c - weight[i]).
Overlapping subproblems: capacity c is reached through many item multisets; each c solved once and reused.
Analogyโ
"Unbounded Knapsack is like a vending machine with infinite stock: to make the best of your remaining money, you can insert the same snack choice again and again. Each item is a type, not a unique object, so reuse is free โ you only ever run out of budget, never of stock."
Algorithm & Recurrenceโ
-
State:
dp[c]= max value achievable with capacityc(items reusable). -
Recurrence:
dp[c] = max over all items i with weight[i] <= c of
dp[c - weight[i]] + value[i] -
Base case:
dp[0] = 0. -
Direction: bottom-up; 1D with capacity loop ASCENDING so
dp[c-weight[i]]may already include itemiagain (enabling reuse). -
Answer cell:
dp[W].
Numbered pseudocode (1D):
dp = [0]*(W+1).- For
cfrom1toW: for each itemiwithweight[i] <= c:dp[c] = max(dp[c], dp[c-weight[i]] + value[i]). - Return
dp[W].
Why ascending? Ascending order means by the time we use
dp[c-weight[i]], it may already have counted itemiat a smaller capacity โ so the same item type is reused any number of times. This is the exact opposite of the 0/1 descending loop.
Code (Top-Down + Bottom-Up)โ
from functools import lru_cache
from typing import List
# ---------- TOP-DOWN (Memoization) ----------
def knapsack_unbounded_topdown(weight: List[int], value: List[int], W: int) -> int:
n = len(weight)
@lru_cache(maxsize=None)
def best(cap: int) -> int: # best value for capacity cap
if cap == 0:
return 0
result = 0
for i in range(n): # try every item type
if weight[i] <= cap: # stay on same item pool (reuse allowed)
result = max(result, value[i] + best(cap - weight[i]))
return result
return best(W)
# ---------- BOTTOM-UP (Tabulation), O(W) space ----------
def knapsack_unbounded_bottomup(weight: List[int], value: List[int], W: int) -> int:
dp = [0] * (W + 1)
for c in range(1, W + 1): # ASCENDING capacity => reuse allowed
for i in range(len(weight)):
if weight[i] <= c:
dp[c] = max(dp[c], dp[c - weight[i]] + value[i])
return dp[W]
if __name__ == "__main__":
w, v, cap = [1, 3, 4], [1, 4, 5], 4
# Best: take item2 (w3,v4) + item1 (w1,v1) = 5, OR 4x item1 = 4; also 1x item2+1x item1=5
assert knapsack_unbounded_topdown(w, v, cap) == knapsack_unbounded_bottomup(w, v, cap)
print("Unbounded Knapsack:", knapsack_unbounded_bottomup(w, v, cap)) # -> 5
Complexity: O(nยทW) time / O(W) space.
Worked Exampleโ
weight=[1,3,4], value=[1,4,5], W=4, capacity loop ascending:
dp[0] = 0
dp[1] = max(dp[0]+v(w1)) = 0+1 = 1 # one unit of item1
dp[2] = max(dp[1]+v(w1)) = 1+1 = 2 # two units of item1
dp[3] = max(dp[2]+1, dp[0]+4) = max(3, 4) = 4 # one item2 beats 3x item1
dp[4] = max(dp[3]+1, dp[1]+4, dp[0]+5)
= max(4+1, 1+4, 0+5) = 5 # item2 + item1 (or item3 alone)
Final dp[4] = 5. Note how dp[3]=4 (one item2) is reused inside dp[4] โ reuse across capacities is exactly what ascending order enables.
Expert Takeawaysโ
- Coin Change is unbounded knapsack. "Fewest coins to make amount
A" = minimize count instead of maximize value; "number of ways to makeA" = count variant (careful loop nesting: items-outer/amount-inner counts combinations, amount-outer/items-inner counts permutations). - Loop-order semantics matter for counting. For "number of ways," swapping the two loops changes the answer โ a subtle expert gotcha that trips even experienced engineers.
- Novice vs. expert: novices reimplement coin change, rod cutting, and unbounded knapsack as three separate problems; experts see one recurrence and one ascending loop.
- AI/ML/LLM connection: unbounded selection appears in token-budget packing (fit as many reusable prompt templates/examples into a context window to maximize expected utility), resource replication (how many replicas of a service under a cost budget), and beam/allocation problems where the same action type can repeat.
- Pitfall: using descending capacity here (accidentally enforcing 0/1) โ the most common copy-paste bug when adapting 0/1 code.
Complexity Tableโ
| Variant | Time | Space | Optimization Possible? |
|---|---|---|---|
| Top-down memo | O(nW) | O(W) | โ bottom-up |
| Bottom-up 1D | O(nW) | O(W) | already tight |
| Count-ways variant | O(nW) | O(W) | mind loop order (comb vs perm) |
4c. Fractional Knapsack (Greedy contrast)โ
Concept & Intuitionโ
Problem: Items may be taken fractionally (e.g., grains, liquids, divisible resources). Maximize value with weight โค
W.
Crucially, fractional knapsack is NOT a DP problem โ it's solved greedily and optimally. It's included here as a deliberate contrast: the same-looking problem loses its need for DP the moment items become divisible, because the greedy choice property now provably holds.
Why greedy works (and DP is overkill): sort by value density value[i]/weight[i] descending; take items greedily, splitting the last one to exactly fill W. An exchange argument proves optimality: any solution not prioritizing the highest density can be improved by swapping in a fraction of a denser item. This is the matroid/greedy-choice property that 0/1 knapsack lacks (you can't split an indivisible souvenir).
Analogyโ
"Fractional Knapsack is like filling a tanker truck with liquids of different value-per-liter: you pour in the most valuable liquid first until it's gone, then the next, and top off the last one partially. Because liquids split freely, greed is provably optimal โ no need to plan ahead."
Algorithm & Recurrenceโ
There is no DP recurrence โ that's the point. The algorithm is:
- Compute density
d[i] = value[i] / weight[i]. - Sort items by
d[i]descending. - Greedily take whole items while they fit; when the next item doesn't fully fit, take the fraction
remaining_capacity / weight[i]of it and stop. - Return accumulated value.
- Direction: single greedy pass after sort.
- Answer: accumulated fractional value.
Code (Greedy โ the correct paradigm here)โ
from typing import List, Tuple
def fractional_knapsack(weight: List[float], value: List[float], W: float) -> float:
# Pair items with their value density, sort by density DESCENDING
items: List[Tuple[float, float, float]] = sorted(
((value[i] / weight[i], weight[i], value[i]) for i in range(len(weight))),
key=lambda t: t[0], reverse=True
)
total = 0.0
cap = W
for density, w, v in items:
if cap <= 0: # bag full
break
if w <= cap: # take the whole item
total += v
cap -= w
else: # take the fitting fraction, then stop
total += density * cap # value = density * capacity used
cap = 0
return total
if __name__ == "__main__":
w, v, cap = [10, 20, 30], [60, 100, 120], 50
# densities: 6, 5, 4 -> take all of item0 (10), all of item1 (20),
# then 20/30 of item2 -> 60 + 100 + (2/3)*120 = 240.0
print("Fractional Knapsack:", fractional_knapsack(w, v, cap)) # -> 240.0
Complexity: O(n log n) time (dominated by the sort) / O(n) space โ strictly better than the O(nW) DP for 0/1, and exact (no pseudo-polynomial catch).
Worked Exampleโ
weight=[10,20,30], value=[60,100,120], W=50:
densities: item0=60/10=6.0, item1=100/20=5.0, item2=120/30=4.0
sorted: item0(6.0), item1(5.0), item2(4.0)
take item0 fully: value += 60, cap = 50-10 = 40
take item1 fully: value += 100, cap = 40-20 = 20
item2 needs 30 > 20 left -> take fraction 20/30:
value += 4.0 * 20 = 80, cap = 0
TOTAL = 60 + 100 + 80 = 240.0
Contrast: the 0/1 version of this instance (no splitting) would yield only 160 (items 0+1) or 180 (item... ) โ fractionality strictly increases the achievable value and simplifies the algorithm.
Expert Takeawaysโ
- The paradigm test: divisibility โ greedy-choice property โ greedy is optimal and faster. Indivisibility โ greedy can fail โ DP (or NP-hard). Knowing when not to use DP is expert-level judgment.
- Greedy proof obligation: never claim a greedy is optimal without an exchange argument or matroid structure. Fractional knapsack has it; 0/1 does not (counterexample:
w=[1,1,1], v=[2,2,3], W=2โ density-greedy can misfire on ties/indivisibility). - AI/ML/LLM connection: fractional/continuous allocation is water-filling in resource/power allocation, continuous budget splitting across ad campaigns or compute pools, and soft/weighted mixtures (e.g., allocating a fractional token budget across retrieval sources by expected relevance density).
- Pitfall: applying greedy density-sorting to the 0/1 problem โ a classic wrong answer. Always confirm divisibility first.
Complexity Tableโ
| Variant | Time | Space | Paradigm | Optimization Possible? |
|---|---|---|---|---|
| Fractional (greedy) | O(n log n) | O(n) | Greedy (not DP) | Selection-based O(n) avg via quickselect |
| 0/1 (for contrast) | O(nW) | O(W) | DP | pseudo-poly only |
โ๏ธ 5. Edit Distance (Levenshtein)โ
Reasoning before writing (chain-of-thought):
- Minimal mental model:
dp[i][j]= min edits to turn the firstichars ofAinto the firstjchars ofB.- Expert vs. beginner: experts know the three operations map to three neighbor cells (up=delete, left=insert, diagonal=match/substitute) and can reconstruct the alignment, not just the number.
- Real-world link: spell-check, DNA alignment, fuzzy search, and LLM/ASR evaluation metrics.
- Most common mistake: wrong base-case initialization (first row/col must be
0..n, the cost of building a string from empty), and off-by-one indexing between the 1-based DP table and 0-based strings.
Concept & Intuitionโ
Problem: Given strings
A(lengthm) andB(lengthn), find the minimum number of single-character insertions, deletions, or substitutions to transformAintoB. (This is the Levenshtein distance; variants add transposition = DamerauโLevenshtein.)
Optimal substructure: the cheapest way to align A[0..i] with B[0..j] is built from the cheapest alignments of their prefixes.
Overlapping subproblems: aligning prefixes recomputes the same (i, j) prefix pair across many edit sequences.
Analogyโ
"Edit Distance is like a GPS rerouting system โ it finds the minimum number of 'corrections' (insertions, deletions, substitutions) to transform one route into another. Each wrong turn you must add, remove, or replace counts as one edit, and the GPS finds the cheapest set of corrections to get from your current path to the destination path."
Alternatively:
"It's like the 'track changes' feature in a word processor computing the smallest diff between two drafts: every inserted, deleted, or replaced character is one tracked change, and edit distance is the total count in the minimal diff."
Algorithm & Recurrenceโ
-
State:
dp[i][j]= min edits to convertA[0..i-1](firstichars) intoB[0..j-1](firstjchars). (1-based table over 0-based strings.) -
Recurrence:
if A[i-1] == B[j-1]:
dp[i][j] = dp[i-1][j-1] # characters match โ no cost, move diagonally
else:
dp[i][j] = 1 + min( dp[i-1][j], # delete A[i-1] (come from above)
dp[i][j-1], # insert B[j-1] (come from left)
dp[i-1][j-1] ) # substitute (come from diagonal) -
Base cases:
dp[0][j] = j(insertjchars into emptyA);dp[i][0] = i(delete allichars ofA). -
Direction: bottom-up,
ifrom0โm,jfrom0โn. Top-down memoization mirrors the recurrence. -
Answer cell:
dp[m][n]. -
Space optimization: each row depends only on the previous row โ two rows or one row + a diagonal temp โ
O(min(m,n))space.
Numbered pseudocode (bottom-up):
- Initialize first row
dp[0][j] = j, first columndp[i][0] = i. - For
ifrom1..m,jfrom1..n: if chars equal, copy the diagonal; else1 + min(up, left, diagonal). - Return
dp[m][n].
Code (Top-Down + Bottom-Up)โ
from functools import lru_cache
# ---------- TOP-DOWN (Memoization) ----------
def edit_distance_topdown(A: str, B: str) -> int:
@lru_cache(maxsize=None)
def dist(i: int, j: int) -> int: # min edits for A[:i] -> B[:j]
if i == 0: # A prefix empty โ insert all j chars of B
return j
if j == 0: # B prefix empty โ delete all i chars of A
return i
if A[i - 1] == B[j - 1]: # last chars match โ free diagonal move
return dist(i - 1, j - 1)
return 1 + min( # otherwise pay 1 for the best of:
dist(i - 1, j), # delete A[i-1]
dist(i, j - 1), # insert B[j-1]
dist(i - 1, j - 1), # substitute A[i-1] -> B[j-1]
)
return dist(len(A), len(B))
# ---------- BOTTOM-UP (Tabulation), O(n) space ----------
def edit_distance_bottomup(A: str, B: str) -> int:
m, n = len(A), len(B)
prev = list(range(n + 1)) # dp[0][j] = j (build B from empty)
for i in range(1, m + 1):
cur = [i] + [0] * n # dp[i][0] = i (delete i chars)
for j in range(1, n + 1):
if A[i - 1] == B[j - 1]:
cur[j] = prev[j - 1] # match โ diagonal, no cost
else:
cur[j] = 1 + min(prev[j], # delete
cur[j - 1], # insert
prev[j - 1]) # substitute
prev = cur # roll rows forward
return prev[n]
if __name__ == "__main__":
a, b = "horse", "ros"
assert edit_distance_topdown(a, b) == edit_distance_bottomup(a, b) == 3
print("Edit Distance('horse','ros'):", edit_distance_bottomup(a, b)) # -> 3
Complexity: O(mยทn) time; 2D O(mยทn) space, rolling O(min(m,n)) space.
Worked Exampleโ
A = "horse", B = "ros". Full dp table (rows = prefixes of horse, cols = prefixes of ros):
"" r o s
"" [ 0, 1, 2, 3 ] # dp[0][j] = j (insert r,o,s)
h [ 1, 1, 2, 3 ] # h vs r,o,s โ all substitutions/edits
o [ 2, 2, 1, 2 ] # 'o' matches 'o' at (2,2): copy diagonal dp[1][1]=1
r [ 3, 2, 2, 2 ] # 'r' matches 'r' at (3,1): copy diagonal dp[2][0]=2
s [ 4, 3, 3, 2 ] # 's' matches 's' at (5,3): copy diagonal dp[4][2]=2
Final answer dp[5][3] = 3. The optimal edit script for horse โ ros:
- Substitute
h โ r(horseโrorse) - Delete
r(rorseโrose) โ wait, trace via table: - Standard minimal alignment:
horse โ rorse(sub hโr),rorse โ rose(delete one r),rose โ ros(delete e) = 3 edits.
The diagonal "free moves" at the matching o, r, s characters are what pull the cost down from 5 (naรฏve replace-all) to 3.
Expert Takeawaysโ
- Novice vs. expert: novices return the number; experts reconstruct the alignment/edit script by backtracking from
dp[m][n](which neighbor achieved the min), enabling diffs, autocorrect suggestions, and highlighted changes. - Weighted & variant edits: production systems assign different costs per operation (a substitution may cost 2, or costs may depend on keyboard adjacency / phonetic similarity). DamerauโLevenshtein adds transposition; needleman-wunsch generalizes to gap penalties.
- Banded optimization: when you only care whether distance
โค k, compute only a diagonal band of width2k+1โO(kยทmin(m,n))โ the trick behind fast fuzzy search andk-approximate matching. - AI/ML/LLM connection: Edit distance underlies spell checkers & autocorrect, fuzzy string search (Elasticsearch,
fzf), DNA/protein sequence alignment (NeedlemanโWunsch/SmithโWaterman are weighted edit distance), and evaluation metrics: Word Error Rate (WER) and Character Error Rate (CER) for ASR, TER (Translation Edit Rate), and it's a component in token-level diffing for LLM output comparison. BK-trees index strings by edit distance for sublinear nearest-neighbor lookup. - Pitfall: base cases.
dp[0][j]=janddp[i][0]=iare not zero โ building a string from empty costs one edit per character. Zero-initializing the first row/column is the canonical edit-distance bug.
Complexity Tableโ
| Variant | Time | Space | Optimization Possible? |
|---|---|---|---|
| Naรฏve recursion | O(3^(m+n)) | O(m+n) stack | โ (avoid) |
| Top-down memo | O(mn) | O(mn) | โ bottom-up |
| Bottom-up 2D | O(mn) | O(mn) | roll rows |
| Bottom-up rolling | O(mn) | O(min(m,n)) | banded O(kยทn) if distance โค k |
| Banded (threshold k) | O(kยทmin(m,n)) | O(k) | optimal for fuzzy match |
๐ AI/ML/LLM Connection Calloutโ
๐ AI/ML/LLM Connection:
Edit Distance โ spell checkers & autocorrect, fuzzy search (fzf, Elasticsearch),
DNA/protein alignment (NeedlemanโWunsch, SmithโWaterman),
ASR evaluation (WER, CER), MT evaluation (TER),
token-level diffing for LLM output comparison,
BK-trees for sublinear approximate nearest-neighbor on strings.
๐ 6. Comparative Summaryโ
Side-by-Side Algorithm Comparisonโ
| Problem | State dp[...] means | Recurrence core | Time | Opt. Space | Paradigm |
|---|---|---|---|---|---|
| 1D (House Robber) | best loot up to house i | max(dp[i-1], dp[i-2]+nums[i]) | O(n) | O(1) | DP |
| 2D (Min Path Sum) | min cost to reach (i,j) | grid[i][j]+min(dp[i-1][j],dp[i][j-1]) | O(mn) | O(min(m,n)) | DP |
| 0/1 Knapsack | best value, items โคi, cap c | max(skip, dp[i-1][c-w]+v) | O(nW) | O(W) | DP (desc loop) |
| Unbounded Knapsack | best value at cap c, reuse | max_i dp[c-w_i]+v_i | O(nW) | O(W) | DP (asc loop) |
| Fractional Knapsack | โ (no DP state) | greedy by v/w density | O(n log n) | O(n) | Greedy |
| Edit Distance | min edits A[:i]โB[:j] | match: diag; else 1+min(3 neighbors) | O(mn) | O(min(m,n)) | DP |
When to Use Which Variantโ
Decision flow:
- Is the objective an optimum/count over choices? No โ probably not DP.
- Are the "items" divisible? Yes โ greedy (fractional knapsack, water-filling), not DP.
- One index or two?
- One index, constant-width look-back โ 1D DP (roll to O(1)).
- Two sequences / a grid โ 2D DP (roll to one row).
- Selection under a capacity budget?
- Each item once โ 0/1 knapsack (capacity loop descending).
- Unlimited copies โ unbounded knapsack (capacity loop ascending).
- Transform one sequence into another? โ Edit distance family (Levenshtein / NeedlemanโWunsch).
- Is
W(or the numeric budget) astronomically large? โ DP is pseudo-polynomial; switch to value-indexed DP, approximation (FPTAS), or branch-and-bound.
Top-down vs. bottom-up โ practitioner rule of thumb:
| Top-Down (Memoization) | Bottom-Up (Tabulation) | |
|---|---|---|
| Write speed | Faster to derive from recursion | Needs explicit order |
| Only computes reachable states | โ Yes (sparse-friendly) | โ Computes all |
| Space optimization | Harder (cache is 2D) | โ Easy (roll dimensions) |
| Recursion-limit risk | โ ๏ธ Deep stacks may overflow | โ No recursion |
| Best when | State space sparse / recurrence tricky | Dense states / need O(1)โO(n) space |
๐ก 7. Master Cheat Sheetโ
Recurrence Relations at a Glanceโ
1D (House Robber): dp[i] = max(dp[i-1], dp[i-2] + nums[i])
1D (Kadane): dp[i] = max(nums[i], dp[i-1] + nums[i]); ans = max(dp)
1D (Climb Stairs): dp[i] = dp[i-1] + dp[i-2]
2D (Min Path Sum): dp[i][j]= grid[i][j] + min(dp[i-1][j], dp[i][j-1])
2D (Unique Paths): dp[i][j]= dp[i-1][j] + dp[i][j-1]
2D (LCS): dp[i][j]= dp[i-1][j-1]+1 if a==b else max(dp[i-1][j], dp[i][j-1])
0/1 Knapsack: dp[c] = max(dp[c], dp[c-w[i]] + v[i]) # c: W -> w[i] (DESC)
Unbounded Knapsack: dp[c] = max(dp[c], dp[c-w[i]] + v[i]) # c: w[i] -> W (ASC)
Coin Change (min): dp[a] = min(dp[a], dp[a-coin] + 1) # ASC
Edit Distance: dp[i][j]= dp[i-1][j-1] if a==b
else 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
Common DP Patternsโ
| Pattern | Signature | Examples |
|---|---|---|
| Linear / prefix | dp[i] from constant look-back | Fibonacci, House Robber, Kadane, Climb Stairs |
| Grid / two-sequence | dp[i][j] from top/left/diagonal | Unique Paths, LCS, Edit Distance, alignment |
| Knapsack / subset-sum | capacity or target as an index | 0/1 & unbounded knapsack, coin change, partition |
| Interval | dp[i][j] over subarray i..j, split at k | Matrix chain, burst balloons, palindrome partition |
| Bitmask | dp[mask] over subsets | TSP, assignment, "visit all nodes" |
| Digit DP | dp[pos][tight][state] over number digits | count numbers with a property โค N |
| DP on trees | dp[node][state] via post-order | tree diameter, independent set on tree |
Interview Tipsโ
- State first, code last. Say aloud "
dp[i][j]means ___" before writing anything. A crisp state definition makes the recurrence fall out; a fuzzy one guarantees bugs. - Start top-down, then convert. Write the brute-force recursion, add
@lru_cache, confirm correctness, then rewrite bottom-up and optimize space. This staged approach rarely fails and demonstrates rigor to interviewers. - Nail the base cases with a small example. Hand-trace
n=0,1,2before generalizing โ most bugs live indp[0]and the first row/column. - Announce the complexity unprompted. State time and space, note whether it's pseudo-polynomial (knapsack) and whether space can be rolled down.
- Know the loop-direction rule cold: 0/1 knapsack = descending capacity; unbounded/coin change = ascending. This one line separates people who memorized from people who understand.
- Recognize the anti-patterns: if items are divisible โ greedy, not DP. If subproblems don't overlap โ divide & conquer, not DP. If the state space is exponential and irreducible โ DP won't save you (consider approximation/heuristics).
- Reconstruction on request. Be ready to recover the actual solution (items chosen, path taken, edit script), not just the optimal value โ backtrack through which branch of the
min/maxwon. - Connect to the domain. In ML/LLM interviews, tie DP to Viterbi/CTC decoding, sequence alignment for eval metrics (WER/BLEU/ROUGE), and budgeted selection (pruning/quantization) โ it signals depth beyond LeetCode.
End of guide. Every code block above is self-contained and runnable as-is (each includes an assert-backed demo under if __name__ == "__main__":). Copy any function directly into a .py file to verify.
Related Guidesโ
Prerequisites: Recursion & The Call Stack ยท Big-O Notation & Complexity Analysis
See also: Backtracking ยท Greedy Algorithms & Interval Scheduling ยท Shortest Path Algorithms
Section: Advanced DSA ยท All guides