Versions this was built and run against. JDK 25.0.5+8 (Temurin, LTS) and JUnit 6.1.3 (latest GA on Maven Central; itslatestandreleaseentries agree). No other library is used: the ten solutions are plain Java. Everything quoted below comes from a committed file in the dynamic-programming module of the java-interview-lab repository. Timings are deliberately absent — the evidence here is counts, tables and exact outputs, which do not vary from machine to machine.
Plain recursion solves the same problem again and again
Start with the smallest example that shows everything. The Fibonacci numbers begin 0, 1, and every next number is the sum of the previous two: F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2). Translating that sentence into Java gives the obvious recursive method. The extracalls array only counts how many times the method runs, so we can measure the waste instead of guessing at it.
public static long naive(int n, long[] calls) {
calls[0]++;
if (n < 2) return n;
return Math.addExact(naive(n - 1, calls), naive(n - 2, calls));
}
That method is in Fibonacci.java. It is correct, and it is slow for a reason you can see in a picture. Each call to F(n) makes two more calls, and those make two more, so the calls form a tree.
Fibonacci: how many calls does each approach make?
==================================================
n fib(n) naive calls memo calls table steps
10 55 177 19 9
20 6765 21891 39 19
30 832040 2692537 59 29
40 102334155 331160281 79 39
naive calls = every call the recursion made (counted, not predicted).
memo calls = the same recursion with a notebook: 2n-1 calls, because each F(k) is computed once.
table steps = additions in the bottom-up loop (n-1).
That transcript is output/01-fibonacci-call-counts.txt, written by FibonacciTest in the same run that asserts it. Look at the naive calls column: going from n = 30 to n = 40 multiplies the number of calls by 123 (331,160,281 divided by 2,692,537), because every extra step costs about 1.6 times more than the one before. The memo calls column is the fix, and the next section builds it.
Two properties make a problem suitable for dynamic programming, and it is worth naming them now because the rest of the page keeps returning to them. Overlapping subproblems: the same smaller question appears many times (the amber boxes). Optimal substructure: the best answer to the big question can be assembled from best answers to smaller ones (for Fibonacci there is only one answer, so this is trivial; for coin change and knapsack it is the point). When both hold, remembering answers beats recomputing them.
Going deeper: why the naive version is exponential, exactly
- The exact count. The recursion makes 2×F(n+1) − 1 calls.
FibonacciTestasserts that identity against the real counter for n = 10, 20, 30 and 40, so the first column of the transcript is checked, not predicted. - Why about 1.6 per step. Successive call counts grow by the golden ratio, about 1.618. Ten steps multiply by 1.61810, which is about 123 — the ratio you can compute from the transcript above.
- Rewriting the recursion does not rescue it. The cost is the number of distinct paths in the tree, not how the method is written. For background on recursion in Java more generally, see the post on tail vs non-tail recursion in Java.
- Related. A plain iterative Fibonacci is covered in Fibonacci series in Java; this page is about why the loop is the same idea as the notebook.
Memoization: keep the recursion, add a notebook
The smallest possible fix is the one a person would use with pencil and paper. Before computing F(n), look in a notebook. If the answer is written there, read it. If not, work it out, write it down, then return it. The recursion does not change at all; it just stops repeating itself.private static long memo(int n, long[] notebook, long[] calls) {
calls[0]++;
if (n < 2) return n;
if (notebook[n] != 0) return notebook[n]; // 0 means "not solved yet" (F(n) > 0 for n >= 1)
return notebook[n] = Math.addExact(memo(n - 1, notebook, calls), memo(n - 2, notebook, calls));
}
This is from Fibonacci.java. The notebook is a plain array indexed by n. A zero means “not solved yet”, which is safe here only because F(n) is never zero for n of 2 or more. Run it and the row for n = 40 in the transcript above reads 79 calls instead of 331,160,281, which is 2n − 1: each F(k) is worked out once, and every other mention of it is a lookup. The tree in the diagram collapses to a single chain with lookups hanging off it.
The tempting one-liner that breaks. Java maps havecomputeIfAbsent, and a recursive memo looks like a perfect fit:cache.computeIfAbsent(n, k -> f(k-1) + f(k-2)). With aHashMapit throws. The mapping function changes the map whilecomputeIfAbsentis still working on it, and since JDK 9HashMapdetects that on a best-effort basis (the change was JDK-8071667; on JDK 8 the same code could silently leave a corrupt map). Look up, compute, then put:
public static long memoWithComputeIfAbsent(int n, Map<Integer, Long> cache) {
if (n < 2) return n;
return cache.computeIfAbsent(n, k -> memoWithComputeIfAbsent(k - 1, cache) + memoWithComputeIfAbsent(k - 2, cache));
}
public static long memoWithGetAndPut(int n, Map<Integer, Long> cache) {
if (n < 2) return n;
Long hit = cache.get(n);
if (hit != null) return hit;
long value = memoWithGetAndPut(n - 1, cache) + memoWithGetAndPut(n - 2, cache);
cache.put(n, value);
return value;
}
1) HashMap.computeIfAbsent as the memo, recursive mapping function, n = 10
-> java.util.ConcurrentModificationException
same idea written as get / compute / put -> 55
Both methods are in Fibonacci.java; the output is the first part of output/02-fibonacci-pitfalls.txt, asserted by FibonacciTest.pitfalls. The exception carries no message, which is one reason this bug costs people an afternoon the first time.
Going deeper: notebooks, sentinels and sharing
- Array or map? When the state is one or two small non-negative integers (n, or n and a capacity), an array is faster and clearer. When states are sparse or are not integers, a
HashMapkeyed by a record works, with the get / compute / put pattern above. - Choosing the “not solved” marker. It must be a value no real answer can take. Fibonacci uses 0;
HouseRobber.memouses −1 because 0 is a legitimate best total;CoinChange.memoneeds two markers, −2 for “not solved” and −1 for “solved, impossible”, because “impossible” is itself an answer worth remembering. - One notebook per call, not one global. In these classes the notebook is created fresh inside each public method. A shared static notebook would be reused by later calls with different inputs (coin change with different coins) and would not be safe across threads.
Tabulation: start from the smallest answer and build upwards
Memoization starts at the big question and works down. Tabulation turns it around: write the answers to the smallest questions first, then fill the table in an order where every cell’s ingredients are already there. There is no recursion and no lookup logic, just a loop.public static long tabulation(int n) {
if (n < 2) return n;
long[] dp = new long[n + 1];
dp[1] = 1;
for (int i = 2; i <= n; i++) dp[i] = Math.addExact(dp[i - 1], dp[i - 2]);
return dp[n];
}
This is Fibonacci.java again. dp[i] means “the answer for input i”; the two base cases are written by hand (dp[0] is already 0, dp[1] = 1) and the loop does the rest.
| Memoization (top-down) | Tabulation (bottom-up) | |
|---|---|---|
| Shape | Recursion plus a notebook | Loops plus a table |
| Who decides the order | The recursion does | You do |
| Which states are computed | Only the ones the question actually reaches | Every cell of the table |
| Stack depth | As deep as the longest chain of states | Constant |
| Easiest to write first | Yes: it is the recurrence, almost verbatim | After you know the recurrence |
| Easiest to space-optimise | No | Yes: you control which cells exist |
Recursion depth is a real limit. A memoized solution recurses once per element of a chain, so a long input can end in StackOverflowError while the table version never recurses at all.
3) Recursion depth, House Robber with every house worth 1, in a thread with a 256 KB stack
memo n = 500 -> 250
memo n = 50,000 -> java.lang.StackOverflowError
tabulation n = 50,000 -> 25000
The method bodies are in HouseRobber.java and the output is the last part of output/02-fibonacci-pitfalls.txt. The 256 KB stack is only there to make the failure small enough to reproduce in a test; the lesson is the shape, not the number. If your input can be a hundred thousand elements long, prefer the table.
The answer can outgrow the type. Fibonacci overflows alongat n = 93. The code usesMath.addExact, which throws instead of wrapping around, and the transcript shows what plain+would have returned: a confident negative number.
2) The answer outgrows a long
fib(92) = 7540113804746346429
fib(93) -> ArithmeticException: long overflow (Math.addExact refuses to wrap around)
plain + instead of addExact would silently return -6246583658587674878
The code is in Fibonacci.java; this is the second part of output/02-fibonacci-pitfalls.txt.
Going deeper: converting memoization to tabulation mechanically
- Parameters become dimensions. If the memo method takes (i, w), the table has an i axis and a w axis. One parameter means a one-dimensional array; two means a grid.
- Base cases become the first cells. Whatever the recursion returns without recursing (
n < 2,i == 0,amount == 0) is what you write into the table before the loop starts. - Calls tell you the fill order. If F(n) calls F(n-1), then F(n-1) must be filled earlier, so loop upwards. If a cell reads the cell above and the cell to its left, loop rows downwards and columns rightwards.
- Interview habit. Write the memoized recursion first because it is nearly the recurrence itself, check it on a tiny input, then convert it if you are asked for the iterative version or for O(1) extra space.
- All ten problems in this module have both forms and a test (
ProblemsAgreeTest, summarised later) that checks they return the same answer on random inputs.
A four-step recipe, practised on climbing stairs and house robber
Fibonacci is a toy; the recipe is what you carry into an interview. For any dynamic programming problem, answer four questions in this order and write the answers down before writing code:- State. What is the smallest description of a sub-question? (“the first k houses”, “amount a”, “the first i items with capacity w”.)
- Recurrence. How is the answer for a state built from the answers to smaller states?
- Base cases. Which states can be answered without looking at any other?
- Order and answer. In what order are cells filled so that inputs are ready, and which cell holds the final answer?
public static long tabulation(int n) {
long[] dp = new long[n + 2];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
That is ClimbingStairs.java. House robber is the same idea with a choice in it. A street of houses holds some money each, and you cannot rob two neighbours. State: “the first k houses”. Recurrence: for house k you either skip it (take the best of the first k-1 houses) or rob it (its money plus the best of the first k-2 houses), and you keep the larger.
public static int[] table(int[] money) {
int[] dp = new int[money.length + 1];
for (int k = 1; k <= money.length; k++) {
int skip = dp[k - 1];
int take = (k >= 2 ? dp[k - 2] : 0) + money[k - 1];
dp[k] = Math.max(skip, take);
}
return dp;
}
From HouseRobber.java. Here is the table filled for the money [2, 7, 9, 3, 1], with each decision spelled out:
House Robber: money = [2, 7, 9, 3, 1]
=====================================
dp[k] = best total using only the first k houses
k [0, 1, 2, 3, 4, 5]
dp[k] [0, 2, 7, 11, 11, 12]
k=1 house worth 2: skip = dp[0] = 0, take = dp[0] + 2 = 2 -> dp[1] = 2
k=2 house worth 7: skip = dp[1] = 2, take = dp[0] + 7 = 7 -> dp[2] = 7
k=3 house worth 9: skip = dp[2] = 7, take = dp[1] + 9 = 11 -> dp[3] = 11
k=4 house worth 3: skip = dp[3] = 11, take = dp[2] + 3 = 10 -> dp[4] = 11
k=5 house worth 1: skip = dp[4] = 11, take = dp[3] + 1 = 12 -> dp[5] = 12
answer = 12 (houses worth 2, 9 and 1)
Read the row for k = 4: the house is worth 3, skipping gives 11, robbing gives dp[2] + 3 = 10, so the best stays 11. That is output/03-house-robber-table.txt. The answer, 12, is houses worth 2, 9 and 1. Nothing in this table needed cleverness; the recurrence did the thinking.
Going deeper: three problems, one recurrence shape
- Fibonacci, climbing stairs and house robber each look back one and two cells. That is why one two-variable loop fits all three (next section).
- The cross-check test runs 300 random inputs per problem and compares every variant. These are the first rows of its summary:
Cross-check: 300 random inputs per problem, every variant must agree (seed 2026)
================================================================================
problem cases variants compared
Climbing Stairs 300 memo = table = two variables (n up to 39)
House Robber 300 memo = table = two variables
The test is ProblemsAgreeTest.java and the full file is output/11-all-variants-agree.txt. A fixed seed keeps the run repeatable.
Space optimisation: remember only what you will read again
Look at the house robber table once more. To computedp[5] you read dp[4] and dp[3]. After that, dp[3] is never read again. A table that remembers every cell is remembering things nobody will ask for.
public static int twoVariables(int[] money) {
int prev2 = 0, prev1 = 0; // best up to two houses ago, best up to the previous house
for (int m : money) {
int best = Math.max(prev1, prev2 + m);
prev2 = prev1;
prev1 = best;
}
return prev1;
}
This is the same method as table with the array thrown away: O(1) extra memory instead of O(n), same answer, and the 300-input cross-check asserts it. Fibonacci and climbing stairs have the same twoVariables method; they live in Fibonacci.java and ClimbingStairs.java.
What you give up. Once cells are discarded you cannot walk back through the table to find which houses were robbed, only how much. If the interviewer asks for the choices and not just the total, keep the table. The LCS section shows the same trade-off with letters.
Going deeper: what to discard in other shapes
- Two-dimensional tables that read only the previous row keep two rows (LCS, edit distance, below), or even one row if you update in the right direction (knapsack, below).
- Coin change is already one-dimensional. Its table is indexed by amount only, so there is nothing further to discard. See
CoinChange.tabulation. - Memoization does not get this for free. A notebook keeps every state it ever solved, which is why the optimisation is usually a tabulation topic.
Coin change: greedy looks right and is wrong
Given coin values and an amount, find the fewest coins that sum to exactly that amount, using as many of each coin as you like, or report that it is impossible. The tempting answer is greedy: always take the biggest coin that fits. For some coin sets that works, which is exactly what makes it dangerous. Take coins 1, 3 and 4 and the amount 6. State: “amount a”. Recurrence: the fewest coins for a is one coin plus the fewest fora - c, minimised over every coin c that fits. Base case: amount 0 needs 0 coins.
public static int tabulation(int[] coins, int amount) {
int impossible = amount + 1;
int[] dp = new int[amount + 1];
Arrays.fill(dp, impossible);
dp[0] = 0;
for (int a = 1; a <= amount; a++) {
for (int c : coins) {
if (c <= a) dp[a] = Math.min(dp[a], dp[a - c] + 1);
}
}
return dp[amount] >= impossible ? -1 : dp[amount];
}
From CoinChange.java. Note the line that makes “impossible” equal to amount + 1. That is deliberate, and the transcript shows why.
Coin Change: greedy, the overflow bug, call counts, loop order
==============================================================
1) Greedy fails: coins {1,3,4}, amount 6
greedy -> 3 coins (4+1+1)
DP (table) -> 2 coins (3+3)
2) 'Impossible' = Integer.MAX_VALUE, then dp[a-c] + 1: coins {2}, amount 3 (really impossible)
correct (impossible = amount + 1) -> -1
Integer.MAX_VALUE version -> -2147483648
3) Calls made: coins {1,2,5}, amount 27, answer 6
naive recursion -> 2688332 calls
memoized -> 77 calls
4) Loop order changes the question: coins {1,2,5}, amount 5
coins in the OUTER loop -> 4 (combinations: 5 / 2+2+1 / 2+1+1+1 / 1x5)
amount in the OUTER loop -> 9 (permutations: 1+2 and 2+1 now count separately)
The full transcript is output/04-coin-change.txt. Four things in it are worth slowing down for.
1. Greedy is wrong. For coins 1, 3, 4 and amount 6, greedy takes 4 then 1 then 1 (three coins); the table finds 3 + 3 (two). Greedy commits to a choice and never looks back; DP tries every coin at every amount.
2. The overflow that returns a negative answer. A common version fills the table withInteger.MAX_VALUEas “impossible” and then evaluatesdp[a - c] + 1. Adding 1 toMAX_VALUEwraps around toMIN_VALUE, which looks like a fantastic (negative) coin count and wins everyMath.min. For coins {2} and amount 3, the correct answer is −1 (impossible) and this version returns −2147483648. The fix is an “impossible” value that is large but cannot overflow when you add 1:amount + 1.
public static int tabulationOverflowBug(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int a = 1; a <= amount; a++) {
for (int c : coins) {
if (c <= a) dp[a] = Math.min(dp[a], dp[a - c] + 1);
}
}
return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
That buggy method is kept in CoinChange.java on purpose, next to the correct one, so you can diff them.
3. Call counts. For coins 1, 2, 5 and amount 27, plain recursion makes 2,688,332 calls to find the answer 6; the memoized version makes 77. Same recurrence, same answer.
4. The loop order changes the question. The same table can count the ways to make an amount, and two innocent-looking orderings answer two different questions:
public static long countCombinations(int[] coins, int amount) {
long[] ways = new long[amount + 1];
ways[0] = 1;
for (int c : coins) {
for (int a = c; a <= amount; a++) ways[a] += ways[a - c];
}
return ways[amount];
}
public static long countPermutations(int[] coins, int amount) {
long[] ways = new long[amount + 1];
ways[0] = 1;
for (int a = 1; a <= amount; a++) {
for (int c : coins) {
if (c <= a) ways[a] += ways[a - c];
}
}
return ways[amount];
}
Both methods are in CoinChange.java. With coins in the outer loop, each coin is considered once in a fixed order, so 1+2 and 2+1 are the same combination: 4 ways to make 5. With amounts in the outer loop, order matters: 9 ways. If an interview asks for “combinations” and you swap the loops, the code still compiles and returns a plausible number.
Going deeper: why coin change is a “forward” table
- Coins are unlimited, so
ways[a]is allowed to build on a cell that already includes the same coin. That is the same forward loop that breaks 0/1 knapsack later; here it is the correct behaviour (this is the “unbounded” variant). - Memoization visits only amounts reachable by subtracting coins from the target, which can be fewer than all amounts up to the target. The table always fills all of them.
- Greedy is right for some coin systems and wrong for others, so a proof or a counter-example is part of the answer. The table is right for every system.
- Source for all four variants (greedy, naive, memo, table) is in
CoinChange.java; the cross-check compares naive, memo and table on random coin sets inoutput/11-all-variants-agree.txt.
Longest increasing subsequence: the state can mean “ending here”
A subsequence keeps the original order but may skip elements. In 10, 9, 2, 5, 3, 7, 101, 18 the values 2, 5, 7, 101 form an increasing subsequence of length 4, and so do 2, 3, 7, 18. The question: how long is the longest strictly increasing one? The state needs a twist. “The best answer for the first k elements” does not compose, because you do not know which element the sequence ends on. So define the state asdp[i] = the longest increasing subsequence that ends exactly at index i. Recurrence: it is 1 (the element alone) or 1 + dp[j] for any earlier j with a smaller value. The final answer is the maximum over the whole table, not its last cell.
public static int[] table(int[] a) {
int[] dp = new int[a.length];
Arrays.fill(dp, 1);
for (int i = 1; i < a.length; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
return dp;
}
Longest Increasing Subsequence: [10, 9, 2, 5, 3, 7, 101, 18]
============================================================
a[i] [10, 9, 2, 5, 3, 7, 101, 18]
dp[i] [1, 1, 1, 2, 2, 3, 4, 4] (dp[i] = longest increasing subsequence ENDING at index i)
answer = max(dp) = 4 (for example 2, 3, 7, 18)
O(n log n) patience version = 4
The code is in LongestIncreasingSubsequence.java and the transcript is output/08-lis.txt. At index 6 (value 101) the table reads 4 because 2, 3, 7 fit before it; index 7 (value 18) ties at 4; the maximum is 4. The nested loop makes this O(n²).
Going deeper: the O(n log n) version
- Keep an array
tailswheretails[k]is the smallest possible last value of an increasing subsequence of length k + 1. For each number, binary-search the first tail that is not smaller and overwrite it; if none exists, the sequence grew by one. The array length at the end is the answer. - It is no longer a table of subproblems, which is why interviewers use it as the follow-up that separates “knows DP” from “knows this trick”. It is
patienceinLongestIncreasingSubsequence.java. - The summary file
output/11-all-variants-agree.txtshows it agreeing with the table and the memoized version on 300 random arrays. - The tails array does not hold the actual subsequence; rebuilding it needs extra bookkeeping (not implemented here).
Two choices per item: 0/1 knapsack
You have items with a weight and a value, and a bag that holds a limited total weight. Each item can go in at most once. Maximise the total value. For each item there are exactly two choices (leave it, or take it), and that is what the recurrence says:best(i, w) is the larger of leaving item i, or taking it and adding its value to the best you can do with the remaining capacity.
The state is two numbers, the item index and the remaining capacity, so the table is a grid.
public static int[][] table(int[] weight, int[] value, int capacity) {
int n = weight.length;
int[][] dp = new int[n + 1][capacity + 1];
for (int i = 1; i <= n; i++) {
for (int w = 0; w <= capacity; w++) {
dp[i][w] = dp[i - 1][w];
if (weight[i - 1] <= w) {
dp[i][w] = Math.max(dp[i][w], value[i - 1] + dp[i - 1][w - weight[i - 1]]);
}
}
}
return dp;
}
From Knapsack01.java. Here is the table for weights [1, 3, 4, 5], values [1, 4, 5, 7] and capacity 7:
1) Table: weights [1,3,4,5], values [1,4,5,7], capacity 7. dp[i][w] = best value with the first i items
w = 0 1 2 3 4 5 6 7
none 0 0 0 0 0 0 0 0
item 1 0 1 1 1 1 1 1 1
item 2 0 1 1 4 5 5 5 5
item 3 0 1 1 4 5 6 6 9
item 4 0 1 1 4 5 7 8 9
answer = dp[4][7] = 9 (weights 3 + 4, values 4 + 5)
That table is the first part of output/05-knapsack.txt. Each row adds one more item to the pool and each column is a capacity. The bottom-right cell is the answer, 9: take the weight-3 and weight-4 items. It is the first example of a table with two dimensions, which is what the string problems use too.
Since each row reads only the row above it, you can keep one row. But now the direction of the capacity loop matters, and getting it wrong does not crash anything.
dp[w] is updated, the cell it reads (dp[w - weight]) still holds the previous item’s value, so the item is used at most once. Going forwards lets an updated cell feed a later one in the same pass, so the item can be reused.
public static int oneRowBackward(int[] weight, int[] value, int capacity) {
int[] dp = new int[capacity + 1];
for (int i = 0; i < weight.length; i++) {
for (int w = capacity; w >= weight[i]; w--) {
dp[w] = Math.max(dp[w], value[i] + dp[w - weight[i]]);
}
}
return dp[capacity];
}
public static int oneRowForwardBug(int[] weight, int[] value, int capacity) {
int[] dp = new int[capacity + 1];
for (int i = 0; i < weight.length; i++) {
for (int w = weight[i]; w <= capacity; w++) {
dp[w] = Math.max(dp[w], value[i] + dp[w - weight[i]]);
}
}
return dp[capacity];
}
2) One row, capacity loop direction: weights [5,4,6,3], values [10,40,30,50], capacity 10
backward loop (each item used once) -> 90
forward loop, same code otherwise -> 150 (the weight-3 item is reused three times: 3 x 50)
Both methods are in Knapsack01.java; the output is the second part of output/05-knapsack.txt.
The fingerprint of the wrong loop direction. The two methods differ only in the direction of the inner loop. Weights [5, 4, 6, 3], values [10, 40, 30, 50], capacity 10: the backward version returns 90 (the weight-4 and weight-3 items), the forward version returns 150 — the weight-3 item counted three times, which no bag could hold. If a 0/1 knapsack answer looks suspiciously high, check the loop direction first.The last part of
output/05-knapsack.txt repeats the call-count comparison from earlier on a 22-item instance:
3) Calls made: 22 items, capacity 60, best value 357
naive recursion -> 1003985 calls
memoized -> 1794 calls
About a million calls for plain recursion against 1,794 with a notebook.
Going deeper: knapsack variants and where the state space explodes
- Unbounded knapsack (unlimited copies of each item) is the forward loop on purpose; coin change is exactly this shape.
- The table is as large as items × capacity. That is polynomial in the number W, not in the number of digits of W, which is why knapsack is called pseudo-polynomial. A capacity of a billion makes the table impractical however clean the recurrence is.
- Source for naive, memoized, 2-D and 1-D versions:
Knapsack01.java. The cross-check compares all four on 300 random instances (output/11-all-variants-agree.txt). - The same “one row, go backwards” trick is used again for partition equal subset sum below.
Two strings, two dimensions: longest common subsequence and edit distance
Longest common subsequence (LCS): given two strings, find the longest sequence of characters that appears in both in the same order, not necessarily adjacent. State: (i, j), the first i characters of one string and the first j of the other. Recurrence: if the last characters match, the answer is 1 plus the answer for both strings shortened by one; if they differ, take the better of dropping the last character of either string.public static int[][] table(String a, String b) {
int[][] dp = new int[a.length() + 1][b.length() + 1];
for (int i = 1; i <= a.length(); i++) {
for (int j = 1; j <= b.length(); j++) {
dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1)
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp;
}
From Lcs.java. For “ABCBDAB” and “BDCABA” (the standard textbook pair) the table is:
- B D C A B A
- 0 0 0 0 0 0 0
A 0 0 0 0 1 1 1
B 0 1 1 1 1 2 2
C 0 1 1 2 2 2 2
B 0 1 1 2 2 3 3
D 0 1 2 2 2 3 3
A 0 1 2 2 3 3 4
B 0 1 2 2 3 4 4
The bottom-right cell is the LCS length, 4. The table gives you more than the length: you can walk it backwards from the corner to recover an actual subsequence.
public static String reconstruct(String a, String b, int[][] dp) {
StringBuilder sb = new StringBuilder();
int i = a.length(), j = b.length();
while (i > 0 && j > 0) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
sb.append(a.charAt(i - 1));
i--;
j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return sb.reverse().toString();
}
length (bottom-right cell) = 4
reconstructed from the full table = "BCBA" (a valid subsequence of both strings)
two-row version = 4 (length only; cannot rebuild the string)
That is Lcs.java and output/06-lcs.txt. The last transcript line is the space-optimised version, and it is where the earlier trade-off bites: it returns the right length but cannot give you the letters, because the rows it threw away are exactly what the backwards walk needs.
public static int twoRows(String a, String b) {
int[] prev = new int[b.length() + 1];
int[] curr = new int[b.length() + 1];
for (int i = 1; i <= a.length(); i++) {
for (int j = 1; j <= b.length(); j++) {
curr[j] = a.charAt(i - 1) == b.charAt(j - 1)
? prev[j - 1] + 1
: Math.max(prev[j], curr[j - 1]);
}
int[] tmp = prev; prev = curr; curr = tmp;
}
return prev[b.length()];
}
The method is in Lcs.java too. The call counts at the bottom of the transcript show why memoization matters here as much as it did for Fibonacci: on two 15-character strings, plain recursion makes 293,086 calls and the memoized version 247. Both give the same answer, 9.
Edit distance (Levenshtein) uses the same grid with three choices on a mismatch: delete a character, insert one, or replace one. State (i, j) is again prefixes of the two strings. Base cases: turning an empty string into j characters takes j inserts, and the reverse takes i deletes.
public static int[][] table(String a, String b) {
int[][] dp = new int[a.length() + 1][b.length() + 1];
for (int i = 0; i <= a.length(); i++) dp[i][0] = i;
for (int j = 0; j <= b.length(); j++) dp[0][j] = j;
for (int i = 1; i <= a.length(); i++) {
for (int j = 1; j <= b.length(); j++) {
dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1)
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1]));
}
}
return dp;
}
Edit Distance: "kitten" -> "sitting"
====================================
- s i t t i n g
- 0 1 2 3 4 5 6 7
k 1 1 2 3 4 5 6 7
i 2 2 1 2 3 4 5 6
t 3 3 2 1 2 3 4 5
t 4 4 3 2 1 2 3 4
e 5 5 4 3 2 2 3 4
n 6 6 5 4 3 3 2 3
answer = 3 (k->s, e->i, insert g)
two-row version = 3
horse -> ros = 3
intention -> execution = 5
From EditDistance.java and output/07-edit-distance.txt. Turning “kitten” into “sitting” takes 3 operations (k to s, e to i, insert g). Read the bottom-right cell, and notice the first row and column: they are the base cases, filled with 0, 1, 2, … before the loop starts.
Going deeper: LCS and edit distance, side by side
- Same grid, different recurrence. Both tables are (m+1) × (n+1), filled row by row. LCS maximises matches; edit distance minimises operations.
- Time is O(m × n) for both; with two rows the space drops to O(n). In the code the rows are sized by the second string; swapping the arguments so the shorter string is the row keeps the rows as small as possible.
- Linear-space reconstruction exists (Hirschberg’s divide-and-conquer technique recovers the actual subsequence without the full table). It is not implemented in this module.
- Both have a memoized form that is a near-verbatim copy of the recurrence:
Lcs.memoandEditDistance.memo. The cross-check inoutput/11-all-variants-agree.txtcompares memo, table and two-row versions on 300 random pairs each.
Grids and subsets: unique paths and partition equal subset sum
Unique paths. A robot starts in the top-left of a grid and may only move right or down. How many routes reach the bottom-right? State: the cell. Recurrence: a cell is reached from above or from the left, sopaths(r, c) = paths(r-1, c) + paths(r, c-1). The top row and left column each have exactly one route. Because each row reads only the row above and the cell to its left, one row is enough:
public static long oneRow(int rows, int cols) {
long[] row = new long[cols];
java.util.Arrays.fill(row, 1);
for (int r = 1; r < rows; r++) {
for (int col = 1; col < cols; col++) row[col] += row[col - 1];
}
return row[cols - 1];
}
Unique Paths on a 3 x 7 grid (right and down only)
==================================================
0 1 2 3 4 5 6
row 0 1 1 1 1 1 1 1
row 1 1 2 3 4 5 6 7
row 2 1 3 6 10 15 21 28
answer = bottom-right cell = 28
10 x 10 grid: table = 48620, one row = 48620, binomial C(18,9) = 48620
From UniquePaths.java and output/09-unique-paths.txt. On a 3 × 7 grid the answer is 28. The last line cross-checks a 10 × 10 grid three ways: the table, one row and a closed formula (a route is a sequence of 18 moves of which 9 are “down”, so there are C(18, 9) = 48,620 of them).
Partition equal subset sum. Can a list of numbers be split into two groups with the same total? If the total is odd, no. Otherwise the question becomes: can some subset reach exactly half the total? That is a yes/no knapsack with no values, so the state is (how many numbers considered, which sum) and the table holds true or false.
public static boolean oneRow(int[] nums) {
int total = Arrays.stream(nums).sum();
if (total % 2 != 0) return false;
int target = total / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
for (int x : nums) {
for (int s = target; s >= x; s--) dp[s] = dp[s] || dp[s - x];
}
return dp[target];
}
Partition Equal Subset Sum: [1, 5, 11, 5], target 11
====================================================
T = some subset of the first i numbers sums to s
s = 0 1 2 3 4 5 6 7 8 9 10 11
none T . . . . . . . . . . .
+1 T T . . . . . . . . . .
+5 T T . . . T T . . . . .
+11 T T . . . T T . . . . T
+5 T T . . . T T . . . T T
[1, 5, 11, 5] -> true (5 + 5 + 1 = 11 = 11)
[1, 2, 3, 5] -> false (total 11 is odd)
[1, 2, 4] -> false (total 7 is odd)
From PartitionEqualSubsetSum.java and output/10-partition.txt. In the table, T means “some subset of the first i numbers sums to s”. The bottom row is T at 11, so [1, 5, 11, 5] splits into 11 and 11 (5 + 5 + 1 against 11). The sums run downwards in the one-row code for the same reason as 0/1 knapsack.
Should you use a table at all? Unique paths has a closed formula, and the transcript shows it agreeing with the DP. When a formula or a provably correct greedy rule exists, it beats a table in time and in code. DP earns its place when the choices genuinely interact, as in knapsack, edit distance and coin change.
Going deeper: variations you will meet
- Grids with blocked cells keep the same recurrence and set blocked cells to zero. Not implemented in this module, so no output is quoted for it.
- Partition with a different target is the general subset-sum problem; the early return for an odd total is only an optimisation for the equal-halves case.
- Memoized partition uses a
Boolean[][]notebook wherenullmeans “not solved”, sofalsecan be stored as a real answer:PartitionEqualSubsetSum.java.
Why the two-row table is not a micro-optimisation
It is easy to read “two rows instead of the whole table” as an academic tidy-up. It is the difference between running and crashing once the input grows. The module ships a small program that runs LCS on two 5,000-character strings under a deliberately small heap (48 MB):max heap (MiB) : 48
strings : two of length 5000
full table cells : 25010001 ints = 100 MB
two-row cells : 10002 ints = 40 KB
two rows LCS length : 2500
full table LCS length : java.lang.OutOfMemoryError: Java heap space
The program is SpaceDemo.java and the output is output/12-space-demo.txt. A full table for those strings has 25,010,001 integers (about 100 MB); two rows have 10,002 (about 40 KB). With a 48 MB heap the two-row version finishes and the full-table version dies with OutOfMemoryError. The first line, the heap size, is whatever the JVM reports for the flag; the outcome does not depend on the machine as long as the heap is smaller than the table.
It is a memory problem, not a correctness problem. Give the JVM a larger heap and both versions return the same length, which is also what the random cross-check asserts on small inputs. Space optimisation is what lets the same algorithm scale to inputs the table cannot hold.
All ten problems on one page
Keep this table next to the recipe. The “state” and “recurrence” columns are the two questions to answer before writing any code; the last two columns are standard analysis of the code in the module (not measurements).| # | Problem | State | Recurrence in one line | Time | Space (table / optimised) |
|---|---|---|---|---|---|
| 1 | Fibonacci | n | F(n-1) + F(n-2) | O(n) | O(n) / O(1) |
| 2 | Climbing stairs | step n | ways(n-1) + ways(n-2) | O(n) | O(n) / O(1) |
| 3 | House robber | first k houses | max(skip, take + best(k-2)) | O(n) | O(n) / O(1) |
| 4 | Coin change | amount a | 1 + min of fewest(a – c) | O(amount × coins) | O(amount) / already 1-D |
| 5 | Longest increasing subsequence | ends at index i | 1 + max dp[j], j < i, a[j] < a[i] | O(n²) (O(n log n) patience) | O(n) |
| 6 | 0/1 knapsack | (item i, capacity w) | max(leave, value + best(i-1, w – weight)) | O(n × W) | O(n × W) / O(W) |
| 7 | Longest common subsequence | (i, j) prefixes | match: 1 + diagonal; else max(up, left) | O(m × n) | O(m × n) / O(n) |
| 8 | Edit distance | (i, j) prefixes | match: diagonal; else 1 + min(delete, insert, replace) | O(m × n) | O(m × n) / O(n) |
| 9 | Unique paths | cell (r, c) | paths(above) + paths(left) | O(r × c) | O(r × c) / O(c) |
| 10 | Partition equal subset sum | (first i numbers, sum s) | reach(i-1, s) or reach(i-1, s – a[i]) | O(n × target) | O(n × target) / O(target) |
Cross-check: 300 random inputs per problem, every variant must agree (seed 2026)
================================================================================
problem cases variants compared
Climbing Stairs 300 memo = table = two variables (n up to 39)
House Robber 300 memo = table = two variables
Coin Change (fewest) 300 naive = memo = table (amount up to 29)
Longest Increasing Subseq. 300 memo = O(n^2) table = O(n log n) patience
0/1 Knapsack 300 naive = memo = 2-D table = one row (backward)
Longest Common Subsequence 300 naive = memo = full table = two rows
Edit Distance 300 memo = full table = two rows
Unique Paths 300 memo = table = one row = binomial formula
Partition Equal Subset Sum 300 memo = 2-D table = one row (backward)
Fibonacci is cross-checked in 01-fibonacci-call-counts.txt (naive = memo = table = two variables).
That is output/11-all-variants-agree.txt, written by ProblemsAgreeTest.java with a fixed random seed.
How to recognise a dynamic programming problem in an interview
You rarely get told “use DP”. These signals usually mean it:- The question asks for a maximum, minimum, count or yes/no over many ways of choosing something (items, coins, moves, characters).
- The brute-force answer is a tree of choices, and when you draw a few levels the same sub-question appears in several branches.
- You can describe the answer for a smaller input and then build the bigger answer from it. If you can write “the best for the first k items” or “the answer for amount a”, you have a state.
- The state values are small integers: lengths, indices, capacities. If a state would be an arbitrary set, a table is not an option.
Should you reach for DP at all? Not always. A provably correct greedy rule or a closed formula (unique paths) is simpler and faster. A table is a poor fit when the state space is enormous — knapsack with a capacity in the billions — or when a question needs the full set of optimal answers rather than one. And if you cannot state the recurrence in a sentence, writing a table will not help: the fix is to find the state, not to type more code.
Going deeper: practice order
- Start with the one-dimensional problems (Fibonacci, climbing stairs, house robber, coin change) until the recipe is automatic.
- Then the two-dimensional ones (knapsack, LCS, edit distance, unique paths), practising the move from the 2-D table to one or two rows.
- Run the code and break it on purpose: flip the knapsack loop, change the coin-change sentinel, swap the coin-change loops, and watch the outputs from the earlier sections appear. Reproducing a failure teaches more than reading it.
Download and run the code
Everything on this page is in the dynamic-programming module of the java-interview-lab repository (one Maven module, JDK 25, JUnit 6). To run the tests, which also regenerate every transcript quoted above:cd dynamic-programming
mvn test
./scripts/run-all.sh
Those commands are described in the module README.md, and output/12-space-demo.txt is produced by scripts/run-all.sh. The first Maven run has to be online; after that -o works.
If you would rather not set up Maven at all, there is a single self-contained file with all ten solutions. It is generated from the module’s sources by scripts/make-single-file.sh and prints one line per problem. Download DynamicProgrammingTenProblems.java and run it with JDK 25, the version it was built and run on:
java DynamicProgrammingTenProblems.java
FAQ
What is dynamic programming in simple words?
It is solving a big problem by solving smaller versions of it once each and remembering the answers, so the same smaller problem is never solved twice. It applies when the same sub-problems repeat and the best answer to the big problem can be built from best answers to smaller ones.
What is the difference between memoization and tabulation?
Memoization is top-down: write the recursion and add a notebook of answers already found. Tabulation is bottom-up: fill a table from the smallest cases upwards with loops. Both do the same work per state; memoization computes only the states the question reaches and can overflow the stack on deep chains, while tabulation fills the whole table and never recurses.
Why does the 0/1 knapsack one-row loop run backwards?
Going backwards guarantees each cell reads a value from before the current item was considered, so the item is used at most once. Going forwards lets an updated cell feed a later one in the same pass, so the item is reused and the code answers the unbounded knapsack instead.
Why does the greedy approach fail for coin change?
Greedy commits to the biggest coin that fits and never revisits the choice. With coins 1, 3 and 4 and amount 6 it picks 4, 1, 1 (three coins) while the best answer is 3 and 3 (two coins). Dynamic programming tries every coin at every amount.
When can I reduce the table to one or two rows?
When each cell reads only the previous row (and cells in the current row). Then you keep two rows, or one row if you update in the right direction. You lose the ability to reconstruct the chosen items or letters, because the discarded rows are what the backward walk needs.
Further reading
- Companion repository: java-interview-lab / dynamic-programming — the ten solutions, the tests and every transcript quoted here.
- LRU Cache in Java — another interview classic built from a map and a list, with the same “run it and quote the output” approach.
- HashMap vs ConcurrentHashMap — useful background for the
computeIfAbsenttrap in the memoization section. - Tail vs non-tail recursion in Java and Fibonacci series in Java.
- Java 25 API:
HashMap(seecomputeIfAbsent) andMath.addExact. - JDK-8071667 — the bug whose fix made recursive
computeIfAbsentthrowConcurrentModificationExceptionfrom JDK 9.
No Comments yet!