Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · stage by stage How often fib(5) asks each smaller question Step through the calls level by level. The sub-label of each question counts how often the plain recursion asks it.
How often fib(5) asks each smaller question, step 6 of 6. Showing fib(5) (asked 1 time), fib(4) (worked out once), fib(3) (worked out once), fib(2) (worked out once), fib(1), a base case (asked 5 times), fib(0), a base case (asked 3 times). In focus: fib(4), fib(3), fib(2).n − 2n − 2n − 2n − 2fib(5)asked 1 timefib(4)worked out oncefib(3)worked out oncefib(2)worked out oncefib(1), a base caseasked 5 timesfib(0), a base caseasked 3 times

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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:

Calls made against different subproblems, in three recursions Python · repeats/repeats.py
"""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

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, C(n,k)=C(n−1,k−1)+C(n−1,k)C(n, k) = C(n-1, k-1) + C(n-1, k), 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

  1. Question 1 of 2 The plain recursion makes 15 calls for fib(5) and 9 for fib(4). How many does it make for fib(6)?

    Type a number.

    A hint

    fib(6) is one call of its own, plus every call that fib(5) and fib(4) make.

    Show the answer: Question 1 of 2

    Answer: 25 calls

    calls(n) = 1 + calls(n − 1) + calls(n − 2), so calls(6) = 1 + 15 + 9 = 25, for only 7 different questions, fib(0) to fib(6). The calls grow by about 1.618 a step while the questions grow by one.

  2. Question 2 of 2 Which of these recursions gains nothing from a memo?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Merge sort, which splits a range into two halves that never meet

    A memo helps only when the same subproblem is asked for more than once. Merge sort of 1,024 items made 2,047 calls on 2,047 different ranges, so every stored answer would be read zero times.

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) with no memo, with a dictionary and with functools.cache Python · memo/pascal_memo.py
"""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

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

  1. Question 1 of 2 The dictionary memo counts every call, the ones answered from the memo included. How many calls does it make for C(4, 2)?

    Type a number.

    A hint

    Write the calls down in order. C(2, 1) is worked out once and asked a second time by C(3, 2).

    Show the answer: Question 1 of 2

    Answer: 9 calls

    C(4, 2) calls C(3, 1), which calls C(2, 0), C(2, 1) (with C(1, 0) and C(1, 1)); then C(3, 2) calls C(2, 1), answered from the memo, and C(2, 2). That is 9 calls against the plain recursion's 11, and 4 answers stored.

  2. Question 2 of 2 Why does a function under functools.cache raise TypeError when one of its arguments is a list?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The cache keys a dictionary by the arguments, and a list is not hashable

    The memo looks each call up by its arguments, so they must be hashable, as the documentation of lru_cache says. Describe the subproblem with numbers, strings or tuples instead of the mutable objects being worked on.

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:

Interactive · line by line Filling one row of Pascal's triangle from right to left Each pass of the outer loop turns the row into the next row of Pascal's triangle. Watch which old value each update reads.

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

After line 6: n is 4; k is 2; row is [1, 4, 6]; i is 4; j is 1.n4k2row104162i4j1
  • 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

6

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.

Line run 6
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. n, k = 4, 2 sets n to 4 and sets k to 2.
  3. Line 2. row = [1, 0, 0] makes row a list of 3 items.
  4. Line 3. for i in range(1, n + 1): gives its first number: i is 1.
  5. Line 4. for j in range(min(i, k), 0, -1): gives its first number: j is 1.
  6. Line 5. row[j] += row[j - 1] changes row: it is now [1, 1, 0].
  7. 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.
  8. Line 3. for i in range(1, n + 1): gives the next number: i is 2.
  9. Line 4. for j in range(min(i, k), 0, -1): gives its first number: j is 2.
  10. Line 5. row[j] += row[j - 1] changes row: 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.
  11. Line 4. for j in range(min(i, k), 0, -1): gives the next number: j is 1.
  12. Line 5. row[j] += row[j - 1] changes row: 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.
  13. 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.
  14. Line 3. for i in range(1, n + 1): gives the next number: i is 3.
  15. Line 4. for j in range(min(i, k), 0, -1): gives its first number: j is 2.
  16. Line 5. row[j] += row[j - 1] changes row: it is now [1, 2, 3].
  17. Line 4. for j in range(min(i, k), 0, -1): gives the next number: j is 1.
  18. Line 5. row[j] += row[j - 1] changes row: it is now [1, 3, 3].
  19. 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.
  20. Line 3. for i in range(1, n + 1): gives the next number: i is 4.
  21. Line 4. for j in range(min(i, k), 0, -1): gives its first number: j is 2.
  22. Line 5. row[j] += row[j - 1] changes row: it is now [1, 3, 6].
  23. Line 4. for j in range(min(i, k), 0, -1): gives the next number: j is 1.
  24. Line 5. row[j] += row[j - 1] changes row: it is now [1, 4, 6].
  25. 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.
  26. 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.
  27. Line 6. print(row[k]) prints 6. 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:

