Skip to content
Commitwise
Menu
Coding Interview

Dynamic programming

Turn exponential recursion into fast solutions by remembering subproblem answers, with a five-step recipe, Climbing Stairs, House Robber, Coin Change, knapsack and Edit Distance in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A recursive function that computes the 50th Fibonacci number the obvious way makes over 40 billion calls. The same function with a small dictionary of answers it has already computed makes 99. That dictionary is the whole idea of dynamic programming (DP): when a problem breaks into smaller problems that repeat, solve each one once and remember it.

DP has a reputation for being the hardest interview topic. It’s less magic than it looks: a fixed recipe of five questions solves most problems you’ll meet.

New to some of these? Open what you need, or skip ahead.

Basics:What is recursion?

A recursive function calls itself on a smaller version of the problem, until it reaches a case small enough to answer directly (the base case):

countdown(3) → prints 3, calls countdown(2) → prints 2, calls countdown(1) → prints 1, calls countdown(0) → stops

Two rules keep it correct: always have a base case, and make sure every call gets closer to it. Each call waits on the call stack until the smaller call finishes, so very deep recursion (hundreds of thousands of levels) can run out of stack space.

Basics:What are arrays and indexes?

An array is a row of values stored one after another: [5, 8, 2, 9]. Each position has an index, starting at 0: here 5 is at index 0 and 9 at index 3. The last index is always count - 1 (Swift) or size - 1 / length - 1 (Kotlin, Java, TypeScript).

Reading nums[2] is instant, whatever the array’s size. Reading an index that doesn’t exist (like nums[4] here) crashes with an “index out of range” error (in TypeScript it quietly gives undefined, a bug of its own), which is why loops usually go from 0 up to, but not including, the length.

Basics:What does O(n) mean?

Big-O describes how the amount of work grows with the size of the input n:

  • O(1): the same work whatever the size (reading array[5]).
  • O(log n): grows very slowly (about 20 steps for a million items).
  • O(n): grows in step with the input (one pass over the array).
  • O(n²): comparing every item with every other item, which gets slow fast.

You’ll see it next to every solution, for time and for extra space (memory). The Big-O tutorial explains it properly.

Part Not started

Remember what you’ve already solved

Imagine someone asks you “what’s 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1?” You count: 8. Then they add ”+ 1” and ask again. You don’t recount from the start; you answer 9 immediately, because you remember 8.

Dynamic programming is that habit, in code:

  1. Break the problem into smaller versions of itself (“the number of ways to climb 10 stairs depends on the ways to climb 9 and 8”).
  2. Notice the overlap: the same smaller problems are needed again and again.
  3. Store each answer the first time you compute it, and reuse it after that.

You can do it top-down (recursion plus a cache, called memoizationMemoizationCaching the results of a function call so the same inputs are never computed twice.Open in glossary →) or bottom-up (fill a table from the smallest problems upward, called tabulation).

DP applies when a problem has optimal substructure (an optimal answer is built from optimal answers to subproblems) and overlapping subproblems (the same subproblems recur). The work is modeling: choose a state that captures exactly what the future depends on, a transition relating it to smaller states, base cases, and an evaluation order. Complexity is then (number of states) × (work per transition).

Senior-level questions go further: 2-D states (sequences, grids), the loop order that distinguishes 0/1 from unbounded knapsack, space optimization with rolling arrays, reconstructing the actual solution, and the trade-offs between recursion with memoization (only reachable states, but stack depth) and iteration. All in the Detailed version.

// Exponential: fib(n - 2) is computed again inside fib(n - 1), and so on.
func slowFib(_ n: Int) -> Int {
    n < 2 ? n : slowFib(n - 1) + slowFib(n - 2)
}

// Top-down: the same recursion, with a cache.
func fib(_ n: Int, _ memo: inout [Int: Int]) -> Int {
    if n < 2 { return n }
    if let known = memo[n] { return known }
    let result = fib(n - 1, &memo) + fib(n - 2, &memo)
    memo[n] = result
    return result
}

