Dynamic Programming: 1D

Solve each subproblem once: define a state, write a recurrence from the last decision, then fill a table from the smallest case up.

IntermediateRead first: Recursion

How It Works

Dynamic programming is recursion that remembers. Plenty of recursive solutions ask the same question over and over: counting the ways up 5 stairs asks about 3 stairs twice and about 2 stairs three times. DP answers each distinct question once, stores the answer and looks it up from then on. In 1D problems, one number identifies each question: an index into the input, a step, an amount of money.

                   ways(5)
            +---------+---------+
         ways(4)             ways(3)
       +----+----+         +----+----+
    ways(3)   ways(2)   ways(2)   ways(1)
   +---+---+
ways(2) ways(1)
Plain recursion for 5 stairs, with the calls below ways(2) left out. ways(3) is solved twice and ways(2) three times, and the repeats grow exponentially with n.

A DP solution needs a state, a precise description of one subproblem, and a recurrence, a rule that builds a state's answer from smaller states. Work them out in this order before writing any code:

  1. Define the state in one sentence. dp[i] = the number of ways to reach step i. If you can't say exactly what dp[i] means, the code that fills it will be wrong in ways that are hard to spot.
  2. Find the recurrence by asking what the last decision was. The last move onto step i was either 1 step (from i - 1) or 2 steps (from i - 2). Those cases don't overlap and they cover everything, so dp[i] = dp[i - 1] + dp[i - 2].
  3. Set the base cases, the smallest states, which you answer directly. dp[0] = 1 (taking no moves is the one way to stand on step 0) and dp[1] = 1.
  4. Choose an order that has every state's inputs ready before the state itself. Here that's increasing i.
  5. Read off the answer. Here it's dp[n]. In other problems it might be the best value over all dp[i].

Filling the Climbing Stairs Table

Step 1 of 7

dp

  • Entry being filled
  • Entries it reads
  • Answer

dp[i] is the number of ways to reach step i. For n = 5 the table needs 6 entries, one for each step from 0 to 5.

All 7 steps as text
  1. dp[i] is the number of ways to reach step i. For n = 5 the table needs 6 entries, one for each step from 0 to 5.
  2. Set the base cases directly. dp[0] = 1, because taking no moves is the one way to stand on step 0, and dp[1] = 1, a single 1-step move.
  3. dp[2] = dp[1] + dp[0] = 1 + 1 = 2. The last move onto step 2 was 1 step from step 1 or 2 steps from step 0, and those cases never overlap.
  4. dp[3] = dp[2] + dp[1] = 2 + 1 = 3. Both inputs are already final, because the loop fills the table in increasing i.
  5. dp[4] = dp[3] + dp[2] = 3 + 2 = 5. Plain recursion would solve ways(2) again inside ways(3). The table just reads it.
  6. dp[5] = dp[4] + dp[3] = 5 + 3 = 8. Plain recursion would solve ways(3) again inside ways(4). The table just reads it.
  7. dp[5] = 8 is the answer. Each entry was computed once from two lookups, so the fill is O(n). Each one read only the two entries before it, which is why two variables can replace the table.

Then decide how to compute it. Top-down (memoization) is the recursive function plus a cache, and it only visits states it actually needs. Bottom-up (tabulation) fills an array with a loop, with no recursion depth to worry about. When dp[i] only reads a fixed number of earlier entries, keep just those in variables and the space drops to O(1).

When to Reach for It

  • The question asks how many ways, or for the fewest, most or cheapest, over a sequence of choices.
  • Each decision depends only on what happened at the last one or two positions, like not robbing two neighboring houses.
  • A brute-force recursion would ask about the same index or amount many times.
  • A greedy rule looks tempting, but a small counterexample breaks it, as with coins [1, 3, 4] and amount 6.

Complexity

OperationTimeSpace
Plain recursion on Climbing StairsO(2^n)O(n) call stack
Memoized recursionO(n)O(n) cache and call stack
Bottom-up tableO(n)O(n)
Rolling variablesO(n)O(1)
Any DP, as a rule of thumbstates × work per statestates you keep

The Template

