Data Structures & Algorithms (DSA) Module 14 – Dynamic programming
Dynamic programming: from recursion to tables
Spot the subproblems a recursion solves again and again, memoise them, turn the memo into a table, and define a DP by its state, transition and base cases.
What you will learn
- Recognise overlapping subproblems by counting a recursion's calls against its different arguments
- Convert a recursion into a memoised one, then into a table filled in dependency order
- Define a dynamic program by its state, transition, base cases and answer
- Justify a transition from optimal substructure, and show where a greedy walk fails
- Choose between top-down and bottom-up from the states reached, the depth and the memory
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise overlapping subproblems by counting a recursion's calls against its different arguments
- Convert a recursion into a memoised one, then into a table filled in dependency order
- Define a dynamic program by its state, transition, base cases and answer
- Justify a transition from optimal substructure, and show where a greedy walk fails
- Choose between top-down and bottom-up from the states reached, the depth and the memory
Before you start
On this page
The plain recursion for the 25th Fibonacci number makes 242,785 calls, yet only 26 different questions stand behind them, fib(0) to fib(25), so each is worked out about 9,300 times on average. Dynamic programming (DP) works each smaller answer out once, stores it and reuses it.
It pays when a recursion keeps asking the same smaller questions, its overlapping subproblems: A recursion has overlapping subproblems when it asks for the same smaller problem many times: the plain recursion for fib(25) makes 242,785 calls over only 26 different arguments., and when a best answer is built from best answers to smaller questions, its optimal substructure: A problem has optimal substructure when a best answer is built from best answers to smaller problems, as the heaviest path from a cell is the cell plus the heaviest path from one of the cells below it.. It comes in two forms: top-down, the recursion with a memo of the answers found so far, and bottom-up, a table filled in a loop, smallest subproblems first. Either way four things define it: what one entry means, how an entry follows from smaller ones, the entries known from the start, and the entry that holds the answer.
The same question, asked again and again
Each Fibonacci number is the sum of the two before it, starting from 0 and 1, so fib(n) written as it reads calls
fib(n − 1) and fib(n − 2), and each of those makes two calls of its own. Step through the calls of fib(5) and count
how often each smaller question is asked:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first call. fib(5) is asked once. It needs fib(4) and fib(3), one step down and two steps down, so it asks both.
- fib(3) again. fib(4) needs fib(3) and fib(2). fib(3) has now been asked twice, and the plain recursion works it out from scratch both times.
- fib(2), three times. Each of the two fib(3) calls asks fib(2) and fib(1). fib(2) has been asked three times, once by fib(4) and once by each fib(3).
- The base cases. Each of the three fib(2) calls asks fib(1) and fib(0). fib(1) is asked 5 times in all and fib(0) 3 times; both answer at once.
- 15 calls, 6 questions. That is 1 + 1 + 2 + 3 + 5 + 3 = 15 calls for only 6 different questions, fib(0) to fib(5). The repeats grow quickly with n.
- With a memo. A memo stores each answer the first time it is worked out. fib(4), fib(3) and fib(2) are then worked out once, their second asks are answered from the memo, and the whole run takes 9 calls.
Fifteen calls for six questions is not much, but the repeats grow fast. This program counts the calls of three recursions and the different questions behind them, starting from the fib(5) of the figure:
"""How often three recursions ask the same question: calls made against distinct subproblems."""
from collections import Counter
def fib_calls(n):
asked = Counter()
def fib(k):
asked[k] += 1
if k < 2:
return k
return fib(k - 1) + fib(k - 2)
fib(n)
return sum(asked.values()), len(asked)
def binomial_calls(n, k):
asked = Counter()
def c(i, j):
asked[(i, j)] += 1
if j == 0 or j == i:
return 1
return c(i - 1, j - 1) + c(i - 1, j)
value = c(n, k)
return value, sum(asked.values()), len(asked)
def merge_sort_calls(n):
asked = Counter()
def sort(lo, hi): # only the ranges the recursion visits; the merging is left out
asked[(lo, hi)] += 1
if hi - lo <= 1:
return
mid = (lo + hi) // 2
sort(lo, mid)
sort(mid, hi)
sort(0, n)
return sum(asked.values()), len(asked)
print("Fibonacci, plain recursion")
print(f"{'n':>3} {'calls':>9} {'distinct':>9} {'calls per subproblem':>21}")
for n in (5, 10, 15, 20, 25):
calls, distinct = fib_calls(n)
print(f"{n:>3} {calls:>9,} {distinct:>9} {calls / distinct:>21,.1f}")
# The number of calls obeys its own recurrence: one call, plus the calls of fib(n - 1) and of fib(n - 2).
calls = [1, 1]
for n in range(2, 36):
calls.append(1 + calls[n - 1] + calls[n - 2])
assert all(calls[n] == fib_calls(n)[0] for n in range(21))
print("Fibonacci, calls predicted by calls(n) = 1 + calls(n - 1) + calls(n - 2)")
for n in (30, 35):
print(f"{n:>3} {calls[n]:>12,}")
print(f"each step multiplies the calls by {calls[35] / calls[34]:.6f}; (1 + 5 ** 0.5) / 2 = {(1 + 5 ** 0.5) / 2:.6f}")
print("Pascal's rule for C(n, k), plain recursion")
for n, k in ((10, 5), (16, 8), (20, 10)):
value, total, distinct = binomial_calls(n, k)
print(f"C({n}, {k}) = {value:,}: {total:,} calls, {distinct} distinct")
total, distinct = merge_sort_calls(1024)
print(f"Merge sort of 1,024 items: {total:,} calls, {distinct:,} distinct ranges") Output
Fibonacci, plain recursion n calls distinct calls per subproblem 5 15 6 2.5 10 177 11 16.1 15 1,973 16 123.3 20 21,891 21 1,042.4 25 242,785 26 9,337.9 Fibonacci, calls predicted by calls(n) = 1 + calls(n - 1) + calls(n - 2) 30 2,692,537 35 29,860,703 each step multiplies the calls by 1.618034; (1 + 5 ** 0.5) / 2 = 1.618034 Pascal's rule for C(n, k), plain recursion C(10, 5) = 252: 503 calls, 35 distinct C(16, 8) = 12,870: 25,739 calls, 80 distinct C(20, 10) = 184,756: 369,511 calls, 120 distinct Merge sort of 1,024 items: 2,047 calls, 2,047 distinct ranges
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 repeats.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
fib(25) makes 242,785 calls for 26 questions. The number of calls follows a recurrence of its own, calls(n) = 1 +
calls(n − 1) + calls(n − 2), which the program fills in a loop: 2,692,537 calls for n = 30 and 29,860,703 for n = 35.
Each step multiplies the count by about 1.618, the number (1 + √5) / 2 that NIST’s dictionary uses to give the size of
the Fibonacci numbers themselves, so every extra unit of n costs about 62 % more calls.
Pascal’s rule for binomial coefficients, , says that a choice of k of n items either takes the last item or leaves it out, so C(4, 2) = C(3, 1) + C(3, 2) = 3 + 3 = 6. Written as a recursion, it behaves the same way: 369,511 calls for C(20, 10), over only 120 different pairs (n, k). Merge sort is the contrast. Sorting 1,024 items made 2,047 calls on 2,047 different ranges: no range is ever sorted twice, because the two halves of a split never overlap. That is divide and conquer, and storing its answers would only cost memory.
Key idea
Count the calls against the different arguments: when the calls far outnumber the arguments, the subproblems overlap, and storing each answer once pays.
Checkpoint
Check: Overlapping subproblems, counted
Memoisation: work each answer out once
NIST’s Dictionary of Algorithms and Data Structures defines memoisation: Storing the answer to each subproblem the first time it is worked out, keyed by the subproblem's arguments, and returning the stored answer whenever the same subproblem is asked again. as saving a
computed answer for later reuse instead of computing it again. For C(n, k) the memo is a dictionary keyed by the pair
(n, k): before working out a pair, look it up; after working it out, store it. The figure above ended this way, with
fib(5) in 9 calls instead of 15.
"""C(n, k) by Pascal's rule: the plain recursion, a dictionary memo, functools.cache, and math.comb to check."""
import math
from functools import cache
calls = 0
def plain(n, k):
global calls
calls += 1
if k == 0 or k == n:
return 1
return plain(n - 1, k - 1) + plain(n - 1, k)
def with_memo(n, k, memo):
global calls
calls += 1
if k == 0 or k == n:
return 1
if (n, k) not in memo: # work it out once, then only look it up
memo[(n, k)] = with_memo(n - 1, k - 1, memo) + with_memo(n - 1, k, memo)
return memo[(n, k)]
@cache
def cached(n, k):
if k == 0 or k == n:
return 1
return cached(n - 1, k - 1) + cached(n - 1, k)
n, k = 20, 10
calls = 0
print(f"plain recursion: C({n}, {k}) = {plain(n, k):,} after {calls:,} calls")
calls = 0
memo = {}
print(f"dictionary memo: C({n}, {k}) = {with_memo(n, k, memo):,} after {calls:,} calls, {len(memo)} answers stored")
print(f"functools.cache: C({n}, {k}) = {cached(n, k):,}")
print(f" {cached.cache_info()}")
print(f"math.comb: C({n}, {k}) = {math.comb(n, k):,}")
cached.cache_clear()
value = cached(60, 30)
print(f"functools.cache: C(60, 30) = {value:,}")
print(f" {cached.cache_info()}")
print(f"equal to math.comb(60, 30): {value == math.comb(60, 30)}")
try:
cached([60], 30)
except TypeError as error:
print(f"a list as an argument: TypeError: {error}") Output
plain recursion: C(20, 10) = 184,756 after 369,511 calls
dictionary memo: C(20, 10) = 184,756 after 201 calls, 100 answers stored
functools.cache: C(20, 10) = 184,756
CacheInfo(hits=81, misses=120, maxsize=None, currsize=120)
math.comb: C(20, 10) = 184,756
functools.cache: C(60, 30) = 118,264,581,564,861,424
CacheInfo(hits=841, misses=960, maxsize=None, currsize=960)
equal to math.comb(60, 30): True
a list as an argument: TypeError: unhashable type: 'list'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pascal_memo.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The dictionary version makes 201 calls instead of 369,511 and stores 100 answers, one for each pair that is not a
base case. functools.cache does the same in one line: the Python documentation calls it a simple unbounded function
cache that returns the same as lru_cache(maxsize=None), a thin wrapper around a dictionary lookup. Its
cache_info() counts 120 misses, the different states including the 20 base cases it met, and 81 hits, calls answered
from the cache. For C(60, 30), which is about 1.2 × 10¹⁷, the cache works out 960 states, and the result equals
math.comb(60, 30), the standard library’s own binomial coefficient.
A memo key that cannot be hashed
The memo’s keys are dictionary keys, so the arguments must be hashable, as the documentation of lru_cache
requires. A list is not, and the last line of the run shows the TypeError it raises. Pass numbers, strings or
tuples that describe the subproblem, never the mutable objects you are working on.
Checkpoint
Check: Memoisation with a dictionary
Tabulation: fill a table in dependency order
A memo finds its order by itself: the recursion asks, and the answers come back from the base cases up. tabulation: Filling a table of subproblem answers in a loop, bottom-up, in an order where every entry comes after the entries it reads, instead of letting a recursion find that order. makes that order explicit. Every entry C(i, j) reads two entries of row i − 1, so filling the rows one after another from row 0 means every entry it needs already exists. That is Pascal’s triangle, and since only the previous row is ever read, one row of k + 1 entries is enough, updated in place for each new i. Step through C(4, 2) in one row:
Program · trace/row_update.py
n, k = 4, 2
row = [1, 0, 0]
for i in range(1, n + 1):
for j in range(min(i, k), 0, -1):
row[j] += row[j - 1]
print(row[k]) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
- the stop value, never given
Printed so far
Nothing printed yet6
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 27 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
n, k = 4, 2setsnto4and setskto2. - Line 2.
row = [1, 0, 0]makesrowa list of 3 items. - Line 3.
for i in range(1, n + 1):gives its first number:iis1. - Line 4.
for j in range(min(i, k), 0, -1):gives its first number:jis1. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 1, 0]. - Line 4.
for j in range(min(i, k), 0, -1):has no number left: a range stops before its stop value,0, so the loop ends. - Line 3.
for i in range(1, n + 1):gives the next number:iis2. - Line 4.
for j in range(min(i, k), 0, -1):gives its first number:jis2. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 1, 1]. Key point row[2] reads row[1], which still holds 1 from the row above, because j runs from right to left. C(2, 2) = 0 + 1 = 1. - Line 4.
for j in range(min(i, k), 0, -1):gives the next number:jis1. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 2, 1]. Only now does row[1] change, to C(2, 1) = 2. row[2] has already used its old value. - Line 4.
for j in range(min(i, k), 0, -1):has no number left: a range stops before its stop value,0, so the loop ends. - Line 3.
for i in range(1, n + 1):gives the next number:iis3. - Line 4.
for j in range(min(i, k), 0, -1):gives its first number:jis2. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 2, 3]. - Line 4.
for j in range(min(i, k), 0, -1):gives the next number:jis1. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 3, 3]. - Line 4.
for j in range(min(i, k), 0, -1):has no number left: a range stops before its stop value,0, so the loop ends. - Line 3.
for i in range(1, n + 1):gives the next number:iis4. - Line 4.
for j in range(min(i, k), 0, -1):gives its first number:jis2. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 3, 6]. - Line 4.
for j in range(min(i, k), 0, -1):gives the next number:jis1. - Line 5.
row[j] += row[j - 1]changesrow: it is now[1, 4, 6]. - Line 4.
for j in range(min(i, k), 0, -1):has no number left: a range stops before its stop value,0, so the loop ends. - Line 3.
for i in range(1, n + 1):has no number left: a range stops before its stop value,5, so the loop ends. - Line 6.
print(row[k])prints6. That was the last line, so the program ends. row[2] is C(4, 2) = 6. Left to right, row[1] would change first, and the next row[2] would read the new value instead.
The direction matters. From right to left, row[j − 1] still holds the old row’s value when row[j] reads it. From
left to right it has already been overwritten with the new row’s value, and C(6, 3) comes out as 48 instead of 20, as
the last line of this run shows:
"""C(n, k) bottom-up: Pascal's triangle filled row by row, then the same in one row updated from right to left."""
import math
def triangle(n):
rows = [[1]]
for i in range(1, n + 1):
above = rows[-1] # row i - 1 is complete before row i starts
rows.append([1] + [above[j - 1] + above[j] for j in range(1, i)] + [1])
return rows
def one_row(n, k):
row = [1] + [0] * k # row[j] holds C(i, j) for the row i reached so far
for i in range(1, n + 1):
for j in range(min(i, k), 0, -1): # right to left: row[j - 1] still holds C(i - 1, j - 1)
row[j] += row[j - 1]
return row[k]
def left_to_right(n, k):
row = [1] + [0] * k
for i in range(1, n + 1):
for j in range(1, min(i, k) + 1): # the wrong order: row[j - 1] already holds C(i, j - 1)
row[j] += row[j - 1]
return row[k]
for i, row in enumerate(triangle(6)):
print(f"row {i}: {' '.join(map(str, row))}")
print(f"one row, right to left: C(60, 30) = {one_row(60, 30):,}")
same = all(one_row(a, b) == math.comb(a, b) for a in range(61) for b in range(a + 1))
print(f"equal to math.comb for every n up to 60 and every k up to n: {same}")
print(f"one row, left to right: C(6, 3) = {left_to_right(6, 3)}, but math.comb(6, 3) = {math.comb(6, 3)}") Output
row 0: 1 row 1: 1 1 row 2: 1 2 1 row 3: 1 3 3 1 row 4: 1 4 6 4 1 row 5: 1 5 10 10 5 1 row 6: 1 6 15 20 15 6 1 one row, right to left: C(60, 30) = 118,264,581,564,861,424 equal to math.comb for every n up to 60 and every k up to n: True one row, left to right: C(6, 3) = 48, but math.comb(6, 3) = 20
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pascal_table.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A table also decides which states get computed: the ones its loops visit. The one-row loop visits only j from 1 to k,
and the run checks it against math.comb for every C(n, k) with n up to 60. The same three versions, the plain
recursion, the memo and the one-row table, are below in 14 languages, each counting its own work:
Python 3.14 · main.py
def plain(n: int, k: int) -> tuple[int, int]:
"""C(n, k) by Pascal's rule, and how many calls it took."""
if k == 0 or k == n:
return 1, 1
left, left_calls = plain(n - 1, k - 1)
right, right_calls = plain(n - 1, k)
return left + right, left_calls + right_calls + 1
def memo(n: int, k: int, seen: dict[tuple[int, int], int]) -> tuple[int, int]:
"""The same recursion with a memo of the states worked out."""
if k == 0 or k == n:
return 1, 1
if (n, k) in seen:
return seen[(n, k)], 1
left, left_calls = memo(n - 1, k - 1, seen)
right, right_calls = memo(n - 1, k, seen)
seen[(n, k)] = left + right
return left + right, left_calls + right_calls + 1
def table(n: int, k: int) -> tuple[int, int]:
"""C(n, k) bottom-up in one row, counting its updates."""
row = [1] + [0] * k
updates = 0
for i in range(1, n + 1):
# Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for j in range(min(i, k), 0, -1):
row[j] += row[j - 1]
updates += 1
return row[k], updates
value, calls = plain(20, 10)
print(f"plain recursion: C(20, 10) = {value} after {calls} calls")
for n, k in [(20, 10), (30, 15)]:
seen: dict[tuple[int, int], int] = {}
value, calls = memo(n, k, seen)
print(
f"memo: C({n}, {k}) = {value} after {calls} calls, {len(seen)} answers stored"
)
value, updates = table(n, k)
print(f"one-row table: C({n}, {k}) = {value} after {updates} updates") In Python: Each function returns a (value, work) tuple, so no global counter is needed; in real code functools.cache and math.comb do the same in one line each.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
// C(n, k) by Pascal's rule, and how many calls it took.
function plain(n, k) {
if (k === 0 || k === n) return [1, 1];
const [left, leftCalls] = plain(n - 1, k - 1);
const [right, rightCalls] = plain(n - 1, k);
return [left + right, leftCalls + rightCalls + 1];
}
// The same recursion with a memo of the states worked out.
function memo(n, k, seen) {
if (k === 0 || k === n) return [1, 1];
const key = `${n},${k}`;
if (seen.has(key)) return [seen.get(key), 1];
const [left, leftCalls] = memo(n - 1, k - 1, seen);
const [right, rightCalls] = memo(n - 1, k, seen);
seen.set(key, left + right);
return [left + right, leftCalls + rightCalls + 1];
}
// C(n, k) bottom-up in one row, counting its updates.
function table(n, k) {
const row = [1, ...new Array(k).fill(0)];
let updates = 0;
for (let i = 1; i <= n; i++) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (let j = Math.min(i, k); j > 0; j--) {
row[j] += row[j - 1];
updates++;
}
}
return [row[k], updates];
}
const [value, calls] = plain(20, 10);
console.log(`plain recursion: C(20, 10) = ${value} after ${calls} calls`);
for (const [n, k] of [
[20, 10],
[30, 15],
]) {
const seen = new Map();
const [memoValue, memoCalls] = memo(n, k, seen);
console.log(
`memo: C(${n}, ${k}) = ${memoValue} after ${memoCalls} calls, ${seen.size} answers stored`,
);
const [tableValue, updates] = table(n, k);
console.log(`one-row table: C(${n}, ${k}) = ${tableValue} after ${updates} updates`);
} In JavaScript: A Map needs a key it can compare by value, so the memo keys each state by the string `${n},${k}`; two arrays [n, k] would be two different keys.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Main {
record Result(int value, int work) {}
record State(int n, int k) {}
/** C(n, k) by Pascal's rule, and how many calls it took. */
static Result plain(int n, int k) {
if (k == 0 || k == n) {
return new Result(1, 1);
}
Result left = plain(n - 1, k - 1);
Result right = plain(n - 1, k);
return new Result(left.value() + right.value(), left.work() + right.work() + 1);
}
/** The same recursion with a memo of the states worked out. */
static Result memo(int n, int k, Map<State, Integer> seen) {
if (k == 0 || k == n) {
return new Result(1, 1);
}
Integer known = seen.get(new State(n, k));
if (known != null) {
return new Result(known, 1);
}
Result left = memo(n - 1, k - 1, seen);
Result right = memo(n - 1, k, seen);
seen.put(new State(n, k), left.value() + right.value());
return new Result(left.value() + right.value(), left.work() + right.work() + 1);
}
/** C(n, k) bottom-up in one row, counting its updates. */
static Result table(int n, int k) {
int[] row = new int[k + 1];
row[0] = 1;
int updates = 0;
for (int i = 1; i <= n; i++) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (int j = Math.min(i, k); j > 0; j--) {
row[j] += row[j - 1];
updates++;
}
}
return new Result(row[k], updates);
}
public static void main(String[] args) {
Result plain = plain(20, 10);
System.out.println(
"plain recursion: C(20, 10) = " + plain.value() + " after " + plain.work() + " calls");
for (State state : List.of(new State(20, 10), new State(30, 15))) {
String name = "C(" + state.n() + ", " + state.k() + ")";
Map<State, Integer> seen = new HashMap<>();
Result memo = memo(state.n(), state.k(), seen);
System.out.println(
"memo: "
+ name
+ " = "
+ memo.value()
+ " after "
+ memo.work()
+ " calls, "
+ seen.size()
+ " answers stored");
Result table = table(state.n(), state.k());
System.out.println(
"one-row table: " + name + " = " + table.value() + " after " + table.work() + " updates");
}
}
} In Java: The records Result and State compare by value, so a State(n, k) works as a HashMap key with no equals or hashCode written by hand.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <map>
#include <print>
#include <utility>
#include <vector>
struct Result {
int value, work;
};
// C(n, k) by Pascal's rule, and how many calls it took.
Result plain(int n, int k) {
if (k == 0 || k == n)
return {1, 1};
const auto [left, left_calls] = plain(n - 1, k - 1);
const auto [right, right_calls] = plain(n - 1, k);
return {left + right, left_calls + right_calls + 1};
}
// The same recursion with a memo of the states worked out.
Result memo(int n, int k, std::map<std::pair<int, int>, int> &seen) {
if (k == 0 || k == n)
return {1, 1};
if (const auto it = seen.find({n, k}); it != seen.end())
return {it->second, 1};
const auto [left, left_calls] = memo(n - 1, k - 1, seen);
const auto [right, right_calls] = memo(n - 1, k, seen);
seen[{n, k}] = left + right;
return {left + right, left_calls + right_calls + 1};
}
// C(n, k) bottom-up in one row, counting its updates.
Result table(int n, int k) {
std::vector<int> row(k + 1);
row[0] = 1;
int updates = 0;
for (int i = 1; i <= n; ++i) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (int j = std::min(i, k); j > 0; --j) {
row[j] += row[j - 1];
++updates;
}
}
return {row[k], updates};
}
int main() {
const auto [value, calls] = plain(20, 10);
std::println("plain recursion: C(20, 10) = {} after {} calls", value, calls);
for (const auto &[n, k] : {std::pair{20, 10}, std::pair{30, 15}}) {
std::map<std::pair<int, int>, int> seen;
const auto [memo_value, memo_calls] = memo(n, k, seen);
std::println("memo: C({}, {}) = {} after {} calls, {} answers stored", n, k,
memo_value, memo_calls, seen.size());
const auto [table_value, updates] = table(n, k);
std::println("one-row table: C({}, {}) = {} after {} updates", n, k,
table_value, updates);
}
} In C++: std::map keys the memo by std::pair<int, int>, because the standard library has no hash for pairs; find looks a state up without inserting it.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var (value, calls) = Plain(20, 10);
Console.WriteLine($"plain recursion: C(20, 10) = {value} after {calls} calls");
foreach (var (n, k) in new[] { (20, 10), (30, 15) })
{
var seen = new Dictionary<(int, int), int>();
var (memoValue, memoCalls) = Memo(n, k, seen);
Console.WriteLine(
$"memo: C({n}, {k}) = {memoValue} after {memoCalls} calls, {seen.Count} answers stored");
var (tableValue, updates) = Table(n, k);
Console.WriteLine($"one-row table: C({n}, {k}) = {tableValue} after {updates} updates");
}
// C(n, k) by Pascal's rule, and how many calls it took.
static (int Value, int Calls) Plain(int n, int k)
{
if (k == 0 || k == n)
{
return (1, 1);
}
var (left, leftCalls) = Plain(n - 1, k - 1);
var (right, rightCalls) = Plain(n - 1, k);
return (left + right, leftCalls + rightCalls + 1);
}
// The same recursion with a memo of the states worked out.
static (int Value, int Calls) Memo(int n, int k, Dictionary<(int, int), int> seen)
{
if (k == 0 || k == n)
{
return (1, 1);
}
if (seen.TryGetValue((n, k), out int known))
{
return (known, 1);
}
var (left, leftCalls) = Memo(n - 1, k - 1, seen);
var (right, rightCalls) = Memo(n - 1, k, seen);
seen[(n, k)] = left + right;
return (left + right, leftCalls + rightCalls + 1);
}
// C(n, k) bottom-up in one row, counting its updates.
static (int Value, int Updates) Table(int n, int k)
{
var row = new int[k + 1];
row[0] = 1;
int updates = 0;
for (int i = 1; i <= n; i++)
{
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (int j = Math.Min(i, k); j > 0; j--)
{
row[j] += row[j - 1];
updates++;
}
}
return (row[k], updates);
} In C#: The tuple (int, int) is a value type with equality built in, so it works as a Dictionary key, and TryGetValue looks a state up and reads it at once.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
typedef struct {
int value, work;
} result;
// C(n, k) by Pascal's rule, and how many calls it took.
static result plain(int n, int k) {
if (k == 0 || k == n)
return (result){1, 1};
const result left = plain(n - 1, k - 1), right = plain(n - 1, k);
return (result){left.value + right.value, left.work + right.work + 1};
}
// The same recursion with a memo of the states worked out.
static result memo(int n, int k, int seen[31][31], int *stored) {
if (k == 0 || k == n)
return (result){1, 1};
if (seen[n][k] > 0)
return (result){seen[n][k], 1};
const result left = memo(n - 1, k - 1, seen, stored);
const result right = memo(n - 1, k, seen, stored);
seen[n][k] = left.value + right.value;
++*stored;
return (result){seen[n][k], left.work + right.work + 1};
}
// C(n, k) bottom-up in one row, counting its updates.
static result table(int n, int k) {
int row[31] = {1};
int updates = 0;
for (int i = 1; i <= n; i++) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (int j = i < k ? i : k; j > 0; j--) {
row[j] += row[j - 1];
updates++;
}
}
return (result){row[k], updates};
}
int main(void) {
const result p = plain(20, 10);
printf("plain recursion: C(20, 10) = %d after %d calls\n", p.value, p.work);
const int cases[2][2] = {{20, 10}, {30, 15}};
for (int c = 0; c < 2; c++) {
const int n = cases[c][0], k = cases[c][1];
int seen[31][31] = {{0}}, stored = 0;
const result m = memo(n, k, seen, &stored);
printf("memo: C(%d, %d) = %d after %d calls, %d answers stored\n", n, k,
m.value, m.work, stored);
const result t = table(n, k);
printf("one-row table: C(%d, %d) = %d after %d updates\n", n, k, t.value,
t.work);
}
return 0;
} In C: C has no hash map, so the memo is a 31 × 31 array in which 0 means not worked out yet; every C(n, k) is at least 1, so 0 can safely mean that.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
/**
* C(n, k) by Pascal's rule, and how many calls it took.
*
* @return array{int, int}
*/
function plain(int $n, int $k): array
{
if ($k === 0 || $k === $n) {
return [1, 1];
}
[$left, $leftCalls] = plain($n - 1, $k - 1);
[$right, $rightCalls] = plain($n - 1, $k);
return [$left + $right, $leftCalls + $rightCalls + 1];
}
/**
* The same recursion with a memo of the states worked out.
*
* @param array<string, int> $seen
* @return array{int, int}
*/
function memo(int $n, int $k, array &$seen): array
{
if ($k === 0 || $k === $n) {
return [1, 1];
}
if (isset($seen["$n,$k"])) {
return [$seen["$n,$k"], 1];
}
[$left, $leftCalls] = memo($n - 1, $k - 1, $seen);
[$right, $rightCalls] = memo($n - 1, $k, $seen);
$seen["$n,$k"] = $left + $right;
return [$left + $right, $leftCalls + $rightCalls + 1];
}
/**
* C(n, k) bottom-up in one row, counting its updates.
*
* @return array{int, int}
*/
function table(int $n, int $k): array
{
$row = array_fill(0, $k + 1, 0);
$row[0] = 1;
$updates = 0;
for ($i = 1; $i <= $n; $i++) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for ($j = min($i, $k); $j > 0; $j--) {
$row[$j] += $row[$j - 1];
$updates++;
}
}
return [$row[$k], $updates];
}
[$value, $calls] = plain(20, 10);
echo "plain recursion: C(20, 10) = {$value} after {$calls} calls\n";
foreach ([[20, 10], [30, 15]] as [$n, $k]) {
$seen = [];
[$value, $calls] = memo($n, $k, $seen);
$stored = count($seen);
echo "memo: C({$n}, {$k}) = {$value} after {$calls} calls, {$stored} answers stored\n";
[$value, $updates] = table($n, $k);
echo "one-row table: C({$n}, {$k}) = {$value} after {$updates} updates\n";
} In PHP: The memo is an array keyed by the string "n,k" and passed by reference (&$seen), since PHP copies arrays passed by value.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::HashMap;
/// C(n, k) by Pascal's rule, and how many calls it took.
fn plain(n: u32, k: u32) -> (u32, u32) {
if k == 0 || k == n {
return (1, 1);
}
let (left, left_calls) = plain(n - 1, k - 1);
let (right, right_calls) = plain(n - 1, k);
(left + right, left_calls + right_calls + 1)
}
/// The same recursion with a memo of the states worked out.
fn memo(n: u32, k: u32, seen: &mut HashMap<(u32, u32), u32>) -> (u32, u32) {
if k == 0 || k == n {
return (1, 1);
}
if let Some(&known) = seen.get(&(n, k)) {
return (known, 1);
}
let (left, left_calls) = memo(n - 1, k - 1, seen);
let (right, right_calls) = memo(n - 1, k, seen);
seen.insert((n, k), left + right);
(left + right, left_calls + right_calls + 1)
}
/// C(n, k) bottom-up in one row, counting its updates.
fn table(n: usize, k: usize) -> (u32, u32) {
let mut row = vec![0; k + 1];
row[0] = 1;
let mut updates = 0;
for i in 1..=n {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for j in (1..=i.min(k)).rev() {
row[j] += row[j - 1];
updates += 1;
}
}
(row[k], updates)
}
fn main() {
let (value, calls) = plain(20, 10);
println!("plain recursion: C(20, 10) = {value} after {calls} calls");
for (n, k) in [(20, 10), (30, 15)] {
let mut seen = HashMap::new();
let (value, calls) = memo(n, k, &mut seen);
let stored = seen.len();
println!("memo: C({n}, {k}) = {value} after {calls} calls, {stored} answers stored");
let (value, updates) = table(n as usize, k as usize);
println!("one-row table: C({n}, {k}) = {value} after {updates} updates");
}
} In Rust: The memo is a HashMap<(u32, u32), u32> borrowed mutably by every call; if let Some(&known) looks a state up and copies its value out.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
type Result = [value: number, work: number];
// C(n, k) by Pascal's rule, and how many calls it took.
function plain(n: number, k: number): Result {
if (k === 0 || k === n) return [1, 1];
const [left, leftCalls] = plain(n - 1, k - 1);
const [right, rightCalls] = plain(n - 1, k);
return [left + right, leftCalls + rightCalls + 1];
}
// The same recursion with a memo of the states worked out.
function memo(n: number, k: number, seen: Map<string, number>): Result {
if (k === 0 || k === n) return [1, 1];
const key = `${n},${k}`;
const known = seen.get(key);
if (known !== undefined) return [known, 1];
const [left, leftCalls] = memo(n - 1, k - 1, seen);
const [right, rightCalls] = memo(n - 1, k, seen);
seen.set(key, left + right);
return [left + right, leftCalls + rightCalls + 1];
}
// C(n, k) bottom-up in one row, counting its updates.
function table(n: number, k: number): Result {
const row: number[] = [1, ...new Array<number>(k).fill(0)];
let updates = 0;
for (let i = 1; i <= n; i++) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (let j = Math.min(i, k); j > 0; j--) {
row[j] += row[j - 1];
updates++;
}
}
return [row[k], updates];
}
const [value, calls] = plain(20, 10);
console.log(`plain recursion: C(20, 10) = ${value} after ${calls} calls`);
const cases: [number, number][] = [
[20, 10],
[30, 15],
];
for (const [n, k] of cases) {
const seen = new Map<string, number>();
const [memoValue, memoCalls] = memo(n, k, seen);
console.log(
`memo: C(${n}, ${k}) = ${memoValue} after ${memoCalls} calls, ${seen.size} answers stored`,
);
const [tableValue, updates] = table(n, k);
console.log(`one-row table: C(${n}, ${k}) = ${tableValue} after ${updates} updates`);
} In TypeScript: The named tuple type Result = [value, work] lets tsc check every return; the memo's get is tested against undefined instead of has then get.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import "fmt"
// plain computes C(n, k) by Pascal's rule and counts its calls.
func plain(n, k int) (value, calls int) {
if k == 0 || k == n {
return 1, 1
}
left, leftCalls := plain(n-1, k-1)
right, rightCalls := plain(n-1, k)
return left + right, leftCalls + rightCalls + 1
}
// memo is the same recursion with a memo of the states worked out.
func memo(n, k int, seen map[[2]int]int) (value, calls int) {
if k == 0 || k == n {
return 1, 1
}
if known, ok := seen[[2]int{n, k}]; ok {
return known, 1
}
left, leftCalls := memo(n-1, k-1, seen)
right, rightCalls := memo(n-1, k, seen)
seen[[2]int{n, k}] = left + right
return left + right, leftCalls + rightCalls + 1
}
// table computes C(n, k) bottom-up in one row, counting its updates.
func table(n, k int) (value, updates int) {
row := make([]int, k+1)
row[0] = 1
for i := 1; i <= n; i++ {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for j := min(i, k); j > 0; j-- {
row[j] += row[j-1]
updates++
}
}
return row[k], updates
}
func main() {
value, calls := plain(20, 10)
fmt.Printf("plain recursion: C(20, 10) = %d after %d calls\n", value, calls)
for _, c := range [][2]int{{20, 10}, {30, 15}} {
n, k := c[0], c[1]
seen := map[[2]int]int{}
value, calls := memo(n, k, seen)
fmt.Printf("memo: C(%d, %d) = %d after %d calls, %d answers stored\n",
n, k, value, calls, len(seen))
value, updates := table(n, k)
fmt.Printf("one-row table: C(%d, %d) = %d after %d updates\n", n, k, value, updates)
}
} In Go: An array [2]int is comparable, so it works as a map key; the functions return two named results, the value and the work.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
struct State: Hashable {
let n: Int
let k: Int
}
/// C(n, k) by Pascal's rule, and how many calls it took.
func plain(_ n: Int, _ k: Int) -> (value: Int, calls: Int) {
if k == 0 || k == n { return (1, 1) }
let left = plain(n - 1, k - 1)
let right = plain(n - 1, k)
return (left.value + right.value, left.calls + right.calls + 1)
}
/// The same recursion with a memo of the states worked out.
func memo(_ n: Int, _ k: Int, _ seen: inout [State: Int]) -> (value: Int, calls: Int) {
if k == 0 || k == n { return (1, 1) }
if let known = seen[State(n: n, k: k)] { return (known, 1) }
let left = memo(n - 1, k - 1, &seen)
let right = memo(n - 1, k, &seen)
seen[State(n: n, k: k)] = left.value + right.value
return (left.value + right.value, left.calls + right.calls + 1)
}
/// C(n, k) bottom-up in one row, counting its updates.
func table(_ n: Int, _ k: Int) -> (value: Int, updates: Int) {
var row = [1] + Array(repeating: 0, count: k)
var updates = 0
for i in stride(from: 1, through: n, by: 1) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for j in stride(from: min(i, k), to: 0, by: -1) {
row[j] += row[j - 1]
updates += 1
}
}
return (row[k], updates)
}
let first = plain(20, 10)
print("plain recursion: C(20, 10) = \(first.value) after \(first.calls) calls")
for (n, k) in [(20, 10), (30, 15)] {
var seen: [State: Int] = [:]
let m = memo(n, k, &seen)
print("memo: C(\(n), \(k)) = \(m.value) after \(m.calls) calls, \(seen.count) answers stored")
let t = table(n, k)
print("one-row table: C(\(n), \(k)) = \(t.value) after \(t.updates) updates")
} In Swift: A small Hashable struct State keys the dictionary, and the memo is passed inout so that every call adds to the same one.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# C(n, k) by Pascal's rule, and how many calls it took.
def plain(count, choose)
return [1, 1] if choose.zero? || choose == count
left, left_calls = plain(count - 1, choose - 1)
right, right_calls = plain(count - 1, choose)
[left + right, left_calls + right_calls + 1]
end
# The same recursion with a memo of the states worked out.
def memo(count, choose, seen)
return [1, 1] if choose.zero? || choose == count
return [seen[[count, choose]], 1] if seen.key?([count, choose])
left, left_calls = memo(count - 1, choose - 1, seen)
right, right_calls = memo(count - 1, choose, seen)
seen[[count, choose]] = left + right
[left + right, left_calls + right_calls + 1]
end
# C(n, k) bottom-up in one row, counting its updates.
def table(count, choose)
row = [1] + Array.new(choose, 0)
updates = 0
1.upto(count) do |i|
# Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
[i, choose].min.downto(1) do |j|
row[j] += row[j - 1]
updates += 1
end
end
[row[choose], updates]
end
value, calls = plain(20, 10)
puts "plain recursion: C(20, 10) = #{value} after #{calls} calls"
[[20, 10], [30, 15]].each do |count, choose|
seen = {}
value, calls = memo(count, choose, seen)
puts "memo: C(#{count}, #{choose}) = #{value} after #{calls} calls, #{seen.size} answers stored"
value, updates = table(count, choose)
puts "one-row table: C(#{count}, #{choose}) = #{value} after #{updates} updates"
end In Ruby: Arrays compare by value, so [count, choose] works as a hash key; the parameters are named count and choose, as RuboCop asks for names of three letters or more.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
// C(n, k) by Pascal's rule, and how many calls it took.
fun plain(
n: Int,
k: Int,
): Pair<Int, Int> {
if (k == 0 || k == n) return 1 to 1
val (left, leftCalls) = plain(n - 1, k - 1)
val (right, rightCalls) = plain(n - 1, k)
return left + right to leftCalls + rightCalls + 1
}
// The same recursion with a memo of the states worked out.
fun memo(
n: Int,
k: Int,
seen: MutableMap<Pair<Int, Int>, Int>,
): Pair<Int, Int> {
if (k == 0 || k == n) return 1 to 1
seen[n to k]?.let { return it to 1 }
val (left, leftCalls) = memo(n - 1, k - 1, seen)
val (right, rightCalls) = memo(n - 1, k, seen)
seen[n to k] = left + right
return left + right to leftCalls + rightCalls + 1
}
// C(n, k) bottom-up in one row, counting its updates.
fun table(
n: Int,
k: Int,
): Pair<Int, Int> {
val row = IntArray(k + 1)
row[0] = 1
var updates = 0
for (i in 1..n) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (j in minOf(i, k) downTo 1) {
row[j] += row[j - 1]
updates++
}
}
return row[k] to updates
}
fun main() {
val (value, calls) = plain(20, 10)
println("plain recursion: C(20, 10) = $value after $calls calls")
for ((n, k) in listOf(20 to 10, 30 to 15)) {
val seen = mutableMapOf<Pair<Int, Int>, Int>()
val (memoValue, memoCalls) = memo(n, k, seen)
println("memo: C($n, $k) = $memoValue after $memoCalls calls, ${seen.size} answers stored")
val (tableValue, updates) = table(n, k)
println("one-row table: C($n, $k) = $tableValue after $updates updates")
}
} In Kotlin: Pair<Int, Int> has value equality, so it keys the MutableMap, and seen[n to k]?.let returns early with a stored answer.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
// C(n, k) by Pascal's rule, and how many calls it took.
(int, int) plain(int n, int k) {
if (k == 0 || k == n) return (1, 1);
final (left, leftCalls) = plain(n - 1, k - 1);
final (right, rightCalls) = plain(n - 1, k);
return (left + right, leftCalls + rightCalls + 1);
}
// The same recursion with a memo of the states worked out.
(int, int) memo(int n, int k, Map<(int, int), int> seen) {
if (k == 0 || k == n) return (1, 1);
final known = seen[(n, k)];
if (known != null) return (known, 1);
final (left, leftCalls) = memo(n - 1, k - 1, seen);
final (right, rightCalls) = memo(n - 1, k, seen);
seen[(n, k)] = left + right;
return (left + right, leftCalls + rightCalls + 1);
}
// C(n, k) bottom-up in one row, counting its updates.
(int, int) table(int n, int k) {
final row = List.filled(k + 1, 0)..[0] = 1;
var updates = 0;
for (var i = 1; i <= n; i++) {
// Right to left, so row[j - 1] still holds row i - 1 when row[j] reads it.
for (var j = i < k ? i : k; j > 0; j--) {
row[j] += row[j - 1];
updates++;
}
}
return (row[k], updates);
}
void main() {
final (value, calls) = plain(20, 10);
print('plain recursion: C(20, 10) = $value after $calls calls');
for (final (n, k) in [(20, 10), (30, 15)]) {
final seen = <(int, int), int>{};
final (memoValue, memoCalls) = memo(n, k, seen);
print(
'memo: C($n, $k) = $memoValue after $memoCalls calls, ${seen.length} answers stored',
);
final (tableValue, updates) = table(n, k);
print('one-row table: C($n, $k) = $tableValue after $updates updates');
}
} In Dart: Records have value equality, so (n, k) keys the Map directly, and the functions return (int, int) records.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
plain recursion: C(20, 10) = 184756 after 369511 calls memo: C(20, 10) = 184756 after 201 calls, 100 answers stored one-row table: C(20, 10) = 184756 after 155 updates memo: C(30, 15) = 155117520 after 451 calls, 225 answers stored one-row table: C(30, 15) = 155117520 after 345 updates
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 11 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 151 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 29 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 717 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 190 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 15 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Every language printed the same counts: 369,511 calls for the plain recursion, 201 for the memo and 155 updates for the table, and for C(30, 15) the memo needed 451 calls and the table 345 updates.
Key idea
Fill every entry after the entries it reads. With a single row, the same rule fixes the direction inside the row: right to left for Pascal’s rule.
Checkpoint
Check: Filling a table in dependency order
How deep top-down can go
The memo removes repeated work, but not the depth of the recursion. To answer C(n, k) top-down, the first call waits for a chain of calls that ends at a base case, and the longest such chain is about n calls long:
"""Top-down keeps one stack frame per level of its recursion; a bottom-up loop keeps none."""
import math
import sys
from functools import cache
@cache
def c_top_down(n, k):
if k == 0 or k == n:
return 1
return c_top_down(n - 1, k - 1) + c_top_down(n - 1, k)
def c_bottom_up(n, k):
row = [1] + [0] * k
for i in range(1, n + 1):
for j in range(min(i, k), 0, -1):
row[j] += row[j - 1]
return row[k]
print(f"recursion limit: {sys.getrecursionlimit()}")
for n in (900, 1500):
c_top_down.cache_clear()
try:
value = c_top_down(n, n // 2)
print(f"top-down C({n}, {n // 2}): {len(str(value))} digits, {c_top_down.cache_info().misses:,} states")
except RecursionError as error:
print(f"top-down C({n}, {n // 2}): RecursionError: {error}")
for n in (900, 1500, 3000):
value = c_bottom_up(n, n // 2)
print(f"bottom-up C({n}, {n // 2}): {len(str(value))} digits, equal to math.comb: {value == math.comb(n, n // 2)}") Output
recursion limit: 1000 top-down C(900, 450): 270 digits, 203,400 states top-down C(1500, 750): RecursionError: maximum recursion depth exceeded bottom-up C(900, 450): 270 digits, equal to math.comb: True bottom-up C(1500, 750): 450 digits, equal to math.comb: True bottom-up C(3000, 1500): 902 digits, equal to math.comb: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 depth.py
C(900, 450) works top-down, after 203,400 states. C(1500, 750) stops with RecursionError, because CPython’s
recursion limit, sys.getrecursionlimit(), is 1,000 frames by default. The table has no depth at all and computes
C(3000, 1500), a number of 902 digits, which again equals math.comb. The lesson on recursion limits measures this
limit in every runtime of the track and explains why raising it is not a dependable fix; for long inputs, write the
loop.
Checkpoint
Check: The depth of a top-down recursion
Four parts of every DP
A DP is easier to write, check and explain once its four parts are written down, in words, before any code. Here are the four parts of Pascal’s rule and of the triangle in the next section, together with the fifth decision, the order:
| Part | C(n, k) by Pascal’s rule | The heaviest path down a triangle |
|---|---|---|
| State | C(i, j): the ways to choose j of the first i items | best(r, c): the heaviest path from cell (r, c) to the bottom |
| Transition | C(i − 1, j − 1) + C(i − 1, j): item i is chosen, or it is not | t(r, c) + the larger of best(r + 1, c) and best(r + 1, c + 1) |
| Base cases | C(i, 0) = C(i, i) = 1 | On the bottom row, the number in the cell itself |
| Answer | C(n, k) | best(0, 0) |
| Order | Rows i = 0, 1, 2 … n | Rows from the bottom up |
The state (dynamic programming): What one entry of a dynamic program means, put into words that can be checked by hand, such as C(i, j), the number of ways to choose j of the first i items. is the part to get right first. “C(i, j) is the number of ways to choose j items from the first i” is a sentence you can check against a small case by hand; “C is a table of numbers” is not, and most wrong DPs start from a state that was never put into words.
Checkpoint
Check: State, transition, base cases and answer
Optimal substructure: the heaviest path down a triangle
A number triangle has one number in its first row, two in the second, and so on. A path starts at the top and steps down to one of the two numbers below, row by row; the task is the heaviest path, the one with the largest total. Step through four rows filled from the bottom up, then the greedy walk on the same rows:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The base cases. On the bottom row a path from a cell is the cell itself, so the heaviest path from each cell weighs its own number.
- Row 2. Each cell adds the larger of the two values below it. 4 + max(0, 7) = 11, 6 + max(7, 1) = 13 and 8 + max(1, 20) = 28.
- Row 1. 9 + max(11, 13) = 22 and 6 + max(13, 28) = 34. The 6 is smaller than the 9 beside it, yet the path below the 6 is heavier.
- The answer. 5 + max(22, 34) = 39. The table filled 10 cells, one per state, and the top cell holds the heaviest path, best(0, 0) = 39.
- The greedy walk. Greedy starts at 5 and always steps to the larger number below. It takes 9, then 6, then 7, and weighs 27; it never sees the 20 behind the smaller 6.
- The heaviest path. Following the larger best value down from the top gives 5, 6, 8 and 20, which weigh 39. Every cell on it was right about the path below it.
The table is right because of optimal substructure. The heaviest path from a cell is the cell’s number plus the heaviest path from one of the two cells below it: if the rest of the path were not the heaviest from where it continues, swapping in a heavier rest would give a heavier path, which is impossible. Richard Bellman, who developed and named dynamic programming, called this the principle of optimality, and NIST defines it as the components of a globally optimal solution being globally optimal themselves. Here is the same comparison in 14 languages, on the four rows of the figure:
Python 3.14 · main.py
TRIANGLE = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]]
def every_path(tri: list[list[int]]) -> int:
"""The heaviest path, by trying all 2^(rows - 1) of them."""
heaviest = 0
for moves in range(2 ** (len(tri) - 1)):
col, total = 0, tri[0][0]
for row in range(1, len(tri)):
col += (moves >> (row - 1)) & 1 # bit row - 1 set: step down and right
total += tri[row][col]
heaviest = max(heaviest, total)
return heaviest
def greedy(tri: list[list[int]]) -> int:
"""Always step to the larger of the two numbers below."""
col, total = 0, tri[0][0]
for row in range(1, len(tri)):
if tri[row][col + 1] > tri[row][col]:
col += 1
total += tri[row][col]
return total
def table(tri: list[list[int]]) -> int:
"""best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up."""
best = tri[-1]
print(f"row {len(tri) - 1}: {' '.join(map(str, best))}")
for row in range(len(tri) - 2, -1, -1):
best = [tri[row][c] + max(best[c], best[c + 1]) for c in range(row + 1)]
print(f"row {row}: {' '.join(map(str, best))}")
return best[0]
print(f"every path: {every_path(TRIANGLE)} after {2 ** (len(TRIANGLE) - 1)} paths")
print(f"greedy walk: {greedy(TRIANGLE)}")
print(f"table: {table(TRIANGLE)}") In Python: Bit row − 1 of moves says whether the path steps down and to the right at that row, so the numbers 0 to 7 name all 8 paths.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const TRIANGLE = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]];
// The heaviest path, by trying all 2^(rows - 1) of them.
function everyPath(tri) {
let heaviest = 0;
for (let moves = 0; moves < 2 ** (tri.length - 1); moves++) {
let col = 0;
let total = tri[0][0];
for (let row = 1; row < tri.length; row++) {
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += tri[row][col];
}
heaviest = Math.max(heaviest, total);
}
return heaviest;
}
// Always step to the larger of the two numbers below.
function greedy(tri) {
let col = 0;
let total = tri[0][0];
for (let row = 1; row < tri.length; row++) {
if (tri[row][col + 1] > tri[row][col]) col++;
total += tri[row][col];
}
return total;
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
function table(tri) {
let best = tri.at(-1);
console.log(`row ${tri.length - 1}: ${best.join(" ")}`);
for (let row = tri.length - 2; row >= 0; row--) {
best = tri[row].map((cell, c) => cell + Math.max(best[c], best[c + 1]));
console.log(`row ${row}: ${best.join(" ")}`);
}
return best[0];
}
console.log(`every path: ${everyPath(TRIANGLE)} after ${2 ** (TRIANGLE.length - 1)} paths`);
console.log(`greedy walk: ${greedy(TRIANGLE)}`);
console.log(`table: ${table(TRIANGLE)}`); In JavaScript: tri.at(-1) reads the last row; each new row of best is a map over the cells above, so best is never updated in place.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
public class Main {
static final int[][] TRIANGLE = {{5}, {9, 6}, {4, 6, 8}, {0, 7, 1, 20}};
/** The heaviest path, by trying all 2^(rows - 1) of them. */
static int everyPath(int[][] tri) {
int heaviest = 0;
for (int moves = 0; moves < 1 << (tri.length - 1); moves++) {
int col = 0;
int total = tri[0][0];
for (int row = 1; row < tri.length; row++) {
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += tri[row][col];
}
heaviest = Math.max(heaviest, total);
}
return heaviest;
}
/** Always step to the larger of the two numbers below. */
static int greedy(int[][] tri) {
int col = 0;
int total = tri[0][0];
for (int row = 1; row < tri.length; row++) {
if (tri[row][col + 1] > tri[row][col]) {
col++;
}
total += tri[row][col];
}
return total;
}
/** best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up. */
static int table(int[][] tri) {
int[] best = tri[tri.length - 1];
System.out.println("row " + (tri.length - 1) + ": " + joined(best));
for (int row = tri.length - 2; row >= 0; row--) {
int[] above = new int[row + 1];
for (int c = 0; c <= row; c++) {
above[c] = tri[row][c] + Math.max(best[c], best[c + 1]);
}
best = above;
System.out.println("row " + row + ": " + joined(best));
}
return best[0];
}
static String joined(int[] values) {
var out = new StringBuilder();
for (int value : values) {
if (out.length() > 0) {
out.append(' ');
}
out.append(value);
}
return out.toString();
}
public static void main(String[] args) {
int paths = 1 << (TRIANGLE.length - 1);
System.out.println("every path: " + everyPath(TRIANGLE) + " after " + paths + " paths");
System.out.println("greedy walk: " + greedy(TRIANGLE));
System.out.println("table: " + table(TRIANGLE));
}
} In Java: The rows are int arrays of different lengths, a jagged array, and a small joined helper prints each row of the table.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <print>
#include <vector>
using Triangle = std::vector<std::vector<int>>;
const Triangle TRIANGLE = {{5}, {9, 6}, {4, 6, 8}, {0, 7, 1, 20}};
// The heaviest path, by trying all 2^(rows - 1) of them.
int every_path(const Triangle &tri) {
const int rows = static_cast<int>(tri.size());
int heaviest = 0;
for (int moves = 0; moves < 1 << (rows - 1); ++moves) {
int col = 0, total = tri[0][0];
for (int row = 1; row < rows; ++row) {
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += tri[row][col];
}
heaviest = std::max(heaviest, total);
}
return heaviest;
}
// Always step to the larger of the two numbers below.
int greedy(const Triangle &tri) {
int col = 0, total = tri[0][0];
for (std::size_t row = 1; row < tri.size(); ++row) {
if (tri[row][col + 1] > tri[row][col])
++col;
total += tri[row][col];
}
return total;
}
void show(std::size_t row, const std::vector<int> &best) {
std::print("row {}:", row);
for (const int value : best)
std::print(" {}", value);
std::println("");
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled
// bottom-up.
int table(const Triangle &tri) {
std::vector<int> best = tri.back();
show(tri.size() - 1, best);
for (int row = static_cast<int>(tri.size()) - 2; row >= 0; --row) {
std::vector<int> above(row + 1);
for (int c = 0; c <= row; ++c)
above[c] = tri[row][c] + std::max(best[c], best[c + 1]);
best = above;
show(row, best);
}
return best[0];
}
int main() {
std::println("every path: {} after {} paths", every_path(TRIANGLE),
1 << (TRIANGLE.size() - 1));
std::println("greedy walk: {}", greedy(TRIANGLE));
std::println("table: {}", table(TRIANGLE));
} In C++: std::println would print a vector with brackets and commas, so show prints the numbers of a row one by one.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
int[][] triangle = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]];
Console.WriteLine($"every path: {EveryPath(triangle)} after {1 << (triangle.Length - 1)} paths");
Console.WriteLine($"greedy walk: {Greedy(triangle)}");
Console.WriteLine($"table: {Table(triangle)}");
// The heaviest path, by trying all 2^(rows - 1) of them.
static int EveryPath(int[][] tri)
{
int heaviest = 0;
for (int moves = 0; moves < 1 << (tri.Length - 1); moves++)
{
int col = 0, total = tri[0][0];
for (int row = 1; row < tri.Length; row++)
{
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += tri[row][col];
}
heaviest = Math.Max(heaviest, total);
}
return heaviest;
}
// Always step to the larger of the two numbers below.
static int Greedy(int[][] tri)
{
int col = 0, total = tri[0][0];
for (int row = 1; row < tri.Length; row++)
{
if (tri[row][col + 1] > tri[row][col])
{
col++;
}
total += tri[row][col];
}
return total;
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
static int Table(int[][] tri)
{
int[] best = tri[^1];
Console.WriteLine($"row {tri.Length - 1}: {string.Join(" ", best)}");
for (int row = tri.Length - 2; row >= 0; row--)
{
best = tri[row].Select((cell, c) => cell + Math.Max(best[c], best[c + 1])).ToArray();
Console.WriteLine($"row {row}: {string.Join(" ", best)}");
}
return best[0];
} In C#: tri[^1] is the last row, and Select((cell, c) => …) gives each cell with its index, the same map the other languages use.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
enum { ROWS = 4 };
static const int TRIANGLE[ROWS][ROWS] = {{5}, {9, 6}, {4, 6, 8}, {0, 7, 1, 20}};
static int max(int a, int b) { return a > b ? a : b; }
// The heaviest path, by trying all 2^(rows - 1) of them.
static int every_path(void) {
int heaviest = 0;
for (int moves = 0; moves < 1 << (ROWS - 1); moves++) {
int col = 0, total = TRIANGLE[0][0];
for (int row = 1; row < ROWS; row++) {
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += TRIANGLE[row][col];
}
heaviest = max(heaviest, total);
}
return heaviest;
}
// Always step to the larger of the two numbers below.
static int greedy(void) {
int col = 0, total = TRIANGLE[0][0];
for (int row = 1; row < ROWS; row++) {
if (TRIANGLE[row][col + 1] > TRIANGLE[row][col])
col++;
total += TRIANGLE[row][col];
}
return total;
}
static void show(int row, const int *best) {
printf("row %d:", row);
for (int c = 0; c <= row; c++)
printf(" %d", best[c]);
printf("\n");
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled
// bottom-up.
static int table(void) {
int best[ROWS];
for (int c = 0; c < ROWS; c++)
best[c] = TRIANGLE[ROWS - 1][c];
show(ROWS - 1, best);
for (int row = ROWS - 2; row >= 0; row--) {
for (int c = 0; c <= row; c++)
best[c] = TRIANGLE[row][c] + max(best[c], best[c + 1]);
show(row, best);
}
return best[0];
}
int main(void) {
printf("every path: %d after %d paths\n", every_path(), 1 << (ROWS - 1));
printf("greedy walk: %d\n", greedy());
printf("table: %d\n", table());
return 0;
} In C: The rows are padded to four numbers in a 4 × 4 array, and best is updated in place from left to right, which is safe because cell c reads only cells c and c + 1 of the row below.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const TRIANGLE = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]];
/**
* The heaviest path, by trying all 2^(rows - 1) of them.
*
* @param list<list<int>> $tri
*/
function everyPath(array $tri): int
{
$heaviest = 0;
for ($moves = 0; $moves < 1 << (count($tri) - 1); $moves++) {
[$col, $total] = [0, $tri[0][0]];
for ($row = 1; $row < count($tri); $row++) {
$col += ($moves >> ($row - 1)) & 1; // bit row - 1 set: step down and right
$total += $tri[$row][$col];
}
$heaviest = max($heaviest, $total);
}
return $heaviest;
}
/**
* Always step to the larger of the two numbers below.
*
* @param list<list<int>> $tri
*/
function greedy(array $tri): int
{
[$col, $total] = [0, $tri[0][0]];
for ($row = 1; $row < count($tri); $row++) {
if ($tri[$row][$col + 1] > $tri[$row][$col]) {
$col++;
}
$total += $tri[$row][$col];
}
return $total;
}
/**
* best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
*
* @param list<list<int>> $tri
*/
function table(array $tri): int
{
$best = $tri[count($tri) - 1];
echo 'row ' . (count($tri) - 1) . ': ' . implode(' ', $best) . "\n";
for ($row = count($tri) - 2; $row >= 0; $row--) {
$best = array_map(
fn(int $c): int => $tri[$row][$c] + max($best[$c], $best[$c + 1]),
range(0, $row),
);
echo "row {$row}: " . implode(' ', $best) . "\n";
}
return $best[0];
}
$paths = 1 << (count(TRIANGLE) - 1);
echo 'every path: ' . everyPath(TRIANGLE) . " after {$paths} paths\n";
echo 'greedy walk: ' . greedy(TRIANGLE) . "\n";
echo 'table: ' . table(TRIANGLE) . "\n"; In PHP: array_map over range(0, $row) builds each new row; the arrow function sees $tri, $row and $best without a use clause.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
const TRIANGLE: [&[u32]; 4] = [&[5], &[9, 6], &[4, 6, 8], &[0, 7, 1, 20]];
/// The heaviest path, by trying all 2^(rows - 1) of them.
fn every_path(tri: &[&[u32]]) -> u32 {
let mut heaviest = 0;
for moves in 0..1u32 << (tri.len() - 1) {
let (mut col, mut total) = (0, tri[0][0]);
for row in 1..tri.len() {
col += ((moves >> (row - 1)) & 1) as usize; // bit row - 1 set: step down and right
total += tri[row][col];
}
heaviest = heaviest.max(total);
}
heaviest
}
/// Always step to the larger of the two numbers below.
fn greedy(tri: &[&[u32]]) -> u32 {
let (mut col, mut total) = (0, tri[0][0]);
for row in &tri[1..] {
if row[col + 1] > row[col] {
col += 1;
}
total += row[col];
}
total
}
fn joined(values: &[u32]) -> String {
values
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(" ")
}
/// best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
fn table(tri: &[&[u32]]) -> u32 {
let mut best = tri[tri.len() - 1].to_vec();
println!("row {}: {}", tri.len() - 1, joined(&best));
for row in (0..tri.len() - 1).rev() {
best = (0..=row)
.map(|c| tri[row][c] + best[c].max(best[c + 1]))
.collect();
println!("row {row}: {}", joined(&best));
}
best[0]
}
fn main() {
let paths = 1 << (TRIANGLE.len() - 1);
println!("every path: {} after {paths} paths", every_path(&TRIANGLE));
println!("greedy walk: {}", greedy(&TRIANGLE));
println!("table: {}", table(&TRIANGLE));
} In Rust: The rows are slices of different lengths, &[u32], and each new row of best is collected from a map over the cells above.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const TRIANGLE: number[][] = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]];
// The heaviest path, by trying all 2^(rows - 1) of them.
function everyPath(tri: number[][]): number {
let heaviest = 0;
for (let moves = 0; moves < 2 ** (tri.length - 1); moves++) {
let col = 0;
let total = tri[0][0];
for (let row = 1; row < tri.length; row++) {
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += tri[row][col];
}
heaviest = Math.max(heaviest, total);
}
return heaviest;
}
// Always step to the larger of the two numbers below.
function greedy(tri: number[][]): number {
let col = 0;
let total = tri[0][0];
for (let row = 1; row < tri.length; row++) {
if (tri[row][col + 1] > tri[row][col]) col++;
total += tri[row][col];
}
return total;
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
function table(tri: number[][]): number {
let best = tri[tri.length - 1];
console.log(`row ${tri.length - 1}: ${best.join(" ")}`);
for (let row = tri.length - 2; row >= 0; row--) {
best = tri[row].map((cell, c) => cell + Math.max(best[c], best[c + 1]));
console.log(`row ${row}: ${best.join(" ")}`);
}
return best[0];
}
console.log(`every path: ${everyPath(TRIANGLE)} after ${2 ** (TRIANGLE.length - 1)} paths`);
console.log(`greedy walk: ${greedy(TRIANGLE)}`);
console.log(`table: ${table(TRIANGLE)}`); In TypeScript: The triangle is typed number[][], and tri[tri.length - 1] stands in for tri.at(-1), whose type would include undefined.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"strings"
)
var triangle = [][]int{{5}, {9, 6}, {4, 6, 8}, {0, 7, 1, 20}}
// everyPath finds the heaviest path by trying all 2^(rows - 1) of them.
func everyPath(tri [][]int) int {
heaviest := 0
for moves := 0; moves < 1<<(len(tri)-1); moves++ {
col, total := 0, tri[0][0]
for row := 1; row < len(tri); row++ {
col += (moves >> (row - 1)) & 1 // bit row - 1 set: step down and right
total += tri[row][col]
}
heaviest = max(heaviest, total)
}
return heaviest
}
// greedy always steps to the larger of the two numbers below.
func greedy(tri [][]int) int {
col, total := 0, tri[0][0]
for _, row := range tri[1:] {
if row[col+1] > row[col] {
col++
}
total += row[col]
}
return total
}
func joined(values []int) string {
return strings.Trim(fmt.Sprint(values), "[]")
}
// table fills best[c], the heaviest path from cell c of a row to the bottom, bottom-up.
func table(tri [][]int) int {
best := tri[len(tri)-1]
fmt.Printf("row %d: %s\n", len(tri)-1, joined(best))
for row := len(tri) - 2; row >= 0; row-- {
above := make([]int, row+1)
for c := range above {
above[c] = tri[row][c] + max(best[c], best[c+1])
}
best = above
fmt.Printf("row %d: %s\n", row, joined(best))
}
return best[0]
}
func main() {
fmt.Printf("every path: %d after %d paths\n", everyPath(triangle), 1<<(len(triangle)-1))
fmt.Printf("greedy walk: %d\n", greedy(triangle))
fmt.Printf("table: %d\n", table(triangle))
} In Go: fmt.Sprint prints a slice as [11 13 28], so joined trims the brackets to print the row.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let triangle = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]]
/// The heaviest path, by trying all 2^(rows - 1) of them.
func everyPath(_ tri: [[Int]]) -> Int {
var heaviest = 0
for moves in 0..<(1 << (tri.count - 1)) {
var (col, total) = (0, tri[0][0])
for row in 1..<tri.count {
col += (moves >> (row - 1)) & 1 // bit row - 1 set: step down and right
total += tri[row][col]
}
heaviest = max(heaviest, total)
}
return heaviest
}
/// Always step to the larger of the two numbers below.
func greedy(_ tri: [[Int]]) -> Int {
var (col, total) = (0, tri[0][0])
for row in tri.dropFirst() {
if row[col + 1] > row[col] { col += 1 }
total += row[col]
}
return total
}
/// best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
func table(_ tri: [[Int]]) -> Int {
var best = tri[tri.count - 1]
print("row \(tri.count - 1): \(best.map(String.init).joined(separator: " "))")
for row in stride(from: tri.count - 2, through: 0, by: -1) {
best = (0...row).map { c in tri[row][c] + max(best[c], best[c + 1]) }
print("row \(row): \(best.map(String.init).joined(separator: " "))")
}
return best[0]
}
print("every path: \(everyPath(triangle)) after \(1 << (triangle.count - 1)) paths")
print("greedy walk: \(greedy(triangle))")
print("table: \(table(triangle))") In Swift: tri.dropFirst() walks the rows below the top one, and stride counts the rows back up to 0.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
TRIANGLE = [[5], [9, 6], [4, 6, 8], [0, 7, 1, 20]].freeze
# The heaviest path, by trying all 2^(rows - 1) of them.
def every_path(tri)
(0...(1 << (tri.size - 1))).map { |moves| path_weight(tri, moves) }.max
end
def path_weight(tri, moves)
col = 0
total = tri[0][0]
(1...tri.size).each do |row|
col += (moves >> (row - 1)) & 1 # bit row - 1 set: step down and right
total += tri[row][col]
end
total
end
# Always step to the larger of the two numbers below.
def greedy(tri)
col = 0
total = tri[0][0]
tri.drop(1).each do |row|
col += 1 if row[col + 1] > row[col]
total += row[col]
end
total
end
def show(row, best) = puts("row #{row}: #{best.join(' ')}")
def above(cells, below) = cells.each_with_index.map { |cell, c| cell + below[c, 2].max }
# best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
def table(tri)
best = tri.last
show(tri.size - 1, best)
(tri.size - 2).downto(0) do |row|
best = above(tri[row], best)
show(row, best)
end
best.first
end
puts "every path: #{every_path(TRIANGLE)} after #{1 << (TRIANGLE.size - 1)} paths"
puts "greedy walk: #{greedy(TRIANGLE)}"
puts "table: #{table(TRIANGLE)}" In Ruby: The path's weight is a method of its own, path_weight, so that each method stays as short as RuboCop asks.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
val triangle = listOf(listOf(5), listOf(9, 6), listOf(4, 6, 8), listOf(0, 7, 1, 20))
// The heaviest path, by trying all 2^(rows - 1) of them.
fun everyPath(tri: List<List<Int>>): Int {
var heaviest = 0
for (moves in 0 until (1 shl (tri.size - 1))) {
var col = 0
var total = tri[0][0]
for (row in 1 until tri.size) {
col += (moves shr (row - 1)) and 1 // bit row - 1 set: step down and right
total += tri[row][col]
}
heaviest = maxOf(heaviest, total)
}
return heaviest
}
// Always step to the larger of the two numbers below.
fun greedy(tri: List<List<Int>>): Int {
var col = 0
var total = tri[0][0]
for (row in tri.drop(1)) {
if (row[col + 1] > row[col]) col++
total += row[col]
}
return total
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled bottom-up.
fun table(tri: List<List<Int>>): Int {
var best = tri.last()
println("row ${tri.lastIndex}: ${best.joinToString(" ")}")
for (row in tri.size - 2 downTo 0) {
val below = best
best = tri[row].mapIndexed { c, cell -> cell + maxOf(below[c], below[c + 1]) }
println("row $row: ${best.joinToString(" ")}")
}
return best[0]
}
fun main() {
println("every path: ${everyPath(triangle)} after ${1 shl (triangle.size - 1)} paths")
println("greedy walk: ${greedy(triangle)}")
println("table: ${table(triangle)}")
} In Kotlin: tri.lastIndex numbers the bottom row, and mapIndexed builds each new row of best from tri[row], cell by cell with its index.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
const triangle = [
[5],
[9, 6],
[4, 6, 8],
[0, 7, 1, 20],
];
// The heaviest path, by trying all 2^(rows - 1) of them.
int everyPath(List<List<int>> tri) {
var heaviest = 0;
for (var moves = 0; moves < 1 << (tri.length - 1); moves++) {
var (col, total) = (0, tri[0][0]);
for (var row = 1; row < tri.length; row++) {
col += (moves >> (row - 1)) & 1; // bit row - 1 set: step down and right
total += tri[row][col];
}
if (total > heaviest) heaviest = total;
}
return heaviest;
}
// Always step to the larger of the two numbers below.
int greedy(List<List<int>> tri) {
var (col, total) = (0, tri[0][0]);
for (final row in tri.skip(1)) {
if (row[col + 1] > row[col]) col++;
total += row[col];
}
return total;
}
// best[c] is the heaviest path from cell c of a row to the bottom, filled
// bottom-up.
int table(List<List<int>> tri) {
var best = tri.last;
print('row ${tri.length - 1}: ${best.join(' ')}');
for (var row = tri.length - 2; row >= 0; row--) {
final below = best;
best = [
for (var c = 0; c <= row; c++)
tri[row][c] + (below[c] > below[c + 1] ? below[c] : below[c + 1]),
];
print('row $row: ${best.join(' ')}');
}
return best[0];
}
void main() {
print(
'every path: ${everyPath(triangle)} after ${1 << (triangle.length - 1)} paths',
);
print('greedy walk: ${greedy(triangle)}');
print('table: ${table(triangle)}');
} In Dart: The new row is a list literal with a collection for, and a conditional expression takes the larger of the two cells below.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
every path: 39 after 8 paths greedy walk: 27 row 3: 0 7 1 20 row 2: 11 13 28 row 1: 22 34 row 0: 39 table: 39
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 10 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 152 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 20 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 720 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 182 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 11 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Trying all 8 paths gives 39, the same as the table, while the greedy walk gets 27. The gap does not close on larger triangles. The next triangles are random but seeded, so every run prints the same numbers:
"""The heaviest path down a number triangle: every path, a greedy walk, and a table filled from the bottom up.
A path starts at the top and moves to one of the two numbers below, row by row. The triangles are random but
seeded, so every run prints the same numbers.
"""
import random
from itertools import product
def make_triangle(rows, seed):
rng = random.Random(seed)
return [[rng.randint(1, 99) for _ in range(r + 1)] for r in range(rows)]
def every_path(tri):
best, count = 0, 0
for moves in product((0, 1), repeat=len(tri) - 1): # 0: straight down, 1: down and right
col, total = 0, tri[0][0]
for r, step in enumerate(moves, start=1):
col += step
total += tri[r][col]
best, count = max(best, total), count + 1
return best, count
def greedy(tri):
col, total = 0, tri[0][0]
for r in range(1, len(tri)):
if tri[r][col + 1] > tri[r][col]:
col += 1
total += tri[r][col]
return total
def heaviest(tri):
best = list(tri[-1]) # best[c]: the heaviest path from cell c of the row being filled to the bottom
for r in range(len(tri) - 2, -1, -1):
best = [tri[r][c] + max(best[c], best[c + 1]) for c in range(r + 1)]
return best[0]
small = make_triangle(14, seed=11)
total, paths = every_path(small)
print(f"14 rows, every path tried: {paths:,} paths, the heaviest weighs {total}")
print(f"14 rows, greedy walk to the larger number below: {greedy(small)}")
print(f"14 rows, table from the bottom row up: {heaviest(small)}, after filling {sum(range(1, 15))} cells")
big = make_triangle(100, seed=11)
print(f"100 rows: {2 ** 99:.2e} paths; the table gives {heaviest(big)} after {sum(range(1, 101)):,} cells; greedy gives {greedy(big)}") Output
14 rows, every path tried: 8,192 paths, the heaviest weighs 981 14 rows, greedy walk to the larger number below: 918 14 rows, table from the bottom row up: 981, after filling 105 cells 100 rows: 6.34e+29 paths; the table gives 7224 after 5,050 cells; greedy gives 6537
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 triangle.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A triangle of 14 rows has 2¹³ = 8,192 paths, and trying them all gives 981. The table gives 981 after filling 105 cells. With 100 rows there are about 6.3 × 10²⁹ paths, far too many to try, and the table still needs only 5,050 cells. The greedy walk got 918 instead of 981 on 14 rows, and 6,537 instead of 7,224 on 100. It commits to a choice after looking one row ahead, while the table compares whole remaining paths.
Key idea
Taking the best of the smaller answers is right because the rest of a best path is itself a best path; a greedy choice has no such guarantee.
Checkpoint
Check: Optimal substructure and the greedy walk
Every DP is a path problem in a graph of states
Draw each state once, with an arrow to each state its transition reads, and a DP becomes a graph. Every arrow points to a smaller subproblem, so the graph has no cycles: it is a directed acyclic graph, or DAG.
The 11 calls of C(4, 2) visit only 8 states
Text description of the diagram
The diagram draws each state of Pascal's rule once, where every C(n, k) asks for the two states of the row above it, with an arrow from a state to each state it asks for, from top to bottom.
- C(4, 2) = 6, the answer, asks for C(3, 1) and C(3, 2).
- C(3, 1) = 3 asks for C(2, 0) and C(2, 1). C(3, 2) = 3 asks for C(2, 1) and C(2, 2).
- C(2, 1) = 2 is reached by two arrows, one from C(3, 1) and one from C(3, 2). It asks for C(1, 0) and C(1, 1).
- C(2, 0), C(2, 2), C(1, 0) and C(1, 1) are base cases, each equal to 1, with no arrow out.
The plain recursion makes 11 calls, because it works out C(2, 1) and its two base cases twice. Drawn as states, the same work is 8 nodes and 8 arrows: a memo or a table works out each node once.
The plain recursion walks this graph as if it were a tree, so it visits C(2, 1) once for each of its two parents, and everything below C(2, 1) twice. A memo or a table visits each node once and follows each arrow once, which is why the cost of a DP is the number of states times the work of one transition. Read this way, a counting DP counts paths: there are exactly 6 paths from C(4, 2) down to a base case, and C(4, 2) = 6. An optimisation DP finds the longest or the shortest path, as the triangle example found the heaviest one. When a new problem looks hard, describe its states and arrows first; if the arrows can never form a cycle, a table can follow them.
Key idea
A memo or a table visits every state once, so a DP costs its number of states times the work of one transition.
Checkpoint
Check: A DP as paths in a graph of states
Top-down or bottom-up: the states, the depth and the memory decide
Three questions about a problem decide most of the choice. Answer them for a problem of your own in the table below, and it marks the version each answer rules out, with the reason:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Version | States computed | Call depth | Memory | Getting there |
|---|---|---|---|---|
| Top-down, a memo Order: the recursion finds it | Good only those the first call reaches: 201 calls for C(20, 10) | Poor about the longest chain of calls; C(1500, 750) passed 1,000 frames | Mixed keeps every answer it worked out | Good add a cache to a recursion you have tested |
| Bottom-up, a table Order: you work it out | Mixed every state the loops visit, needed or not | Good none: a loop computed C(3000, 1500) | Good keep only the rows the transition reads: one row of k + 1 | Mixed work out an order in which every state follows the states it reads |
How the answers decide
- “Does the answer need only a small part of all the states?” Yes, a small part: adds a caveat to Bottom-up, a table. A table fills every state its loops visit, so it can do far more work than a memo that reaches only the states the answer needs.
- “Can a chain of calls be longer than about 1,000, Python's default recursion limit?” Yes, inputs can be that long: rules out Top-down, a memo. A memo is as deep as its longest chain of calls, and CPython stops at 1,000 frames by default, as C(1500, 750) did.
- “Do you need to keep memory down to the last row or two?” Yes, memory is tight: rules out Top-down, a memo. A memo keeps every answer it has found; a table that reads only the previous row can keep just that row.
- “Does the answer need only a small part of all the states?” No, most of them and “Can a chain of calls be longer than about 1,000, Python's default recursion limit?” No, it stays short and “Do you need to keep memory down to the last row or two?” No, all the answers fit: adds a caveat to Top-down, a memo and Bottom-up, a table. Both fit. Start from the recursion with a memo, then write the table if inputs grow, and check that the two agree on every small case.
In short: a memo when the answer reaches only a few of the states or the order is hard to see, a table when inputs are long, most states are needed or memory is tight. A good habit is to do both in that order: write the recursion, test it on small inputs, add the memo, and then, if the input can be long, turn it into a table and check that the two agree on every small case.
GATE CS
The GATE 2027 syllabus for Computer Science lists dynamic programming in its Algorithms section, as an algorithm design technique beside greedy algorithms and divide and conquer. Tracing a small table by hand, row by row as the examples above print it, is good practice for it.
Checkpoint
Check: Choosing top-down or bottom-up
Practise: count the ways to pay
The exercise turns a correct but slow recursion into a table. Its sample tests run in your browser and include amounts far deeper than any recursion can go:
Exercise · Easy · Python
Count the ordered ways to pay with 1 and 2 coins
A ticket machine takes coins worth ₹1 and ₹2 and accepts them one at a time, so paying ₹3 as 1 + 2 and as 2 + 1 are two different ways. pay_ways(n) returns the number of ordered ways to pay exactly ₹n, modulo 1,000,000,007: pay_ways(3) is 3 (1 + 1 + 1, 1 + 2 and 2 + 1) and pay_ways(0) is 1, the empty payment.
The starter code is a correct recursion: the last coin is ₹1 or ₹2, so the ways to pay n are the ways to pay n − 1 plus the ways to pay n − 2. It asks for the same amounts again and again, and it goes n calls deep.
Rewrite pay_ways bottom-up: a table, or two variables, filled from 0 upwards, with no recursion. Reduce modulo 1,000,000,007 as you go. The sample tests check small amounts, ₹0, ₹1,000, whose exact count has 209 digits, and ₹2,00,000 (200,000). The last two are deeper than any recursion can go under Python's default recursion limit, with a cache or without one, so the starter fails them at once.
Starter code · pay.py
MOD = 1_000_000_007
def pay_ways(n):
"""The number of ordered ways to pay exactly n with coins worth 1 and 2, modulo MOD."""
# Correct, but slow and deep: it asks for the same amounts many times, and it is n calls deep.
# Rewrite it as a table (or two variables) filled from 0 upwards.
if n < 0:
return 0
if n == 0:
return 1
return (pay_ways(n - 1) + pay_ways(n - 2)) % MOD The sample tests · test_pay.py
from pay import pay_ways
def test_small_amounts():
"""1, 2, 3, 5, 8: each amount adds the counts of the two amounts below it"""
assert [pay_ways(n) for n in range(1, 6)] == [1, 2, 3, 5, 8]
assert pay_ways(12) == 233
def test_nothing_to_pay():
"""paying 0 has exactly one way: no coins at all"""
assert pay_ways(0) == 1
def test_counts_are_reduced():
"""the exact count for 1,000 has 209 digits, so the answer is its remainder; no recursion reaches it"""
assert pay_ways(1_000) == 107_579_939
def test_two_hundred_thousand():
"""200,000, far past the depth any recursion can reach under the default limit"""
assert pay_ways(200_000) == 835_799_900 A hint
Keep ways[0] = 1 and ways[1] = 1, then fill ways[i] = (ways[i - 1] + ways[i - 2]) % MOD for i from 2 to n in a for loop. Only the last two entries are ever read, so two variables are enough.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
These questions draw on every section. Each one comes from the idea where your answers are weakest so far, and a right answer moves you up a level:
Practice that adapts to you
Practise this lesson
15 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What do overlapping subproblems and optimal substructure mean, and how can you check a recursion for the first one?
Overlapping subproblems means that a recursion asks for the same smaller problem many times: the plain recursion for
fib(25) makes 242,785 calls over only 26 different arguments. Optimal substructure means that the best answer to
a problem is built from the best answers to its smaller problems, as the heaviest path from a cell is the cell plus
the heaviest path from one of the cells below it. The first property makes a memo or a table worth having; the second
makes the transition correct for maximising or minimising. To check for overlap, count: record each call’s arguments
on a small input and compare the number of calls with the number of different arguments. Merge sort makes as many
calls as there are different ranges, so storing its answers saves nothing.
Three likely follow-ups come next. Choose the answer you would give to each, and read why the other answers fall short:
Checkpoint
Interview round: three follow-up questions
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A memoised recursion over pairs (i, j), with i and j up to 2,000, is fast on real inputs because they reach only about 50,000 of the 4 million possible states. A colleague wants to rewrite it as a bottom-up table to make it faster. What do you measure first, and which version do you keep?
-
Fresher to mid
A colleague finds the heaviest path down a number triangle by always stepping to the larger of the two numbers below. It passes all three sample tests. Show whether it is correct, give a solution that is, and say how you would test it before trusting it.
Key takeaways
- Dynamic programming stores the answer to each subproblem and reuses it. It is worth it when the subproblems
overlap:
fib(25)asks 26 questions in 242,785 calls, while merge sort never asks the same one twice. - A memo keeps the recursion and adds a dictionary keyed by the hashable arguments;
functools.cacheis that dictionary in one line, andcache_info()shows its hits and misses. - A table fills the states in an order where every state comes after the states it reads; with one row, the order inside the row matters too.
- A memo is still as deep as its longest chain of calls, and CPython stops at 1,000 frames by default; a table has no depth.
- Write every DP as a state, a transition, base cases and an answer, in words, then choose the order. Its states and transitions form a graph without cycles, and the DP counts or optimises paths through it.
Terms in this lesson
- memoisation
- Storing the answer to each subproblem the first time it is worked out, keyed by the subproblem's arguments, and returning the stored answer whenever the same subproblem is asked again.
- optimal substructure
- A problem has optimal substructure when a best answer is built from best answers to smaller problems, as the heaviest path from a cell is the cell plus the heaviest path from one of the cells below it.
- overlapping subproblems
- A recursion has overlapping subproblems when it asks for the same smaller problem many times: the plain recursion for fib(25) makes 242,785 calls over only 26 different arguments.
- state (dynamic programming)
- What one entry of a dynamic program means, put into words that can be checked by hand, such as C(i, j), the number of ways to choose j of the first i items.
- tabulation
- Filling a table of subproblem answers in a loop, bottom-up, in an order where every entry comes after the entries it reads, instead of letting a recursion find that order.
References
- The theory of dynamic programming (Bulletin of the American Mathematical Society 60(6)) (American Mathematical Society)
- functools.cache and functools.lru_cache (Python Software Foundation)
- math.comb() (Python Software Foundation)
- sys.getrecursionlimit() and sys.setrecursionlimit() (Python Software Foundation)
- NIST Dictionary of Algorithms and Data Structures: dynamic programming (National Institute of Standards and Technology (NIST))
- NIST Dictionary of Algorithms and Data Structures: memoization (National Institute of Standards and Technology (NIST))
- NIST Dictionary of Algorithms and Data Structures: principle of optimality (National Institute of Standards and Technology (NIST))
- NIST Dictionary of Algorithms and Data Structures: Fibonacci number (National Institute of Standards and Technology (NIST))
- GATE 2027 syllabus for Computer Science and Information Technology (CS) (Indian Institute of Technology Madras (GATE 2027))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress