3.7.8 · D3Algorithm Paradigms

Worked examples — Tabulation (bottom-up DP) — iterative

4,240 words19 min readBack to topic

This is the "roll up your sleeves" companion to the parent Tabulation note. There we learned the 5-step recipe (State, Recurrence, Base, Order, Answer). Here we stress-test it against every kind of case a tabulation problem can hand you — including the ugly ones the parent note only hinted at.

Before we begin, one promise: every symbol is earned. dp[i] just means "the answer we stored in box number of a list of boxes." A table is that list of boxes. Nothing more mysterious than a row of labelled shoeboxes on a shelf.


The scenario matrix

Think of a tabulation problem as a machine. What kinds of inputs can jam it? Here is the full menu of "case classes" — every worked example below is tagged with the cell(s) it covers.

Cell Case class What makes it tricky Covered by
C1 1D table, additive recurrence the easy baseline Ex 1
C2 Degenerate / tiny input (, empty set) base case is the whole answer Ex 2
C3 2D table, binary "take/skip" choice two dependencies from previous row Ex 3
C4 Impossible / "no solution" state must represent or safely Ex 4 (Coin Change)
C5 Order matters — wrong loop direction breaks it high→low vs low→high Ex 5
C6 Two strings, 2D grid, match-vs-mismatch diagonal dependency Ex 6 (LCS)
C7 Real-world word problem translating English → state Ex 7
C8 Exam twist: a "trap" recurrence the obvious recurrence is wrong Ex 8

We will hit all eight cells in eight examples. Let's go.


Ex 1 — C1: the additive baseline (Tribonacci)

Forecast: guess whether is closer to 10 or to 25 before reading on.

  1. State. dp[i] = the -th Tribonacci number. Why this step? We always name the box first so the recurrence has something concrete to fill.
  2. Recurrence. dp[i] = dp[i-1] + dp[i-2] + dp[i-3]. Why this step? The definition literally sums the three predecessors — that IS the optimal substructure.
  3. Base cases. dp[0]=0, dp[1]=1, dp[2]=1. Why this step? At the recurrence would read dp[-1], which doesn't exist. We must seed enough boxes to cover the deepest reach-back (here, 3 boxes).
  4. Order. Increasing . Why this step? When we fill dp[i], all of dp[i-1], dp[i-2], dp[i-3] are already written.
  5. Answer. dp[6].

Filling the shelf left to right — I write out each new box in words so nothing is hidden inside a loop:

  • dp[3] .
  • dp[4] .
  • dp[5] .
  • dp[6] .
i 0 1 2 3 4 5 6
dp[i] 0 1 1 2 4 7 13

Verify: the sum of any window of 3 consecutive earlier boxes gives the next — e.g. . ✅ Answer 13 (closer to 10 — did your forecast hold?).


Ex 2 — C2: the degenerate input

Forecast: zero stairs — is the answer 0 ways, or 1 way?

  1. State. dp[i] = number of distinct ways to reach step . Why this step? We must decide what "reaching step 0" means before answering.
  2. Base case reasoning. There is exactly one way to "climb zero stairs": do nothing. That empty sequence of moves is a valid way. Why this step? The most common bug here is writing dp[0]=0. But 0 would say "impossible," which is false — standing still is a way.
  3. No recurrence runs. For the loop that would fill dp[2], dp[3], … never starts (there is nothing between index 2 and index 0). So the answer is read straight from the base cell — this is exactly the case where the base case is the whole answer.
  4. Answer. dp[0] = 1.

Verify: ; and downstream (the two real ways: 1,1 and 2). ✅ Answer 1 way.


Ex 3 — C3: 2D take/skip (0-1 Knapsack full trace)

Forecast: can we do better than value 5?

First, the notation — every symbol defined before use:

We build a grid: rows = "first items available", columns = "capacity from 0 to 4". dp[i][c] = best value using the first items with capacity .

The recurrence (from the parent note), now that are all defined:

Why two dependencies from the previous row? Because item is either out (copy the row above at the same ) or in (its value plus the best packing of the earlier items into the leftover capacity — also from the row above).

The figure below shows exactly these two arrows. Read it like this: the yellow cell (bottom, pink ?) is dp[i][c] we are computing. The blue arrow straight down from the row above is the "skip" choice — it copies dp[i-1][c]. The pink diagonal arrow comes from dp[i-1][c-w_i] further left (leftover capacity after paying ) and represents the "take" choice, to which we add . We keep whichever is bigger.

