Memoization vs tabulation, optimal substructure, overlapping subproblems — the DP mindset.
Published September 21, 2026
Dynamic programming (DP) is a technique for problems where a naive recursive solution solves the same subproblems over and over. Instead of recomputing them, you solve each distinct subproblem once, store its answer, and reuse it. That often turns exponential time into polynomial time.
DP has a reputation for being hard. The hard part isn't the technique itself, which is just "recursion plus a cache". It's defining the right subproblem. This lesson focuses on that skill.
Two properties must hold:
Typical signals in a problem statement: "count the number of ways", "minimum/maximum cost/length/profit", "is it possible to…", over sequences, grids or capacities where choices at each step affect the future.
int fib(int n) { // naive: O(2^n)
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
fib(5) calls fib(3) twice and fib(2) three times, and the duplication grows exponentially. There are only n + 1 distinct subproblems, though.
Keep the recursive structure, and cache the results:
long fib(int n, long[] memo) { // call with memo = new long[n + 1], filled with -1
if (n <= 1) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
}
Each state is computed once, so this is O(n) time. It uses long because fib(47) already overflows an int.
Fill a table from the smallest subproblems upward, so every dependency is ready before it's needed:
long fib(int n) {
if (n <= 1) return n;
long prev2 = 0, prev1 = 1; // only the last two values are ever needed
for (int i = 2; i <= n; i++) {
long cur = prev1 + prev2;
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
That's O(n) time and O(1) space.
| Top-down (memoization) | Bottom-up (tabulation) | |
|---|---|---|
| Written as | Recursion + cache | Loops over a table |
| Computes | Only the states actually reached | Every state, in order |
| Risk | Stack overflow on deep recursion | Must work out a valid fill order |
| Space optimization | Harder | Easy: keep only the rows or values still needed |
Both have the same complexity. Top-down is often easier to derive from the recurrence, and bottom-up is easier to optimize.
For any DP problem, write these down explicitly before writing code:
dp[i] (or dp[i][j]) mean, in words? Example: "dp[i] = the minimum cost to reach step i."Complexity is then (number of states) × (work per transition).
cost[i] is the cost of stepping on stair i. From a stair you may climb 1 or 2 steps. You can start at stair 0 or 1. Find the minimum cost to reach the top (just past the last stair).
dp[i] = the minimum cost to arrive at position i.dp[i] = min(dp[i−1] + cost[i−1], dp[i−2] + cost[i−2]).dp[0] = dp[1] = 0 (you can start there for free).dp[n], computed left to right.int minCostClimbingStairs(int[] cost) {
int n = cost.length;
int twoBack = 0, oneBack = 0; // dp[i-2], dp[i-1]
for (int i = 2; i <= n; i++) {
int cur = Math.min(oneBack + cost[i - 1], twoBack + cost[i - 2]);
twoBack = oneBack;
oneBack = cur;
}
return oneBack;
}
That's O(n) time and O(1) space.
Houses in a row hold nums[i] money. You can't rob two adjacent houses. Maximize the loot.
dp[i] = the best loot using houses 0..i.dp[i−1]), or rob it and skip house i−1 (dp[i−2] + nums[i]).dp[i] = max(dp[i−1], dp[i−2] + nums[i]).int rob(int[] nums) {
int prev2 = 0, prev1 = 0;
for (int x : nums) {
int cur = Math.max(prev1, prev2 + x);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
"Skip or take" choices like this are at the heart of the knapsack family (see 0/1 Knapsack Pattern).
| Family | State usually looks like | Examples |
|---|---|---|
| Linear (1-D) | dp[i] = the answer for a prefix | Climbing stairs, house robber, decode ways |
| Two sequences | dp[i][j] = the answer for prefixes of both | LCS, edit distance |
| Knapsack | dp[i][capacity] | Subset sum, coin change, partition |
| Intervals | dp[l][r] = the answer for a sub-range | Burst balloons, matrix-chain multiplication, palindromic partitioning |
| Grids | dp[r][c] | Unique paths, minimum path sum |
| Trees | Values computed per node from its children | Tree diameter, house robber III |
| Bitmask | dp[mask] = the answer for a set of chosen items | Travelling salesman on small n |
dp[i] means, the transition will be wrong.long, or apply the modulus the problem specifies.Q: How do you tell whether a problem needs DP or greedy? A: Greedy works when a locally best choice is provably part of some optimal solution (an exchange argument). If you can find a small counter-example where the locally best choice leads to a worse overall result, you need to consider all choices, which means DP.
Q: Memoization or tabulation: which should I use in an interview? A: Whichever you can write correctly fastest. Memoization is usually easiest to derive from the recurrence. Convert to tabulation if recursion depth could overflow the stack, or if the interviewer asks for space optimization.
Q: How do you reduce DP space?
A: Look at which previous states the transition reads. If dp[i] only needs dp[i−1] and dp[i−2], keep two variables. If a 2-D row only needs the previous row, keep one or two rows.
Q: How do you reconstruct the actual solution, not just its value? A: Keep the full table (or a parent/choice array), then walk backwards from the answer state, following which transition produced each value.
Q: What's the time complexity of a DP solution? A: The number of distinct states multiplied by the cost of computing each one. For example, LCS has m × n states with O(1) work each, which is O(m·n).