Pascal's triangle row by row, then in a single row Python · table/pascal_table.py
"""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

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:

One program · 14 languages C(n, k) three ways, with the work each one does O(nk) states for the memo and the table

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

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

  1. Question 1 of 2 In the one-row version of Pascal's triangle, why must j run from right to left?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: So that row[j − 1] still holds the value from the previous row when row[j] is updated

    Going right to left, row[j − 1] has not been updated yet in this pass when row[j] reads it. Going left to right it already holds the new row's value, so every later entry adds a number that is too large.

  2. Question 2 of 2 The one-row loop updates row[j] for j from min(i, k) down to 1, for i from 1 to n. How many updates does it make for C(5, 2)?

    Type a number.

    A hint

    Add min(i, 2) for i = 1, 2, 3, 4, 5.

    Show the answer: Question 2 of 2

    Answer: 9 updates

    Row 1 updates one entry and rows 2 to 5 update two each: 1 + 2 + 2 + 2 + 2 = 9. For C(20, 10) the same sum is 1 + 2 + … + 10 + 10 × 10 = 155, the count every language printed.

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:

The memo and the table for C(n, n/2) as n grows Python · depth/depth.py
"""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

  1. One question A memoised recursion works for n = 900 and raises RecursionError for n = 1,500. Which fix works for every n?

    Choose one answer.

    Show the answer: One question

    Answer: Fill a table bottom-up in a loop, so that no call waits on another

    The memo removes repeated work but not depth: C(n, k) top-down is still about n calls deep, and CPython stops at its recursion limit of 1,000 frames by default. A loop that fills the table from the base cases has no depth.

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

  1. Question 1 of 2 You count the ways to climb a staircase of n steps, taking one or two steps at a time. Which is the best state for the DP?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ways(i): the number of ways to reach step i

    A state says what one entry means in words that can be checked: ways(i) counts the ways to reach step i. Then the transition is clear, the last move came from step i − 1 or i − 2, and the base cases are ways(0) and ways(1).

  2. Question 2 of 2 In the heaviest-path DP, what are the base cases?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The cells of the bottom row, each worth its own number

    The base cases are the entries known without the transition. On the bottom row a path from a cell is the cell itself, so best(bottom, c) is the number in it, and every other row is filled from the row below.

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:

Interactive · stage by stage The heaviest path down four rows, filled from the bottom up Step from the bottom row to the top. Each cell keeps the heaviest path below it, the larger of its two children's.
The heaviest path down four rows, filled from the bottom up, step 6 of 6. Showing row 0: 5 (best: 39), row 1: 9 6 (best: 22 34), row 2: 4 6 8 (best: 11 13 28), row 3: 0 7 1 20 (best: 0 7 1 20).row 0: 5best: 39row 1: 9 6best: 22 34row 2: 4 6 8best: 11 13 28row 3: 0 7 1 20best: 0 7 1 20table: 5 + 6 + 8 + 20 = 39

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  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:

One program · 14 languages The heaviest path down four rows, three ways O(rows²) cells for the table

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

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:

