Data Structure 📂 Advance Algorithms · 1 of 3 84 min read

Comprehensive Tutorial on Dynamic Programming

A full-depth, visual tutorial on Dynamic Programming covering the cinema-seat analogy intro, both DP strategies (memoisation vs tabulation), 6 worked examples (Fibonacci, 0/1 Knapsack, LCS, Coin Change, LIS, Matrix Chain), an animated live table visualiser, a complexity reference table with rb badges, the universal 5-step DP framework, a pattern taxonomy, a before/after pitfall grid, and 6 golden rules

Section 01

What Is Dynamic Programming?

The Cinema Seat Problem — Why Recomputing Is Wasteful
Picture a 10-row cinema. A naive approach re-solves Row 1 512 times by Row 10. A smarter manager writes the Row 1 answer on a notepad the first time and every subsequent row just glances at the notepad.

That notepad is the entire idea behind Dynamic Programming. Store the answer to each sub-problem once, look it up whenever needed, never recompute.

Dynamic Programming (DP) breaks complex problems into smaller overlapping sub-problems, solving each only once and storing its result — via memoisation (top-down) or tabulation (bottom-up).

💡
Two Conditions for DP to Apply

Optimal substructure (best solution built from best sub-solutions) AND overlapping sub-problems (same sub-problems solved repeatedly). If either is absent, use greedy or divide-and-conquer.


Section 02

Top-Down vs Bottom-Up

⬇️
Top-Down (Memoisation)
recursive + cache
Write the natural recursive solution, add a memo dict. Check cache before recursing.

Pros: Only computes needed sub-problems.
Cons: Stack overflow risk on deep problems.
⬆️
Bottom-Up (Tabulation)
iterative + table
Solve smallest sub-problems first, build table up to the answer. No recursion.

Pros: No stack overflow. Cache-friendly.
Cons: Must solve all sub-problems.
🔑
Which to Choose?

Interviews: top-down first. Production large inputs: bottom-up (no recursion limit, better memory locality). Both have identical asymptotic complexity.

🎥 Quick Preview — Fibonacci Bottom-Up Table Fill

Watch the DP table fill cell-by-cell. Each cell uses only the two before it.

Press Play ▶

Section 03

Example 1 — Fibonacci Numbers

The Rabbit Colony — When Recursion Becomes Catastrophic
A biologist counts rabbits: each month, every adult pair produces a new pair. To count month 40, naive recursion recomputes month 39 and month 38. To compute month 39, it recomputes month 38 again. By month 40, month 2 is recomputed over 500 million times.

The fix is embarrassingly simple: write the answer for each month on a notepad the first time you compute it. Every future request just reads the notepad. T(n) = T(n−1) + T(n−2) — an exponential disaster — becomes a single linear pass. This one insight is the core of all DP.

The Fibonacci sequence is the traditional first DP problem. Its naive recursive implementation is O(2ⁿ) — fixing it with DP gives O(n).

💡
Why O(2ⁿ) Becomes O(n) With Memoisation

Without memo, fib(5) calls fib(4) and fib(3). fib(4) also calls fib(3) — again. The call tree grows as φⁿ ≅ 1.618ⁿ. With memo, each of fib(0)…fib(n) is computed exactly once: n+1 total computations instead of exponentially many.

⚡ Recurrence
Base
F(0)=0 | F(1)=1
Recurr.
F(n)=F(n−1)+F(n−2)
Naive
O(2ⁿ) — exponential
DP
O(n) — linear

Approach A — Top-Down (Memoisation)

import sys
sys.setrecursionlimit(10000)
# memo dictionary stores already-computed results
memo = {}
def fib_memo(n):
    if n <= 1: return n
    if n in memo: return memo[n]   # cache hit
    memo[n] = fib_memo(n-1) + fib_memo(n-2)
    return memo[n]
print([fib_memo(i) for i in range(10)])
OUTPUT
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
🔬 Phase-by-Phase: fib_memo(5) — Trace Every Line
Speed
● READY
Press ▶ Play or → Step to trace fib_memo(5).
Call Stack
Memo Cache
0/0

Approach B — Bottom-Up (Tabulation)

def fib_dp(n):
    if n <= 1: return n
    dp = [0] * (n+1)
    dp[1] = 1
    for i in range(2, n+1):
        dp[i] = dp[i-1] + dp[i-2]  # recurrence
    return dp[n]
OUTPUT
F(40): 102334155
💡
How Bottom-Up Works Step by Step

