Dynamic Programming/Coin Change & Unbounded Knapsack

Lesson 8.53,602 words

Coin Change & Unbounded Knapsack

The previous lesson let each item be taken at most once. Drop that cap — items may be reused any number of times — and the 0/1 knapsack collapses from a two-dimensional table to a one-dimensional one, because there is no longer a prefix of "already-used" items to track.

╌╌╌╌

The previous lesson solved 0/1 knapsack, where each item is taken whole or left behind, at most once. The 0/1 in the name was the include/exclude bit on every item, and it forced a two-dimensional table : we had to remember which prefix of items was still on the table, because once item was used it could not be used again. Now relax exactly that constraint. Let every item be available in unlimited supply, so the thief may pack as many copies of item as fit. This is the unbounded knapsack problem, and allowing infinite copies removes one whole dimension from the dynamic program's table.

In 0/1 knapsack the second index existed to stop us from reusing an item; whether item had already been taken was genuine state. When items may be reused without limit, that question is meaningless (the set of available items never shrinks), so the only thing a subproblem needs to remember is how much capacity is left. One number, one dimension.

Unbounded knapsack: one dimension instead of two

Define the subproblem on capacity alone:

The answer is (or if we want at most ; padding with a zero-value, weight-one item makes them equal). To fill , consider the last item placed into the knapsack. It is some type with ; after placing it we have value plus the best we can do with the remaining budget , and that remaining budget may itself use type again:

Compare the right-hand side to 0/1 knapsack's . There the include branch read , the previous row, item removed from the pool. Here the include branch reads , the same , item still in the pool. That one difference is the whole distinction between use once and use any number of times.

The figure below folds one item of weight into a single array and contrasts the two sweep directions. Ascending, cell reads after that cell was already touched this pass, so an item can chain into itself (reuse). Descending, reads while it still holds the pre-pass value, so each item lands at most once.

One item of weight folded into a 1-D array. Ascending sweep (top): reads an already-updated , so the item can be reused. Descending sweep (bottom): reads the old , so the item is used at most once.

Because the available-item set never changes, the item loop and the weight loop may be nested in either order; there is no previous row to respect, only the ascending-weight rule. We fill cells, each scanning up to items:

time, the same as 0/1 knapsack, but in space: one array, no second dimension to collapse.

unbounded_knapsack.pypython
from typing import NamedTuple, Sequence

class Item(NamedTuple):
  """
    One item type: the value gained and the weight it costs per copy.\n
  """
  value: int
  weight: int

def unbounded_knapsack(items: Sequence[Item], capacity: int) -> int:
  """
    Best total value of a multiset of `items` whose weights fit within\n
    `capacity`, with every item type available in unlimited supply.\n
    Weights are positive integers; the value is 0 when no item fits.\n
  """
  # best_value[budget] is the most value reachable within that budget.
  best_value: list[int] = [0 for _ in range(capacity + 1)]

  # ascending budget lets best_value[budget - item.weight] already hold a
  # copy of this item, which is what permits reuse.
  for budget in range(capacity + 1):
    for item in items:
      if item.weight <= budget:
        candidate: int = best_value[budget - item.weight] + item.value
        best_value[budget] = max(best_value[budget], candidate)

  return best_value[capacity]

Coin change — minimum coins

The cleanest instance of unbounded knapsack strips the values away, just as subset-sum stripped them from 0/1 knapsack. Fix a set of coin denominations , available in unlimited supply, and an amount . Ask: what is the fewest coins that sum to exactly ?

This is unbounded knapsack with every item's value set to (one coin) and the objective flipped to minimize: we want the smallest count, not the largest value. Let be the minimum number of coins summing to amount :

The pays for the coin we just placed; the over denominations picks the best amount to reach the shortfall . An amount that no combination of coins can hit keeps the sentinel value , which propagates: if every is then so is . The figure below shows the 1-D table filling left to right, each cell reaching back positions for each denomination.

The 1-D coin-change table fills left to right; cell reads back positions for coin , taking (the highlighted transition uses coin ).