Climbing Stairs, Three Ways

Count the distinct ways to climb n stairs when each move is 1 or 2 steps (LeetCode 70). The state is ways(i), the number of ways to reach step i, and the recurrence is ways(i) = ways(i - 1) + ways(i - 2) with ways(0) = ways(1) = 1. LeetCode caps n at 45, the last value whose answer fits in a 32-bit int. The file computes it three ways, from most direct to most compact:

  1. Memoized recursion. The recurrence written as a function, plus a memo array. The first call for each i does the work and every later call returns the stored value, so each of the n states is computed once. 0 marks an unsolved entry, which is safe only because every real answer is at least 1.
  2. Bottom-up table. The same values in a dp array, filled from step 2 upward. By the time the loop reaches i, dp[i - 1] and dp[i - 2] are already final, and there's no recursion to overflow the stack.
  3. Rolling variables. dp[i] only reads the two entries just before it, so the rest of the table is dead weight. Two variables, twoBack and oneBack, slide up the stairs instead.
 i         0   1   2   3   4   5
 dp[i]     1   1   2   3   5   8
                       ^   ^   ^
                       3 + 5 = 8
Each entry is the sum of the two before it, so the rolling version only ever needs to hold two of them.

Time O(n)Space O(n) for memo and table, O(1) rolling

# Number of ways to climb n stairs (n >= 1) taking 1 or 2 steps at a time,
# computed three ways. All three use ways(i) = ways(i - 1) + ways(i - 2).


def climb_stairs_memo(n: int) -> int:
    """1. Memoized recursion: the recurrence as written, plus a cache so each
    step is solved only once."""
    memo = [0] * (n + 1)  # 0 means unsolved: every real answer is at least 1.
    return ways(n, memo)


def ways(i: int, memo: list[int]) -> int:
    if i <= 1:
        return 1  # One way to stand on step 0 (do nothing) and one to reach step 1.
    if memo[i] == 0:
        memo[i] = ways(i - 1, memo) + ways(i - 2, memo)
    return memo[i]


def climb_stairs_table(n: int) -> int:
    """2. Bottom-up table: fill dp from the bottom step up, so both inputs to
    dp[i] are always ready."""
    dp = [0] * (n + 1)  # dp[i] = ways to reach step i
    dp[0] = 1
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]  # The last move was either one step or two.
    return dp[n]


def climb_stairs_rolling(n: int) -> int:
    """3. Rolling variables: dp[i] only reads the two entries before it, so keep
    just those two."""
    two_back = 1  # ways(i - 2), starting at ways(0)
    one_back = 1  # ways(i - 1), starting at ways(1)
    for _ in range(2, n + 1):
        here = one_back + two_back  # ways(i)
        two_back = one_back
        one_back = here
    return one_back


if __name__ == "__main__":
    # Prints 1 1 1, 2 2 2, 3 3 3, then 1836311903 three times.
    for n in [1, 2, 3, 45]:
        print(climb_stairs_memo(n), climb_stairs_table(n), climb_stairs_rolling(n))
// Number of ways to climb n stairs (n >= 1) taking 1 or 2 steps at a time,
// computed three ways. All three use ways(i) = ways(i - 1) + ways(i - 2).

// 1. Memoized recursion: the recurrence as written, plus a cache so each step
// is solved only once.
export function climbStairsMemo(n: number): number {
  const memo = new Array<number>(n + 1).fill(0); // 0 means unsolved: every real answer is at least 1.
  return ways(n, memo);
}

function ways(i: number, memo: number[]): number {
  if (i <= 1) return 1; // One way to stand on step 0 (do nothing) and one to reach step 1.
  if (memo[i] === 0) memo[i] = ways(i - 1, memo) + ways(i - 2, memo);
  return memo[i];
}

// 2. Bottom-up table: fill dp from the bottom step up, so both inputs to
// dp[i] are always ready.
export function climbStairsTable(n: number): number {
  const dp = new Array<number>(n + 1).fill(0); // dp[i] = ways to reach step i
  dp[0] = 1;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]; // The last move was either one step or two.
  }
  return dp[n];
}

