Dynamic programming is usually introduced as a slogan: break the problem into overlapping subproblems and store their answers. That is true, but it leaves out the part most people get stuck on in practice, which is the table itself. Which cells exist? Which cells does a cell read? In what order can you fill them so nothing is read before it is written? And once the table is full, how do you get the actual answer out of it, not just its cost?

This article is about those mechanics. It treats a DP table as a small machine you can watch: a grid of states, a dependency stencil, a fill order and a reconstruction walk. We fill one table cell by cell as an animation would, look at three fill orders and the stencils that allow them, write code that recovers the optimal choices, and finish with a debugging method that catches most wrong recurrences in minutes. If you want the broader recipe for spotting DP problems and choosing between memoisation and tabulation, read the dynamic programming overview first; this page assumes you know what a recurrence is and want to get good at turning one into a correct table.

The four parts of every DP table

Every tabulated DP has four parts. Getting each one explicit before writing code removes most bugs.

  1. The state space. The set of subproblems, each named by a small tuple such as (row, column) or (i, j) or (item, capacity). The table is just an array indexed by that tuple. Its size is the number of states, and that number times the work per state is your running time.
  2. The stencil. For a single cell, the list of other cells its recurrence reads. In the grid example below, cell (r, c) reads (r-1, c) and (r, c-1). The stencil is a fixed shape that slides over the table, and it decides everything about which fill orders are legal.
  3. The boundary. The cells whose stencil would read outside the table. They need either base values or a guard. Boundary mistakes are the most common DP bug, ahead of wrong recurrences.
  4. The fill order. Any order that visits each cell after every cell in its stencil. Formally the states and stencil edges form a directed acyclic graph, and a fill order is a topological order of it.

A fifth part is optional but almost always wanted: a parent record that remembers which stencil neighbour won each cell, so that the optimal decisions can be replayed after the table is full.

Worked example: filling a grid frame by frame

Take a 3 by 4 grid of costs. You start at the top-left, may move only right or down, and want the cheapest path to the bottom-right, where a path's cost is the sum of the cells it visits. The costs are:

1 3 1 2
1 5 1 3
4 2 1 1

State: dp[r][c] is the cheapest cost of any path from (0, 0) that ends at (r, c). Recurrence: you reach (r, c) either from above or from the left, so dp[r][c] = cost[r][c] + min(dp[r-1][c], dp[r][c-1]). Boundary: dp[0][0] = cost[0][0]; the top row can only be reached from the left and the left column only from above.

Now fill it the way an animation would, one cell per frame, in row-major order. Frame 1 writes 1. Frames 2 to 4 run along the top row, adding each cost to its left neighbour: 4, 5, 7. Frame 5 starts row 1 at the left edge: 1 + 1 = 2. Frame 6 is the first cell with a real choice: cost 5 plus min(4 above, 2 left) gives 7. Frame 7: 1 + min(5, 7) = 6, taking the value from above. Frame 8: 3 + min(7, 6) = 9. Row 2 goes 2 + 4 = 6 at the edge, then 2 + min(7, 6) = 8, then 1 + min(6, 8) = 7, and finally 1 + min(9, 7) = 8.

Filling dp[r][c] = cost[r][c] + min(dp[r-1][c], dp[r][c-1]) in row-major order#1cost 11#2cost 34#3cost 15#4cost 27#5cost 12#6cost 57#7cost 16#8cost 39#9cost 46#10cost 28#11cost 17#12cost 18Stencileach cell reads up and left onlyFill orderany order that finishes both firstReconstructionwalk back to the smaller parentNumbers in the corner give the fill order. Green cells are the optimal path: 1+3+1+1+1+1 = 8.
The finished table with its fill order. Each cell is final the moment it is written, because both cells it reads are already final.

Two observations generalise. First, every value is final when written; DP never revisits a cell, which is what separates it from iterative relaxation methods like Bellman-Ford. Second, the bottom-right value 8 is only a cost. To show the user which path costs 8 you need reconstruction, which is a second walk over the same table.

Reconstruction: getting the decisions back