Figure — Tabulation (bottom-up DP) — iterative

Row 0 (no items) = all zeros — with zero items available you can never earn value, whatever the capacity. Now fill each cell in words:

  • Row 1, item . For every : skip = 0, take = . So dp[1][1..4] all become 1; dp[1][0]=0 (can't fit even weight 1).
  • Row 2, item . dp[2][0]=dp[2][1]=dp[2][2]= copy row 1 (weight 3 doesn't fit) . dp[2][3]: skip , take ; max . dp[2][4]: skip , take ; max .
  • Row 3, item . Cells copy row 2. dp[3][4]: skip , take ; max (a tie — item 3 alone equals items 1+2).
0 1 2 3 4
0 (none) 0 0 0 0 0
1 (1,1) 0 1 1 1 1
2 (3,4) 0 1 1 4 5
3 (4,5) 0 1 1 4 5
  1. Order. Rows increasing. Why this step? Every read hits row , which is finished.
  2. Answer. Bottom-right cell dp[3][4] = 5.

Verify: enumerate all subsets fitting : {} →0, {1}→1, {2}→4, {3}→5, {1,2}: w=4,v=5 ✓, {1,3}: w=5 ✗, {2,3}: w=7 ✗, {1,2,3}: w=8 ✗. Best feasible value = 5. ✅


Ex 4 — C4: the "impossible" state (Coin Change — minimum coins)

Forecast: greedy would grab a 4, then need 2 → 4+1+1 = three coins. Can we beat that?

Here the table can hold an impossible entry — some amounts might be unmakeable. We need a value that means "no solution" and survives the min.

  1. State. dp[a] = fewest coins summing to amount . Why this step? One box per amount from 0 up to the target.
  2. Sentinel initialisation. Initialise every dp[a] to INF (as defined above), except dp[0]=0. Why this step? INF means "not yet reachable." Because we take a min, an unreachable path never wins.
  3. Recurrence. For each amount and coin : Why this step? Using one coin leaves a smaller subproblem ; add 1 for the coin we just spent. If is still INF, then stays effectively INF and loses the min — impossibility propagates safely.
  4. Order. from 1 up to target. Why this step? has a smaller amount, already finalised.
  5. Answer. dp[target], or "impossible" (convert to ) if it's still INF.

Filling in words (coins ):

  • dp[1]: only coin 1 fits → .
  • dp[2]: only coin 1 fits → .
  • dp[3]: coin 1 → ; coin 3 → ; min .
  • dp[4]: coin 1 → ; coin 3 → ; coin 4 → ; min .
  • dp[5]: coin 1 → ; coin 3 → ; coin 4 → ; min .
  • dp[6]: coin 1 → ; coin 3 → ; coin 4 → ; min .
a 0 1 2 3 4 5 6
dp[a] 0 1 2 1 1 2 2

So dp[6] = 2 (coins ). Beats greedy's 3!

Impossible case: with coins only, both dp[1] and dp[2] are never reached (no coin ), so they stay INF — amount 1 or 2 is genuinely unmakeable, and our sentinel reports that cleanly without corrupting any later min.

Verify: uses 2 coins; no single coin equals 6 and no coin pair beats 2, so minimum = 2. And with , amount = impossible. ✅


Ex 5 — C5: order matters (1D space-optimised knapsack)

Forecast: if we loop capacity low→high instead, will the answer be too big or too small?

We keep only one row and update it in place. The recurrence becomes dp[c] = max(dp[c], v_i + dp[c-w_i]), using the same (weight/value of item ) defined in Ex 3. The subtlety: dp[c-w_i] must still mean the previous item's value, not this item's freshly-updated value (that would let us pick the same item twice — breaking 0/1). (See Space Optimization in DP.)

The figure makes the danger visible. The single row of cells is our dp[c] shelf, . The yellow arrow at the top shows the safe sweep direction (high→low). When we update the yellow upd cell dp[4], the pink curved arrow shows it reading the blue old cell to its left — and because we sweep from the right, that left cell has not been overwritten yet this round, so it still holds the previous item's value. Sweep the other way and that left cell would already be this-round's value → the item gets reused.

Figure — Tabulation (bottom-up DP) — iterative
  1. Correct — loop high→low. When we write dp[4], the cell dp[4-w] to its left is still last-round's value because we haven't touched it yet this round. Why this step? High→low guarantees a read from a not-yet-overwritten (i.e. previous-row) cell.
  2. Wrong — loop low→high. We'd update dp[1] first, then dp[2] might read the just-updated dp[1], effectively reusing an item.

Concrete failure with item starting from zeros, low→high (I write each update in order): dp[1]=max(0,1+dp[0])=1, then dp[2]=max(0,1+dp[1])=2, then dp[3]=max(0,1+dp[2])=3, then dp[4]=max(0,1+dp[3])=4 — using item 1 four times! That's unbounded knapsack, not 0/1.

High→low on the full item list gives the correct 5 (matching Ex 3's dp[3][4]).

Verify: correct 1D high→low answer = 5 (equals the 2D result). The buggy low→high on a single item with yields 4 (wrong, item reused). ✅


Ex 6 — C6: two strings, diagonal dependency (LCS)

Forecast: guess the LCS length — 3, 4, or 5?

A subsequence keeps order but may skip letters. First, name the two strings so the recurrence is unambiguous:

The recurrence has a match case and a mismatch case:

The figure shows the three neighbours every cell depends on. The pink ? cell is dp[i][j]. On a match (yellow diagonal arrow) it copies the up-left cell dp[i-1][j-1] and adds 1 — the shared last letter caps a common subsequence, the rest lives in the strictly-smaller prefixes. On a mismatch (two pink arrows) it takes the larger of the up cell dp[i-1][j] (drop 's last letter) and the left cell dp[i][j-1] (drop 's last letter).

Figure — Tabulation (bottom-up DP) — iterative

Now apply the full 5-step recipe:

  1. State. dp[i][j] = length of the LCS of the first letters of and first letters of (as defined above).
  2. Recurrence. The match/mismatch cases shown above. Why this step? If the last letters match they must be reusable as the tail of a common subsequence; if not, the answer ignores one of the two last letters, so we try both and keep the better.
  3. Table dimensions & base. Allocate a grid dp of size — one extra row and column for the empty prefix. Initialise row 0 and column 0 to all zeros. Why this step? An empty string shares no letters with anything, so its LCS length is 0; and the recurrence at or must read a valid row 0 / column 0.
  4. Order. Fill row by row (increasing ), and within each row left to right (increasing ). Why this step? Each cell reads up (dp[i-1][j]), left (dp[i][j-1]) and up-left (dp[i-1][j-1]); this order guarantees all three are already filled before we read them.
  5. Answer. The bottom-right cell dp[|X|][|Y|] = dp[7][5]. Why this step? That cell is the LCS of the whole against the whole .

Here is the full table (rows = letters of ABCBDAB top-to-bottom, columns = letters of BDCAB left-to-right). Row/col 0 are the empty-prefix zeros.

B D C A B
0 0 0 0 0 0
A 0 0 0 0 1 1
B 0 1 1 1 1 2
C 0 1 1 2 2 2
B 0 1 1 2 2 3
D 0 1 2 2 2 3
A 0 1 2 2 3 3
B 0 1 2 2 3 4

Read a few cells to see the rules fire:

  • Row A, col A: letters match (A=A) → up-left 0 + 1 = 1.
  • Row B, col B (last column): match (B=B) → up-left cell (row A, col A = 1) + 1 = 2.
  • Row C, col C: match → up-left (row B, col D = 1) + 1 = 2.
  • Bottom-right (row B, col B): match → up-left (row A, col A = 3) + 1 = 4.
  • A mismatch example — row B, col D: B≠D → max(up = 0, left = 1) = 1.

The bottom-right cell is 4 — one witnessing subsequence is "BCAB".

Verify: "BCAB" appears in order in both ABCBDAB and BDCAB, and no length-5 common subsequence exists. LCS length = 4. ✅


Ex 7 — C7: a real-world word problem (max non-adjacent loot)

Forecast: grabbing the two biggest, 9 and 7, would clash (adjacent). What's the real max?

The English constraint "no two adjacent" becomes a take/skip decision per house — pure tabulation. Let mean "the cash in house " (0-indexed: ).

  1. State. dp[i] = max loot considering houses . Why this step? Translate the informal goal into a box meaning.
  2. Recurrence. For house with cash : either skip it (keep dp[i-1]) or rob it (take plus best from two houses back, dp[i-2], since is now forbidden): Why ? Robbing house bans house ; the nearest safe prior state is .
  3. Base. dp[0]=a_0=2; dp[1]=max(a_0,a_1)=max(2,7)=7. Why this step? With one or two houses there's no "two back" to recurse to.
  4. Order. Increasing ; Answer. dp[4].

Filling in words:

  • dp[2] .
  • dp[3] .
  • dp[4] .
i 0 1 2 3 4
2 7 9 3 1
dp[i] 2 7 11 11 12

Verify: loot houses . Any set avoiding adjacency: , , — none beat 12. Max = 12. ✅


Ex 8 — C8: the exam trap (a wrong "obvious" recurrence)

Forecast: the combinations of 4 from {1,2} are: 1+1+1+1, 1+1+2, 2+2 → 3 ways. Will the student's formula give 3?

  1. The trap, worked out. The student's dp[a]=dp[a-1]+dp[a-2] is the Fibonacci/stairs shape — but that counts ordered sequences (it asks "was the last piece a 1 or a 2?", which distinguishes order). Let's actually run it with dp[0]=1, dp[1]=1:

    • dp[2] .
    • dp[3] .
    • dp[4] .

    So the student gets 5. Those 5 are the ordered writings 1111, 112, 121, 211, 22 — it counts 112, 121, 211 as three different things, overcounting the one combination "two 1's and a 2." Why this step? We must see the wrong number appear to trust the fix.

  2. The fix — coin outer loop. To count combinations, iterate coins on the outside, amounts inside:

    dp = [0]*(amount+1); dp[0] = 1
    for c in coins:            # commit to one coin type at a time
        for a in range(c, amount+1):
            dp[a] += dp[a-c]

    Why this step (the crucial justification)? Putting the coin loop outside means we finish all decisions about coin 1 before we ever touch coin 2. Once we move to coin 2, we can only append 2's on top of amounts already built from 1's — we never go back and interleave a 1 after a 2. That fixed processing order (all 1's, then all 2's) makes every multiset appear in exactly one canonical order, so reorderings like 112 vs 121 can never both be counted. Swap the loops (amount outside, coin inside) and you'd re-introduce every ordering → back to counting permutations.

  3. Trace (coins , amount 4). After the coin-1 pass, every amount has exactly one way (all 1's): dp = [1,1,1,1,1]. Now the coin-2 pass does dp[a] += dp[a-2] for :

    • dp[2] += dp[0]: .
    • dp[3] += dp[1]: .
    • dp[4] += dp[2]: .
a 0 1 2 3 4
after coin 1 1 1 1 1 1
after coin 2 1 1 2 2 3
  1. Answer. dp[4] = 3 — matching our hand count {1111, 112, 22}.

Verify: combinations of 4 from {1,2} = {1111,112,22} = 3; the naive stairs recurrence overcounts to 5. ✅


Recall Scenario checklist before you code any tabulation
  • Did I handle the degenerate input (empty / zero)? (Ex 2)
  • Does any state mean "impossible" — and is my sentinel min/max-safe? (Ex 4)
  • Is my loop direction right for in-place / 1D collapse? (Ex 5)
  • Am I counting order or combinations — is the loop nesting correct? (Ex 8)

Active recall

Tribonacci base cases — how many do you need and why?
Three (dp[0],dp[1],dp[2]) because the recurrence reaches back 3 steps; fewer would read a negative index.
Why is dp[0]=1 (not 0) in climbing-stairs / counting DP?
The empty sequence of moves is one valid way; using 0 would break downstream sums.
In Coin Change min-coins, why initialise unreachable cells to ∞ not −1?
So the min() never wrongly picks an impossible path; −1 looks smaller and corrupts the min.
1D 0/1 knapsack: which capacity loop direction and why?
High→low, so dp[c−w] still holds the previous item's value (no reuse).
LCS match vs mismatch recurrence?
Match: dp[i−1][j−1]+1; mismatch: max(dp[i−1][j], dp[i][j−1]).
LCS table dimensions and base?
Size (|X|+1)×(|Y|+1); row 0 and column 0 all zeros (empty prefix shares nothing).
House robber recurrence?
dp[i]=max(dp[i−1], a_i+dp[i−2]); robbing i forbids i−1 so we fall back to i−2.
Coin Change: ordered count vs combination count — what changes?
Combination count puts the coin loop OUTSIDE the amount loop; ordered count puts amount outside.

Connections

  • Tabulation (bottom-up DP) — iterative (index 3.7.8) — the parent recipe these examples exercise
  • 0-1 Knapsack, Coin Change, Longest Common Subsequence — the classic tables traced here
  • Space Optimization in DP — the 1D collapse of Ex 5
  • Recurrence Relations — where each recurrence comes from
  • Optimal Substructure, Overlapping Subproblems — why every example even works
  • Memoization (top-down DP) — the top-down twin