// 3. Rolling variables: dp[i] only reads the two entries before it, so keep
// just those two.
export function climbStairsRolling(n: number): number {
  let twoBack = 1; // ways(i - 2), starting at ways(0)
  let oneBack = 1; // ways(i - 1), starting at ways(1)
  for (let i = 2; i <= n; i++) {
    const here = oneBack + twoBack; // ways(i)
    twoBack = oneBack;
    oneBack = here;
  }
  return oneBack;
}

// Prints 1 1 1, 2 2 2, 3 3 3, then 1836311903 three times.
for (const n of [1, 2, 3, 45]) {
  console.log(climbStairsMemo(n), climbStairsTable(n), climbStairsRolling(n));
}
// Number of ways to climb n stairs (n >= 1) taking 1 or 2 steps at a time,
// computed three ways. All three use ways(i) = ways(i - 1) + ways(i - 2).
package main

import "fmt"

// climbStairsMemo is memoized recursion: the recurrence as written, plus a
// cache so each step is solved only once.
func climbStairsMemo(n int) int {
	memo := make([]int, n+1) // 0 means unsolved: every real answer is at least 1.
	return ways(n, memo)
}

func ways(i int, memo []int) int {
	if i <= 1 {
		return 1 // One way to stand on step 0 (do nothing) and one to reach step 1.
	}
	if memo[i] == 0 {
		memo[i] = ways(i-1, memo) + ways(i-2, memo)
	}
	return memo[i]
}

// climbStairsTable is a bottom-up table: fill dp from the bottom step up, so
// both inputs to dp[i] are always ready.
func climbStairsTable(n int) int {
	dp := make([]int, n+1) // dp[i] = ways to reach step i
	dp[0] = 1
	dp[1] = 1
	for i := 2; i <= n; i++ {
		dp[i] = dp[i-1] + dp[i-2] // The last move was either one step or two.
	}
	return dp[n]
}

// climbStairsRolling keeps rolling variables: dp[i] only reads the two
// entries before it, so keep just those two.
func climbStairsRolling(n int) int {
	twoBack := 1 // ways(i - 2), starting at ways(0)
	oneBack := 1 // ways(i - 1), starting at ways(1)
	for i := 2; i <= n; i++ {
		here := oneBack + twoBack // ways(i)
		twoBack = oneBack
		oneBack = here
	}
	return oneBack
}

func main() {
	// Prints 1 1 1, 2 2 2, 3 3 3, then 1836311903 three times.
	for _, n := range []int{1, 2, 3, 45} {
		fmt.Println(climbStairsMemo(n), climbStairsTable(n), climbStairsRolling(n))
	}
}
using System;

// Number of ways to climb n stairs (n >= 1) taking 1 or 2 steps at a time,
// computed three ways. All three use ways(i) = ways(i - 1) + ways(i - 2).
public class Solution
{
    // 1. Memoized recursion: the recurrence as written, plus a cache so each
    // step is solved only once.
    public static int ClimbStairsMemo(int n)
    {
        var memo = new int[n + 1]; // 0 means unsolved: every real answer is at least 1.
        return Ways(n, memo);
    }

    static int Ways(int i, int[] memo)
    {
        if (i <= 1) return 1; // One way to stand on step 0 (do nothing) and one to reach step 1.
        if (memo[i] == 0) memo[i] = Ways(i - 1, memo) + Ways(i - 2, memo);
        return memo[i];
    }