Tabulation turns the recurrence into a loop. We allocate an array dp[0…n], seed the base cases (dp[0]=0, dp[1]=1), then iterate i from 2 to n, filling dp[i] = dp[i−1] + dp[i−2]. Because we always fill left-to-right, the values we depend on are already computed. No recursion, no stack, no risk of overflow — just a simple loop.

🔬 Phase-by-Phase: fib_dp(7) — Bottom-Up Table
Speed
● READY
Press ▶ to trace fib_dp(7) bottom-up.
dp[ ] array
0/0
Space Optimisation Pattern

If dp[i] depends only on dp[i-1] and dp[i-2], replace the full table with two variables: O(n) → O(1) space.


Section 04

Example 2 — 0/1 Knapsack Problem

The Thief’s Dilemma — Pack Smart, Not Hard
A thief’s bag holds W=8 kg. 4 artefacts, all-or-nothing. DP builds a table asking: “Max value using first i items with exactly w kg capacity?” — row by row, never redoing work.
🎰 Recurrence — Knapsack
State
dp[i][w] = max value, first i items, capacity w
Skip
dp[i][w] = dp[i−1][w]
Take
dp[i][w] = dp[i−1][w−wt] + val
Choice
max(skip, take)

The 2-D table captures all possibilities. Row i asks: "What if I could only use items 1…i?" Column w asks: "What if my capacity were exactly w kg?" For each cell (i, w) we face a binary choice — take item i or skip it — and record the better option. Because row i only depends on row i−1, the table can be space-optimised to a single 1-D array filled in reverse.

💡
The 0/1 Constraint — What Makes it Hard

The “0/1” means you either take an item completely (1) or not at all (0). You cannot take half an artefact. This is what rules out the greedy approach (which works perfectly for the fractional knapsack). The binary choice forces you to consider all item subsets — 2ⁿ combinations — unless you use DP to eliminate redundant sub-problems and bring it down to O(n×W).

def knapsack(weights, values, W):
    n = len(weights)
    dp = [[0]*(W+1) for _ in range(n+1)]
    for i in range(1, n+1):
        for w in range(W+1):
            skip = dp[i-1][w]
            take = 0
            if weights[i-1] <= w:
                take = dp[i-1][w-weights[i-1]] + values[i-1]
            dp[i][w] = max(skip, take)
    return dp[n][W]
OUTPUT
Max value: 10 | Items: [Item3 wt=5 val=6, Item0 wt=2 val=3]
Traceback: Which Items Were Taken?

dp[n][W] gives the optimal value. To find which items, walk backwards from dp[n][W]: if dp[i][w] == dp[i−1][w], item i was skipped (move up a row). Otherwise item i was taken — record it and subtract its weight (w −= weights[i−1]). Repeat until i=0. This traceback is O(n+W) and works for any 2-D DP table.

🔬 Phase-by-Phase: knapsack([2,3,4,5],[3,4,5,6],W=8)
Speed
● READY
Watch dp[i][w] fill row-by-row. ■ Orange=active. ■ Green=done.
dp[i][w] table
0/0
📊 Final Knapsack DP Table

■ Blue = traceback path. ■ Green = optimal answer dp[4][8]=10.

⚠️
Complexity: Time vs Space

2-D table is O(n×W) time and space. If W is enormous (e.g. 10⁵), use branch-and-bound instead.


Section 05

Example 3 — Longest Common Subsequence (LCS)

Git Diff — How Version Control Finds What Changed
Every git diff computes the LCS of two file versions. Lines in the LCS are “unchanged”; everything else is an insertion or deletion. LCS also powers DNA alignment, spelling correction, and plagiarism detectors.

LCS is solved with a 2-D DP table. dp[i][j] stores the length of the longest common subsequence of the first i characters of X and the first j characters of Y. The table is filled row by row; each cell depends on at most three neighbours (diagonal, above, left), so the fill order is straightforward.

🔡 Recurrence — LCS
Match
X[i]==Y[j]: dp[i][j]=dp[i−1][j−1]+1
No Match
X[i]!=Y[j]: dp[i][j]=max(dp[i−1][j], dp[i][j−1])
Base
dp[0][j]=dp[i][0]=0
def lcs(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]

X,Y = "ABCB", "BDCB"  # LCS = "BCB", length 3
OUTPUT
LCS='BCB' length=3
📊
Complexity: O(m×n) Time and Space