// Bottom-up: from the smallest answers up, keeping only what's needed.
func fastFib(_ n: Int) -> Int {
    var (a, b) = (0, 1)                 // fib(0), fib(1)
    for _ in 0..<n { (a, b) = (b, a + b) }
    return a
}
// Exponential: fib(n - 2) is computed again inside fib(n - 1), and so on.
fun slowFib(n: Int): Long = if (n < 2) n.toLong() else slowFib(n - 1) + slowFib(n - 2)

// Top-down: the same recursion, with a cache.
fun fib(n: Int, memo: MutableMap<Int, Long> = HashMap()): Long {
    if (n < 2) return n.toLong()
    memo[n]?.let { return it }
    return (fib(n - 1, memo) + fib(n - 2, memo)).also { memo[n] = it }
}

// Bottom-up: from the smallest answers up, keeping only what's needed.
fun fastFib(n: Int): Long {
    var a = 0L
    var b = 1L                          // fib(0), fib(1)
    repeat(n) { val next = a + b; a = b; b = next }
    return a
}
// Exponential: fib(n - 2) is computed again inside fib(n - 1), and so on.
static long slowFib(int n) {
    return n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
}

// Top-down: the same recursion, with a cache.
static long fib(int n, Map<Integer, Long> memo) {
    if (n < 2) return n;
    Long known = memo.get(n);
    if (known != null) return known;
    long result = fib(n - 1, memo) + fib(n - 2, memo);
    memo.put(n, result);
    return result;
}

// Bottom-up: from the smallest answers up, keeping only what's needed.
static long fastFib(int n) {
    long a = 0, b = 1;                  // fib(0), fib(1)
    for (int i = 0; i < n; i++) { long next = a + b; a = b; b = next; }
    return a;
}
// Exponential: fib(n - 2) is computed again inside fib(n - 1), and so on.
function slowFib(n: number): number {
  return n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
}

// Top-down: the same recursion, with a cache.
function fib(n: number, memo = new Map<number, number>()): number {
  if (n < 2) return n;
  const known = memo.get(n);
  if (known !== undefined) return known;      // not "if (known)": 0 is a valid answer
  const result = fib(n - 1, memo) + fib(n - 2, memo);
  memo.set(n, result);
  return result;
}

// Bottom-up: from the smallest answers up, keeping only what's needed.
function fastFib(n: number): number {
  let [a, b] = [0, 1];                        // fib(0), fib(1)
  for (let i = 0; i < n; i++) [a, b] = [b, a + b];
  return a;                                   // exact up to fib(78); use bigint beyond
}

All three compute the same numbers. The slow one takes O(2ⁿ) time because it recomputes the same values over and over; the two DP versions compute each value once, O(n). The bottom-up version also notices that it only ever needs the last two answers, so it uses O(1) memory.

A cache that forgets

Spot the bugMemoization that doesn't speed anything up

Tap the line with the bug.

func ways(_ n: Int) -> Int {
    var memo: [Int: Int] = [:]
    if n <= 2 { return n }
    if let known = memo[n] { return known }
    let result = ways(n - 1) + ways(n - 2)
    memo[n] = result
    return result
}
fun ways(n: Int): Int {
    val memo = HashMap<Int, Int>()
    if (n <= 2) return n
    memo[n]?.let { return it }
    val result = ways(n - 1) + ways(n - 2)
    memo[n] = result
    return result
}
int ways(int n) {
    Map<Integer, Integer> memo = new HashMap<>();
    if (n <= 2) return n;
    if (memo.containsKey(n)) return memo.get(n);
    int result = ways(n - 1) + ways(n - 2);
    memo.put(n, result);
    return result;
}
function ways(n: number): number {
  const memo = new Map<number, number>();
  if (n <= 2) return n;
  const known = memo.get(n);
  if (known !== undefined) return known;
  const result = ways(n - 1) + ways(n - 2);
  memo.set(n, result);
  return result;
}

How bad is “without memory”?

Predict the output

How many times is fib called to compute fib(5) with the naive recursion?

var calls = 0
func fib(_ n: Int) -> Int {
    calls += 1
    return n < 2 ? n : fib(n - 1) + fib(n - 2)
}
_ = fib(5)
print(calls)
var calls = 0
fun fib(n: Int): Int {
    calls++
    return if (n < 2) n else fib(n - 1) + fib(n - 2)
}

