Tabulation
What Is Tabulation?
Tabulation is the bottom-up approach to DP: fill a table iteratively starting from the smallest sub-problems, building toward the final answer without any recursion.
THE CORE DIFFERENCE FROM MEMOIZATION: MEMOIZATION (top-down): TABULATION (bottom-up): Start from the question. Start from the answers. Recurse toward base cases. Fill base cases first. Cache results along the way. Build up to the final answer. Lazy — only needed states. Eager — ALL states in table. BOTH produce the same answer. Both have the same time complexity. Tabulation is usually faster in practice (no recursion overhead). TABULATION STRUCTURE: 1. Declare table: dp[0..n] or dp[0..m][0..n] 2. Fill base cases: dp[0] = ..., dp[1] = ... 3. Fill remaining cells in dependency order 4. Return answer: dp[n] or dp[m-1][n-1]
Conversion Recipe: Memoized → Tabulated
STEP-BY-STEP RECIPE: MEMOIZED: TABULATED: ① declare cache ① declare table (same shape as cache) ② base case checks inside fn ② explicit base case fills BEFORE loop ③ cache check (no cache check needed) ④ recursive calls ③ direct table lookups dp[i-1], dp[i-2] ⑤ store result (automatic — loop fills in order) ⑥ return result ④ return final cell dp[n] or dp[m][n] TRANSLATION RULES: fib(n-1) → dp[i-1] (recursive call → table cell) fib(n-2) → dp[i-2] memo[n] = result → dp[i] = ... (store stays, no cache check needed) return memo[n] → (implicit — loop fills sequentially)
Conversion Example: Fibonacci
MEMOIZED: TABULATED:
memo = [-1] * (n+1) dp = [0] * (n+1)
dp[0] = 0 ← base case from: if n<=1
def fib(n, memo): dp[1] = 1 ← base case from: if n<=1
if n <= 1: return n ┐
if memo[n] != -1: │ for i in range(2, n+1):
return memo[n] ├──────→ dp[i] = dp[i-1] + dp[i-2]
memo[n] = fib(n-1,memo) │
+ fib(n-2,memo) ┘ return dp[n]
return memo[n]
WHAT CHANGED:
- Recursive calls fib(n-1) and fib(n-2) → direct lookups dp[i-1] and dp[i-2]
- Cache check (if memo[n] != -1) → removed (loop fills in order, no stale reads)
- Base case inside function → explicit assignment dp[0]=0, dp[1]=1 before loop
- return memo[n] → return dp[n] (single location after loop)
FILL ORDER visualised:
i=0: dp[0]=0 (base)
i=1: dp[1]=1 (base)
i=2: dp[2]=dp[1]+dp[0]=1
i=3: dp[3]=dp[2]+dp[1]=2
i=4: dp[4]=dp[3]+dp[2]=3
i=5: dp[5]=dp[4]+dp[3]=5
...
When computing dp[i], both dp[i-1] and dp[i-2] are already filled. ✓
Worked Example 1: Climbing Stairs
STATE: dp[i] = number of distinct ways to reach step i RECUR: dp[i] = dp[i-1] + dp[i-2] BASE: dp[0] = 1, dp[1] = 1 FILL: left to right (i = 2 to n) ANSWER: dp[n] Table for n=6: i: 0 1 2 3 4 5 6 dp: 1 1 2 3 5 8 13 dp[2] = dp[1]+dp[0] = 1+1 = 2 dp[3] = dp[2]+dp[1] = 2+1 = 3 dp[4] = dp[3]+dp[2] = 3+2 = 5 dp[5] = dp[4]+dp[3] = 5+3 = 8 dp[6] = dp[5]+dp[4] = 8+5 = 13 Ways to climb 6 stairs = 13 ✓
Worked Example 2: Unique Paths (2D Table)
STATE: dp[i][j] = number of paths to cell (i,j)
RECUR: dp[i][j] = dp[i-1][j] + dp[i][j-1]
BASE: dp[0][j] = 1 for all j (first row: only rightward moves)
dp[i][0] = 1 for all i (first col: only downward moves)
FILL: row by row, left to right (i=1..m-1, j=1..n-1)
ANSWER: dp[m-1][n-1]
Table for 4×4 grid:
j=0 j=1 j=2 j=3
i=0 [ 1, 1, 1, 1 ] ← first row all 1
i=1 [ 1, 2, 3, 4 ]
i=2 [ 1, 3, 6, 10 ]
i=3 [ 1, 4, 10, 20 ]
dp[1][1] = dp[0][1]+dp[1][0] = 1+1 = 2
dp[1][2] = dp[0][2]+dp[1][1] = 1+2 = 3
dp[2][2] = dp[1][2]+dp[2][1] = 3+3 = 6
dp[3][3] = dp[2][3]+dp[3][2] = 10+10 = 20
Paths in 4×4 grid = 20 ✓
Worked Example 3: 0/1 Knapsack
STATE: dp[i][w] = max value using items 0..i-1 with capacity w
RECUR: dp[i][w] = max(
dp[i-1][w], ← skip item i
values[i-1] + dp[i-1][w-weights[i-1]] ← take item i
)
(take only if weights[i-1] <= w)
BASE: dp[0][w] = 0 for all w (no items → no value)
dp[i][0] = 0 for all i (no capacity → no value)
FILL: i=1..n, w=1..W (row by row)
ANSWER: dp[n][W]
items = [(weight=2,val=3),(weight=3,val=4),(weight=4,val=5),(weight=5,val=6)]
W=5, n=4
Table dp[i][w]:
w=0 w=1 w=2 w=3 w=4 w=5
i=0 [ 0, 0, 0, 0, 0, 0 ] ← base: no items
i=1 [ 0, 0, 3, 3, 3, 3 ] ← item1: w=2,v=3
i=2 [ 0, 0, 3, 4, 4, 7 ] ← item2: w=3,v=4
i=3 [ 0, 0, 3, 4, 5, 7 ] ← item3: w=4,v=5
i=4 [ 0, 0, 3, 4, 5, 7 ] ← item4: w=5,v=6
dp[2][5] = max(dp[1][5], 4+dp[1][5-3]) = max(3, 4+3) = 7 ← take both
dp[4][W=5] = 7 ✓ (items with w=2,v=3 + w=3,v=4 = w=5, v=7)
Complete Tabulation Implementations
1import java.util.*;
2
3public class TabulationExamples {
4
5 // ── 1. Fibonacci — O(n) time, O(n) space ──────────────────────────
6 public static long fibonacci(int n) {
7 if (n <= 1) return n;
8 long[] dp = new long[n + 1];
9 dp[0] = 0; dp[1] = 1;
10 for (int i = 2; i <= n; i++) {
11 dp[i] = dp[i-1] + dp[i-2];
12 }
13 return dp[n];
14 }
15
16 // ── 2. Climbing Stairs — O(n) time, O(n) space ────────────────────
17 public static int climbStairs(int n) {
18 if (n <= 1) return 1;
19 int[] dp = new int[n + 1];
20 dp[0] = 1; dp[1] = 1;
21 for (int i = 2; i <= n; i++) {
22 dp[i] = dp[i-1] + dp[i-2];
23 }
24 return dp[n];
25 }
26
27 // ── 3. Unique Paths — O(m*n) time, O(m*n) space ───────────────────
28 public static int uniquePaths(int m, int n) {
29 int[][] dp = new int[m][n];
30
31 // Base cases: entire first row and column = 1
32 for (int i = 0; i < m; i++) dp[i][0] = 1;
33 for (int j = 0; j < n; j++) dp[0][j] = 1;
34
35 for (int i = 1; i < m; i++) {
36 for (int j = 1; j < n; j++) {
37 dp[i][j] = dp[i-1][j] + dp[i][j-1];
38 }
39 }
40 return dp[m-1][n-1];
41 }
42
43 // ── 4. Coin Change — O(A*k) time, O(A) space ──────────────────────
44 public static int coinChange(int[] coins, int amount) {
45 int[] dp = new int[amount + 1];
46 Arrays.fill(dp, amount + 1); // Sentinel: larger than any valid answer
47 dp[0] = 0;
48
49 for (int i = 1; i <= amount; i++) {
50 for (int coin : coins) {
51 if (coin <= i) {
52 dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
53 }
54 }
55 }
56
57 return dp[amount] > amount ? -1 : dp[amount];
58 }
59
60 // ── 5. 0/1 Knapsack — O(n*W) time, O(n*W) space ──────────────────
61 public static int knapsack(int[] weights, int[] values, int W) {
62 int n = weights.length;
63 int[][] dp = new int[n + 1][W + 1];
64
65 // dp[0][w] = 0 for all w (no items) — default int[] is 0
66 for (int i = 1; i <= n; i++) {
67 for (int w = 0; w <= W; w++) {
68 // Option 1: skip item i-1
69 dp[i][w] = dp[i-1][w];
70
71 // Option 2: take item i-1 (if it fits)
72 if (weights[i-1] <= w) {
73 dp[i][w] = Math.max(dp[i][w],
74 values[i-1] + dp[i-1][w - weights[i-1]]);
75 }
76 }
77 }
78
79 return dp[n][W];
80 }
81
82 // ── 6. Longest Common Subsequence — O(m*n) time/space ─────────────
83 public static int lcs(String s1, String s2) {
84 int m = s1.length(), n = s2.length();
85 int[][] dp = new int[m + 1][n + 1];
86
87 // dp[0][j] = 0 and dp[i][0] = 0 by default (empty string LCS = 0)
88 for (int i = 1; i <= m; i++) {
89 for (int j = 1; j <= n; j++) {
90 if (s1.charAt(i-1) == s2.charAt(j-1)) {
91 dp[i][j] = 1 + dp[i-1][j-1]; // Characters match: extend
92 } else {
93 dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]); // Skip one
94 }
95 }
96 }
97
98 return dp[m][n];
99 }
100
101 public static void main(String[] args) {
102 System.out.println("fib(10): " + fibonacci(10)); // 55
103 System.out.println("climbStairs(6): " + climbStairs(6)); // 13
104 System.out.println("uniquePaths(4,4): " + uniquePaths(4,4)); // 20
105 System.out.println("coinChange([1,3,4], 6): " +
106 coinChange(new int[]{1,3,4}, 6)); // 2
107 System.out.println("knapsack(W=5): " +
108 knapsack(new int[]{2,3,4,5}, new int[]{3,4,5,6}, 5)); // 7
109 System.out.println("LCS(abcde,ace): " + lcs("abcde","ace")); // 3
110 }
111}Output:
fib(10): 55
climbStairs(6): 13
uniquePaths(4,4): 20
coinChange([1,3,4]): 2
knapsack(W=5): 7
LCS(abcde,ace): 3
Space Optimisation
Tabulation's key advantage: you can reduce space once you identify which table cells are still needed.
1D Space Reduction: Rolling Variables
WHEN: dp[i] depends only on the last k values.
Fibonacci: dp[i] = dp[i-1] + dp[i-2]
Only last 2 values needed. Use two variables.
BEFORE (O(n) space): AFTER (O(1) space):
dp = [0]*(n+1) prev2, prev1 = 0, 1
dp[0]=0; dp[1]=1 for i in range(2, n+1):
for i in range(2,n+1): prev2, prev1 = prev1, prev1+prev2
dp[i] = dp[i-1]+dp[i-2] return prev1
return dp[n]
2D Space Reduction: Rolling Row
WHEN: dp[i][j] depends only on the current row and the PREVIOUS row.
Unique Paths: dp[i][j] = dp[i-1][j] + dp[i][j-1]
Only need previous row and current row being built.
BEFORE (O(m*n)): AFTER (O(n)):
dp[m][n] prev = [1]*n ← row i-1
fill all rows for i in range(1,m):
return dp[m-1][n-1] curr = [1]*n ← row i
for j in range(1,n):
curr[j]=prev[j]+curr[j-1]
prev = curr
return prev[n-1]
LCS: dp[i][j] = dp[i-1][j-1], dp[i-1][j], dp[i][j-1]
Needs dp[i-1] (previous row) and dp[i][j-1] (current row, previous col).
O(m*n) → O(n) with single rolling row.
0/1 Knapsack: O(W) Space
INSIGHT: dp[i][w] = dp[i-1][w] or dp[i-1][w-weight[i]] + value[i]
Only needs the PREVIOUS row dp[i-1].
Can use a single 1D array if we iterate w from W DOWN TO 0.
WHY RIGHT TO LEFT?
When updating dp[w], we need dp[w - weight[i]] (smaller index).
If we go left to right, dp[w-weight] is already UPDATED for item i.
→ We'd be using item i TWICE (unbounded knapsack behaviour).
Going right to left: dp[w-weight] hasn't been updated yet → still row i-1.
→ Correct 0/1 knapsack (use each item at most once).
BEFORE (O(n*W)): AFTER (O(W)):
dp[n+1][W+1] 2D table dp = [0]*(W+1)
for i in range(n):
for i in range(1,n+1): for w in range(W, weights[i]-1, -1):
for w in range(W+1): dp[w] = max(dp[w],
dp[i][w]=dp[i-1][w] values[i]+dp[w-weights[i]])
if weights[i-1]<=w: return dp[W]
dp[i][w]=max(...)
return dp[n][W]
1public class SpaceOptimisedDP {
2
3 // ── Fibonacci: O(n)→O(1) ──────────────────────────────────────────
4 public static long fib(int n) {
5 if (n <= 1) return n;
6 long prev2 = 0, prev1 = 1;
7 for (int i = 2; i <= n; i++) {
8 long curr = prev1 + prev2;
9 prev2 = prev1;
10 prev1 = curr;
11 }
12 return prev1;
13 }
14
15 // ── Unique Paths: O(m*n)→O(n) ─────────────────────────────────────
16 public static int uniquePaths(int m, int n) {
17 int[] prev = new int[n];
18 Arrays.fill(prev, 1); // First row all 1s
19
20 for (int i = 1; i < m; i++) {
21 int[] curr = new int[n];
22 curr[0] = 1; // First column always 1
23 for (int j = 1; j < n; j++) {
24 curr[j] = prev[j] + curr[j-1];
25 }
26 prev = curr; // Roll: current becomes previous
27 }
28 return prev[n-1];
29 }
30
31 // ── LCS: O(m*n)→O(n) ─────────────────────────────────────────────
32 public static int lcs(String s1, String s2) {
33 int m = s1.length(), n = s2.length();
34 int[] dp = new int[n + 1]; // Single rolling row
35
36 for (int i = 1; i <= m; i++) {
37 int[] newDp = new int[n + 1];
38 for (int j = 1; j <= n; j++) {
39 if (s1.charAt(i-1) == s2.charAt(j-1)) {
40 newDp[j] = 1 + dp[j-1]; // Diagonal: dp[i-1][j-1]
41 } else {
42 newDp[j] = Math.max(dp[j], newDp[j-1]);
43 }
44 }
45 dp = newDp;
46 }
47 return dp[n];
48 }
49
50 // ── 0/1 Knapsack: O(n*W)→O(W), iterate W→0 ───────────────────────
51 public static int knapsack(int[] weights, int[] values, int W) {
52 int[] dp = new int[W + 1]; // dp[w] = max value with capacity w
53
54 for (int i = 0; i < weights.length; i++) {
55 // RIGHT TO LEFT: ensures each item used at most once
56 for (int w = W; w >= weights[i]; w--) {
57 dp[w] = Math.max(dp[w], values[i] + dp[w - weights[i]]);
58 }
59 }
60 return dp[W];
61 }
62
63 public static void main(String[] args) {
64 System.out.println("fib(10): " + fib(10)); // 55
65 System.out.println("uniquePaths(4,4): " + uniquePaths(4,4)); // 20
66 System.out.println("LCS(abcde,ace): " + lcs("abcde","ace")); // 3
67 System.out.println("knapsack(W=5): " +
68 knapsack(new int[]{2,3,4,5}, new int[]{3,4,5,6}, 5)); // 7
69 }
70}Output (space-optimised): fib(10): 55 uniquePaths(4,4): 20 LCS(abcde,ace): 3 knapsack(W=5): 7
Fill Order Reference
RECURRENCE FILL ORDER WHY
──────────────────────────────────────────────────────────────────────
dp[i]=f(dp[i-1]) i: 0 → n need i-1 before i
dp[i]=f(dp[i-1],dp[i-2]) i: 0 → n need i-1,i-2 before i
dp[i][j]=f(dp[i-1][j], i: 0→m, j: 0→n (row by row) need row above and
dp[i][j-1]) left in same row
dp[i][j]=f(dp[i][j+1], i: m→0 (reverse), j: n→0 need row below and
dp[i+1][j]) right (reverse fill)
dp[i][j]=f(dp[i][k], i: length ascending, need smaller
dp[k+1][j]) j = i+len for each len intervals first
all k in [i,j] (interval DP)
dp[i][j]=f(dp[i-1][j-1], i: 0→m, j: 0→n need diagonal above-left
dp[i-1][j], and adjacent cells
dp[i][j-1])
Complexity Summary
| Problem | Time | Space (full) | Space (optimised) |
|---|---|---|---|
| Fibonacci | O(n) | O(n) | O(1) |
| Climbing Stairs | O(n) | O(n) | O(1) |
| Unique Paths | O(m×n) | O(m×n) | O(n) |
| Coin Change | O(A×k) | O(A) | O(A) — already 1D |
| 0/1 Knapsack | O(n×W) | O(n×W) | O(W) |
| LCS | O(m×n) | O(m×n) | O(n) |
Common Mistakes
Wrong fill order — reading an uninitialised cell. For dp[i][j] = dp[i-1][j] + dp[i][j-1], if you fill column by column (j outer, i inner), then dp[i][j-1] refers to column j-1 which was fully computed — correct. But dp[i-1][j] refers to the previous row in the current column, already computed — also correct. Either row-first or column-first works for this recurrence. But for LCS where dp[i][j] needs the diagonal dp[i-1][j-1], any row-major fill is correct. Always trace dependencies before choosing loop order.
Knapsack: iterating w left to right in the 1D optimised version. The 1D 0/1 Knapsack MUST iterate w from W down to weights[i]. Left-to-right updates dp[w-weight] before dp[w] reads it, effectively using item i twice. Right-to-left reads dp[w-weight] from the previous item's row state. This is the single most common knapsack implementation bug.
Forgetting to initialise base cases explicitly. Languages like Java and C++ zero-initialise int arrays, so dp[0][w] = 0 is automatic. Python creates lists explicitly. JavaScript's new Array(n).fill(0) is needed. But for non-zero base cases (e.g., first row of Unique Paths should be all 1s), explicit initialisation is always required — never assume default values are correct.
Off-by-one: table size vs last index. LCS of strings of length m and n uses a table of size (m+1) × (n+1), indexed dp[0..m][0..n]. The +1 accommodates the empty string base case at index 0. Using dp[m][n] as table size (without +1) causes out-of-bounds when accessing dp[m][n]. Always allocate n+1 when the state range is 0..n.
Using sentinel INT_MAX then adding 1. For minimum problems, dp = [INT_MAX] * (amount+1) followed by dp[i] = min(dp[i], 1 + dp[i-coin]) overflows if dp[i-coin] == INT_MAX. Use amount+1 as sentinel instead — it can't be a valid answer (minimum coins for amount is at most amount, using all 1s), and 1 + (amount+1) = amount+2 still compares correctly without overflow.
Interview Questions
Q: How do you determine the fill order for a 2D DP table?
Trace the dependencies of dp[i][j]. If it needs dp[i-1][j] (above) and dp[i][j-1] (left), fill row by row left to right — ensuring both are already filled. If it needs dp[i+1][j] (below) or dp[i][j+1] (right), reverse the fill direction. For interval DP where dp[i][j] needs dp[i][k] and dp[k+1][j] (sub-intervals), fill by increasing interval length. The rule: dependencies must always be filled before the dependent.
Q: Why does 1D Knapsack optimisation require iterating W downward?
The 2D recurrence is dp[i][w] = max(dp[i-1][w], values[i] + dp[i-1][w-wt]) — both references are from row i-1. In the 1D version, dp[w] represents the previous row before being updated. If we iterate w from small to large, dp[w-wt] is updated before dp[w] reads it — meaning item i was already "used" at capacity w-wt. Iterating from W down to wt ensures dp[w-wt] still holds the i-1 row value when we read it.
Q: What is the space complexity of tabulated LCS after space optimisation?
Full 2D LCS uses O(m×n). Since dp[i][j] only depends on dp[i-1][j] (above), dp[i][j-1] (current row, left), and dp[i-1][j-1] (diagonal above-left), computing row i only needs row i-1. Use two 1D arrays (prev and curr) of size n+1 — O(n) space. If m < n, swap the strings to use O(min(m,n)) space.
Summary
Tabulation is bottom-up DP: fill a table iteratively from base cases toward the answer, with no recursion.
The four-step structure:
- ›Declare the table with the right dimensions and sentinel
- ›Fill base cases explicitly before loops
- ›Fill remaining cells in dependency order (small → large)
- ›Return the final cell
Conversion from memoization:
- ›Recursive calls → direct table lookups
- ›Cache check → removed (loop order guarantees freshness)
- ›Base cases inside function → explicit table initialisation before loops
Space optimisation patterns:
- ›1D problems depending on last k values → k rolling variables
- ›2D problems depending only on previous row → two 1D arrays
- ›0/1 Knapsack → single 1D array, iterate W downward to 0
Fill order reference:
- ›
dp[i]needsdp[i-1]→ fill left to right - ›
dp[i][j]needsdp[i-1][j]anddp[i][j-1]→ row by row, left to right - ›Interval DP
dp[i][j]needs sub-intervals → fill by increasing length
In the next topic you will explore Memoization vs Tabulation — a direct comparison of when each approach is faster, which handles sparse sub-problems better, and how to choose between them.
In bottom-up tabulation, what determines the order in which table cells must be filled?