    // 2. Bottom-up table: fill dp from the bottom step up, so both inputs to
    // dp[i] are always ready.
    public static int ClimbStairsTable(int n)
    {
        var dp = new int[n + 1]; // dp[i] = ways to reach step i
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i <= n; i++)
        {
            dp[i] = dp[i - 1] + dp[i - 2]; // The last move was either one step or two.
        }
        return dp[n];
    }

    // 3. Rolling variables: dp[i] only reads the two entries before it, so keep
    // just those two.
    public static int ClimbStairsRolling(int n)
    {
        int twoBack = 1; // ways(i - 2), starting at ways(0)
        int oneBack = 1; // ways(i - 1), starting at ways(1)
        for (int i = 2; i <= n; i++)
        {
            int here = oneBack + twoBack; // ways(i)
            twoBack = oneBack;
            oneBack = here;
        }
        return oneBack;
    }

    public static void Main()
    {
        // Prints 1 1 1, 2 2 2, 3 3 3, then 1836311903 three times.
        foreach (int n in new[] { 1, 2, 3, 45 })
        {
            Console.WriteLine($"{ClimbStairsMemo(n)} {ClimbStairsTable(n)} {ClimbStairsRolling(n)}");
        }
    }
}
// Number of ways to climb n stairs (n >= 1) taking 1 or 2 steps at a time,
// computed three ways. All three use ways(i) = ways(i - 1) + ways(i - 2).
public class Solution {
    // 1. Memoized recursion: the recurrence as written, plus a cache so each
    // step is solved only once.
    static int climbStairsMemo(int n) {
        int[] memo = new int[n + 1]; // 0 means unsolved: every real answer is at least 1.
        return ways(n, memo);
    }

    static int ways(int i, int[] memo) {
        if (i <= 1) return 1; // One way to stand on step 0 (do nothing) and one to reach step 1.
        if (memo[i] == 0) memo[i] = ways(i - 1, memo) + ways(i - 2, memo);
        return memo[i];
    }

    // 2. Bottom-up table: fill dp from the bottom step up, so both inputs to
    // dp[i] are always ready.
    static int climbStairsTable(int n) {
        int[] dp = new int[n + 1]; // dp[i] = ways to reach step i
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2]; // The last move was either one step or two.
        }
        return dp[n];
    }

    // 3. Rolling variables: dp[i] only reads the two entries before it, so keep
    // just those two.
    static int climbStairsRolling(int n) {
        int twoBack = 1; // ways(i - 2), starting at ways(0)
        int oneBack = 1; // ways(i - 1), starting at ways(1)
        for (int i = 2; i <= n; i++) {
            int here = oneBack + twoBack; // ways(i)
            twoBack = oneBack;
            oneBack = here;
        }
        return oneBack;
    }

    public static void main(String[] args) {
        // Prints 1 1 1, 2 2 2, 3 3 3, then 1836311903 three times.
        for (int n : new int[] {1, 2, 3, 45}) {
            System.out.println(climbStairsMemo(n) + " " + climbStairsTable(n) + " " + climbStairsRolling(n));
        }
    }
}

Worked Problems

Each solution stays folded until you open it, so you can try the problem first. Every one is a complete program in Python, TypeScript, Go, C# and Java, run and checked against its expected output.

House Robber

MEDIUMLeetCode 198

Problem

Each house on a street holds some amount of money, given in order as nums. You can rob any set of houses except two that sit next to each other, because that trips the alarm. Return the most money you can take.

Example: [2, 7, 9, 3, 1] returns 12, by robbing houses 0, 2 and 4 for 2 + 9 + 1.

Show Solution

Approach

State: best(i) = the most money you can take from the first i houses. Now ask what happened to the last of them, house i - 1. Either you skipped it, and the answer is best(i - 1), or you robbed it, which rules out house i - 2 and leaves best(i - 2) + nums[i - 1]. Take the larger of the two.

 i             0    1    2    3    4    5
 house              2    7    9    3    1
 best(i)       0    2    7   11   11   12

 best(3) = max(best(2), best(1) + 9)
         = max(7, 2 + 9) = 11
best(i) covers the first i houses, so it sits under house i - 1, the last house it decides on.

Each value reads only the two before it, so two variables replace the table: oneBack holds best(i - 1) and twoBack holds best(i - 2). Both start at 0, because before the first house there's nothing to take.

Time O(n)Space O(1)

def rob(nums: list[int]) -> int:
    """Most money you can take from a row of houses without robbing two neighbors."""
    # best(i) = most money from the first i houses
    #         = max(best(i - 1), best(i - 2) + nums[i - 1])
    # Each value reads only the two before it, so keep just those two.
    two_back = 0  # best(i - 2)
    one_back = 0  # best(i - 1)
    for money in nums:
        # Skip this house and keep one_back, or rob it on top of two_back.
        here = max(one_back, two_back + money)
        two_back = one_back
        one_back = here
    return one_back