fib(5)
println(calls)
static int calls = 0;
static int fib(int n) {
    calls++;
    return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

fib(5);
System.out.println(calls);
let calls = 0;
function fib(n: number): number {
  calls++;
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

fib(5);
console.log(calls);

Top-down or bottom-up?

Memoized recursion is often easier to write correctly (it mirrors the recurrence) and only visits reachable states, which matters for sparse state spaces. Its costs: recursion depth (a top-down solution over 10⁵ states can overflow the stack), hash-map overhead, and harder space optimization. Bottom-up loops are faster in practice and make the memory layout explicit. A common workflow: write the recurrence top-down, then convert it to a table once the order is clear.

  • Top-down memoization mirrors the recurrence and visits only reachable states, but costs recursion depth and hashing; bottom-up tables are faster and make memory explicit. Write it top-down, convert once the order is clear.
  • The memo must be shared by one computation’s calls: not recreated per call, not a global that outlives it.

Check this part

  1. What makes a problem suitable for dynamic programming?

  2. What is memoization?

  3. A memo dictionary is created inside the recursive function. What happens?

  4. A top-down solution has 10⁵ states in a chain. What can go wrong that a bottom-up loop avoids?

When subproblems repeat, solve each once and remember it: top-down with recursion and a shared cache (memoization), or bottom-up by filling a table. Exponential recursion becomes O(number of states).

Part Not started

The last decision

Climbing Stairs: a staircase has n steps, and each time you can climb 1 or 2. In how many distinct ways can you reach the top? (n = 3 → 3: 1+1+1, 1+2, 2+1.) Think about the last move: to stand on step n, you were on step n − 1 (then took 1 step) or on step n − 2 (then took 2). So ways(n) = ways(n − 1) + ways(n − 2), with ways(1) = 1 and ways(2) = 2: Fibonacci in disguise.

func climbStairs(_ n: Int) -> Int {
    if n <= 2 { return n }
    var (twoBelow, oneBelow) = (1, 2)            // ways(1), ways(2)
    for _ in 3...n {
        (twoBelow, oneBelow) = (oneBelow, twoBelow + oneBelow)
    }
    return oneBelow
}
fun climbStairs(n: Int): Int {
    if (n <= 2) return n
    var twoBelow = 1                             // ways(1)
    var oneBelow = 2                             // ways(2)
    repeat(n - 2) {
        val current = twoBelow + oneBelow
        twoBelow = oneBelow
        oneBelow = current
    }
    return oneBelow
}
int climbStairs(int n) {
    if (n <= 2) return n;
    int twoBelow = 1, oneBelow = 2;              // ways(1), ways(2)
    for (int step = 3; step <= n; step++) {
        int current = twoBelow + oneBelow;
        twoBelow = oneBelow;
        oneBelow = current;
    }
    return oneBelow;
}
function climbStairs(n: number): number {
  if (n <= 2) return n;
  let [twoBelow, oneBelow] = [1, 2];         // ways(1), ways(2)
  for (let step = 3; step <= n; step++) {
    [twoBelow, oneBelow] = [oneBelow, twoBelow + oneBelow];
  }
  return oneBelow;
}

The insight is the last move: every way to reach step n ends with a 1-step or a 2-step, and those two groups don’t overlap, so their counts add. “What was the last decision?” is the most useful question in DP.

Practice: the five-step recipe

Here’s the recipe on a classic: House Robber. Houses in a row hold some money; you can’t rob two neighbors (the alarms are connected). What’s the most you can take from [2, 7, 9, 3, 1]?

  1. State: what does a subproblem look like? best(i) = the most money from the first i + 1 houses.
  2. Transition (the last decision): for house i, either skip it (best(i − 1)) or rob it, which means skipping its neighbor (best(i − 2) + money[i]). Take the larger.
  3. Base cases: best(0) = money[0], best(1) = max(money[0], money[1]).
  4. Order: each value needs the two before it, so go left to right.
  5. Answer: best(last). And since only the last two values are used, two variables are enough.
imoneyskip: best(i−1)rob: best(i−2) + moneybest(i)
02——2
17277
2972 + 9 = 1111
33117 + 3 = 1011
411111 + 1 = 1212
func rob(_ money: [Int]) -> Int {
    var (twoBack, oneBack) = (0, 0)              // best up to i − 2, best up to i − 1
    for amount in money {
        (twoBack, oneBack) = (oneBack, max(oneBack, twoBack + amount))   // skip or rob
    }
    return oneBack
}
fun rob(money: IntArray): Int {
    var twoBack = 0                              // best up to i − 2
    var oneBack = 0                              // best up to i − 1
    for (amount in money) {
        val best = maxOf(oneBack, twoBack + amount)                         // skip or rob
        twoBack = oneBack
        oneBack = best
    }
    return oneBack
}
int rob(int[] money) {
    int twoBack = 0, oneBack = 0;                // best up to i − 2, best up to i − 1
    for (int amount : money) {
        int best = Math.max(oneBack, twoBack + amount);                     // skip or rob
        twoBack = oneBack;
        oneBack = best;
    }
    return oneBack;
}
function rob(money: number[]): number {
  let [twoBack, oneBack] = [0, 0];           // best up to i − 2, best up to i − 1
  for (const amount of money) {
    [twoBack, oneBack] = [oneBack, Math.max(oneBack, twoBack + amount)];   // skip or rob
  }
  return oneBack;
}

Starting both variables at 0 makes the base cases fall out of the loop: an empty street yields 0, one house yields its money.

State design and evaluation order

  • One dimension: prefixes (best[i] over the first i items), amounts (fewest[a]), positions.
  • Two dimensions: pairs of prefixes for two sequences (LCS, Edit Distance), items × capacity (knapsack), grid cells (unique paths), intervals [i, j] (palindromic substrings, burst balloons).
  • Order follows dependencies: every state must be computed after the states it reads. For intervals, iterate by increasing length; for knapsack, the direction of the capacity loop decides whether an item can be reused (the puzzle below).
  • Space: when row i only reads rows i and i − 1, keep two rows (or one, iterating in the right direction): O(n·m) memory becomes O(m).
  • Reconstruction: to output the actual choices (which coins, which edits), store the decision per state or walk the finished table backwards; space optimization usually makes this harder, so decide which you need.
Exercisemedium

Edit Distance

Given two words, return the minimum number of operations to turn the first into the second, where an operation inserts, deletes or replaces one character. (This is the Levenshtein distance behind “did you mean…?” and fuzzy search.)

"horse" → "ros"            = 3   (replace h→r, delete r, delete e)
"intention" → "execution"  = 5

Give the time and space complexity, and how to reduce the space.

›

State: d[i][j] = the distance between the first i characters of word 1 and the first j of word 2. Base cases: turning i characters into nothing takes i deletions.

›

Last decision: if the last characters match, d[i][j] = d[i−1][j−1]. Otherwise, 1 + the minimum of replace (d[i−1][j−1]), delete (d[i−1][j]) and insert (d[i][j−1]).

SolutionTry it yourself firstTime O(m × n)Space O(m × n), reducible to O(min(m, n))
func minDistance(_ word1: String, _ word2: String) -> Int {
    let a = Array(word1), b = Array(word2)
    var d = Array(repeating: Array(repeating: 0, count: b.count + 1), count: a.count + 1)
    for i in 0...a.count { d[i][0] = i }
    for j in 0...b.count { d[0][j] = j }
    for i in stride(from: 1, through: a.count, by: 1) {
        for j in stride(from: 1, through: b.count, by: 1) {
            d[i][j] = a[i - 1] == b[j - 1]
                ? d[i - 1][j - 1]
                : 1 + min(d[i - 1][j - 1], d[i - 1][j], d[i][j - 1])
        }
    }
    return d[a.count][b.count]
}
fun minDistance(a: String, b: String): Int {
    val d = Array(a.length + 1) { IntArray(b.length + 1) }
    for (i in 0..a.length) d[i][0] = i                 // delete everything
    for (j in 0..b.length) d[0][j] = j                 // insert everything
    for (i in 1..a.length) {
        for (j in 1..b.length) {
            d[i][j] = if (a[i - 1] == b[j - 1]) {
                d[i - 1][j - 1]                        // last characters match: free
            } else {
                1 + minOf(d[i - 1][j - 1],             // replace
                          d[i - 1][j],                 // delete from a
                          d[i][j - 1])                 // insert into a
            }
        }
    }
    return d[a.length][b.length]
}
int minDistance(String a, String b) {
    int[][] d = new int[a.length() + 1][b.length() + 1];
    for (int i = 0; i <= a.length(); i++) d[i][0] = i;
    for (int j = 0; j <= b.length(); j++) d[0][j] = j;
    for (int i = 1; i <= a.length(); i++) {
        for (int j = 1; j <= b.length(); j++) {
            d[i][j] = a.charAt(i - 1) == b.charAt(j - 1)
                ? d[i - 1][j - 1]
                : 1 + Math.min(d[i - 1][j - 1], Math.min(d[i - 1][j], d[i][j - 1]));
        }
    }
    return d[a.length()][b.length()];
}
function minDistance(a: string, b: string): number {
  const d = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
  for (let i = 0; i <= a.length; i++) d[i][0] = i;   // delete everything
  for (let j = 0; j <= b.length; j++) d[0][j] = j;   // insert everything
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      d[i][j] = a[i - 1] === b[j - 1]
        ? d[i - 1][j - 1]                            // last characters match: free
        : 1 + Math.min(d[i - 1][j - 1], d[i - 1][j], d[i][j - 1]);
    }
  }
  return d[a.length][b.length];
}