The 2-D table has m×n cells, each computed in O(1) — giving O(m×n) time. Space is also O(m×n) for the full table. Since each row only needs the previous row, this can be reduced to O(min(m,n)) space by keeping only two rows. The traceback step requires the full table, so only do the space optimisation when you only need the length, not the actual LCS string.

🔬 Phase-by-Phase: lcs("ABCB","BDCB") — 2-D Grid Fill
Speed
● READY
Green=match(+1). Blue=max-inherit. Purple=traceback path.
0/0
🔬
LCS Foundation

Edit Distance, Shortest Common Supersequence, DNA alignment (Smith-Waterman) — all are variations on the LCS DP table. The recurrence changes; the strategy stays identical.


Section 06

Example 4 — Coin Change (Minimum Coins)

The Vending Machine — Why Greedy Fails
Coins [10,7,1] to make 14. Greedy: 10+1+1+1+1=5 coins. Optimal: 7+7=2 coins. DP asks the right question: “Min coins for each amount from 0 to A?”
🎉 Recurrence — Coin Change
State
dp[a] = min coins to make amount a
Base
dp[0] = 0  |  dp[a] = ∞ for a > 0 (initially unreachable)
Recurrence
For each coin c: if c ≤ a and dp[a−c] ≠ ∞: dp[a] = min(dp[a], dp[a−c] + 1)
Answer
dp[amount] (or −1 if still ∞)

The key insight: to make amount a using coin c, we need to already know how to make amount a−c optimally. By iterating a from 1 to amount (smallest to largest), every dp[a−c] we reference is already filled. We try every coin for every amount, keeping the minimum. This is an unbounded knapsack variant — coins can be reused any number of times.

def coin_change(coins, amount):
    INF = float('inf')
    dp = [INF] * (amount+1)
    dp[0] = 0
    for a in range(1, amount+1):
        for c in coins:
            if c <= a and dp[a-c] != INF:
                dp[a] = min(dp[a], dp[a-c]+1)
    return dp[amount] if dp[amount]!=INF else -1
OUTPUT
coin_change([10,7,1],14)=2 | coin_change([1,5,6,9],11)=2
⚠️
Bounded vs Unbounded — Same Table, Different Loops

Unbounded Coin Change (coins reusable): outer loop over amounts, inner loop over coins — any coin can extend any sub-amount repeatedly.
0/1 Knapsack (each item once): outer loop over items, inner loop over capacity in reverse — reversing prevents an item being used twice in one pass. The recurrence looks similar; the loop order is what makes them different problems.

🔬 Phase-by-Phase: coin_change([1,5,6], amount=11)
Speed
● READY
Orange=current a. Blue=solved. Green=0 (base).
0/0

Section 07

Example 5 — Longest Increasing Subsequence (LIS)

Stack of Boxes — How Tall Can You Build?
A warehouse has boxes of varying heights. You can stack box B on box A only if B is strictly shorter than A. Given any sequence of boxes, what is the tallest stack you can build?

This is exactly LIS: the box heights are the array; the stack is the increasing subsequence. The same pattern appears in scheduling jobs by deadline, finding the longest chain of events, and the patience card-sorting algorithm used in real card games. O(n log n) LIS is one of the most elegant DP tricks.

Find the longest subsequence where each element is strictly greater than the previous. Elements need not be contiguous.

📈 Recurrence — LIS (O(n²) version)
State
dp[i] = length of longest increasing subsequence ending at index i
Base
dp[i] = 1 for all i (every element is a subsequence of length 1)
Recurrence
For j < i: if arr[j] < arr[i]: dp[i] = max(dp[i], dp[j] + 1)
Answer
max(dp) — the best ending position
O(n log n) Optimisation — Patience Sorting

Keep a tails[] array where tails[k] is the smallest tail element of any increasing subsequence of length k+1. For each new element, binary-search for where it fits in tails. This runs in O(log n) per element — O(n log n) total. The tails array length at the end equals the LIS length. Note: tails itself is not the LIS; it is a structural bookkeeping array.

def lis_dp(arr):
    n = len(arr)
    dp   = [1] * n
    prev = [-1] * n
    for i in range(1, n):
        for j in range(i):
            if arr[j] < arr[i] and dp[j]+1 > dp[i]:
                dp[i] = dp[j]+1; prev[i]=j
    return max(dp)
OUTPUT
LIS length=4, sequence=[2,3,7,101]
Traceback: Reconstructing the Actual Subsequence