Reconstruction starts at the answer cell and repeatedly steps to the stencil neighbour that produced the winning value. From (2, 3) with value 8 the candidates were 9 above and 7 to the left, so the path came from the left. At (2, 2) the candidates were 6 above and 8 left, so it came from above, and so on back to (0, 0). Reversing the walk gives right, right, down, down, right. You can recompute the winner from the table, or store it while filling. Storing costs memory but removes any chance that the forward and backward passes disagree on ties.

def min_path(cost):
    R, C = len(cost), len(cost[0])
    INF = float("inf")
    dp = [[INF] * C for _ in range(R)]
    parent = [[None] * C for _ in range(R)]
    for r in range(R):                      # row-major: up and left are done
        for c in range(C):
            if r == 0 and c == 0:
                dp[r][c] = cost[r][c]
                continue
            up = dp[r - 1][c] if r > 0 else INF
            left = dp[r][c - 1] if c > 0 else INF
            if up <= left:                  # tie rule is explicit and shared
                dp[r][c], parent[r][c] = cost[r][c] + up, "D"
            else:
                dp[r][c], parent[r][c] = cost[r][c] + left, "R"
    moves, r, c = [], R - 1, C - 1
    while parent[r][c] is not None:
        step = parent[r][c]
        moves.append(step)
        if step == "D":
            r -= 1
        else:
            c -= 1
    return dp[R - 1][C - 1], moves[::-1]

print(min_path([[1, 3, 1, 2], [1, 5, 1, 3], [4, 2, 1, 1]]))
# (8, ['R', 'R', 'D', 'D', 'R'])

The parent table records the move that arrived at each cell: D means the cell was entered from above, R from the left. Notice the use of infinity for out-of-range neighbours instead of special-casing the first row and column. Padding the boundary with a neutral value (infinity for a minimum, zero for a count, negative infinity for a maximum) shrinks the boundary logic to one place and is the single most effective habit for avoiding off-by-one errors.

Fill orders follow from the stencil

Row-major order is not special. It is legal here because the stencil points only up and left. Change the stencil and the legal orders change with it.

Three legal fill orders for three different stencilsRow-majorreads (r-1,c) and (r,c-1)Anti-diagonalcells with equal r+c are independentBy interval lengthdp[i][j] reads dp[i+1][j-1]grid paths, LCS, edit distancesame problems, parallel wavefrontpalindromes, matrix chain, burst balloonsThe rule is the same in every case: a cell is computed only after every cell it reads.
Fill order follows from the stencil, never the other way round.
  • Column-major also works for the grid: walking down each column still finishes up and left first. Choosing between row-major and column-major is then a cache question; in a row-major array in C, Java or NumPy, walking along rows touches memory sequentially.
  • Anti-diagonal (wavefront). All cells with the same r + c depend only on the previous diagonal, so a whole diagonal can be computed in parallel. This is how GPU and SIMD implementations of sequence alignment get their speed, and it is the order to reach for when one table is large enough to justify parallel hardware.
  • By interval length. Problems over substrings, such as longest palindromic subsequence, define dp[i][j] over the range i..j and read dp[i+1][j-1], dp[i+1][j] and dp[i][j-1]. Those are shorter intervals, so the legal order is all length-1 intervals, then length 2, and so on. Row-major from the top is wrong here, because dp[0][4] would read dp[1][3] before row 1 exists. Iterating i from the bottom row upward and j left to right is an equivalent legal order.
  • By item, then capacity. In 0/1 knapsack row k reads only row k-1, which is what makes the one-row space optimisation possible, provided capacity is scanned from high to low so each item is used at most once. Scanning low to high silently turns it into the unbounded version, which is exactly the distinction drawn in the two coin change variants.

Memory: rolling arrays and what they cost

The stencil also tells you how much of the table you must keep. If every cell reads only the current and previous row, two rows suffice, and for the grid one row is enough because the left neighbour is in the row being written and the upper neighbour is the old value at the same index. That cuts memory from R times C to C.

