Dynamic Programming Tabulation: Build the Table Bottom-Up
Data Structures & AlgorithmsLesson 2 of 45 min read
What you'll learn
- Convert a top-down recursion into a bottom-up loop with a table
- Choose a fill order that guarantees every needed cell is already computed
- Seed base cases correctly, including an "unreachable" marker
- Reconstruct the actual choices, not just the optimal number
On this page
Memoization is lovely until the input is large enough that the recursion runs out of stack. A river crossing with 50,000 stepping stones, each charging a toll, will crash a recursive solution with a RecursionError long before it runs out of time. The same logic written as a loop has no such limit. That is tabulation: instead of asking for answers on demand, you compute every smaller answer first, in an order that makes each one available when needed.
A problem to build the table for
A river has stones in a row. Landing on stone i costs tolls[i]. You start on the near bank, can jump 1, 2 or 3 stones at a time, and want to reach the far bank paying as little as possible. The far bank counts as reachable from any of the last three stones.
Define the state first. Let best[i] be the cheapest total toll to be standing on stone i. To stand on stone i, you must have come from stone i-1, i-2 or i-3 (or from the near bank, which costs nothing), so best[i] = tolls[i] + min(best[i-1], best[i-2], best[i-3]). Each cell depends only on cells to its left, which dictates the fill order: left to right.
def cheapest_crossing(tolls):
"""tolls[i] = fee to land on stone i. Start on the bank before stone 0,
finish on the bank after the last stone. Each jump covers 1, 2 or 3 stones."""
n = len(tolls)
INF = float("inf")
# best[i] = cheapest total fee to be standing on stone i (i = 0..n-1)
best = [INF] * n
came_from = [None] * n
for i in range(n):
for jump in (1, 2, 3):
prev = i - jump
if prev < -1:
continue
base = 0 if prev == -1 else best[prev]
if base + tolls[i] < best[i]:
best[i] = base + tolls[i]
came_from[i] = prev
# the far bank can be reached from any of the last three stones
finish = min(range(max(0, n - 3), n), key=lambda i: best[i])
path = []
i = finish
while i != -1:
path.append(i)
i = came_from[i]
return best[finish], path[::-1], best
tolls = [4, 2, 9, 1, 7, 3, 8, 2]
cost, path, table = cheapest_crossing(tolls)
print("tolls:", tolls)
print("table:", table)
print("cost :", cost)
print("path :", path)Output
tolls: [4, 2, 9, 1, 7, 3, 8, 2] table: [4, 2, 9, 3, 9, 6, 11, 8] cost : 6 path : [1, 3, 5]
Read the table as a story. Stone 1 costs 2 because you can jump straight onto it from the near bank (a two-stone jump) and skip stone 0 entirely. Stone 3 costs 3: land on stone 1 for 2, then pay 1. Stone 5 costs 6 and is one jump from the far bank, so the cheapest crossing is stones 1, 3, 5 for a toll of 6. The came_from array is the breadcrumb trail that lets us rebuild that route.
Notice what the loop does not do: it never calls itself and never asks "have I seen this before?". Because the fill order is left to right and every cell only reads cells to its left, the answer to any sub-question is guaranteed to exist by the time it is needed. The cost is easy to count as well: eight stones, three jumps each, so 24 tiny steps. For 50,000 stones it is 150,000 steps and no deep stack, which is exactly why production code and long inputs tend to prefer this form.
Why the base case is the trickiest line
The table starts as INF, meaning "not reachable yet", and the near bank is represented by index -1 with a cost of 0. It is tempting to initialise the table to zeros because zero looks harmless. It is not: zero means "free to reach", which is a claim. Here is what that mistake does to the same input.
tolls = [4, 2, 9, 1, 7, 3, 8, 2]
best = [0] * len(tolls) # 0 looks harmless but lies about "unreached"
for i in range(len(tolls)):
best[i] = tolls[i] + min(best[max(i-1,0)], best[max(i-2,0)], best[max(i-3,0)])
print(best)Output
[4, 6, 13, 5, 12, 8, 13, 10]
Compare with the correct table: stone 1 is now 6 instead of 2, because the buggy code forced the jump to come from stone 0. The loop also clamps indices with max(i-1, 0), so cells reach back to stone 0 as a fake predecessor. Nothing crashes; every number is just quietly wrong, which is the worst kind of bug. Initialise to the identity of your operation: infinity for a minimum, negative infinity for a maximum, 0 for a sum of counts.
Top-down and bottom-up must agree
A good habit is to write the memoised version too and compare the two on the same inputs. The recursive one is almost a direct transcription of the recurrence.
from functools import lru_cache
def cheapest_top_down(tolls):
n = len(tolls)
@lru_cache(maxsize=None)
def best(i):
if i < 0:
return 0 if i == -1 else float("inf")
return tolls[i] + min(best(i - 1), best(i - 2), best(i - 3))
return min(best(i) for i in range(max(0, n - 3), n))
print(cheapest_top_down([4, 2, 9, 1, 7, 3, 8, 2]))Output
6
| Top-down (memoization) | Bottom-up (tabulation) | |
|---|---|---|
| Style | Recursion plus a cache | Loops filling a table |
| Computes | Only the states actually reached | Every state in the table |
| Stack depth | Grows with the input; can hit RecursionError | Constant |
| Fill order | Handled by the call stack | You must choose it correctly |
| Space saving tricks | Hard | Easy: keep only the rows you need |
Common mistakes and the symptoms they cause
- Wrong fill order. If a cell reads a neighbour that has not been computed, you read the initial value (INF or 0) and get a silently wrong answer. Check that every dependency of cell
ihas a smaller index thani. - Off-by-one on table size. Tables over amounts or lengths need
n + 1entries so that index 0 can mean "nothing". Forgetting it raisesIndexError: list index out of rangeat the last cell. - Adding to INF in integer code.
float("inf") + 1is fine in Python but overflows or wraps in languages with fixed-width integers. Use a large sentinel and guard before adding. - Returning the number but not the path. Interviewers often ask "which stones?". Store the decision (
came_from) as you fill, rather than reverse-engineering it afterwards.
How this shows up in real projects and interviews
Cheapest-route and minimum-cost problems over a line or grid are staples of coding rounds, and they are also real: cost-aware routing, pricing tiers, and resource scheduling all reduce to "best value of a state computed from earlier states". In an interview, say the state and recurrence aloud before writing a single loop, then state the fill order and base cases. If you get the recurrence right, the code is a transcription; if the recurrence is wrong, no amount of loop tuning will save it.
Try it yourself
A hiker on a mountain trail can climb 1, 2 or 3 ledges at a time, and each ledge has an effort score. Starting before ledge 0 and finishing after the last ledge, write a bottom-up function that returns the minimum total effort and the ledges used. Check it against a memoised version on 100 random lists using assert.
Show a hint
Reuse the shape of the stepping-stones table. Use random.randint to generate effort lists of length 1 to 12, and watch the one-ledge and two-ledge edge cases, where fewer than three predecessors exist.
Key takeaways
- Tabulation computes every smaller subproblem first, in an order where all dependencies already exist.
- Define
best[i]in words before writing the loop; the recurrence and the fill order both follow from that sentence. - Initialise to the identity of the operation (infinity for min), never to 0 by habit.
- Store the decision made at each cell so you can reconstruct the path, not only the optimal value.
Test yourself
A trick from this lesson's ride: Deep input? Loop it. A recursion pays one stack frame per level, a loop pays none. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is tabulation in dynamic programming?
Tabulation is the bottom-up way to implement DP. You allocate a table, fill the smallest subproblems first and compute larger ones from earlier cells, so no recursion is needed. It avoids stack depth limits and makes space optimisation easier.
Should I use memoization or tabulation in an interview?
Use whichever lets you state a correct recurrence fastest. Memoization is usually easier to derive because it mirrors the brute-force recursion. If asked about memory or stack depth, convert to tabulation and then optimise space, as covered in the final lesson.
Why do I get index out of range in my DP table?
Most often the table is one cell too short. Problems indexed by an amount or a length need n + 1 entries so that index 0 represents the empty case. The other common cause is a recurrence that reads i - k without checking that it is not negative.