dp[i] tells you the length but not which elements. To reconstruct, maintain a prev[i] array: whenever dp[i] is updated via index j, store prev[i] = j. After filling dp, start at the index with max(dp) and follow prev pointers backwards until you reach −1. Reverse the collected indices to get the subsequence in order.

🔬 Phase-by-Phase: lis_dp([3,1,4,1,5,9,2,6])
Speed
● READY
Orange=i. Blue=j. Green=updated dp[i]. Purple=LIS path.
arr[ ] values
dp[ ] LIS-length-ending-here
0/0

Section 08

Example 6 — Matrix Chain Multiplication

🔢
Why Matrix Order Matters

A(10×30)·B(30×5)·C(5×60): (A·B)·C=4,500 ops vs A·(B·C)=27,000 ops. DP finds the optimal parenthesisation in O(n³).

🔢 Recurrence — Matrix Chain
State
dp[i][j] = min scalar multiplications to compute Aₐ·A₁·…·Aⱼ
Base
dp[i][i] = 0 — one matrix needs no multiplication
Split
For each split k (i ≤ k < j): cost = dp[i][k] + dp[k+1][j] + dims[i]·dims[k+1]·dims[j+1]
Recurrence
dp[i][j] = min over all k of (cost)
Fill Order
By increasing chain length l=2,3,…,n — diagonals of the table

This is interval DP: the state spans a contiguous sub-range [i,j]. We try every possible split point k — meaning every possible place to put the last parenthesis — and take the minimum cost. The key is to fill by increasing chain length: a chain of length l depends only on sub-chains of length < l, which are already computed.

📊
Why Brute-Force Is Catastrophic: Catalan Numbers

The number of ways to parenthesise n matrices is the (n−1)-th Catalan number Cₙ₋₁. For n=10 that is 4,862. For n=20 it is 1,767,263,190. For n=50 it exceeds 10³³. DP solves all n matrices in O(n³) time and O(n²) space — a transformation of almost unimaginable scale.

def matrix_chain(dims):
    n = len(dims)-1
    dp = [[0]*n for _ in range(n)]
    for l in range(2, n+1):
        for i in range(n-l+1):
            j = i+l-1; dp[i][j] = float('inf')
            for k in range(i,j):
                cost = dp[i][k]+dp[k+1][j]+dims[i]*dims[k+1]*dims[j+1]
                if cost<dp[i][j]: dp[i][j]=cost
    return dp[0][n-1]
OUTPUT
Min multiplications: 26000 | Order: ((A0*(A1*A2))*A3)
Traceback: Recovering the Optimal Parenthesisation

Alongside dp[i][j], maintain a split[i][j] table recording which k achieved the minimum. To print the optimal order, recurse: print_opt(i,j) calls print_opt(i, split[i][j]) and print_opt(split[i][j]+1, j), wrapping each in parentheses. The recursion bottoms out when i==j (a single matrix). This gives the full parenthesisation in O(n) time after the O(n³) fill.

🔬 Phase-by-Phase: matrix_chain([10,30,5,60,40]) — Interval DP
Speed
● READY
Interval DP fills diagonals. Orange=[i,j]. Purple=split k. Green=improved.
dp[i][j] cost table
0/0

Section 09

Time & Space Complexity Reference

Every DP problem has two costs: time (how many states × how much work per state) and space (how big the table is). The table below shows both for each example in this tutorial, plus whether the space can be reduced. The “optimised space” column assumes you only need the final value, not the full traceback.

💡
Reading Complexity: States × Transitions

A general rule: DP time = (number of distinct states) × (work per state transition). For 1-D DP with n states and O(1) transitions: O(n). For 2-D DP with m×n states and O(1) transitions: O(m×n). Matrix chain has O(n²) states and O(n) transitions (trying all k), giving O(n³).

ProblemBrute ForceDP TimeDP SpaceOpt. SpaceApproach
FibonacciO(2ⁿ)O(n)O(n)O(1)1-D table
0/1 KnapsackO(2ⁿ)O(n×W)O(n×W)O(W)2-D table
LCSO(2ⁿ)O(m×n)O(m×n)O(min(m,n))2-D grid
Coin ChangeO(Sⁿ)O(n×A)O(A)O(A)1-D table
LISO(2ⁿ)O(n²)O(n)O(n log n)binary search
Matrix ChainO(4ⁿ)O(n³)O(n²)O(n²)interval DP
Edit DistanceO(3^(m+n))O(m×n)O(m×n)O(min(m,n))2-D grid

Section 10

The 5-Step Framework