Every path, a greedy walk and the table, on two triangles Python · triangle/triangle.py
"""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

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

  1. Question 1 of 2 For the triangle 3 / 4 2 / 1 1 9 (one row at a time, top first), what is best(0, 0), the heaviest path?

    Type a number.

    A hint

    Fill from the bottom up. Each cell of row 1 adds the larger of the two cells below it.

    Show the answer: Question 1 of 2

    Answer: 14

    The bottom row is 1 1 9. Row 1 becomes 4 + max(1, 1) = 5 and 2 + max(1, 9) = 11, and the top 3 + max(5, 11) = 14, the path 3, 2, 9. The greedy walk steps to the larger 4 and ends with 3 + 4 + 1 = 8.

  2. Question 2 of 2 Why is best(r, c) = t(r, c) + the larger of best(r + 1, c) and best(r + 1, c + 1) a correct transition?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The rest of a heaviest path must itself be the heaviest path from where it continues

    A heaviest path from (r, c) takes one step down and then follows some path from the cell it reached. If that rest were not the heaviest from there, a heavier rest would give a heavier whole path, so it must be best(r + 1, ·).

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 states of C(4, 2) by Pascal's rule: 8 states joined by arrows, with C(2, 1) reached from both C(3, 1) and C(3, 2).C(4, 2) = 6the answerC(3, 1) = 3C(3, 2) = 3C(2, 0) = 1base caseC(2, 1) = 2reached twiceC(2, 2) = 1base caseC(1, 0) = 1base caseC(1, 1) = 1base case

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.

  1. C(4, 2) = 6, the answer, asks for C(3, 1) and C(3, 2).
  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).
  3. 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).
  4. 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

  1. Question 1 of 2 In the graph of states of Pascal's rule, how many paths lead from C(5, 2) down to a base case?

    Type a number.

    A hint

    A counting DP counts paths. What is C(5, 2)?

    Show the answer: Question 1 of 2

    Answer: 10 paths

    Every path from C(5, 2) to a base case is one base case reached by the plain recursion, and each of those adds 1 to the total, so the number of paths is C(5, 2) = 10, as C(4, 2) = 6 had 6 paths.

  2. Question 2 of 2 A DP has n × n states, and each transition reads n smaller states. What does a memo or a table cost?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: O(n³), the states times the work of one transition

    A memo or a table visits every node of the graph of states once and follows each arrow once, so the cost is the number of states times the work of one transition: n² × n = O(n³).

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:

Interactive · answer and compare Memo or table for your problem? Answer three questions about your problem. The table marks what each answer rules out, and why.
Does the answer need only a small part of all the states?
Can a chain of calls be longer than about 1,000, Python's default recursion limit?
Do you need to keep memory down to the last row or two?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

A memoised recursion and a bottom-up table against the four things that decide between them
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

  1. One question A DP over a text of 50,000 characters has a chain of calls as long as the text. Which version do you write in Python?

    Choose one answer.

    Show the answer: One question

    Answer: The bottom-up table

    Depth decides here. A memoised recursion is as deep as its longest chain of calls, and 50,000 is far past the default limit of 1,000, so fill a table in a loop.

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.

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.

Permutation & Combination Calculator Check any C(n, k) from Pascal's triangle, such as C(20, 10) = 184,756, against a direct calculation.

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.

  1. Question 1 of 15 What does it mean that a recursion has overlapping subproblems?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: It asks for the same smaller problem many times

    Overlapping subproblems means repeats: the same arguments come back again and again. Count the calls against the different arguments to see it, as the program did for fib, C(n, k) and merge sort.

  2. Question 2 of 15 A recursion counts the paths across a grid that move only right or down, by adding the paths from the cell to the right and from the cell below. Does a memo help?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Yes, because each cell can be reached along many routes and its count would be worked out again for each

    A cell is asked for by the cell to its left and the cell above it, so the plain recursion works it out once per route. With a memo every cell is worked out once, and the count needs one addition per cell.

  3. Question 3 of 15 What does the memo of a memoised recursion store?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: The answer to each subproblem it has worked out, keyed by the subproblem's arguments

    A memo is a dictionary from a subproblem's arguments to its answer. It turns every repeated call into a lookup, as 369,511 calls for C(20, 10) became 201.

  4. Question 4 of 15 The memoised C(20, 10) starts with an empty cache. How many misses does cache_info() report, that is, how many different states does it work out?

    Type a number.

    Show the answer: Question 4 of 15

    Answer: 120 states

    Each miss is a state seen for the first time: 100 states that add two smaller ones and 20 base cases. The other 81 calls were hits. The plain recursion made 369,511 calls to reach the same 120 states.

  5. Question 5 of 15 What rule decides the order in which a table is filled?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Every entry comes after the entries it reads

    A table follows the arrows of the graph of states backwards: every entry is filled after the entries it reads. For Pascal's triangle that means rows in increasing order and, in one row, j from right to left.

  6. Question 6 of 15 In another DP, each new entry adds the entry to its left in the new row and the entry above it in the old row. Keeping one row, updated in place, in which direction must j run?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Left to right, so the left neighbour is already new and the entry itself still old

    Ask which values each update must see. Here row[j] must still be old (the entry above) and row[j − 1] already new (the entry to the left), so j runs left to right, the opposite of Pascal's rule.

  7. Question 7 of 15 What stops a memoised recursion in CPython on a very long input?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: The recursion limit, 1,000 frames by default

    A memo removes repeated work but not depth. sys.getrecursionlimit() returns 1,000 by default, and a top-down C(n, k) waits on a chain about n calls long.

  8. Question 8 of 15 About how long is the longest chain of waiting calls when C(n, k) is computed top-down?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: About n calls

    Every call is for a smaller n, so the deepest chain has about n calls waiting at once. That is why C(900, 450) worked and C(1500, 750) passed CPython's 1,000 frames.

  9. Question 9 of 15 Which of the four parts of a DP says which entry holds the result you return?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The answer

    State, transition, base cases and answer: the last names the entry to return, C(n, k) or best(0, 0), and the order, the fifth decision, says when it is ready.

  10. Question 10 of 15 For the exercise, the ordered ways to pay exactly n with coins of 1 and 2, which base cases make ways(n) = ways(n − 1) + ways(n − 2) work for every n of 2 or more?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: ways(0) = 1 and ways(1) = 1

    ways(0) = 1 counts the empty payment and ways(1) = 1 the single coin. From there the transition gives 2, 3, 5, 8, as the exercise's tests expect.

  11. Question 11 of 15 What did Richard Bellman call the principle of optimality?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: The parts of a best solution are best solutions of their own subproblems

    The principle of optimality says the components of a globally optimal solution are optimal themselves. It is the reason a table can combine best answers of smaller problems into the best answer of a larger one.

  12. Question 12 of 15 A colleague fixes the greedy walk by looking two rows ahead instead of one. Does that make it correct?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: No, because a heavy number can hide any number of rows below a small one

    The table works because each cell knows the heaviest path below it, over all remaining rows. A look-ahead of any fixed depth sees only part of that, so a deeper triangle can always hide a heavier path from it.

  13. Question 13 of 15 Why does the graph of states of a DP have no cycles?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Every arrow points to a smaller subproblem

    Each transition reads smaller subproblems, so every path through the graph keeps shrinking the problem and ends at a base case. A graph like that is a directed acyclic graph, and a table can follow its arrows.

  14. Question 14 of 15 Which version computes only the states the first call actually reaches?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: The memoised recursion

    A memo follows the recursion, so it touches exactly the reachable states. A table follows its loops, which is why it can do more work when only a few states are needed.

  15. Question 15 of 15 A problem has 10⁹ possible states, a typical answer reaches about 10⁴ of them, and no chain of calls is longer than 50. Which version do you write?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: The memoised recursion

    With few states reached and shallow chains, the memo's two weak points do not apply, while a table would fill all 10⁹ states. Depth and memory would have to change for the table to win.

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

  1. Question 1 of 3 Your memoised solution passes every sample, then fails on the largest input with RecursionError. The interviewer asks what you would change. Which answer is the strongest?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: Rewrite it bottom-up in the same state order, then check the table against the memo on small inputs

    The error comes from depth, not from repeated work, so the fix is a table filled in a loop. Keeping the memo as a reference and comparing the two on small inputs is how you show the rewrite is right.

  2. Question 2 of 3 The interviewer asks for the running time of your memoised DP. How do you work it out?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Count the states and multiply by the work of one transition

    A memo or a table visits each state once and follows each of its arrows once. For C(n, k) that is about n·k states with two reads each, so O(nk).

  3. Question 3 of 3 A candidate memoises a function f(items, i) by caching on both arguments, and items is a list that never changes. What do you suggest?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Keep items out of the key and cache on i alone, with items fixed outside the function

    A memo's key should be the state and nothing else. Here only i changes from call to call, so caching on i, with the fixed list outside, is both correct and cheap.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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.cache is that dictionary in one line, and cache_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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.