The price is reconstruction. Once rows are overwritten, the parent walk has nothing to read. Three ways out, in rising order of effort:

  • Keep a compact parent table of one byte or even two bits per cell while using a rolling array for values. For a 10,000 by 10,000 alignment that is 100 MB of bytes instead of 800 MB of 64-bit values.
  • Checkpoint every k-th row during the forward pass and recompute the band you need during the backward walk. Memory drops by a factor of k and time roughly doubles.
  • Use Hirschberg's divide-and-conquer technique, which finds the middle cell of the optimal path with two linear-space passes and recurses on the halves. It reconstructs the full path in linear space for about twice the time, and is the standard answer for long-sequence LCS and alignment.

Debugging a DP with brute force

A wrong DP usually fails quietly: it returns a plausible number. The fastest way to catch it is to write a brute force that is obviously correct, run both on thousands of small random inputs, and print the first disagreement together with both tables.

import itertools, random

def brute(cost):
    R, C = len(cost), len(cost[0])
    best = float("inf")
    for downs in itertools.combinations(range(R + C - 2), R - 1):
        r = c = 0
        total = cost[0][0]
        for k in range(R + C - 2):
            if k in downs:
                r += 1
            else:
                c += 1
            total += cost[r][c]
        best = min(best, total)
    return best

for trial in range(5000):
    R, C = random.randint(1, 5), random.randint(1, 5)
    g = [[random.randint(0, 9) for _ in range(C)] for _ in range(R)]
    assert min_path(g)[0] == brute(g), g

Small sizes matter: 1 by 1, 1 by n and n by 1 grids exercise the boundary code that hand-written tests skip. Also check that the reconstructed moves, replayed on the grid, sum to the reported cost. That one assertion catches parent-table bugs that the value check cannot see. When the check fails, printing the table cell by cell in fill order (the same view as the diagram above) usually shows the bad cell in seconds.

Failure modes

The failure modes below cover most broken DP tables seen in code review.

SymptomUsual causeFix
Answer too small for a minimumBoundary cells left at 0 instead of infinityPad with the neutral value for the operation
Works on examples, fails on 1-row inputsSeparate first-row code that is never exercisedPad, or test 1 by n and n by 1 explicitly
Interval DP returns garbageRow-major fill reads unwritten longer intervalsFill by length, or i descending
Knapsack uses an item twiceCapacity scanned upward on a rolling arrayScan capacity downward for 0/1
Path does not sum to the costFill and walk use different tie rulesStore parents during the fill
Time or memory blows upState includes something unbounded, such as a value sumRe-check state size times work per state

The last row deserves a word. A DP over (item, remaining capacity) runs in time proportional to the capacity value, not its number of digits. That is pseudo-polynomial: fine for a capacity of 10,000, hopeless for 10 to the power 12. If the table would not fit in memory, the problem needs a different state, a meet-in-the-middle split or an approximation, not a cleverer fill order.

Trade-offs: tabulation versus memoisation

Tabulation with an explicit fill order is not always the right tool. Memoised recursion visits only reachable states, which wins when the reachable set is a small fraction of the full table, as in many game and parsing problems. It costs recursion depth and hash lookups, and Python's default recursion limit of 1,000 frames is reached quickly. Tabulation visits every state, has predictable memory access, enables rolling arrays and wavefront parallelism, and is the version you want for performance-critical code. A sensible workflow is to prototype top-down, confirm against brute force, then convert to a table once the state space and stencil are clear, because the stencil is easy to read off a working memoised function: it is the set of recursive calls.

What to do next

  • Take one DP you already know and write down its state tuple, stencil, boundary and a legal fill order before touching code. If you cannot name the stencil, you do not yet understand the recurrence.
  • Re-implement the grid example with a parent table, then replace the value table with a single rolling row while keeping a one-byte parent table, and confirm the reconstructed path is unchanged.
  • Solve longest palindromic subsequence twice, once with a deliberately wrong row-major fill, and watch the brute-force harness catch it.
  • Add a randomised brute-force cross-check to every DP you ship, with inputs small enough that the brute force runs in milliseconds, and include 1 by n and empty-input cases.
  • For a large two-sequence table, try the anti-diagonal order and measure whether vectorising a diagonal pays off on your hardware.
Key takeaway: A DP table is a state space, a stencil, a boundary and a fill order. Pick any order that finishes a cell's stencil before the cell, pad the boundary with the operation's neutral value, store parents if you need the decisions, and check every implementation against a brute force on small random inputs.