if __name__ == "__main__":
    print(rob([1, 2, 3, 1]))  # 4
    print(rob([2, 7, 9, 3, 1]))  # 12
    print(rob([2, 1, 1, 2]))  # 4
// Most money you can take from a row of houses without robbing two neighbors.
export function rob(nums: number[]): number {
  // best(i) = most money from the first i houses
  //         = max(best(i - 1), best(i - 2) + nums[i - 1])
  // Each value reads only the two before it, so keep just those two.
  let twoBack = 0; // best(i - 2)
  let oneBack = 0; // best(i - 1)
  for (const money of nums) {
    // Skip this house and keep oneBack, or rob it on top of twoBack.
    const here = Math.max(oneBack, twoBack + money);
    twoBack = oneBack;
    oneBack = here;
  }
  return oneBack;
}

console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([2, 7, 9, 3, 1])); // 12
console.log(rob([2, 1, 1, 2])); // 4
package main

import "fmt"

// rob returns the most money you can take from a row of houses without
// robbing two neighbors.
func rob(nums []int) int {
	// best(i) = most money from the first i houses
	//         = max(best(i - 1), best(i - 2) + nums[i - 1])
	// Each value reads only the two before it, so keep just those two.
	twoBack := 0 // best(i - 2)
	oneBack := 0 // best(i - 1)
	for _, money := range nums {
		// Skip this house and keep oneBack, or rob it on top of twoBack.
		here := max(oneBack, twoBack+money)
		twoBack = oneBack
		oneBack = here
	}
	return oneBack
}

func main() {
	fmt.Println(rob([]int{1, 2, 3, 1}))    // 4
	fmt.Println(rob([]int{2, 7, 9, 3, 1})) // 12
	fmt.Println(rob([]int{2, 1, 1, 2}))    // 4
}
using System;

public class Solution
{
    // Most money you can take from a row of houses without robbing two neighbors.
    public static int Rob(int[] nums)
    {
        // best(i) = most money from the first i houses
        //         = max(best(i - 1), best(i - 2) + nums[i - 1])
        // Each value reads only the two before it, so keep just those two.
        int twoBack = 0; // best(i - 2)
        int oneBack = 0; // best(i - 1)
        foreach (int money in nums)
        {
            // Skip this house and keep oneBack, or rob it on top of twoBack.
            int here = Math.Max(oneBack, twoBack + money);
            twoBack = oneBack;
            oneBack = here;
        }
        return oneBack;
    }

    public static void Main()
    {
        Console.WriteLine(Rob(new[] { 1, 2, 3, 1 })); // 4
        Console.WriteLine(Rob(new[] { 2, 7, 9, 3, 1 })); // 12
        Console.WriteLine(Rob(new[] { 2, 1, 1, 2 })); // 4
    }
}
public class Solution {
    // Most money you can take from a row of houses without robbing two neighbors.
    static int rob(int[] nums) {
        // best(i) = most money from the first i houses
        //         = max(best(i - 1), best(i - 2) + nums[i - 1])
        // Each value reads only the two before it, so keep just those two.
        int twoBack = 0; // best(i - 2)
        int oneBack = 0; // best(i - 1)
        for (int money : nums) {
            // Skip this house and keep oneBack, or rob it on top of twoBack.
            int here = Math.max(oneBack, twoBack + money);
            twoBack = oneBack;
            oneBack = here;
        }
        return oneBack;
    }

    public static void main(String[] args) {
        System.out.println(rob(new int[] {1, 2, 3, 1})); // 4
        System.out.println(rob(new int[] {2, 7, 9, 3, 1})); // 12
        System.out.println(rob(new int[] {2, 1, 1, 2})); // 4
    }
}

Coin Change

MEDIUMLeetCode 322

Problem

You have coins in the denominations listed in coins, with an unlimited supply of each. Return the fewest coins that add up to exactly amount, or -1 if no combination of the coins can make it.