A full table, cell by cell. Take coins and fill left to right. Each cell tries all three denominations and keeps the smallest ; the winning coin is recorded in for reconstruction.

  • — empty pile, no coins. undefined.
  • ; only coin fits. .
  • ; again only coin fits. .
  • ; coin wins over three ones. .
  • ; coin wins. .
  • ; coins or tie, e.g. . .
  • ; coin wins, landing on . .

The finished tables:

0123456
0121122
113443

So , achieved by : two coins, not the three a careless greedy pick () would take. The next figure fills the same array with every winning transition drawn in, so the whole computation is visible at once.

The finished min-coins array for coins , amounts , with the traceback for : follow to , then to , spending coin twice. The value sits under each traced cell.

The row under the array records the winning coin at each amount. The two blue arrows are the traceback for amount : from follow to , then to — coin twice, the two shaded cells.

Algorithm 1:Min-Coins(c[1..n],A)\textsc{Min-Coins}(c[1..n], A) — fewest coins summing to amount AA
  1. 1
    C[0]0C[0] \gets 0
  2. 2
    for a1a \gets 1 to AA do
  3. 3
    C[a]+C[a] \gets +\infty
    unreachable so far
  4. 4
    prev[a]nilprev[a] \gets \text{nil}
  5. 5
    for i1i \gets 1 to nn do
  6. 6
    if c[i]ac[i] \le a and C[ac[i]]+1<C[a]C[a - c[i]] + 1 < C[a] then
  7. 7
    C[a]C[ac[i]]+1C[a] \gets C[a - c[i]] + 1
  8. 8
    prev[a]c[i]prev[a] \gets c[i]
    coin that closed the gap
  9. 9
    return C[A]C[A]
    ++\infty: AA unreachable

The outer loop runs over amounts and the inner over coins, so the running time is in space, pseudo-polynomial in the sense of the previous lesson: polynomial in the numeric value but exponential in its bit length.1

Reconstruction. The value is the coin count; the coins themselves come from the array. Starting at , the denomination is the last coin used, so emit it and jump to ; repeat until .

Algorithm 2:Recover-Coins(prev,A)\textsc{Recover-Coins}(prev, A) — list the coins of an optimal solution
  1. 1
    aAa \gets A; SS \gets \langle\,\rangle
  2. 2
    while a>0a > 0 do
  3. 3
    SS{prev[a]}S \gets S \cup \set{prev[a]}
    coin that closed aa
  4. 4
    aaprev[a]a \gets a - prev[a]
  5. 5
    return SS
min_coins.pypython
from math import inf
from typing import Optional, Sequence

def min_coins(denominations: Sequence[int], amount: int) -> Optional[int]:
  """
    Fewest coins from `denominations` (unlimited supply) summing to exactly\n
    `amount`, or None when the amount is unreachable.\n
  """
  # best_count[t] is the fewest coins for t; 0 needs none, rest unreachable.
  best_count: list[float] = [inf for _ in range(amount + 1)]
  best_count[0] = 0

  # for each target, take the coin that leaves the cheapest remainder.
  for target in range(1, amount + 1):
    for coin in denominations:
      if coin <= target and best_count[target - coin] + 1 < best_count[target]:
        best_count[target] = best_count[target - coin] + 1

  reached: float = best_count[amount]
  return None if reached == inf else int(reached)

def min_coins_with_recovery(
  denominations: Sequence[int], amount: int
) -> Optional[list[int]]:
  """
    An optimal multiset of coins summing to exactly `amount`, or None when\n
    the amount is unreachable. The returned list has length `min_coins(...)`\n
    and its elements sum to `amount`.\n
  """
  # best_count[t] is the fewest coins for t; 0 needs none, rest unreachable.
  best_count: list[float] = [inf for _ in range(amount + 1)]
  best_count[0] = 0

  # last_coin[target] is the denomination that closed `target` optimally.
  last_coin: list[Optional[int]] = [None for _ in range(amount + 1)]
  for target in range(1, amount + 1):
    for coin in denominations:
      if coin <= target and best_count[target - coin] + 1 < best_count[target]:
        best_count[target] = best_count[target - coin] + 1
        last_coin[target] = coin

  if best_count[amount] == inf:
    return None

  # walk the back-pointers from `amount` down to 0.
  coins: list[int] = []
  position: int = amount
  while position > 0:
    chosen: Optional[int] = last_coin[position]
    assert chosen is not None
    coins.append(chosen)
    position -= chosen

  return coins