O(m × n) states, O(1) work each. Each row only reads the row above and the current row, so two rows of length min(m, n) + 1 are enough (swap the words so the shorter one is the columns), giving O(min(m, n)) space, at the cost of not being able to walk the table back to list the actual edits. Production notes: for spelling suggestions over a big dictionary, you don’t run this against every word; a BK-tree or a bounded search (stop when the distance exceeds 2) does the filtering, and Unicode needs care (compare grapheme clusters, which Swift’s Character already does; in TypeScript, Intl.Segmenter splits text into them).

Deep dives

Deep diveLongest increasing subsequence in O(n log n)Interview favorite

The textbook DP for the longest increasing subsequence is O(n²): lis[i] = 1 + max(lis[j]) over earlier, smaller elements. The O(n log n) version keeps an array tails, where tails[k] is the smallest possible last element of an increasing subsequence of length k + 1.

For each number, binary search the first tail that is >= it and replace it, or append if there’s none. tails stays sorted, and its length is the answer (it isn’t itself a valid subsequence, which is a classic follow-up trap). The idea comes from the card game patience sorting, and it’s a favorite “can you do better?” question.

StepQuestion
1. StateWhat smallest description of a subproblem does the future depend on?
2. TransitionWhat was the last decision, and how does it relate to smaller states?
3. Base casesWhat are the trivially known states (empty, zero, first element)?
4. OrderWhich states must be computed before which (loop direction!)?
5. Answer & spaceWhich state is the answer; can rows be rolled to save memory?