Every DP problem, regardless of domain, can be solved by following these five steps in order. Skipping steps — especially step 1 — is the leading cause of bugs in DP code. Work through a small example (3–4 elements) on paper before writing a single line of code.

01
Define the State
Ask: “What info fully describes a sub-problem?” Write dp[i] = ... in one plain-English sentence.
02
Identify the Recurrence
How does the large sub-problem depend on smaller ones? Write the maths before the code.
03
Establish Base Cases
Trivially-known sub-problems that seed the table. Missing bases cause silent wrong answers.
04
Determine Fill Order & Implement
Ensure dp[i-1] is computed before dp[i]. For 2-D tables: row-by-row, column-by-column, or diagonally.
05
Answer + Traceback
Read dp[n] for the value. Walk backwards to reconstruct which choices were made.

Section 11

DP Pattern Taxonomy

Recognising the pattern a problem belongs to is the fastest route to a solution. Most DP problems are disguised instances of one of six canonical patterns. Once you identify the pattern, the state definition, recurrence, and fill order follow almost mechanically.

💡
Pattern Recognition in Interviews

When you see a problem: (1) Is there a single sequence? → Linear DP. (2) Two sequences or a grid? → 2-D DP. (3) Sub-array / sub-chain? → Interval DP. (4) A tree structure? → Tree DP. (5) n ≤ 20 with subset choices? → Bitmask DP. (6) Count integers in a range with digit constraints? → Digit DP. Most real problems reduce to one of these in under a minute of analysis.

📏
Linear / 1-D DP
dp[i] depends on dp[i-k]
Single sequence. Examples: Fibonacci, Climbing Stairs, House Robber, Coin Change.
Grid / 2-D DP
dp[i][j] over two sequences
Two sequences or grid traversal. Examples: LCS, Edit Distance, Knapsack, Unique Paths.
🔸
Interval DP
dp[i][j] over subarray
Optimal strategy over a contiguous sub-range. Examples: Matrix Chain, Burst Balloons.
🌳
Tree DP
dp on DAG/tree nodes
State computed during DFS; child results combined at parent. Examples: Max Path Sum.
🎭
Bitmask DP
dp[mask] over subsets
State encodes chosen items as bitmask. For small n≤20. Examples: Travelling Salesman.
🎲
Digit DP
dp[pos][tight][...]
Count numbers in [L,R] satisfying a digit-level constraint. Examples: Count digit-sum-K numbers.

Section 12

Common Mistakes & How to Avoid Them

Most DP bugs fall into a small number of repeating categories. The table below shows the five most common mistakes, what symptom they produce, and the concrete fix. Recognising these patterns in your own code is worth more than memorising a hundred DP solutions.

❌ Wrong Pattern
MistakeSymptom
Wrong state definitionAnswer always 0 or ∞
Missing base caseIndex error / silent wrong
Off-by-one table sizeIndexError on dp[n]
Wrong fill orderUsing dp[i+1] before computed
Mutating memo mid-loopNon-deterministic results
✅ Correct Pattern
FixHow
State must fully describe sub-problemTest with small example by hand
Init dp[0] before the loopTrace what happens when i=1
Use dp=[0]*(n+1) not [0]*nOne extra cell for “zero items”
Fill smallest i firstDraw dependency arrows
Separate dp table from inputNever modify input in place
⚠️
The “Wrong Greedy” Trap

Does your greedy choice ever need to be revised based on future information? If yes, it is not greedy — it is DP.


Section 13

Golden Rules of Dynamic Programming

These six rules distil hundreds of solved DP problems into actionable habits. They are not tips to memorise — they are habits to build through deliberate practice. Apply them on every new problem until they become automatic.

⚡ Dynamic Programming — 6 Non-Negotiable Rules
1
Define the state before writing a single line of code. Write dp[i] = ... in plain English. If you cannot explain it in one sentence, your state is wrong.
2
Verify your recurrence with a 3–4 element example by hand. Fill the table manually on paper. This catches wrong recurrences before they become bugs.
3
Start with memoisation in interviews; convert to tabulation for production. Top-down is faster to derive; bottom-up is faster to run and easier to space-optimise.
4
Check if the table can be collapsed. If dp[i] depends only on dp[i-1] and dp[i-2], replace the array with two variables.
5
Never confuse “number of ways” (additive) with “optimal value” (min/max). Counting: dp[i]+=dp[i-k]. Optimisation: dp[i]=min/max(dp[i],…).
6
Implement traceback separately from the fill. First fill the table correctly, then write a clean backward pass that reads decisions from it.