Recursion & Dynamic Programming: Solve It Once, Remember It
What you'll learn
- How recursion solves a problem with smaller copies of itself: the base case, the recursive case and the call stack underneath
- Why naive recursion can explode exponentially, and how to spot it in the call tree
- Memoization (top-down) and tabulation (bottom-up): two ways to solve every subproblem only once
- A five-step recipe for dynamic programming: state, transition, base case, order, answer
- The classics: Fibonacci, coin change, longest common subsequence and the 0/1 knapsack
- You will write: a minimum-coins solver, an LCS table and a knapsack packer
The staircase and the notebook
You climb a staircase of n steps, taking 1 or 2 steps at a time. In how many different ways can you reach the top?
Think about your last move. You arrived at step n either from step n - 1 (a single step) or from step n - 2 (a double step). So:
ways(n) = ways(n - 1) + ways(n - 2) ways(1) = 1, ways(2) = 2
That is recursion: the answer for n is built from answers to smaller copies of the same question. It is also the Fibonacci sequence in disguise.
Now a friend asks for ways(40). If you follow the rule blindly, you compute ways(38) twice, ways(37) three times, ways(36) five times... and hundreds of millions of calls later you have the answer. If you keep a notebook and write down each answer the first time you find it, you need only 40 small additions.
Dynamic programming is recursion plus a notebook. The name was chosen by the mathematician Richard Bellman in the 1950s; "programming" meant planning a schedule, not writing code.
Recursion and the call stack
A recursive function calls itself on a smaller input. It always has two parts:
- Base case: an input small enough to answer directly, without another call.
- Recursive case: shrink the problem, call yourself, and combine the result.
Every call gets a frame on the call stack (its parameters and local variables). The frames pile up until a base case is reached, then they are removed in reverse order as each call returns:
fact(4) call stack at the deepest point:
= 4 * fact(3) fact(1) <- base case, returns 1
= 3 * fact(2) fact(2)
= 2 * fact(1) fact(3)
= 1 fact(4)
then 2 * 1 = 2, 3 * 2 = 6, 4 * 6 = 24 as the frames pop
Trust the smaller call. When you write fact(n) = n * fact(n - 1), assume fact(n - 1) already works and only check two things: the combination is right, and every path eventually reaches a base case.
No reachable base case means infinite recursion. The stack grows until the language stops it: Python raises RecursionError (its default limit is about 1,000 nested calls, see sys.getrecursionlimit()), C and Rust programs crash with a stack overflow. Python also does not optimise tail calls, so a very deep recursion must be rewritten as a loop, with an explicit stack if needed (as with DFS in Trees & Graphs).
Halving instead of shrinking by one is where recursion shines. Computing x to the power n by multiplying n times is O(n); recursion gives O(log n):
def power(x, n):
if n == 0:
return 1 # base case
half = power(x, n // 2) # solve a problem half the size, once
return half * half if n % 2 == 0 else half * half * x
This is divide and conquer, the same idea as merge sort: split, solve the parts, combine.
When recursion explodes, and how to stop it
Here is the call tree of the naive fib(5) (with fib(0) = 0, fib(1) = 1, and fib(n) = fib(n - 1) + fib(n - 2)):
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ ... ...
fib(2) fib(1)
...
fib(3) is computed twice and fib(2) three times. The number of calls for fib(n) is exactly 2 x fib(n + 1) - 1: 177 calls for fib(10), but 2,692,537 for fib(30). Each step up multiplies the work by about 1.618 (the golden ratio): exponential time.
The waste comes from overlapping subproblems: the same smaller question is asked again and again. Dynamic programming applies when a problem has overlapping subproblems and optimal substructure (the best answer is built from the best answers to subproblems). Merge sort has no overlap (each half is different), so a notebook would not help it.
Memoization (top-down). Keep the recursive code and add a cache keyed by the arguments: look up before computing, store after computing. In Python, @functools.cache (or @functools.lru_cache(maxsize=None)) does it for you; the arguments must be hashable.
Tabulation (bottom-up). Fill an array from the base cases upwards, in an order where everything an entry needs is already filled. No recursion, so no depth limit, and you can often keep only the last row or the last two values.
Either way, time = number of states x work per state. For Fibonacci that is n states x O(1) = O(n) instead of O(1.618ⁿ).
The recipe for any DP problem:
- State: say in one sentence what
dp[i](ordp[i][j]) means. - Transition: how a state is computed from smaller states.
- Base cases: the smallest states, known directly.
- Order: compute every state after the states it depends on.
- Answer: which state holds the final result.
The naive version needs 177, then 21,891, then 242,785 calls: the work multiplies with every step. The memoized version computes each of fib(0) to fib(90) exactly once (91 calls), and the table version does the same with a plain loop and two variables, so even fib(1000) is instant.
from functools import lru_cache
calls = 0
def fib_naive(n):
global calls
calls += 1
if n < 2: # base cases: fib(0) = 0, fib(1) = 1
return n
return fib_naive(n - 1) + fib_naive(n - 2)
for n in (10, 20, 25):
calls = 0
value = fib_naive(n)
print(f"naive fib({n}) = {value:>6} calls: {calls:,}")
memo_calls = 0
@lru_cache(maxsize=None) # the notebook: every n is computed once
def fib_memo(n):
global memo_calls
memo_calls += 1
if n < 2:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
print("memo fib(90) =", fib_memo(90), " calls:", memo_calls)
def fib_table(n): # bottom-up, keeping only the last two values
prev, cur = 0, 1 # fib(0), fib(1)
for _ in range(n):
prev, cur = cur, prev + cur
return prev
print("table fib(90) =", fib_table(90), " no recursion, O(1) memory")
print("fib(1000) has", len(str(fib_table(1000))), "digits")
Coin change
You must pay an amount A using as few coins as possible, with unlimited coins of each given value.
Greedy is not always right. "Take the largest coin that fits" works for coins like 1, 5, 10, 25, but not for 1, 3, 4: for A = 6 greedy pays 4 + 1 + 1 (three coins) while 3 + 3 needs only two. A greedy choice can be wrong because it never reconsiders; DP considers every last coin.
The recipe:
- State:
dp[a]= the fewest coins that make exactlya(infinity if impossible). - Transition: the last coin is some
c <= a, sodp[a] = 1 + min(dp[a - c])over all coins. - Base case:
dp[0] = 0. - Order:
a = 1, 2, ..., A(everya - cis smaller). - Answer:
dp[A], or -1 if it is still infinity.
coins {1, 3, 4}
a 0 1 2 3 4 5 6
dp[a] 0 1 2 1 1 2 2 dp[6] = 1 + min(dp[5], dp[3], dp[2]) = 1 + 1
Time O(A x k) for k coin values, memory O(A).
A close cousin: counting the ways. How many combinations of coins make A? Put the coin loop outside, so each combination is counted once, in coin order:
ways = [1] + [0] * A # one way to make 0: no coins
for c in coins:
for a in range(c, A + 1):
ways[a] += ways[a - c]
Swap the two loops and you count ordered sequences instead (1 + 2 and 2 + 1 become different). Loop order is part of the state's meaning.
Lab exercise 1
PythonGraded in Code LabPay with the fewest coins. You have unlimited coins of each given value. Find the minimum number of coins whose values add up to exactly A.
Input: the first line has k A (A is between 0 and 10,000). The second line has k different positive coin values.
Output: the minimum number of coins, or -1 if A cannot be made.
Example: input
3 6
1 3 4
prints 2 (3 + 3).
The starter code is the greedy "largest coin first" idea: run it and see which tests it fails. Replace it with the bottom-up DP from the lesson: dp[0] = 0 and dp[a] = 1 + min(dp[a - c]).
- The example: greedy says 3
- Greedy fails again: 9 + 1 + 1 vs 5 + 6
- Impossible amount
- Amount zero
- Coins 7 and 11, amount 9,998
- US coins, 9,999 cents
Example run
- Input
3 6 1 3 4
- Expected output
2
import sys data = sys.stdin.read().split() k, amount = int(data[0]), int(data[1]) coins = [int(x) for x in data[2:2 + k]] # Greedy: always take the largest coin that still fits. # It is fast, but it is WRONG for some coin systems (try coins 1 3 4 and amount 6). # TODO: replace it with dynamic programming: # dp = [0] + [infinity] * amount dp[a] = fewest coins that make exactly a # for a in 1..amount: for every coin c <= a: dp[a] = min(dp[a], dp[a - c] + 1) # print dp[amount], or -1 if it is still infinity
More lines in Code Lab: 5
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Longest common subsequence
A subsequence keeps the order of characters but may skip some: ACE is a subsequence of ABCDE; AEC is not. (A substring would have to be contiguous.) The longest common subsequence (LCS) of two strings is the longest string that is a subsequence of both. For ABCBDAB and BDCABA it has length 4, for example BCBA.
LCS is the heart of comparing sequences. A line-based diff shows the lines to delete and insert, and the lines it keeps are a longest common subsequence of the two files' lines (Git's default diff algorithm is Myers' algorithm, published in 1986). The same idea aligns DNA sequences.
The recipe:
- State:
dp[i][j]= LCS length of the firsticharacters ofXand the firstjcharacters ofY. - Transition: if
X[i-1] == Y[j-1], that character can end the common subsequence:dp[i][j] = dp[i-1][j-1] + 1. Otherwise one of the two last characters is not used:dp[i][j] = max(dp[i-1][j], dp[i][j-1]). - Base cases:
dp[0][j] = dp[i][0] = 0(an empty string has nothing in common). - Order: row by row, left to right.
- Answer:
dp[n][m].
X = ACE, Y = ABCDE
"" A B C D E
"" 0 0 0 0 0 0
A 0 1 1 1 1 1 A = A: diagonal + 1
C 0 1 1 2 2 2 C = C: dp[A][B] + 1 = 2
E 0 1 1 2 2 3 E = E: dp[C][D] + 1 = 3 -> LCS length 3
Time and memory O(n x m). If you only need the length, each row depends only on the previous one, so two rows (O(m) memory) are enough. To print the subsequence itself, keep the full table and walk back from dp[n][m]: move diagonally on a match, otherwise towards the larger neighbour.
Lab exercise 2
JavaGraded in Code LabLongest common subsequence. Read two strings and print the length of their longest common subsequence.
Input: two lines, each a string of 0 to 2,000 letters (a line can be empty).
Output: one integer, the LCS length.
Example: input
ABCBDAB
BDCABA
prints 4 (for example BCBA).
Fill the table dp[i][j] from the lesson: dp[i-1][j-1] + 1 when the characters match, otherwise Math.max(dp[i-1][j], dp[i][j-1]). Row 0 and column 0 stay 0. With 2,000 x 2,000 cells the O(n x m) table takes a fraction of a second; a plain recursion without a table would never finish.
- The example
- A subsequence of the other string
- Nothing in common
- One empty line
- Identical strings
- Two strings of 2,000 letters
Example run
- Input
ABCBDAB BDCABA
- Expected output
4
import java.io.*;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
String a = in.readLine();
String b = in.readLine();
a = (a == null) ? "" : a.trim();
b = (b == null) ? "" : b.trim();
int n = a.length(), m = b.length();
// TODO: dp[i][j] = LCS length of a[0..i) and b[0..j).More lines in Code Lab: 8
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
The 0/1 knapsack
A hiker can carry at most W kilograms. There are n items, each with a weight and a value, and each item can be taken at most once (that is the "0/1"). Which items give the largest total value?
Greedy by value per kilogram fails. Items (weight 1, value 6), (2, 10) and (3, 12) with W = 5: the best ratios are 6 and 5, so greedy takes the first two for 16 and has no room for the third. Taking (2, 10) and (3, 12) gives 22.
The recipe:
- State:
dp[i][c]= best value using only the firstiitems with capacityc. - Transition: skip item
i, or take it if it fits:dp[i][c] = max(dp[i-1][c], dp[i-1][c - w] + v). - Base cases:
dp[0][c] = 0(no items, no value). - Order: item by item.
- Answer:
dp[n][W].
items (w, v): (1, 6) (2, 10) (3, 12) W = 5
capacity c 0 1 2 3 4 5
no items 0 0 0 0 0 0
+ (1, 6) 0 6 6 6 6 6
+ (2, 10) 0 6 10 16 16 16
+ (3, 12) 0 6 10 16 18 22 dp[5] = max(16, dp_prev[2] + 12) = 22
One row is enough if you update capacities from high to low:
dp = [0] * (W + 1)
for w, v in items:
for c in range(W, w - 1, -1): # downwards: dp[c - w] still means "without this item"
dp[c] = max(dp[c], dp[c - w] + v)
Going upwards would let dp[c - w] already include the same item, so it could be taken again and again: that is the unbounded knapsack, the same loop shape as coin change. The direction of one loop decides which problem you solve.
O(n x W) is called pseudo-polynomial: it grows with the numeric value of W, not with the number of digits needed to write it. The knapsack problem is NP-hard in general, so no algorithm is known that is polynomial in n and the size of the numbers; DP works well when W is moderate.
Lab exercise 3
RustGraded in Code LabPack the knapsack. Each item can be taken at most once. Find the largest total value whose total weight is at most W.
Input: the first line has n W (n up to 300, W up to 20,000). Then n lines, each w v: a weight of at least 1 and a value up to 10⁹.
Output: the maximum total value. Values can add up beyond 2³², so use u64.
Example: input
3 5
1 6
2 10
3 12
prints 22 (the items of weight 2 and 3).
Use the one-row DP from the lesson: let mut dp = vec![0u64; cap + 1]; and, for each item, loop for c in (w..=cap).rev() (downwards!) with dp[c] = max(dp[c], dp[c - w] + v).
- The example: greedy says 16
- Nothing fits
- Each item at most once
- No items
- A total beyond 32 bits
- 240 items, capacity 1,001
Example run
- Input
3 5 1 6 2 10 3 12
- Expected output
22
use std::io::Read;
fn main() {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).unwrap();
let mut it = input.split_whitespace().map(|t| t.parse::<u64>().unwrap());
let n = it.next().unwrap() as usize;
let cap = it.next().unwrap() as usize;
let mut items: Vec<(usize, u64)> = Vec::with_capacity(n);
for _ in 0..n {
let w = it.next().unwrap() as usize;
let v = it.next().unwrap();More lines in Code Lab: 11
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
What happens when a recursive function has no base case that can be reached?
Question 2 of 5
Why is the naive recursive fib(n) so slow?
Question 3 of 5
Coins 1, 3 and 4, amount 6. How many coins does greedy (largest coin first) use, and how many are needed?
Question 4 of 5
In the one-row 0/1 knapsack, why does the capacity loop run from W down to w?
Question 5 of 5
For the LCS table, what is dp[i][j] when X[i-1] and Y[j-1] are different?
Summary
- Recursion = a base case + a recursive case on a smaller input. Each call is a frame on the call stack; without a reachable base case the stack overflows. Halving the problem (fast power, merge sort) gives logarithmic depth.
- Naive recursion explodes when it re-solves overlapping subproblems (
fib(30)makes 2,692,537 calls). Memoization caches answers top-down; tabulation fills a table bottom-up. Time = number of states x work per state. - The DP recipe: state, transition, base cases, order, answer. Say what
dp[...]means in one sentence before writing any loop. - Coin change:
dp[a] = 1 + min(dp[a - c]); greedy can be wrong. Coin loop outside counts combinations. - LCS:
dp[i][j]grows along the diagonal on a match, otherwise takes the larger neighbour; O(n x m), two rows for the length alone. - 0/1 knapsack:
dp[c] = max(dp[c], dp[c - w] + v)with capacities downwards; upwards is the unbounded version.
Practice next: compute the edit distance between two words (insert, delete, replace), find the longest increasing subsequence, and count the paths through a grid with obstacles. Then continue with Heaps & Priority Queues.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1PythonNot passed yet Try in Code Lab
- Lab exercise 2JavaNot passed yet Try in Code Lab
- Lab exercise 3RustNot passed yet Try in Code Lab