Classics: 1-D: climbing stairs, house robber, coin change, longest increasing subsequence (O(n²) or O(n log n)). 2-D: LCS, edit distance, unique paths, 0/1 knapsack (capacity loop down), unbounded knapsack (loop up), interval DP by length. Gotchas: memo recreated per call, MAX_VALUE + 1 overflow, wrong loop direction, top-down recursion depth, greedy where DP is needed.

var best = Array(repeating: 0, count: n + 1)
best[0] = base
for i in 1...n { best[i] = transition(best, i) }          // only reads smaller indices
val best = IntArray(n + 1)
best[0] = base
for (i in 1..n) best[i] = transition(best, i)            // only reads smaller indices
int[] best = new int[n + 1];
best[0] = base;
for (int i = 1; i <= n; i++) best[i] = transition(best, i);   // only reads smaller indices
const best = new Array<number>(n + 1).fill(0);
best[0] = base;
for (let i = 1; i <= n; i++) best[i] = transition(best, i);   // only reads smaller indices

Check this part

  1. In Climbing Stairs, why is ways(n) = ways(n − 1) + ways(n − 2)?

  2. Why can the bottom-up Climbing Stairs use O(1) memory?

  3. How can Edit Distance use O(min(m, n)) space?

  4. What is the time complexity of a DP solution, in general?

