DP correctness
Why a dynamic programming solution is correct: state completeness, acyclic dependencies and induction on the state order, with a stress-testing harness.
Learning goals
- Check that a state is complete, with every future-relevant fact included
- Verify that the dependency graph is acyclic and name an evaluation order
- Prove a recurrence correct by induction over the states in evaluation order
- Cross-check a DP against brute force on small random inputs
Prerequisites
A table that fills without errors can still be wrong. Three short checks, each corresponding to part of the proof, catch nearly every incorrect dynamic programming solution.
State completeness
A state is **complete** when the best way to finish from it depends on nothing but the state. If the rules refer to something the state forgets, the recurrence quietly optimises the wrong problem. Take 'pick numbers with the largest sum so that no three consecutive numbers are picked'. The state 'best for the first i numbers' is incomplete, because whether number i + 1 may be picked depends on how many of the last numbers were picked. Adding the **current streak** (0, 1 or 2) completes it.
The test: write down what the future decisions need and make sure the state implies it. If two histories lead to the same state but allow different futures, the state is too small. If a state contains facts the future never uses, it is too large and wastes time and memory.
1// largest sum with no three consecutive picks; dp[i][k] = best for the first i numbers, ending with k consecutive picks (k = 0, 1, 2)2static int maxSumNoThree(int[] a) {3int NEG = Integer.MIN_VALUE / 2;4int[][] dp = new int[a.length + 1][3];5for (int[] row : dp) Arrays.fill(row, NEG);6dp[0][0] = 0;7for (int i = 1; i <= a.length; i++) {8dp[i][0] = Math.max(dp[i - 1][0], Math.max(dp[i - 1][1], dp[i - 1][2])); // skip number i: streak resets9dp[i][1] = dp[i - 1][0] + a[i - 1]; // pick it, starting a streak10dp[i][2] = dp[i - 1][1] + a[i - 1]; // pick it, extending a streak11}12return Math.max(dp[a.length][0], Math.max(dp[a.length][1], dp[a.length][2]));13}
Dependency acyclicity
The recurrence must not be circular. A state that depends, directly or through others, on itself has no value to compute. Check that **every dependency points to a state that is evaluated earlier**: smaller index, smaller amount, fewer items, a strict subset. When that holds, the loop order in the bottom-up code is a topological order and a top-down recursion terminates.
A recurrence like f(i) = min(f(i - 1), f(i + 1)) + cost[i] has cycles (i depends on i + 1, which depends on i), and plain dynamic programming does not apply. Shortest paths in graphs with cycles need Dijkstra or Bellman-Ford, because the states have no natural order. Look at the DAG view: if the graph has a cycle, use a graph algorithm.
1// deps.get(s) lists the states that s depends on; returns true when the dependency graph has no cycle (Kahn's algorithm)2static boolean isAcyclic(int states, List<List<Integer>> deps) {3int[] pending = new int[states];4List<List<Integer>> users = new ArrayList<>();5for (int s = 0; s < states; s++) users.add(new ArrayList<>());6for (int s = 0; s < states; s++) for (int d : deps.get(s)) { pending[s]++; users.get(d).add(s); }7Deque<Integer> ready = new ArrayDeque<>();8for (int s = 0; s < states; s++) if (pending[s] == 0) ready.add(s);9int done = 0;10while (!ready.isEmpty()) {11int s = ready.poll();12done++;13for (int u : users.get(s)) if (--pending[u] == 0) ready.add(u);14}15return done == states; // every state could be evaluated after its dependencies16}
Induction on state order
The correctness proof of a DP is an **induction over the evaluation order**. Claim: for every state s, the computed value equals the true optimum for s. *Base:* the base cases are correct by definition. *Step:* assume every state before s is correct (the inductive hypothesis); the recurrence for s combines those values over all possible last decisions, and by optimal substructure the best of the combinations is the optimum for s.
For house robber: base best[0] = 0 is correct (no houses, no loot). Step: the optimum for the first i houses either skips house i, whose best is the optimum for the first i - 1 houses, or robs it, which forbids house i - 1 and leaves the optimum for the first i - 2 houses plus value i. These two cases are exhaustive, so the larger is the optimum.
Backing the proof with a cheap **stress test** catches implementation slips the proof does not: run the DP and a brute force on thousands of random small inputs and compare.
1// stress test: the DP must agree with exhaustive search on every small random input2static boolean stressHouseRobber(int trials, long seed) {3Random rnd = new Random(seed);4for (int t = 0; t < trials; t++) {5int n = rnd.nextInt(10);6int[] value = new int[n];7for (int i = 0; i < n; i++) value[i] = rnd.nextInt(20);8if (robDp(value) != robBrute(value)) return false; // a counterexample: print 'value' to debug9}10return true;11}1213static int robDp(int[] value) {14int skip = 0, take = 0;15for (int v : value) { int nt = skip + v; skip = Math.max(skip, take); take = nt; }16return Math.max(skip, take);17}1819static int robBrute(int[] value) {20int best = 0;21for (int mask = 0; mask < (1 << value.length); mask++) {22if ((mask & (mask >> 1)) != 0) continue; // two adjacent houses chosen23int sum = 0;24for (int i = 0; i < value.length; i++) if ((mask >> i & 1) == 1) sum += value[i];25best = Math.max(best, sum);26}27return best;28}
House robber: take it or skip it
Interactive · edit the inputhouse money
dp[i] = best from the first i houses
For the first i houses, the best loot either skips house i (best of the first i−1) or robs it (its money plus the best of the first i−2, because the neighbour is off limits).
1static int rob(int[] a) { // no two adjacent houses2int n = a.length; int[] dp = new int[n + 1]; // dp[i] = best loot from the first i houses3dp[1] = a[0];4for (int i = 2; i <= n; i++)5dp[i] = Math.max(dp[i - 1], dp[i - 2] + a[i - 1]); // skip house i, or rob it and use the best before the neighbour6return dp[n];7}
Live state
houses
5
Execution trace (dry run table)Show 6 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | houses=5 | For the first i houses, the best loot either skips house i (best of the first i−1) or robs it (its money plus the best of the first i−2, because the neighbour is off limits). |
| 2 | L5 | i=2 skip=2 rob=7 dp[i]=7 | House 2: skipping keeps 2; robbing gives dp[0] + 7 = 7. Best 7 (rob it). |
| 3 | L5 | i=3 skip=7 rob=11 dp[i]=11 | House 3: skipping keeps 7; robbing gives dp[1] + 9 = 11. Best 11 (rob it). |
| 4 | L5 | i=4 skip=11 rob=10 dp[i]=11 | House 4: skipping keeps 11; robbing gives dp[2] + 3 = 10. Best 11 (skip it). |
| 5 | L5 | i=5 skip=11 rob=12 dp[i]=12 | House 5: skipping keeps 11; robbing gives dp[3] + 1 = 12. Best 12 (rob it). |
| 6 | L6 | return=12 houses=[1, 3, 5] | Maximum loot 12. Tracing the take/skip decisions backwards from house 5 gives the houses [1, 3, 5]. |
Transition exhaustiveness
The recurrence must list **every legal last decision** and **only legal ones**. Missing an option makes the answer too poor (the optimum may be one you never considered); including an illegal option makes it infeasible. Exhaustiveness is the other half of the induction step above: the 'best of the combinations' is the optimum only if the combinations cover every possibility.
Edit distance (the fewest insertions, deletions and replacements turning one string into another) is the standard illustration. At the last characters you either match them for free, replace one, delete one or insert one, four cases in all. Dropping 'replace' still gives a plausible-looking table but computes something else: for "kitten" and "sitting" the correct answer is 3, while a table with only insertions and deletions gives 5.
A practical check is to write the decision as a sentence, 'the last thing that happens is one of: ...', and count the cases against the code's branches.
1// dp[i][j] = fewest edits turning the first i characters of a into the first j characters of b2static int editDistance(String a, String b) {3int m = a.length(), n = b.length();4int[][] dp = new int[m + 1][n + 1];5for (int i = 0; i <= m; i++) dp[i][0] = i; // delete everything6for (int j = 0; j <= n; j++) dp[0][j] = j; // insert everything7for (int i = 1; i <= m; i++) {8for (int j = 1; j <= n; j++) {9int same = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;10dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, // delete a's last character11dp[i][j - 1] + 1), // insert b's last character12dp[i - 1][j - 1] + same); // match (0) or replace (1)13}14}15return dp[m][n];16}1718// the same table with the replace option left out: insertions and deletions only19static int editDistanceNoReplace(String a, String b) {20int m = a.length(), n = b.length();21int[][] dp = new int[m + 1][n + 1];22for (int i = 0; i <= m; i++) dp[i][0] = i;23for (int j = 0; j <= n; j++) dp[0][j] = j;24for (int i = 1; i <= m; i++) {25for (int j = 1; j <= n; j++) {26dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1) ? dp[i - 1][j - 1] : Math.min(dp[i - 1][j], dp[i][j - 1]) + 1;27}28}29return dp[m][n];30}
Practice ladder
Now try it yourself
Easy
- House Robber (stress test)
- Min Cost Climbing Stairs (stress test)
Medium
- Paint House
- Best Time to Buy and Sell Stock with Cooldown
Advanced
- Best Time to Buy and Sell Stock IV
- Dungeon Game
Key takeaways
- A state is complete when the best finish depends only on the state; add the missing fact otherwise.
- Every dependency must point to an earlier state; a cycle means a graph algorithm, not plain DP.
- Correctness is induction over the evaluation order: base cases, then the recurrence over all last decisions.
- Stress-test the DP against brute force on small random inputs.