Master tabulation (bottom-up DP) completely — how to build DP tables iteratively from base cases, converting recursive solutions to iterative ones, space optimization by rolling arrays, 1D and 2D tabulation examples, and when bottom-up beats top-down memoization.
What Is Tabulation?
Tabulation is the bottom-up approach to dynamic programming. Instead of starting from the original problem and recursing down (memoization), tabulation starts from the smallest subproblems and works up to the final answer — filling a table one entry at a time.
The name comes from filling a "table" (array) of precomputed results.
Step-by-Step: Converting Memoization to Tabulation
Pattern
Memoization approach
def dp(i, j, ...):
if (i,j) in cache: return cache[(i,j)]
# base cases
result = f(dp(i-1,...), dp(i,...-1), ...)
cache[(i,j)] = result
return result
Tabulation approach
dp = [[0]*cols for _ in range(rows)]
# Fill base cases
for i in range(rows): dp[i][0] = base_value
for j in range(cols): dp[0][j] = base_value
# Fill remaining cells
for i in range(1, rows):
for j in range(1, cols):
dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
# Answer
return dp[rows-1][cols-1]
Example 1 — Fibonacci (1D Tabulation)
def fibonacci_tabulation(n):
"""
Tabulation: build table from base cases forward.
Time: O(n), Space: O(n)
"""
if n <= 1: return n
dp = [0] * (n + 1)
dp[0] = 0 # Base case
dp[1] = 1 # Base case
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2] # Fill from known values
return dp[n]
print(fibonacci_tabulation(10)) # 55
# Space optimization: only need last 2 values
def fibonacci_optimized(n):
if n <= 1: return n
prev2, prev1 = 0, 1
for _ in range(2, n+1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
print(fibonacci_optimized(10)) # 55
Table visualization for n=6:
Index: 0 1 2 3 4 5 6
dp: 0 1 1 2 3 5 8
↑ ↑ ↑ ↑ ↑ ↑ ↑
BC BC ←+← ←+← ←+← ←+←
Example 2 — Longest Common Subsequence (2D Tabulation)
def lcs(s1, s2):
"""
Longest Common Subsequence using 2D tabulation.
Time: O(m×n), Space: O(m×n)
"""
m, n = len(s1), len(s2)
# dp[i][j] = LCS of s1[:i] and s2[:j]
dp = [[0] * (n+1) for _ in range(m+1)]
# Base cases: dp[i][0] = dp[0][j] = 0 (already initialized)
for i in range(1, m+1):
for j in range(1, n+1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1 # Characters match
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) # Take best
return dp[m][n]
# Reconstruct the actual LCS string:
def lcs_string(s1, s2):
m, n = len(s1), len(s2)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if s1[i-1] == s2[j-1]: dp[i][j] = dp[i-1][j-1]+1
else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])
# Backtrack to find the sequence
result = []
i, j = m, n
while i > 0 and j > 0:
if s1[i-1] == s2[j-1]:
result.append(s1[i-1]); i -= 1; j -= 1
elif dp[i-1][j] > dp[i][j-1]: i -= 1
else: j -= 1
return ''.join(reversed(result))
print(lcs("ABCBDAB", "BDCAB")) # 4
print(lcs_string("ABCBDAB", "BDCAB")) # "BCAB"
Table visualization for s1="ABCD", s2="ACBD":
"" A C B D
"" 0 0 0 0 0
A 0 1 1 1 1
B 0 1 1 2 2
C 0 1 2 2 2
D 0 1 2 2 3
Example 3 — 0/1 Knapsack (2D Tabulation)
def knapsack_01(weights, values, capacity):
"""
0/1 Knapsack: each item used at most once.
dp[i][w] = max value using items 0..i-1 with capacity w
Time: O(n×W), Space: O(n×W)
"""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n+1):
w_i = weights[i-1]
v_i = values[i-1]
for w in range(capacity + 1):
# Option 1: Don't take item i
dp[i][w] = dp[i-1][w]
# Option 2: Take item i (if it fits)
if w >= w_i:
dp[i][w] = max(dp[i][w], dp[i-1][w - w_i] + v_i)
return dp[n][capacity]
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 5
print(knapsack_01(weights, values, capacity)) # 7 (items with w=2,3 → v=3+4=7)
# Space-optimized: 1D array (process capacity in REVERSE to avoid reuse)
def knapsack_01_1d(weights, values, capacity):
dp = [0] * (capacity + 1)
for i in range(len(weights)):
for w in range(capacity, weights[i]-1, -1): # REVERSE order!
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
print(knapsack_01_1d(weights, values, capacity)) # 7
Why reverse order in space-optimized version? In the 2D table, dp[i][w] uses dp[i-1][w - w_i] — from the PREVIOUS row. If we process forward, we'd accidentally use updated values from the CURRENT row (which would allow reusing an item). Reverse processing preserves the "previous row" semantics.
Example 4 — Coin Change (1D Tabulation)
def coin_change(coins, amount):
"""
Minimum coins to make amount.
dp[a] = minimum coins to make amount a
Time: O(amount × len(coins)), Space: O(amount)
"""
dp = [float('inf')] * (amount + 1)
dp[0] = 0 # Base case: 0 coins for amount 0
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
dp[a] = min(dp[a], dp[a - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
print(coin_change([1, 5, 6, 9], 11)) # 2 (5+6)
print(coin_change([2], 3)) # -1 (impossible)
# Table for coins=[1,5,6], amount=10:
# a: 0 1 2 3 4 5 6 7 8 9 10
# dp: 0 1 2 3 4 1 1 2 2 3 2
# ↑ ↑
# 5=1 6=1
Space Optimization — Rolling Array
When dp[i][j] only depends on dp[i-1][j] and dp[i-1][j-1], we can reduce from O(m×n) to O(n):
def lcs_space_optimized(s1, s2):
"""
LCS with O(n) space instead of O(m×n).
Only need previous row to compute current row.
"""
m, n = len(s1), len(s2)
prev = [0] * (n + 1) # "Previous row"
for i in range(1, m+1):
curr = [0] * (n + 1) # "Current row"
for j in range(1, n+1):
if s1[i-1] == s2[j-1]:
curr[j] = prev[j-1] + 1
else:
curr[j] = max(prev[j], curr[j-1])
prev = curr # Slide: current becomes previous
return prev[n]
print(lcs_space_optimized("ABCBDAB", "BDCAB")) # 4
Filling Order Rules
The correct filling order ensures every cell is filled before it's needed:
For dp[i][j] that depends on dp[i-1][...] and dp[i][j-1]:
→ Fill left to right, top to bottom ✓
For dp[i][j] that depends on dp[i+1][...] and dp[i][j+1]:
→ Fill right to left, bottom to top ✓
For interval DP dp[i][j] that depends on dp[i][k] and dp[k+1][j]:
→ Fill by increasing length of interval ✓
Tabulation for Shortest Path
def min_path_sum(grid):
"""
Minimum path sum from top-left to bottom-right.
Only move right or down.
"""
m, n = len(grid), len(grid[0])
dp = [[0]*n for _ in range(m)]
dp[0][0] = grid[0][0]
# Fill first row (can only come from left)
for j in range(1, n):
dp[0][j] = dp[0][j-1] + grid[0][j]
# Fill first column (can only come from above)
for i in range(1, m):
dp[i][0] = dp[i-1][0] + grid[i][0]
# Fill rest
for i in range(1, m):
for j in range(1, n):
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
return dp[m-1][n-1]
grid = [[1,3,1],[1,5,1],[4,2,1]]
print(min_path_sum(grid)) # 7 (1→3→1→1→1)
Comparison: Memoization vs Tabulation
| Aspect | Memoization | Tabulation |
|---|
| Direction | Top-down (recursive) | Bottom-up (iterative) |
| Computation | Only needed states | All states in order |
| Stack overhead | Yes (recursion) | No |
| Stack overflow | Possible for large n | Not possible |
| Space optimization | Harder | Easy (rolling array) |
| Implementation | Faster to write | Slightly more code |
| Cache hits | Automatic | N/A (all computed) |
Summary
Tabulation builds the DP table from base cases upward:
- Define the DP table — what does
dp[i] or dp[i][j] represent? - Fill base cases — edges of the table, known initial values
- Fill the rest — correct order ensures dependencies are satisfied
- Return the answer — the final cell contains the answer
Space optimization: When the recurrence only looks at the previous row/column, reduce from 2D to 1D by maintaining a rolling array.
Key advantage over memoization: No recursion stack → no stack overflow risk → can safely solve problems with n = 10^6 or more.
*Next Lesson: Fibonacci DP*