Coin change — counting combinations

A different question on the same coins: not how few coins, but how many distinct ways to make amount . This is Coin Change II, and it counts multisets of coins. and are two ways to make , but and are the same way, because a multiset has no order. Let be the number of such combinations summing to , with (the empty multiset is the one way to make ).

The naive recurrence "" is wrong for combinations; it counts and separately. The fix is structural and is the most famous loop-order subtlety in dynamic programming.

Coins-outer avoids the double count because each denomination is introduced exactly once and never revisited: every multiset is built in a fixed canonical order of denominations (coin first, then , and so on), so each multiset is reached by exactly one path through the loops. The amount-outer loop, by contrast, considers every coin as the possible last coin at every amount, so the same multiset is counted once for each ordering of its coins.

Coins-outer combination fill for . After folding in coin every amount has one way (all ones); folding in coin adds , giving without ever double-counting and .

Because coin is introduced only after coin is fully folded in, each multiset is built in the fixed order ones first, then twos, so counts and once apiece; a ordering is never generated.

Algorithm 3:Count-Combinations(c[1..n],A)\textsc{Count-Combinations}(c[1..n], A) — number of multisets summing to AA
  1. 1
    N[0..A]0N[0..A] \gets 0; N[0]1N[0] \gets 1
  2. 2
    for i1i \gets 1 to nn do
    coins outer: combinations
  3. 3
    for ac[i]a \gets c[i] to AA do
    amount inner, ascending: reuse
  4. 4
    N[a]N[a]+N[ac[i]]N[a] \gets N[a] + N[a - c[i]]
  5. 5
    return N[A]N[A]

Swapping the two loops (for a outside, for i inside) computes instead, the ordered count (Combination Sum IV), where and are distinct. Same body, same time; the nesting alone flips the meaning.

A ways table, one coin at a time. Coins , amount . Start with (only the empty bag makes ), then fold in each coin, updating for ascending from to .

after folding
start100000
coin 111111
coin 112233
coin 112234

After coin every amount has a single all-ones bag. Folding in coin adds, for each , the ways that end with (i.e. include) a , so becomes : , , . Folding in coin touches only , adding the single bag : , namely , , , and . Because each coin is folded in exactly once, no bag is ever counted under two orderings.

A worked count: combinations vs sequences

Take coins and amount . As an unordered count the answers are and : two combinations. As an ordered count we also distinguish the arrangements of , giving , , and , for three sequences. The figure traces both, and ties each to its loop order.

Coins , amount : the multiset count (2, coins-outer loop) is less than the ordered count (3, amount-outer loop), because and collapse to one combination.

The blue link shows the discrepancy: the single combination on the left corresponds to the two ordered sequences and on the right. Counting the left column is the coins-outer loop; counting the right is amount-outer. Picking the wrong nesting silently computes the wrong quantity — no error, just a wrong number — which is why it is the classic bug.

count_coin_change.pypython
from typing import Sequence

def count_combinations(denominations: Sequence[int], amount: int) -> int:
  """
    Number of distinct multisets of coins summing to exactly `amount`.\n
    Coins outer, amount inner: each denomination is folded in once, so every\n
    multiset is built in a fixed canonical order and counted exactly once.\n
    N[0] = 1 (the empty multiset is the lone way to make 0).\n
  """
  # ways[t] counts multisets making t; the empty multiset makes 0.
  ways: list[int] = [0 for _ in range(amount + 1)]
  ways[0] = 1

  # fold in one denomination at a time to fix a canonical build order.
  for coin in denominations:
    for target in range(coin, amount + 1):
      ways[target] += ways[target - coin]

  return ways[amount]

