Menu
DSA interview questionsQuestion 44 of 147

DSA interview question · Question 44 of 147

Coin Change: Fewest Coins with Bottom-Up DP

  • Medium
  • coding
  • ~20 min
  • High relevance
  • 6 min read
  • Updated Oct 2026

Short answer

Let fewest(a) be the minimum number of coins that sum to a. The last coin used is some c, so fewest(a) = 1 + min over coins c <= a of fewest(a - c), with fewest(0) = 0 and unreachable amounts marked as infinity. Fill a table from 0 up to the target and return -1 if the target stays infinite. With A the amount and k coin types this is O(A * k) time and O(A) space. Greedy (largest coin first) is wrong for many coin sets.

On this page
  1. Problem
  2. Examples
  3. Approach 1: plain recursion
  4. Approach 2: optimal, dynamic programming
  5. State, recurrence and base cases
  6. Filled table for coins [1, 4, 5], amount 8
  7. Memoised
  8. Bottom-up
  9. BFS alternative
  10. Complexity
  11. Tests
  12. Edge cases and pitfalls
  13. Where this shows up in data engineering

Problem

You are given a list of coin values and a target amount. You have an unlimited supply of each coin. Return the smallest number of coins that add up exactly to the amount, or -1 if it cannot be done. An amount of 0 needs 0 coins.

This is widely known as LeetCode 322, “Coin Change”. Counting the number of ways instead is Coin Change II.

Assume a dozen or so positive coin values and an amount up to 10,000.

Examples

coins [1, 4, 5], amount 8   -> 2   (4 + 4; greedy 5+1+1+1 uses 4 coins)
coins [2, 7], amount 11     -> 3   (2 + 2 + 7)
coins [4], amount 6         -> -1  (unreachable)
coins [3, 9], amount 0      -> 0

Approach 1: plain recursion

def coin_change_recursive(coins, amount):
    def fewest(rest):
        if rest == 0:
            return 0
        best = float("inf")
        for c in coins:
            if c <= rest:
                best = min(best, 1 + fewest(rest - c))
        return best
    result = fewest(amount)
    return -1 if result == float("inf") else result

It tries every sequence of coins, roughly O(k^(A / smallest coin)), and recomputes the same remainders constantly.

Approach 2: optimal, dynamic programming

State, recurrence and base cases

  • State: fewest[a] = minimum coins summing to exactly a (infinity if impossible).
  • Recurrence: fewest[a] = 1 + min(fewest[a - c] for each coin c <= a).
  • Base case: fewest[0] = 0.
  • Answer: fewest[amount], or -1 if infinite.

This is the unbounded knapsack pattern: each coin may be reused, so fewest[a - c] may already contain coin c.

Filled table for coins [1, 4, 5], amount 8

a 0 1 2 3 4 5 6 7 8
fewest 0 1 2 3 1 1 2 3 2

fewest[8] = 1 + min(fewest[7], fewest[4], fewest[3]) = 1 + min(3, 1, 3) = 2.

Memoised

from functools import lru_cache

def coin_change_memo(coins, amount):
    @lru_cache(maxsize=None)
    def fewest(rest):
        if rest == 0:
            return 0
        best = float("inf")
        for c in coins:
            if c <= rest:
                best = min(best, 1 + fewest(rest - c))
        return best
    result = fewest(amount)
    return -1 if result == float("inf") else result

The memoised version can hit Python’s recursion limit for large amounts with a coin of 1, which is one reason to prefer bottom-up.

Bottom-up

def coin_change(coins, amount):
    INF = amount + 1                       # more coins than could ever be needed
    fewest = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and fewest[a - c] + 1 < fewest[a]:
                fewest[a] = fewest[a - c] + 1
    return fewest[amount] if fewest[amount] != INF else -1

Using amount + 1 as infinity avoids floats: no answer can need more than amount coins, because the smallest coin is at least 1.

BFS alternative

Amounts are nodes, each coin is an edge, and every edge costs one coin, so BFS from 0 finds the fewest coins.

from collections import deque

def coin_change_bfs(coins, amount):
    if amount == 0:
        return 0
    seen = {0}
    queue = deque([0])
    steps = 0
    while queue:
        steps += 1
        for _ in range(len(queue)):
            total = queue.popleft()
            for c in coins:
                nxt = total + c
                if nxt == amount:
                    return steps
                if nxt < amount and nxt not in seen:
                    seen.add(nxt)
                    queue.append(nxt)
    return -1

Complexity

  • DP: O(A * k) time, O(A) space.
  • BFS: same bounds, and it can stop early when the answer is small.
  • No standard space optimisation: every earlier amount may be needed.

Tests

for fn in (coin_change, coin_change_memo, coin_change_bfs, coin_change_recursive):
    assert fn([1, 4, 5], 8) == 2                   # greedy would give 4
    assert fn([2, 7], 11) == 3
    assert fn([4], 6) == -1                        # unreachable
    assert fn([3, 9], 0) == 0                      # zero amount
    assert fn([5], 5) == 1                         # single coin, exact
    assert fn([], 3) == -1                         # no coins
    assert fn([7, 3], 1) == -1                     # every coin too large

assert coin_change([1, 2, 5], 100) == 20
assert coin_change([37, 52, 91, 140], 3000) == coin_change_bfs([37, 52, 91, 140], 3000)

import random
random.seed(14)
for _ in range(60):
    cs = random.sample(range(1, 12), random.randint(1, 3))
    amt = random.randint(0, 25)
    assert coin_change(cs, amt) == coin_change_bfs(cs, amt) == coin_change_memo(cs, amt)

Edge cases and pitfalls

  • Greedy is not correct in general. It works for coin systems like 1, 5, 10, 25 but fails for [1, 4, 5] at 8.
  • Infinity arithmetic. Adding 1 to a large sentinel can overflow in fixed-width languages; using amount + 1 is safe.
  • Amount 0 returns 0, not -1.
  • Large coins. Coins larger than the amount are simply skipped.
  • Recursion limits for the memoised version with small coins and a large amount.

Where this shows up in data engineering

The same minimum-count recurrence describes packing work into the fewest fixed-size units, for example covering a backfill window with the fewest runs when each run can process one of a few allowed window lengths. In practice the search space is tiny and the DP is overkill, but it is the right way to reason about why greedy splitting can be suboptimal.

By Data Career Hub Editorial · Last reviewed Oct 2026 · Python 3 solutions verified with assert-based tests

Progress is saved in this browser only. No account needed.

Search
Filter by type