Five questions solve most DP problems: the state, the transition (what was the last decision?), base cases, evaluation order, and the answer. Complexity is the number of states times the work per transition.

Part Not started

When greedy isn’t enough

Always taking the biggest coin feels right, and fails: with coins [1, 3, 4] and amount 6, greedy picks 4 + 1 + 1 (three coins), while 3 + 3 needs two. DP tries every possible last coin instead: fewest(a) = 1 + min over coins c ≤ a of fewest(a − c), with fewest(0) = 0, filled from 1 up to the amount.

Exercisemedium

Coin Change

Given coin values and an amount, return the fewest coins that add up to the amount (you have unlimited coins of each value), or -1 if it’s impossible.

coins = [1, 2, 5], amount = 11  → 3   (5 + 5 + 1)
coins = [2], amount = 3         → -1
coins = [1], amount = 0         → 0
›

Greedy (always take the biggest coin) fails for coins like [1, 3, 4] and amount 6: greedy gives 4 + 1 + 1, the best is 3 + 3. Use the recipe: what’s the state, and what’s the last decision?

›

State: fewest(a) for every amount a from 0 to the target. Last decision: which coin was used last? fewest(a) = 1 + min over coins c ≤ a of fewest(a − c), with fewest(0) = 0. Fill it from 1 upward.

SolutionTry it yourself firstTime O(amount × coins)Space O(amount)
func coinChange(_ coins: [Int], _ amount: Int) -> Int {
    let impossible = amount + 1                    // more coins than any real answer
    var fewest = Array(repeating: impossible, count: amount + 1)
    fewest[0] = 0
    if amount > 0 {
        for a in 1...amount {
            for coin in coins where coin <= a {
                fewest[a] = min(fewest[a], fewest[a - coin] + 1)
            }
        }
    }
    return fewest[amount] == impossible ? -1 : fewest[amount]
}
fun coinChange(coins: IntArray, amount: Int): Int {
    val impossible = amount + 1                    // more coins than any real answer
    val fewest = IntArray(amount + 1) { impossible }
    fewest[0] = 0
    for (a in 1..amount) {
        for (coin in coins) {
            if (coin <= a) fewest[a] = minOf(fewest[a], fewest[a - coin] + 1)
        }
    }
    return if (fewest[amount] == impossible) -1 else fewest[amount]
}
int coinChange(int[] coins, int amount) {
    int impossible = amount + 1;                   // more coins than any real answer
    int[] fewest = new int[amount + 1];
    Arrays.fill(fewest, impossible);
    fewest[0] = 0;
    for (int a = 1; a <= amount; a++) {
        for (int coin : coins) {
            if (coin <= a) fewest[a] = Math.min(fewest[a], fewest[a - coin] + 1);
        }
    }
    return fewest[amount] == impossible ? -1 : fewest[amount];
}
function coinChange(coins: number[], amount: number): number {
  const impossible = amount + 1;                 // more coins than any real answer
  const fewest = new Array<number>(amount + 1).fill(impossible);
  fewest[0] = 0;
  for (let a = 1; a <= amount; a++) {
    for (const coin of coins) {
      if (coin <= a) fewest[a] = Math.min(fewest[a], fewest[a - coin] + 1);
    }
  }
  return fewest[amount] === impossible ? -1 : fewest[amount];
}

amount + 1 works as “impossible” because no real answer can use more coins than the amount itself (all coins are at least 1), and unlike Int.MAX, adding 1 to it can’t overflow (see the senior puzzle). For amount 11 with [1, 2, 5]: fewest[5] = 1, fewest[10] = 2, fewest[11] = fewest[10] + 1 = 3. Follow-up: “how many ways to make the amount” (Coin Change II) uses the same table with sums instead of minimums, and the loop order matters there to avoid counting 1 + 2 and 2 + 1 separately.

Using the same item twice

Predict the output