Example: coins = [1, 2, 5] and amount = 11 returns 3, from 5 + 5 + 1.

Show Solution

Approach

Taking the biggest coin that fits works for coin systems like US cents, but not in general. With coins = [1, 3, 4] and amount = 6, greedy takes 4 + 1 + 1 for 3 coins, while 3 + 3 needs only 2. You have to consider every coin as the possible last one.

State: dp[a] = the fewest coins that make amount a. If the last coin is c, the rest must be the best way to make a - c, so dp[a] = 1 + min(dp[a - c]) over every coin c <= a. The base case is dp[0] = 0, and filling a from 1 upward means every dp[a - c] is ready when it's read.

 coins = [1, 3, 4]

 a         0   1   2   3   4   5   6
 dp[a]     0   1   2   1   1   2   2

 dp[6] = 1 + min(dp[5], dp[3], dp[2])
       = 1 + min(2, 1, 2) = 2

Amounts that no coins can reach keep the placeholder amount + 1. It works as infinity because a real answer never needs more than amount coins (all 1s), and unlike a language's maximum integer, adding 1 to it can't overflow. If dp[amount] still holds it at the end, return -1. Each of the amount states tries all k coins, so the time is O(amount · k).

Time O(amount · k)Space O(amount)

def coin_change(coins: list[int], amount: int) -> int:
    """Fewest coins that add up to exactly `amount`, with unlimited coins of each
    denomination, or -1 if no combination of coins can make it."""
    # Stands in for infinity. A real answer never needs more than `amount`
    # coins (all 1s), and unlike a max integer, adding 1 can't overflow.
    unreachable = amount + 1
    dp = [unreachable] * (amount + 1)  # dp[a] = fewest coins that make a
    dp[0] = 0  # Zero coins make zero.
    for a in range(1, amount + 1):
        for coin in coins:
            # If the last coin is `coin`, the rest is the best way to make a - coin.
            if coin <= a:
                dp[a] = min(dp[a], dp[a - coin] + 1)
    return -1 if dp[amount] == unreachable else dp[amount]


if __name__ == "__main__":
    print(coin_change([1, 2, 5], 11))  # 3
    print(coin_change([2], 3))  # -1
    print(coin_change([1], 0))  # 0
    print(coin_change([1, 3, 4], 6))  # 2 (3 + 3, where greedy picks 4 + 1 + 1)
// Fewest coins that add up to exactly `amount`, with unlimited coins of each
// denomination, or -1 if no combination of coins can make it.
export function coinChange(coins: number[], amount: number): number {
  // Stands in for infinity. A real answer never needs more than `amount`
  // coins (all 1s), and unlike a max integer, adding 1 can't overflow.
  const unreachable = amount + 1;
  const dp = new Array<number>(amount + 1).fill(unreachable); // dp[a] = fewest coins that make a
  dp[0] = 0; // Zero coins make zero.
  for (let a = 1; a <= amount; a++) {
    for (const coin of coins) {
      // If the last coin is `coin`, the rest is the best way to make a - coin.
      if (coin <= a) dp[a] = Math.min(dp[a], dp[a - coin] + 1);
    }
  }
  return dp[amount] === unreachable ? -1 : dp[amount];
}

console.log(coinChange([1, 2, 5], 11)); // 3
console.log(coinChange([2], 3)); // -1
console.log(coinChange([1], 0)); // 0
console.log(coinChange([1, 3, 4], 6)); // 2 (3 + 3, where greedy picks 4 + 1 + 1)
package main

import "fmt"

// coinChange returns the fewest coins that add up to exactly amount, with
// unlimited coins of each denomination, or -1 if no combination can make it.
func coinChange(coins []int, amount int) int {
	// Stands in for infinity. A real answer never needs more than amount
	// coins (all 1s), and unlike a max integer, adding 1 can't overflow.
	unreachable := amount + 1
	dp := make([]int, amount+1) // dp[a] = fewest coins that make a
	for a := range dp {
		dp[a] = unreachable
	}
	dp[0] = 0 // Zero coins make zero.
	for a := 1; a <= amount; a++ {
		for _, coin := range coins {
			// If the last coin is coin, the rest is the best way to make a - coin.
			if coin <= a {
				dp[a] = min(dp[a], dp[a-coin]+1)
			}
		}
	}
	if dp[amount] == unreachable {
		return -1
	}
	return dp[amount]
}