def count_sequences(denominations: Sequence[int], amount: int) -> int:
  """
    Number of ordered sequences (compositions) of coins summing to exactly\n
    `amount`, where 1+3 and 3+1 are distinct. Amount outer, coins inner:\n
    every amount re-asks which coin came last, so each ordering is counted.\n
  """
  # ways[t] counts ordered sequences making t; the empty sequence makes 0.
  ways: list[int] = [0 for _ in range(amount + 1)]
  ways[0] = 1

  # for each target, sum over every choice of last coin.
  for target in range(1, amount + 1):
    for coin in denominations:
      if coin <= target:
        ways[target] += ways[target - coin]

  return ways[amount]

Why greedy fails — and when it works

Coin change has a natural greedy heuristic: repeatedly take the largest coin that fits. For the U.S. currency system it always gives the minimum, which is why cashiers can make change without dynamic programming.

Largest-coin-first leaves a remainder () that the denominations cover badly, while a less greedy first step () leaves a remainder the coins cover perfectly.

Greedy change-making fails on for amount : largest-coin-first takes (three coins), while the DP optimum is (two coins).

A coin system is called canonical when the greedy algorithm is optimal for every amount; standard currencies (like ) are deliberately designed to be canonical so that greedy change-making works. Whether an arbitrary system is canonical is itself a nontrivial question (it can be decided by checking greedy against the DP optimum over a bounded range of amounts), but the safe default for an unknown denomination set is the dynamic program, which is correct for any coins.3

greedy_change.pypython
from math import inf
from typing import Optional, Sequence

from min_coins import min_coins

def greedy_change(denominations: Sequence[int], amount: int) -> Optional[list[int]]:
  """
    Coins chosen by repeatedly taking the largest denomination that fits,\n
    or None when greedy cannot reach `amount` exactly. Not always optimal:\n
    use `min_coins` for a guaranteed minimum on arbitrary coin systems.\n
  """
  # take as many of each denomination as fit, largest first.
  coins: list[int] = []
  remaining: int = amount
  for coin in sorted(denominations, reverse=True):
    while coin <= remaining:
      coins.append(coin)
      remaining -= coin

  return coins if remaining == 0 else None

def is_canonical(denominations: Sequence[int], limit: int) -> bool:
  """
    Whether largest-coin-first greedy matches the DP optimum for every amount\n
    in 0..limit. A coin set of 1 must be present for every amount to be\n
    reachable; otherwise both sides may be unreachable and still agree.\n
  """
  for amount in range(limit + 1):
    # compare greedy's coin count against the DP optimum for this amount.
    optimal: Optional[int] = min_coins(denominations, amount)
    greedy: Optional[list[int]] = greedy_change(denominations, amount)

    # treat unreachable on either side as +infinity so they compare equal.
    greedy_count: float = inf if greedy is None else len(greedy)
    optimal_count: float = inf if optimal is None else optimal

    if greedy_count != optimal_count:
      return False

  return True

The same shape elsewhere: Perfect Squares and Word Break

Coin change is unbounded knapsack, and two well-known problems are the identical recurrence with the coins renamed.

Perfect Squares asks for the fewest perfect squares () summing to . That is all over again, with the coins being the squares :

The squares are reusable (you may use four times to make ), so it is the ascending-weight minimization we already wrote; only the denomination set changes.

Word Break asks whether a string can be segmented into dictionary words. The coins are now words, the amount is a string prefix, and the table is indexed by prefix length. Let be true if the first characters of split into dictionary words:

It is the boolean () flavor, like subset-sum was to knapsack, where a word ending at position plays the role of a coin of value landing the prefix on the earlier boundary . The empty prefix is the always-reachable base, exactly like .

perfect_squares.pypython
def perfect_squares(number: int) -> int:
  """
    Minimum count of perfect squares (1, 4, 9, ...) summing to exactly\n
    `number`, a non-negative integer. Returns 0 for `number` == 0.\n
  """
  # best_count[k] is the fewest squares summing to k.
  best_count: list[int] = [0 for _ in range(number + 1)]

  for target in range(1, number + 1):
    # every target is reachable (1 is a square), so start from the worst case.
    fewest: int = target

    # try subtracting each square 1, 4, 9, ... that fits.
    root: int = 1
    while root * root <= target:
      candidate: int = best_count[target - root * root] + 1
      fewest = min(fewest, candidate)
      root += 1

    best_count[target] = fewest

  return best_count[number]