0/1 knapsack in one array: one item with weight 2 and value 3, capacity 4, and the capacity loop goes upward. What gets printed?

let weights = [2], values = [3], capacity = 4
var best = Array(repeating: 0, count: capacity + 1)
for i in weights.indices {
    for c in weights[i]...capacity {                       // upward
        best[c] = max(best[c], best[c - weights[i]] + values[i])
    }
}
print(best[capacity])
val weights = intArrayOf(2)
val values = intArrayOf(3)
val capacity = 4
val best = IntArray(capacity + 1)
for (i in weights.indices) {
    for (c in weights[i]..capacity) {                      // upward
        best[c] = maxOf(best[c], best[c - weights[i]] + values[i])
    }
}
println(best[capacity])
int[] weights = {2}, values = {3};
int capacity = 4;
int[] best = new int[capacity + 1];
for (int i = 0; i < weights.length; i++) {
    for (int c = weights[i]; c <= capacity; c++) {         // upward
        best[c] = Math.max(best[c], best[c - weights[i]] + values[i]);
    }
}
System.out.println(best[capacity]);
const weights = [2], values = [3], capacity = 4;
const best = new Array<number>(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
  for (let c = weights[i]; c <= capacity; c++) {          // upward
    best[c] = Math.max(best[c], best[c - weights[i]] + values[i]);
  }
}
console.log(best[capacity]);

“Infinity” plus one

Spot the bugCoin Change that returns negative coins

Tap the line with the bug.

func coinChange(_ coins: [Int], _ amount: Int) -> Int {
    var dp = Array(repeating: Int.max, count: amount + 1)
    dp[0] = 0
    for a in stride(from: 1, through: amount, by: 1) {
        for coin in coins where coin <= a {
            dp[a] = min(dp[a], dp[a - coin] + 1)
        }
    }
    return dp[amount] == Int.max ? -1 : dp[amount]
}
fun coinChange(coins: IntArray, amount: Int): Int {
    val dp = IntArray(amount + 1) { Int.MAX_VALUE }
    dp[0] = 0
    for (a in 1..amount) {
        for (coin in coins) {
            if (coin <= a) dp[a] = minOf(dp[a], dp[a - coin] + 1)
        }
    }
    return if (dp[amount] == Int.MAX_VALUE) -1 else dp[amount]
}
int coinChange(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 coin : coins) {
            if (coin <= a) dp[a] = Math.min(dp[a], dp[a - coin] + 1);
        }
    }
    return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}

  • 1-D knapsack: iterate capacity down for 0/1 (each item once), up for unbounded (coins). Coin Change II (number of ways) puts coins in the outer loop so 1 + 2 and 2 + 1 count once.
  • Sentinels: amount + 1 (or skip unreachable states) instead of MAX_VALUE, which overflows or traps on + 1.

Check this part

  1. Why does greedy (largest coin first) fail for coins [1, 3, 4] and amount 6?

  2. What is fewest(0) in Coin Change?

  3. In a 1-D knapsack table, what does iterating capacity downward ensure?

  4. Why is Int.max a risky "impossible" value in min-based DP?Why is Int.MAX_VALUE a risky "impossible" value in min-based DP?Why is Integer.MAX_VALUE a risky "impossible" value in min-based DP?

  5. Coin Change uses Infinity for amounts that can't be made. What is Infinity + 1, and why does it matter?

Greedy picks one choice; DP tries every possible last choice. Coin Change fills fewest(a) from 0 upward. Loop direction decides whether an item can be reused, and a sentinel for impossible states must survive adding 1.

Key takeaways

  • DP = solve each repeated subproblem once and remember it: memoization (top-down) or tabulation (bottom-up).
  • Use the recipe: state, transition (the last decision), base cases, order, answer.
  • Share one memo across all calls of a computation; keep only the states you still need.
  • When greedy fails (coin change), DP considers every last choice.
  • Design the state to hold exactly what the future depends on; complexity is states × transition work.
  • Loop direction encodes the problem: down for 0/1 knapsack, up for unbounded.
  • Roll arrays to cut space, but keep decisions if you must reconstruct the solution.
  • Watch overflow in sentinels and recursion depth in top-down solutions.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.