func main() {
	fmt.Println(coinChange([]int{1, 2, 5}, 11)) // 3
	fmt.Println(coinChange([]int{2}, 3))        // -1
	fmt.Println(coinChange([]int{1}, 0))        // 0
	fmt.Println(coinChange([]int{1, 3, 4}, 6))  // 2 (3 + 3, where greedy picks 4 + 1 + 1)
}
using System;

public class Solution
{
    // Fewest coins that add up to exactly amount, with unlimited coins of each
    // denomination, or -1 if no combination of coins can make it.
    public static int CoinChange(int[] coins, int amount)
    {
        // Stands in for infinity. A real answer never needs more than amount
        // coins (all 1s), and unlike a max integer, adding 1 can't overflow.
        int unreachable = amount + 1;
        var dp = new int[amount + 1]; // dp[a] = fewest coins that make a
        Array.Fill(dp, unreachable);
        dp[0] = 0; // Zero coins make zero.
        for (int a = 1; a <= amount; a++)
        {
            foreach (int coin in coins)
            {
                // If the last coin is coin, the rest is the best way to make a - coin.
                if (coin <= a) dp[a] = Math.Min(dp[a], dp[a - coin] + 1);
            }
        }
        return dp[amount] == unreachable ? -1 : dp[amount];
    }

    public static void Main()
    {
        Console.WriteLine(CoinChange(new[] { 1, 2, 5 }, 11)); // 3
        Console.WriteLine(CoinChange(new[] { 2 }, 3)); // -1
        Console.WriteLine(CoinChange(new[] { 1 }, 0)); // 0
        Console.WriteLine(CoinChange(new[] { 1, 3, 4 }, 6)); // 2 (3 + 3, where greedy picks 4 + 1 + 1)
    }
}
import java.util.Arrays;

public class Solution {
    // Fewest coins that add up to exactly amount, with unlimited coins of each
    // denomination, or -1 if no combination of coins can make it.
    static int coinChange(int[] coins, int amount) {
        // Stands in for infinity. A real answer never needs more than amount
        // coins (all 1s), and unlike a max integer, adding 1 can't overflow.
        int unreachable = amount + 1;
        int[] dp = new int[amount + 1]; // dp[a] = fewest coins that make a
        Arrays.fill(dp, unreachable);
        dp[0] = 0; // Zero coins make zero.
        for (int a = 1; a <= amount; a++) {
            for (int coin : coins) {
                // If the last coin is coin, the rest is the best way to make a - coin.
                if (coin <= a) dp[a] = Math.min(dp[a], dp[a - coin] + 1);
            }
        }
        return dp[amount] == unreachable ? -1 : dp[amount];
    }

    public static void main(String[] args) {
        System.out.println(coinChange(new int[] {1, 2, 5}, 11)); // 3
        System.out.println(coinChange(new int[] {2}, 3)); // -1
        System.out.println(coinChange(new int[] {1}, 0)); // 0
        System.out.println(coinChange(new int[] {1, 3, 4}, 6)); // 2 (3 + 3, where greedy picks 4 + 1 + 1)
    }
}

Common Pitfalls

  • Writing code before you can say in one sentence what dp[i] means. Most off-by-one bugs in DP come from a fuzzy state.
  • Marking unsolved memo entries with a value that can also be a real answer, such as 0 when an answer can be 0. Those entries look unsolved forever, so they're recomputed on every call and the run can go exponential again. Use -1, null or a map.
  • Using the maximum integer as infinity in a min-DP and then adding 1 to it. In Java, C# and Go it wraps around to a huge negative number that wins every min.
  • Filling the table in an order where dp[i] reads an entry that hasn't been computed yet.
  • Memoized recursion on long inputs in Python. The default recursion limit is about 1000, so a chain of 10,000 states crashes. Write it bottom-up instead.

Practice

More problems to try on your own.