word_break.pypython
from typing import Iterable

def word_break(text: str, dictionary: Iterable[str]) -> bool:
  """
    Whether `text` can be segmented into a sequence of words drawn (with\n
    repetition) from `dictionary`. The empty string is trivially breakable.\n
  """
  # O(1) membership for the candidate words.
  words: set[str] = set(dictionary)

  # reachable[k] is true if text[:k] splits into dictionary words.
  reachable: list[bool] = [False for _ in range(len(text) + 1)]
  reachable[0] = True

  for end in range(1, len(text) + 1):
    for start in range(end):
      # a word ending at `end` lands the prefix on a reachable `start`.
      if reachable[start] and text[start:end] in words:
        reachable[end] = True
        break

  return reachable[len(text)]

Canonical systems, Frobenius, and generating functions

The claim that greedy works on canonical systems rests on a subtle theory. Deciding whether an arbitrary -coin system is canonical was open for years; Pearson (2005) gave an test, and Kozen and Zaks (1994) showed the smallest counterexample — the least amount where greedy fails — always lies below (the sum of the two largest coins), so a canonical system can be certified by checking greedy against the DP only up to that bound. The everyday is canonical by design, but even small tweaks break it: the once-real British pre-decimal system and hypothetical sets like are not, which is exactly why a cash register that must handle arbitrary denominations falls back to the DP.

Coin change also touches classical number theory. The Frobenius problem — given coprime denominations, what is the largest amount that cannot be made at all? — asks which cells of the DP table stay . For two coins the answer is the closed form (the Frobenius number, or Chicken McNugget number), but for three or more coins no closed form is known and computing it is NP-hard in general (Ramírez Alfonsín, 1996). The reachable set (which amounts have ) is eventually periodic with period , a structure the DP table exhibits numerically.

The counting variant connects to generating functions from partition theory. The number of ways to make amount with coins is the coefficient of in , and the coins-outer DP loop is precisely the term-by-term multiplication of these geometric series — folding in one factor per coin. When the coins are all positive integers , this product is Euler's partition generating function, and the DP becomes a way to compute the partition numbers (the subject of Hardy and Ramanujan's famous asymptotic ). The Word Break instance, meanwhile, is the recognition problem for a language over a finite dictionary, and its natural generalization — count or weight the segmentations — reproduces the forward algorithm of a weighted finite-state model, which underlies tokenization and word segmentation.4

Takeaways

  • Unbounded knapsack lets each item be used any number of times. That single change deletes the item dimension: the subproblem depends only on remaining capacity, giving the 1-D recurrence in time and space, versus 0/1 knapsack's 2-D .
  • The include branch reads at the same item-availability (not the previous row), so we sweep weight ascending to permit reuse, the exact opposite of 0/1's descending sweep, which forbids it.
  • Coin change (min coins) is unbounded knapsack with unit values and a objective: , , if unreachable; a array reconstructs the coins.
  • Counting ways is governed by loop order: coins outer, amount inner counts unordered combinations (Coin Change II); amount outer, coins inner counts ordered sequences (Combination Sum IV). Same code, different question: the classic bug.
  • Greedy (largest coin first) fails in general (, amount : greedy , optimal ) but is correct for canonical systems like standard currency; the DP is correct for any denominations.
  • Perfect Squares (squares as coins) and Word Break (dictionary words as coins over string prefixes) are the same unbounded-DP shape.

Footnotes

  1. Skiena, § — Knapsack / Coin Change: making change as unbounded knapsack, and pseudo-polynomial in the amount.
  2. Erickson, Ch. — Dynamic Programming: combinations vs. compositions and how the nesting of the item and target loops selects between unordered and ordered counts.
  3. Skiena, § — Knapsack / Coin Change: greedy change-making is optimal only for canonical denomination systems; the DP is correct for arbitrary coins.
  4. Kozen & Zaks (1994) bound the smallest greedy-counterexample below , and Pearson (2005) gives an canonicity test; Ramírez Alfonsín (1996) on the NP-hardness of the Frobenius number. The counting DP is the coefficient extraction of , Euler's partition generating function.
Practice

╌╌ END ╌╌