Add dynamic-programming module: ten classic DP problems, memoization vs tabulation vs space-optimised, call-count and pitfall transcripts (11 tests)

This commit is contained in:
2026-11-09 08:41:01 +00:00
parent 911aef5515
commit cb1b28682f
33 changed files with 1551 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# dynamic-programming
Runnable companion for the ankurm.com article
[Dynamic Programming in Java for Beginners: Memoization vs Tabulation (10 Classic Problems)](https://ankurm.com/dynamic-programming-java-memoization-vs-tabulation/).
Ten classic problems, each solved top-down (memoization), bottom-up (tabulation) and with a space-optimised
table. Tests cross-check every variant on random inputs, and write the transcripts the article quotes.
There is no `docs/` folder on purpose: the explanations live in the article.
| Component | Version | Verified against |
|---|---|---|
| JDK | 25.0.5+8 (Temurin, LTS) | `java -version` |
| JUnit | 6.1.3 | `maven-metadata.xml` on Maven Central (`latest` = `release`) |
| Maven | 3.9.11 | `mvn -version` |
```bash
export JAVA_HOME=/path/to/jdk-25 && export PATH="$JAVA_HOME/bin:$PATH"
cd dynamic-programming
mvn test # 11 tests; rewrites output/01-11
./scripts/run-all.sh # also regenerates output/12 (SpaceDemo under a 48 MB heap)
./scripts/make-single-file.sh # builds DynamicProgrammingTenProblems.java: java DynamicProgrammingTenProblems.java
```
| # | Problem | Source |
|---|---|---|
| 1 | Fibonacci | `Fibonacci` |
| 2 | Climbing Stairs | `ClimbingStairs` |
| 3 | House Robber | `HouseRobber` |
| 4 | Coin Change (fewest coins, and number of ways) | `CoinChange` |
| 5 | Longest Increasing Subsequence | `LongestIncreasingSubsequence` |
| 6 | 0/1 Knapsack | `Knapsack01` |
| 7 | Longest Common Subsequence | `Lcs` |
| 8 | Edit Distance | `EditDistance` |
| 9 | Unique Paths | `UniquePaths` |
| 10 | Partition Equal Subset Sum | `PartitionEqualSubsetSum` |
Captured output is in `output/`: `01` Fibonacci call counts, `02` Fibonacci pitfalls (`computeIfAbsent`, `long` overflow,
stack depth), `03`-`10` one dry run per problem, `11` the cross-check summary, `12` the heap-limited space demo.
@@ -0,0 +1,12 @@
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).
@@ -0,0 +1,16 @@
Fibonacci: three things that go wrong
=====================================
1) HashMap.computeIfAbsent as the memo, recursive mapping function, n = 10
-> java.util.ConcurrentModificationException
same idea written as get / compute / put -> 55
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
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
@@ -0,0 +1,14 @@
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)
@@ -0,0 +1,18 @@
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)
@@ -0,0 +1,19 @@
0/1 Knapsack
============
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)
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)
3) Calls made: 22 items, capacity 60, best value 357
naive recursion -> 1003985 calls
memoized -> 1794 calls
+20
View File
@@ -0,0 +1,20 @@
Longest Common Subsequence: "ABCBDAB" and "BDCABA"
==================================================
- 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
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)
Calls made on two 15-character strings "ACGTTGCAACGTGCA" and "TGCATGCCGATGACG" (answer 9):
naive recursion -> 293086 calls
memoized -> 247 calls
@@ -0,0 +1,16 @@
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
+8
View File
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,14 @@
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)
@@ -0,0 +1,15 @@
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).
@@ -0,0 +1,6 @@
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
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.interviewlab</groupId>
<artifactId>dynamic-programming</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>java-interview-lab :: dynamic-programming</name>
<description>
Runnable companion code for ankurm.com's "Dynamic Programming in Java for Beginners" article:
ten classic problems, each solved by memoization, tabulation and a space-optimised table,
with tests that cross-check every variant and write the transcripts the article quotes.
</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.3</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<argLine>-Xmx512m -Xss1m -Duser.timezone=UTC -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Builds one self-contained DynamicProgrammingTenProblems.java from the ten problem classes, so it runs with
# java DynamicProgrammingTenProblems.java
# No Maven needed. Output goes to ./DynamicProgrammingTenProblems.java (not committed).
set -euo pipefail
cd "$(dirname "$0")/.."
SRC=src/main/java/com/ankurm/interviewlab/dp
OUT=${1:-DynamicProgrammingTenProblems.java}
python3 - "$SRC" "$OUT" <<'PY'
import re, sys, pathlib
src, out = pathlib.Path(sys.argv[1]), sys.argv[2]
order = ["Fibonacci","ClimbingStairs","HouseRobber","CoinChange","LongestIncreasingSubsequence",
"Knapsack01","Lcs","EditDistance","UniquePaths","PartitionEqualSubsetSum"]
imports, bodies = set(), []
for name in order:
text = (src / f"{name}.java").read_text()
for m in re.finditer(r"^import [^;]+;", text, re.M): imports.add(m.group(0))
text = re.sub(r"^package [^;]+;\s*", "", text, flags=re.M)
text = re.sub(r"^import [^;]+;\s*", "", text, flags=re.M)
text = text.replace("public final class", "final class", 1)
bodies.append(text.strip())
main = '''
/** Prints a one-line answer for each of the ten problems. Run: java DynamicProgrammingTenProblems.java */
public class DynamicProgrammingTenProblems {
public static void main(String[] args) {
long[] calls = {0};
System.out.println("1 Fibonacci(40) naive = " + Fibonacci.naive(40, calls) + " (" + calls[0] + " calls)");
calls[0] = 0;
System.out.println(" memo = " + Fibonacci.memo(40, calls) + " (" + calls[0] + " calls)");
System.out.println("2 Climbing stairs(10) " + ClimbingStairs.tabulation(10));
System.out.println("3 House robber [2,7,9,3,1] " + HouseRobber.tabulation(new int[] {2, 7, 9, 3, 1}));
System.out.println("4 Coin change {1,3,4}, 6 " + CoinChange.tabulation(new int[] {1, 3, 4}, 6) + " coins (greedy says " + CoinChange.greedy(new int[] {1, 3, 4}, 6) + ")");
System.out.println("5 LIS [10,9,2,5,3,7,101,18] " + LongestIncreasingSubsequence.tabulation(new int[] {10, 9, 2, 5, 3, 7, 101, 18}));
System.out.println("6 Knapsack 0/1 " + Knapsack01.oneRowBackward(new int[] {5, 4, 6, 3}, new int[] {10, 40, 30, 50}, 10));
System.out.println("7 LCS ABCBDAB / BDCABA " + Lcs.tabulation("ABCBDAB", "BDCABA"));
System.out.println("8 Edit distance kitten/sitting " + EditDistance.tabulation("kitten", "sitting"));
System.out.println("9 Unique paths 3 x 7 " + UniquePaths.tabulation(3, 7));
System.out.println("10 Partition [1,5,11,5] " + PartitionEqualSubsetSum.tabulation(new int[] {1, 5, 11, 5}));
}
}
'''
header = "// Generated by scripts/make-single-file.sh from the java-interview-lab/dynamic-programming module.\n// Run with: java DynamicProgrammingTenProblems.java (JDK 25)\n"
pathlib.Path(out).write_text(header + "\n".join(sorted(imports)) + "\n" + main + "\n" + "\n\n".join(bodies) + "\n")
PY
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Regenerates every file in output/. Needs JDK 25 and Maven. The first Maven run must be online.
# 01-11 are written by JUnit tests that assert the same numbers they print (deterministic).
# 12 runs SpaceDemo under a 48 MB heap: the outcome is deterministic, the "max heap" line is whatever the JVM reports.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
mvn -B -q -T1 test
mvn -B -q -T1 compile
{ java -Xmx48m -cp target/classes com.ankurm.interviewlab.dp.SpaceDemo 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' ; } > output/12-space-demo.txt
echo "done:"; ls -1 output
@@ -0,0 +1,37 @@
package com.ankurm.interviewlab.dp;
/**
* Problem 2: Climbing Stairs. You may climb 1 or 2 steps at a time. How many distinct ways reach step n?
* ways(n) = ways(n-1) + ways(n-2): the last move was either a 1-step or a 2-step. (It is Fibonacci in disguise.)
*/
public final class ClimbingStairs {
private ClimbingStairs() {}
public static long memo(int n) {
return memo(n, new long[n + 2]);
}
private static long memo(int n, long[] notebook) {
if (n <= 1) return 1; // one way to stand still, one way to take a single step
if (notebook[n] != 0) return notebook[n];
return notebook[n] = memo(n - 1, notebook) + memo(n - 2, notebook);
}
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];
}
public static long twoVariables(int n) {
long prev = 1, curr = 1;
for (int i = 2; i <= n; i++) {
long next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
}
@@ -0,0 +1,106 @@
package com.ankurm.interviewlab.dp;
import java.util.Arrays;
/**
* Problem 4: Coin Change. Fewest coins that add up to an amount (unlimited supply of each coin), or -1 if impossible.
* fewest(a) = 1 + min over coins c of fewest(a - c).
* Also: how many ways can the coins make the amount, and why the loop order of that table matters.
*/
public final class CoinChange {
private CoinChange() {}
/** Greedy "always take the biggest coin". Wrong for some coin systems, e.g. {1,3,4} and amount 6. */
public static int greedy(int[] coins, int amount) {
int[] sorted = coins.clone();
Arrays.sort(sorted);
int count = 0;
for (int i = sorted.length - 1; i >= 0 && amount > 0; i--) {
count += amount / sorted[i];
amount %= sorted[i];
}
return amount == 0 ? count : -1;
}
/** Plain recursion; {@code calls[0]} counts the calls. Exponential. */
public static int naive(int[] coins, int amount, long[] calls) {
calls[0]++;
if (amount == 0) return 0;
int best = -1;
for (int c : coins) {
if (c > amount) continue;
int sub = naive(coins, amount - c, calls);
if (sub >= 0 && (best == -1 || sub + 1 < best)) best = sub + 1;
}
return best;
}
/** Top-down with a notebook. -2 = "not solved", -1 = "solved: impossible". */
public static int memo(int[] coins, int amount, long[] calls) {
int[] notebook = new int[amount + 1];
Arrays.fill(notebook, -2);
return memo(coins, amount, notebook, calls);
}
private static int memo(int[] coins, int amount, int[] notebook, long[] calls) {
calls[0]++;
if (amount == 0) return 0;
if (notebook[amount] != -2) return notebook[amount];
int best = -1;
for (int c : coins) {
if (c > amount) continue;
int sub = memo(coins, amount - c, notebook, calls);
if (sub >= 0 && (best == -1 || sub + 1 < best)) best = sub + 1;
}
return notebook[amount] = best;
}
/** Bottom-up. "Impossible" is amount + 1, a value that can never be a real answer, so adding 1 cannot overflow. */
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];
}
/** The classic bug: Integer.MAX_VALUE as "impossible", then dp[a - c] + 1 wraps around to a negative number. */
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];
}
/** Number of COMBINATIONS (order ignored): coins in the OUTER loop. 1D array, which is already the space-optimised form. */
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];
}
/** Swap the loops (amount outer) and the same table now counts PERMUTATIONS (order matters): a different problem. */
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];
}
}
@@ -0,0 +1,65 @@
package com.ankurm.interviewlab.dp;
import java.util.Arrays;
/**
* Problem 8: Edit Distance (Levenshtein). Fewest insert / delete / replace operations that turn a into b.
* dist(i, j) = a[i-1]==b[j-1] ? dist(i-1, j-1) : 1 + min(dist(i-1, j) [delete], dist(i, j-1) [insert], dist(i-1, j-1) [replace]).
*/
public final class EditDistance {
private EditDistance() {}
public static int memo(String a, String b) {
int[][] notebook = new int[a.length() + 1][b.length() + 1];
for (int[] row : notebook) Arrays.fill(row, -1);
return memo(a, b, a.length(), b.length(), notebook);
}
private static int memo(String a, String b, int i, int j, int[][] notebook) {
if (i == 0) return j; // only inserts left
if (j == 0) return i; // only deletes left
if (notebook[i][j] != -1) return notebook[i][j];
int result;
if (a.charAt(i - 1) == b.charAt(j - 1)) {
result = memo(a, b, i - 1, j - 1, notebook);
} else {
result = 1 + Math.min(memo(a, b, i - 1, j, notebook),
Math.min(memo(a, b, i, j - 1, notebook), memo(a, b, i - 1, j - 1, notebook)));
}
return notebook[i][j] = result;
}
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;
}
public static int tabulation(String a, String b) {
return table(a, b)[a.length()][b.length()];
}
public static int twoRows(String a, String b) {
int[] prev = new int[b.length() + 1];
int[] curr = new int[b.length() + 1];
for (int j = 0; j <= b.length(); j++) prev[j] = j;
for (int i = 1; i <= a.length(); i++) {
curr[0] = i;
for (int j = 1; j <= b.length(); j++) {
curr[j] = a.charAt(i - 1) == b.charAt(j - 1)
? prev[j - 1]
: 1 + Math.min(prev[j], Math.min(curr[j - 1], prev[j - 1]));
}
int[] tmp = prev; prev = curr; curr = tmp;
}
return prev[b.length()];
}
}
@@ -0,0 +1,74 @@
package com.ankurm.interviewlab.dp;
import java.util.HashMap;
import java.util.Map;
/**
* Problem 1: Fibonacci. F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2).
* The smallest problem that shows every idea in dynamic programming.
* All results are exact: Math.addExact throws once a value no longer fits in a long (n = 93).
*/
public final class Fibonacci {
private Fibonacci() {}
/** Plain recursion. Correct, and exponentially slow. {@code calls[0]} counts every call made. */
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));
}
/** Top-down: the same recursion, plus a notebook (memo) so each n is solved only once. */
public static long memo(int n, long[] calls) {
return memo(n, new long[n + 1], calls);
}
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));
}
/** Bottom-up: fill a table from the smallest subproblem upwards. No recursion at all. */
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];
}
/** Space-optimised: dp[i] only needs the previous two cells, so keep two variables. */
public static long twoVariables(int n) {
if (n < 2) return n;
long prev = 0, curr = 1;
for (int i = 2; i <= n; i++) {
long next = Math.addExact(prev, curr);
prev = curr;
curr = next;
}
return curr;
}
/**
* The tempting one-liner memo. On JDK 9 and later HashMap.computeIfAbsent detects that the mapping
* function changed the map and throws ConcurrentModificationException.
*/
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));
}
/** The safe version of the same idea: look up, compute, then put. */
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;
}
public static Map<Integer, Long> newCache() { return new HashMap<>(); }
}
@@ -0,0 +1,46 @@
package com.ankurm.interviewlab.dp;
/**
* Problem 3: House Robber. Pick houses to maximise money, never two neighbours.
* best(i) = max(best(i-1), best(i-2) + money[i]): skip house i, or take it and jump over its neighbour.
*/
public final class HouseRobber {
private HouseRobber() {}
public static int memo(int[] money) {
int[] notebook = new int[money.length];
java.util.Arrays.fill(notebook, -1); // -1 = "not solved"; 0 is a legitimate answer
return memo(money, money.length - 1, notebook);
}
private static int memo(int[] money, int i, int[] notebook) {
if (i < 0) return 0;
if (notebook[i] != -1) return notebook[i];
return notebook[i] = Math.max(memo(money, i - 1, notebook), memo(money, i - 2, notebook) + money[i]);
}
/** dp[k] = best total using only the first k houses. dp[0] = 0 (no houses). */
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;
}
public static int tabulation(int[] money) {
return table(money)[money.length];
}
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;
}
}
@@ -0,0 +1,81 @@
package com.ankurm.interviewlab.dp;
import java.util.Arrays;
/**
* Problem 6: 0/1 Knapsack. Each item may be taken at most once. Maximise total value within a weight capacity.
* best(i, w) = max(best(i-1, w), value[i] + best(i-1, w - weight[i])): leave item i, or take it.
*/
public final class Knapsack01 {
private Knapsack01() {}
public static int naive(int[] weight, int[] value, int capacity, long[] calls) {
return naive(weight, value, weight.length - 1, capacity, calls);
}
private static int naive(int[] weight, int[] value, int i, int cap, long[] calls) {
calls[0]++;
if (i < 0) return 0;
int leave = naive(weight, value, i - 1, cap, calls);
if (weight[i] > cap) return leave;
return Math.max(leave, value[i] + naive(weight, value, i - 1, cap - weight[i], calls));
}
/** Top-down: the state is TWO numbers (item index, remaining capacity), so the notebook is 2-D. */
public static int memo(int[] weight, int[] value, int capacity, long[] calls) {
int[][] notebook = new int[weight.length][capacity + 1];
for (int[] row : notebook) Arrays.fill(row, -1);
return memo(weight, value, weight.length - 1, capacity, notebook, calls);
}
private static int memo(int[] weight, int[] value, int i, int cap, int[][] notebook, long[] calls) {
calls[0]++;
if (i < 0) return 0;
if (notebook[i][cap] != -1) return notebook[i][cap];
int leave = memo(weight, value, i - 1, cap, notebook, calls);
int best = leave;
if (weight[i] <= cap) best = Math.max(leave, value[i] + memo(weight, value, i - 1, cap - weight[i], notebook, calls));
return notebook[i][cap] = best;
}
/** dp[i][w] = best value using only the first i items with capacity w. */
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;
}
public static int tabulation(int[] weight, int[] value, int capacity) {
return table(weight, value, capacity)[weight.length][capacity];
}
/** One row is enough, but the capacity loop MUST run downwards so each item is used at most once. */
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];
}
/** Same code with the loop flipped. It compiles, runs, and silently answers a different problem (unbounded knapsack). */
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];
}
}
@@ -0,0 +1,88 @@
package com.ankurm.interviewlab.dp;
import java.util.Arrays;
/**
* Problem 7: Longest Common Subsequence of two strings (characters in order, not necessarily adjacent).
* lcs(i, j) = a[i-1]==b[j-1] ? 1 + lcs(i-1, j-1) : max(lcs(i-1, j), lcs(i, j-1)).
*/
public final class Lcs {
private Lcs() {}
public static int naive(String a, String b, long[] calls) {
return naive(a, b, a.length(), b.length(), calls);
}
private static int naive(String a, String b, int i, int j, long[] calls) {
calls[0]++;
if (i == 0 || j == 0) return 0;
if (a.charAt(i - 1) == b.charAt(j - 1)) return 1 + naive(a, b, i - 1, j - 1, calls);
return Math.max(naive(a, b, i - 1, j, calls), naive(a, b, i, j - 1, calls));
}
public static int memo(String a, String b, long[] calls) {
int[][] notebook = new int[a.length() + 1][b.length() + 1];
for (int[] row : notebook) Arrays.fill(row, -1);
return memo(a, b, a.length(), b.length(), notebook, calls);
}
private static int memo(String a, String b, int i, int j, int[][] notebook, long[] calls) {
calls[0]++;
if (i == 0 || j == 0) return 0;
if (notebook[i][j] != -1) return notebook[i][j];
int result = a.charAt(i - 1) == b.charAt(j - 1)
? 1 + memo(a, b, i - 1, j - 1, notebook, calls)
: Math.max(memo(a, b, i - 1, j, notebook, calls), memo(a, b, i, j - 1, notebook, calls));
return notebook[i][j] = result;
}
/** dp[i][j] = LCS length of the first i characters of a and the first j characters of b. */
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;
}
public static int tabulation(String a, String b) {
return table(a, b)[a.length()][b.length()];
}
/** Walk the full table backwards from the corner to recover an actual subsequence. Needs the whole table. */
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();
}
/** Two rows instead of the full table: O(min-length) memory, but the answer is a length only; no reconstruction. */
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()];
}
}
@@ -0,0 +1,59 @@
package com.ankurm.interviewlab.dp;
import java.util.Arrays;
/**
* Problem 5: Longest Increasing Subsequence (strictly increasing, elements need not be adjacent).
* endingAt(i) = 1 + max endingAt(j) over j < i with a[j] < a[i]. The answer is the maximum over all i.
*/
public final class LongestIncreasingSubsequence {
private LongestIncreasingSubsequence() {}
public static int memo(int[] a) {
int[] notebook = new int[a.length]; // 0 = not solved (a real answer is at least 1)
int best = 0;
for (int i = 0; i < a.length; i++) best = Math.max(best, endingAt(a, i, notebook));
return best;
}
private static int endingAt(int[] a, int i, int[] notebook) {
if (notebook[i] != 0) return notebook[i];
int best = 1;
for (int j = 0; j < i; j++) {
if (a[j] < a[i]) best = Math.max(best, 1 + endingAt(a, j, notebook));
}
return notebook[i] = best;
}
/** O(n^2) table: dp[i] = length of the best increasing subsequence that ENDS at index i. */
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;
}
public static int tabulation(int[] a) {
return Arrays.stream(table(a)).max().orElse(0);
}
/**
* O(n log n) "patience" version: tails[k] = the smallest possible last value of an increasing subsequence of length k+1.
* Not a table of subproblems any more, but the interviewer's usual follow-up.
*/
public static int patience(int[] a) {
int[] tails = new int[a.length];
int size = 0;
for (int x : a) {
int pos = Arrays.binarySearch(tails, 0, size, x);
if (pos < 0) pos = -(pos + 1); // first tail >= x
tails[pos] = x;
if (pos == size) size++;
}
return size;
}
}
@@ -0,0 +1,58 @@
package com.ankurm.interviewlab.dp;
import java.util.Arrays;
/**
* Problem 10: Partition Equal Subset Sum. Can the numbers be split into two groups with the same sum?
* Equivalent question: can some subset reach exactly total/2? (A yes/no knapsack with no values.)
*/
public final class PartitionEqualSubsetSum {
private PartitionEqualSubsetSum() {}
public static boolean memo(int[] nums) {
int total = Arrays.stream(nums).sum();
if (total % 2 != 0) return false; // an odd total can never split evenly
int target = total / 2;
Boolean[][] notebook = new Boolean[nums.length][target + 1];
return memo(nums, nums.length - 1, target, notebook);
}
private static boolean memo(int[] nums, int i, int remaining, Boolean[][] notebook) {
if (remaining == 0) return true;
if (i < 0 || remaining < 0) return false;
if (notebook[i][remaining] != null) return notebook[i][remaining];
boolean result = memo(nums, i - 1, remaining - nums[i], notebook) || memo(nums, i - 1, remaining, notebook);
return notebook[i][remaining] = result;
}
/** dp[i][s] = can the first i numbers make sum s? */
public static boolean[][] table(int[] nums, int target) {
boolean[][] dp = new boolean[nums.length + 1][target + 1];
dp[0][0] = true;
for (int i = 1; i <= nums.length; i++) {
for (int s = 0; s <= target; s++) {
dp[i][s] = dp[i - 1][s] || (s >= nums[i - 1] && dp[i - 1][s - nums[i - 1]]);
}
}
return dp;
}
public static boolean tabulation(int[] nums) {
int total = Arrays.stream(nums).sum();
if (total % 2 != 0) return false;
return table(nums, total / 2)[nums.length][total / 2];
}
/** One boolean row; sums run downwards for the same reason as 0/1 knapsack. */
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];
}
}
@@ -0,0 +1,29 @@
package com.ankurm.interviewlab.dp;
/**
* Run with a deliberately small heap to see why the two-row table matters:
* {@code java -Xmx48m -cp target/classes com.ankurm.interviewlab.dp.SpaceDemo}
* Full LCS table for two 5,000-character strings = 5001 x 5001 ints = about 100 MB. Two rows = about 40 KB.
*/
public final class SpaceDemo {
public static void main(String[] args) {
int n = 5000;
String a = pattern(n, 7), b = pattern(n, 5);
System.out.println("max heap (MiB) : " + Runtime.getRuntime().maxMemory() / (1024 * 1024));
System.out.println("strings : two of length " + n);
System.out.println("full table cells : " + (long) (n + 1) * (n + 1) + " ints = " + (long) (n + 1) * (n + 1) * 4 / 1_000_000 + " MB");
System.out.println("two-row cells : " + 2L * (n + 1) + " ints = " + 2L * (n + 1) * 4 / 1000 + " KB");
System.out.println("two rows LCS length : " + Lcs.twoRows(a, b));
try {
System.out.println("full table LCS length : " + Lcs.tabulation(a, b));
} catch (OutOfMemoryError e) {
System.out.println("full table LCS length : " + e);
}
}
private static String pattern(int n, int step) {
StringBuilder sb = new StringBuilder(n);
for (int i = 0; i < n; i++) sb.append((char) ('a' + (i * step + i / 3) % 4));
return sb.toString();
}
}
@@ -0,0 +1,51 @@
package com.ankurm.interviewlab.dp;
/**
* Problem 9: Unique Paths. A robot on an m x n grid starts top-left and may only move right or down.
* How many routes reach the bottom-right? paths(r, c) = paths(r-1, c) + paths(r, c-1).
*/
public final class UniquePaths {
private UniquePaths() {}
public static long memo(int rows, int cols) {
return memo(rows - 1, cols - 1, new long[rows][cols]);
}
private static long memo(int r, int c, long[][] notebook) {
if (r == 0 || c == 0) return 1; // one straight line along an edge
if (notebook[r][c] != 0) return notebook[r][c];
return notebook[r][c] = memo(r - 1, c, notebook) + memo(r, c - 1, notebook);
}
public static long[][] table(int rows, int cols) {
long[][] dp = new long[rows][cols];
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
dp[r][c] = (r == 0 || c == 0) ? 1 : dp[r - 1][c] + dp[r][c - 1];
}
}
return dp;
}
public static long tabulation(int rows, int cols) {
return table(rows, cols)[rows - 1][cols - 1];
}
/** One row: row[c] already holds the cell above, row[c-1] is the cell to the left. */
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 c = 1; c < cols; c++) row[c] += row[c - 1];
}
return row[cols - 1];
}
/** Closed form check: choose which (rows-1) of the (rows+cols-2) moves are "down". */
public static long binomial(int rows, int cols) {
long result = 1;
int k = Math.min(rows - 1, cols - 1), n = rows + cols - 2;
for (int i = 1; i <= k; i++) result = result * (n - k + i) / i; // stays an exact integer at every step
return result;
}
}
@@ -0,0 +1,100 @@
package com.ankurm.interviewlab.dp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.ankurm.interviewlab.dp.support.Transcript;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class FibonacciTest {
@Test
void callCounts() {
Transcript t = Transcript.start("01-fibonacci-call-counts.txt",
"Fibonacci: how many calls does each approach make?");
t.line(String.format("%-4s %-22s %-14s %-14s %s", "n", "fib(n)", "naive calls", "memo calls", "table steps"));
for (int n : new int[] {10, 20, 30, 40}) {
long[] naiveCalls = {0}, memoCalls = {0};
long a = Fibonacci.naive(n, naiveCalls);
long b = Fibonacci.memo(n, memoCalls);
assertEquals(a, b);
assertEquals(a, Fibonacci.tabulation(n));
assertEquals(a, Fibonacci.twoVariables(n));
// Known identity: the naive recursion makes 2*F(n+1)-1 calls. A real count, checked against it.
assertEquals(2 * Fibonacci.twoVariables(n + 1) - 1, naiveCalls[0]);
assertEquals(2L * n - 1, memoCalls[0]);
t.line(String.format("%-4d %-22d %-14d %-14d %d", n, a, naiveCalls[0], memoCalls[0], n - 1));
}
t.blank();
t.line("naive calls = every call the recursion made (counted, not predicted).");
t.line("memo calls = the same recursion with a notebook: 2n-1 calls, because each F(k) is computed once.");
t.line("table steps = additions in the bottom-up loop (n-1).");
t.flush();
}
@Test
void pitfalls() throws Exception {
Transcript t = Transcript.start("02-fibonacci-pitfalls.txt", "Fibonacci: three things that go wrong");
// 1. computeIfAbsent with a recursive mapping function.
Map<Integer, Long> cache = new HashMap<>();
ConcurrentModificationException cme = assertThrows(ConcurrentModificationException.class,
() -> Fibonacci.memoWithComputeIfAbsent(10, cache));
t.line("1) HashMap.computeIfAbsent as the memo, recursive mapping function, n = 10");
t.line(" -> " + cme.getClass().getName());
assertEquals(55L, Fibonacci.memoWithGetAndPut(10, new HashMap<>()));
t.line(" same idea written as get / compute / put -> " + Fibonacci.memoWithGetAndPut(10, new HashMap<>()));
t.blank();
// 2. long overflow.
long f92 = Fibonacci.tabulation(92);
ArithmeticException overflow = assertThrows(ArithmeticException.class, () -> Fibonacci.tabulation(93));
t.line("2) The answer outgrows a long");
t.line(" fib(92) = " + f92);
t.line(" fib(93) -> " + overflow.getClass().getSimpleName() + ": " + overflow.getMessage() + " (Math.addExact refuses to wrap around)");
long wrapped = 7540113804746346429L + 4660046610375530309L; // F(92) + F(91) with plain +
assertEquals(wrapped, f92 + Fibonacci.tabulation(91));
assertTrue(wrapped < 0);
t.line(" plain + instead of addExact would silently return " + wrapped);
t.blank();
// 3. recursion depth: memo recurses once per element, the table does not. Same JVM, a 256 KB thread stack.
int[] small = new int[500];
int[] big = new int[50_000];
java.util.Arrays.fill(small, 1);
java.util.Arrays.fill(big, 1);
AtomicReference<Object> smallMemo = new AtomicReference<>(), bigMemo = new AtomicReference<>(), bigTable = new AtomicReference<>();
runWithStack(256 * 1024, () -> {
smallMemo.set(attempt(() -> HouseRobber.memo(small)));
bigMemo.set(attempt(() -> HouseRobber.memo(big)));
bigTable.set(attempt(() -> HouseRobber.tabulation(big)));
});
t.line("3) Recursion depth, House Robber with every house worth 1, in a thread with a 256 KB stack");
t.line(" memo n = 500 -> " + smallMemo.get());
t.line(" memo n = 50,000 -> " + bigMemo.get());
t.line(" tabulation n = 50,000 -> " + bigTable.get());
assertEquals(250, smallMemo.get());
assertEquals("java.lang.StackOverflowError", bigMemo.get());
assertEquals(25_000, bigTable.get());
t.flush();
}
private static Object attempt(java.util.function.IntSupplier s) {
try {
return s.getAsInt();
} catch (StackOverflowError e) {
return e.getClass().getName();
}
}
private static void runWithStack(long bytes, Runnable r) throws InterruptedException {
Thread th = new Thread(null, r, "small-stack", bytes);
th.start();
th.join();
}
}
@@ -0,0 +1,106 @@
package com.ankurm.interviewlab.dp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.ankurm.interviewlab.dp.support.Transcript;
import java.util.Random;
import org.junit.jupiter.api.Test;
/** Every variant of every problem must give the same answer on the same input. Fixed seed, so the run is repeatable. */
class ProblemsAgreeTest {
private static final int CASES = 300;
@Test
void allVariantsAgree() {
Random rnd = new Random(2026);
Transcript t = Transcript.start("11-all-variants-agree.txt",
"Cross-check: " + CASES + " random inputs per problem, every variant must agree (seed 2026)");
t.line(String.format("%-28s %-8s %s", "problem", "cases", "variants compared"));
for (int c = 0; c < CASES; c++) {
int n = rnd.nextInt(0, 40);
assertEquals(ClimbingStairs.tabulation(n), ClimbingStairs.memo(n));
assertEquals(ClimbingStairs.tabulation(n), ClimbingStairs.twoVariables(n));
}
t.line(String.format("%-28s %-8d %s", "Climbing Stairs", CASES, "memo = table = two variables (n up to 39)"));
for (int c = 0; c < CASES; c++) {
int[] money = rnd.ints(rnd.nextInt(0, 30), 0, 50).toArray();
int expected = HouseRobber.tabulation(money);
assertEquals(expected, HouseRobber.memo(money));
assertEquals(expected, HouseRobber.twoVariables(money));
}
t.line(String.format("%-28s %-8d %s", "House Robber", CASES, "memo = table = two variables"));
for (int c = 0; c < CASES; c++) {
int[] coins = rnd.ints(rnd.nextInt(1, 4), 1, 9).distinct().toArray();
int amount = rnd.nextInt(0, 30);
int expected = CoinChange.tabulation(coins, amount);
assertEquals(expected, CoinChange.memo(coins, amount, new long[1]));
assertEquals(expected, CoinChange.naive(coins, amount, new long[1]));
}
t.line(String.format("%-28s %-8d %s", "Coin Change (fewest)", CASES, "naive = memo = table (amount up to 29)"));
for (int c = 0; c < CASES; c++) {
int[] a = rnd.ints(rnd.nextInt(0, 25), 0, 20).toArray();
int expected = LongestIncreasingSubsequence.tabulation(a);
assertEquals(expected, LongestIncreasingSubsequence.memo(a));
assertEquals(expected, LongestIncreasingSubsequence.patience(a));
}
t.line(String.format("%-28s %-8d %s", "Longest Increasing Subseq.", CASES, "memo = O(n^2) table = O(n log n) patience"));
for (int c = 0; c < CASES; c++) {
int n = rnd.nextInt(0, 12), cap = rnd.nextInt(0, 40);
int[] w = rnd.ints(n, 1, 15).toArray(), v = rnd.ints(n, 1, 30).toArray();
int expected = Knapsack01.tabulation(w, v, cap);
assertEquals(expected, Knapsack01.naive(w, v, cap, new long[1]));
assertEquals(expected, Knapsack01.memo(w, v, cap, new long[1]));
assertEquals(expected, Knapsack01.oneRowBackward(w, v, cap));
}
t.line(String.format("%-28s %-8d %s", "0/1 Knapsack", CASES, "naive = memo = 2-D table = one row (backward)"));
for (int c = 0; c < CASES; c++) {
String a = word(rnd, rnd.nextInt(0, 12)), b = word(rnd, rnd.nextInt(0, 12));
int expected = Lcs.tabulation(a, b);
assertEquals(expected, Lcs.naive(a, b, new long[1]));
assertEquals(expected, Lcs.memo(a, b, new long[1]));
assertEquals(expected, Lcs.twoRows(a, b));
}
t.line(String.format("%-28s %-8d %s", "Longest Common Subsequence", CASES, "naive = memo = full table = two rows"));
for (int c = 0; c < CASES; c++) {
String a = word(rnd, rnd.nextInt(0, 12)), b = word(rnd, rnd.nextInt(0, 12));
int expected = EditDistance.tabulation(a, b);
assertEquals(expected, EditDistance.memo(a, b));
assertEquals(expected, EditDistance.twoRows(a, b));
}
t.line(String.format("%-28s %-8d %s", "Edit Distance", CASES, "memo = full table = two rows"));
for (int c = 0; c < CASES; c++) {
int r = rnd.nextInt(1, 17), k = rnd.nextInt(1, 17);
long expected = UniquePaths.tabulation(r, k);
assertEquals(expected, UniquePaths.memo(r, k));
assertEquals(expected, UniquePaths.oneRow(r, k));
assertEquals(expected, UniquePaths.binomial(r, k));
}
t.line(String.format("%-28s %-8d %s", "Unique Paths", CASES, "memo = table = one row = binomial formula"));
for (int c = 0; c < CASES; c++) {
int[] nums = rnd.ints(rnd.nextInt(0, 12), 1, 20).toArray();
boolean expected = PartitionEqualSubsetSum.tabulation(nums);
assertEquals(expected, PartitionEqualSubsetSum.memo(nums));
assertEquals(expected, PartitionEqualSubsetSum.oneRow(nums));
}
t.line(String.format("%-28s %-8d %s", "Partition Equal Subset Sum", CASES, "memo = 2-D table = one row (backward)"));
t.blank();
t.line("Fibonacci is cross-checked in 01-fibonacci-call-counts.txt (naive = memo = table = two variables).");
t.flush();
}
private static String word(Random rnd, int len) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++) sb.append((char) ('a' + rnd.nextInt(4)));
return sb.toString();
}
}
@@ -0,0 +1,234 @@
package com.ankurm.interviewlab.dp;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.ankurm.interviewlab.dp.support.Tables;
import com.ankurm.interviewlab.dp.support.Transcript;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
/** One dry-run transcript per problem. Each test asserts the same numbers it prints. */
class TranscriptsTest {
@Test
void houseRobber() {
int[] money = {2, 7, 9, 3, 1};
int[] dp = HouseRobber.table(money);
assertArrayEquals(new int[] {0, 2, 7, 11, 11, 12}, dp);
assertEquals(12, HouseRobber.memo(money));
assertEquals(12, HouseRobber.twoVariables(money));
Transcript t = Transcript.start("03-house-robber-table.txt", "House Robber: money = [2, 7, 9, 3, 1]");
t.line("dp[k] = best total using only the first k houses");
t.line("k " + Arrays.toString(new int[] {0, 1, 2, 3, 4, 5}));
t.line("dp[k] " + Arrays.toString(dp));
t.blank();
for (int k = 1; k <= money.length; k++) {
int skip = dp[k - 1], take = (k >= 2 ? dp[k - 2] : 0) + money[k - 1];
t.line(String.format("k=%d house worth %d: skip = dp[%d] = %d, take = dp[%d] + %d = %d -> dp[%d] = %d",
k, money[k - 1], k - 1, skip, Math.max(k - 2, 0), money[k - 1], take, k, dp[k]));
}
t.blank();
t.line("answer = " + dp[money.length] + " (houses worth 2, 9 and 1)");
t.flush();
}
@Test
void coinChange() {
int[] coins = {1, 3, 4};
assertEquals(3, CoinChange.greedy(coins, 6));
assertEquals(2, CoinChange.tabulation(coins, 6));
assertEquals(-1, CoinChange.greedy(new int[] {5, 3}, 7));
int[] two = {2};
assertEquals(-1, CoinChange.tabulation(two, 3));
assertEquals(Integer.MIN_VALUE, CoinChange.tabulationOverflowBug(two, 3));
long[] naiveCalls = {0}, memoCalls = {0};
int[] cc = {1, 2, 5};
int amount = 27;
int a = CoinChange.naive(cc, amount, naiveCalls);
int b = CoinChange.memo(cc, amount, memoCalls);
assertEquals(a, b);
assertEquals(a, CoinChange.tabulation(cc, amount));
assertTrue(memoCalls[0] <= (long) amount * cc.length + 1);
assertTrue(naiveCalls[0] > memoCalls[0] * 100);
long combos = CoinChange.countCombinations(cc, 5);
long perms = CoinChange.countPermutations(cc, 5);
assertEquals(4, combos);
assertEquals(9, perms);
Transcript t = Transcript.start("04-coin-change.txt", "Coin Change: greedy, the overflow bug, call counts, loop order");
t.line("1) Greedy fails: coins {1,3,4}, amount 6");
t.line(" greedy -> " + CoinChange.greedy(coins, 6) + " coins (4+1+1)");
t.line(" DP (table) -> " + CoinChange.tabulation(coins, 6) + " coins (3+3)");
t.blank();
t.line("2) 'Impossible' = Integer.MAX_VALUE, then dp[a-c] + 1: coins {2}, amount 3 (really impossible)");
t.line(" correct (impossible = amount + 1) -> " + CoinChange.tabulation(two, 3));
t.line(" Integer.MAX_VALUE version -> " + CoinChange.tabulationOverflowBug(two, 3));
t.blank();
t.line("3) Calls made: coins {1,2,5}, amount " + amount + ", answer " + a);
t.line(" naive recursion -> " + naiveCalls[0] + " calls");
t.line(" memoized -> " + memoCalls[0] + " calls");
t.blank();
t.line("4) Loop order changes the question: coins {1,2,5}, amount 5");
t.line(" coins in the OUTER loop -> " + combos + " (combinations: 5 / 2+2+1 / 2+1+1+1 / 1x5)");
t.line(" amount in the OUTER loop -> " + perms + " (permutations: 1+2 and 2+1 now count separately)");
t.flush();
}
@Test
void knapsack() {
int[] w = {1, 3, 4, 5}, v = {1, 4, 5, 7};
int[][] dp = Knapsack01.table(w, v, 7);
assertEquals(9, dp[4][7]);
assertEquals(9, Knapsack01.oneRowBackward(w, v, 7));
int[] w2 = {5, 4, 6, 3}, v2 = {10, 40, 30, 50};
assertEquals(90, Knapsack01.tabulation(w2, v2, 10));
assertEquals(90, Knapsack01.oneRowBackward(w2, v2, 10));
int bug = Knapsack01.oneRowForwardBug(w2, v2, 10);
assertEquals(150, bug);
int n = 22;
int[] bw = new int[n], bv = new int[n];
for (int i = 0; i < n; i++) { bw[i] = 3 + (i * 7) % 11; bv[i] = 10 + (i * 13) % 37; }
long[] naiveCalls = {0}, memoCalls = {0};
int x = Knapsack01.naive(bw, bv, 60, naiveCalls);
int y = Knapsack01.memo(bw, bv, 60, memoCalls);
assertEquals(x, y);
assertEquals(x, Knapsack01.oneRowBackward(bw, bv, 60));
assertTrue(memoCalls[0] <= 2L * n * 61 + 1);
Transcript t = Transcript.start("05-knapsack.txt", "0/1 Knapsack");
t.line("1) Table: weights [1,3,4,5], values [1,4,5,7], capacity 7. dp[i][w] = best value with the first i items");
String[] rows = new String[5];
rows[0] = "none";
for (int i = 1; i <= 4; i++) rows[i] = "item " + i;
t.line(Tables.ints("w =", rows, Tables.numbers(0, 7), dp));
t.line("answer = dp[4][7] = " + dp[4][7] + " (weights 3 + 4, values 4 + 5)");
t.blank();
t.line("2) One row, capacity loop direction: weights [5,4,6,3], values [10,40,30,50], capacity 10");
t.line(" backward loop (each item used once) -> " + Knapsack01.oneRowBackward(w2, v2, 10));
t.line(" forward loop, same code otherwise -> " + bug + " (the weight-3 item is reused three times: 3 x 50)");
t.blank();
t.line("3) Calls made: " + n + " items, capacity 60, best value " + x);
t.line(" naive recursion -> " + naiveCalls[0] + " calls");
t.line(" memoized -> " + memoCalls[0] + " calls");
t.flush();
}
@Test
void lcs() {
String a = "ABCBDAB", b = "BDCABA";
int[][] dp = Lcs.table(a, b);
assertEquals(4, dp[a.length()][b.length()]);
String sub = Lcs.reconstruct(a, b, dp);
assertEquals(4, sub.length());
assertTrue(isSubsequence(sub, a) && isSubsequence(sub, b));
assertEquals(4, Lcs.twoRows(a, b));
String p = "ACGTTGCAACGTGCA", q = "TGCATGCCGATGACG";
long[] naiveCalls = {0}, memoCalls = {0};
int x = Lcs.naive(p, q, naiveCalls);
int y = Lcs.memo(p, q, memoCalls);
assertEquals(x, y);
assertEquals(x, Lcs.twoRows(p, q));
assertTrue(memoCalls[0] <= 2L * (p.length() + 1) * (q.length() + 1) + 1);
Transcript t = Transcript.start("06-lcs.txt", "Longest Common Subsequence: \"" + a + "\" and \"" + b + "\"");
t.line(Tables.ints("", Tables.chars(a), Tables.chars(b), dp));
t.blank();
t.line("length (bottom-right cell) = " + dp[a.length()][b.length()]);
t.line("reconstructed from the full table = \"" + sub + "\" (a valid subsequence of both strings)");
t.line("two-row version = " + Lcs.twoRows(a, b) + " (length only; cannot rebuild the string)");
t.blank();
t.line("Calls made on two 15-character strings \"" + p + "\" and \"" + q + "\" (answer " + x + "):");
t.line(" naive recursion -> " + naiveCalls[0] + " calls");
t.line(" memoized -> " + memoCalls[0] + " calls");
t.flush();
}
@Test
void editDistance() {
String a = "kitten", b = "sitting";
int[][] dp = EditDistance.table(a, b);
assertEquals(3, dp[a.length()][b.length()]);
assertEquals(3, EditDistance.twoRows(a, b));
assertEquals(3, EditDistance.memo("horse", "ros"));
assertEquals(5, EditDistance.tabulation("intention", "execution"));
Transcript t = Transcript.start("07-edit-distance.txt", "Edit Distance: \"" + a + "\" -> \"" + b + "\"");
t.line(Tables.ints("", Tables.chars(a), Tables.chars(b), dp));
t.blank();
t.line("answer = " + dp[a.length()][b.length()] + " (k->s, e->i, insert g)");
t.line("two-row version = " + EditDistance.twoRows(a, b));
t.line("horse -> ros = " + EditDistance.tabulation("horse", "ros"));
t.line("intention -> execution = " + EditDistance.tabulation("intention", "execution"));
t.flush();
}
@Test
void lis() {
int[] a = {10, 9, 2, 5, 3, 7, 101, 18};
int[] dp = LongestIncreasingSubsequence.table(a);
assertArrayEquals(new int[] {1, 1, 1, 2, 2, 3, 4, 4}, dp);
assertEquals(4, LongestIncreasingSubsequence.patience(a));
assertEquals(4, LongestIncreasingSubsequence.memo(a));
Transcript t = Transcript.start("08-lis.txt", "Longest Increasing Subsequence: " + Arrays.toString(a));
t.line("a[i] " + Arrays.toString(a));
t.line("dp[i] " + Arrays.toString(dp) + " (dp[i] = longest increasing subsequence ENDING at index i)");
t.blank();
t.line("answer = max(dp) = " + Arrays.stream(dp).max().getAsInt() + " (for example 2, 3, 7, 18)");
t.line("O(n log n) patience version = " + LongestIncreasingSubsequence.patience(a));
t.flush();
}
@Test
void uniquePaths() {
long[][] dp = UniquePaths.table(3, 7);
assertEquals(28, dp[2][6]);
assertEquals(48620, UniquePaths.tabulation(10, 10));
assertEquals(48620, UniquePaths.binomial(10, 10));
Transcript t = Transcript.start("09-unique-paths.txt", "Unique Paths on a 3 x 7 grid (right and down only)");
t.line(Tables.longs("", new String[] {"row 0", "row 1", "row 2"}, Tables.numbers(0, 6), dp));
t.blank();
t.line("answer = bottom-right cell = " + dp[2][6]);
t.line("10 x 10 grid: table = " + UniquePaths.tabulation(10, 10) + ", one row = " + UniquePaths.oneRow(10, 10)
+ ", binomial C(18,9) = " + UniquePaths.binomial(10, 10));
t.flush();
}
@Test
void partition() {
int[] yes = {1, 5, 11, 5}, no = {1, 2, 3, 5}, odd = {1, 2, 4};
assertTrue(PartitionEqualSubsetSum.tabulation(yes));
assertTrue(PartitionEqualSubsetSum.oneRow(yes));
assertFalse(PartitionEqualSubsetSum.tabulation(no));
assertFalse(PartitionEqualSubsetSum.tabulation(odd));
boolean[][] dp = PartitionEqualSubsetSum.table(yes, 11);
assertTrue(dp[4][11]);
Transcript t = Transcript.start("10-partition.txt", "Partition Equal Subset Sum: " + Arrays.toString(yes) + ", target 11");
String[] rows = {"none", "+1", "+5", "+11", "+5"};
t.line("T = some subset of the first i numbers sums to s");
t.line(Tables.bools("s =", rows, Tables.numbers(0, 11), dp));
t.blank();
t.line(Arrays.toString(yes) + " -> " + PartitionEqualSubsetSum.tabulation(yes) + " (5 + 5 + 1 = 11 = 11)");
t.line(Arrays.toString(no) + " -> " + PartitionEqualSubsetSum.tabulation(no) + " (total 11 is odd)");
t.line(Arrays.toString(odd) + " -> " + PartitionEqualSubsetSum.tabulation(odd) + " (total 7 is odd)");
t.flush();
}
private static boolean isSubsequence(String s, String of) {
int i = 0;
for (int j = 0; j < of.length() && i < s.length(); j++) if (of.charAt(j) == s.charAt(i)) i++;
return i == s.length();
}
}
@@ -0,0 +1,47 @@
package com.ankurm.interviewlab.dp.support;
/** Formats a DP table with row and column labels, for the transcripts. */
public final class Tables {
private Tables() {}
public static String ints(String corner, String[] rowLabels, String[] colLabels, int[][] cells) {
return render(corner, rowLabels, colLabels, cells.length, cells[0].length, (r, c) -> String.valueOf(cells[r][c]));
}
public static String longs(String corner, String[] rowLabels, String[] colLabels, long[][] cells) {
return render(corner, rowLabels, colLabels, cells.length, cells[0].length, (r, c) -> String.valueOf(cells[r][c]));
}
public static String bools(String corner, String[] rowLabels, String[] colLabels, boolean[][] cells) {
return render(corner, rowLabels, colLabels, cells.length, cells[0].length, (r, c) -> cells[r][c] ? "T" : ".");
}
/** "" followed by each character of s, so the labels line up with the (n+1)-wide DP table. */
public static String[] chars(String s) {
String[] out = new String[s.length() + 1];
out[0] = "-";
for (int i = 0; i < s.length(); i++) out[i + 1] = String.valueOf(s.charAt(i));
return out;
}
public static String[] numbers(int from, int toInclusive) {
String[] out = new String[toInclusive - from + 1];
for (int i = 0; i < out.length; i++) out[i] = String.valueOf(from + i);
return out;
}
private interface Cell { String at(int r, int c); }
private static String render(String corner, String[] rows, String[] cols, int nr, int nc, Cell cell) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("%-8s", corner));
for (int c = 0; c < nc; c++) sb.append(String.format("%4s", cols[c]));
sb.append('\n');
for (int r = 0; r < nr; r++) {
sb.append(String.format("%-8s", rows[r]));
for (int c = 0; c < nc; c++) sb.append(String.format("%4s", cell.at(r, c)));
sb.append('\n');
}
return sb.toString().stripTrailing();
}
}
@@ -0,0 +1,43 @@
package com.ankurm.interviewlab.dp.support;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Writes output/NN-*.txt while the calling test asserts the same numbers it prints. */
public final class Transcript {
private final Path path;
private final List<String> lines = new ArrayList<>();
private Transcript(Path path, String header) {
this.path = path;
lines.add(header);
lines.add("=".repeat(Math.min(header.length(), 100)));
lines.add("");
}
public static Transcript start(String fileName, String header) {
Path dir = Path.of("output");
try {
Files.createDirectories(dir);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return new Transcript(dir.resolve(fileName), header);
}
public Transcript line(String text) { lines.add(text); return this; }
public Transcript blank() { lines.add(""); return this; }
public void flush() {
try {
Files.write(path, (String.join("\n", lines) + "\n").getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}