The Coin Changing Problem

The Change Problem

The Change Problem (Coin Changing Problem) asks us to convert some amount of money into denominations using the fewest coins.

Input: An integer (money) and an array of d denominations (\(c = c_1, c_2, \ldots, c_d\)) in decreasing order of value (\(c_1 > c_2> \cdots > c_d\)).

Output: A list of d integers \(i_1, i_2, \ldots, i_d\) such that \(c_1 \cdot i_1 + c_2 \cdot i_2 + \cdots + c_d \cdot i_d = money\) and \(i_1 + i_2 + \cdots + i_d\) is as small as possible.

SetUp

Imports

# python
from collections import namedtuple
from functools import partial

# pypi
from expects import contain_exactly, expect, equal

import altair
import pandas

# my stuff
from graeae.visualization.altair_helpers import output_path, save_chart

Setup

ChangeCounts = namedtuple("ChangeCounts", ["coin_counts", "run_count"])
ChangeCountsTable = namedtuple("ChangeCounts", ["coin_counts", "run_count", "table"])

SLUG = "the-coin-changing-problem"
OUTPUT_PATH = output_path(SLUG)
save_it = partial(save_chart, output_path=OUTPUT_PATH)

PlotParameters = namedtuple("PlotParameters", ["height", "width"],
                            defaults=[525, 750])()

Denominations = namedtuple("Denominations", "US double_dime obsolete",
                           defaults=[[25, 10, 5, 1],
                                    [25, 20, 10, 5, 1],
                                    [25, 20, 10, 5, 3, 2, 1]])()

The Cashier's Algorithm

The way many cashier's make change for customers is using a greedy approach.

Pseudocode

\begin{algorithm}
\caption{CashiersChange}
\begin{algorithmic}
\INPUT Amount owed ($money$) and a supply of coins ($c$).
\PROCEDURE{CashiersChange}{$money, c$}
\WHILE {\(money > 0\)}
  \STATE \(coin \gets\) Coin with the largest denomination that doesn't exceed \(money\).
  \STATE Give $coin$ to the customer.
  \STATE \(money \gets money - coin\)
\ENDWHILE
\ENDPROCEDURE
\end{algorithmic}
\end{algorithm}

Python Implementation

def cashier_changer(amount: int, denominations: list) -> ChangeCounts:
    """Implements the cashier's method for making change

    Args:
     amount: the value of the change needed
     denominations: values for coins (in descending order)

    Returns:
     list of coin-counts, number of loops ran
    """
    coins = [0] * len(denominations)
    remainder = amount
    loop_count = 0

    for index, denomination in enumerate(denominations):
        coin_count = 0
        while denomination <= remainder:
            coin_count += 1
            loop_count += 1
            remainder = remainder - denomination

        coins[index] = coin_count
    return ChangeCounts(coin_counts=coins, run_count=loop_count)

A First Test

Examples from LATPAPS.

def check_coins(answer: list, denominations: list,
                expected_coins: int,
                expected_total: int) -> None:
    """Check that our answer matches the expected

    Args:
     answer: list of counts for each denomination
     denominations: list of coin denominations available
     expected_coins: the expected list of counts for each denomination
     expected_total: what the value our coins should add up to

    Raises:
     AssertionError: something about our answer doesn't match the expected
    """
    expect(sum(answer)).to(equal(sum(expected_coins)))
    expect(answer).to(contain_exactly(*expected_coins))
    expect(sum(count * coin for count, coin in zip(answer, denominations))).to(
        equal(expected_total)
    )
    return
# case 1
money = 2
coins = [10, 5, 1]
actual = cashier_changer(money, coins)
check_coins(actual.coin_counts, coins, [0, 0, 2], money)
print(f"case 1: {actual.run_count}")

# case 2
money = 28
actual = cashier_changer(money, coins)
check_coins(actual.coin_counts, coins, [2, 1, 3], money)
print(f"case 2: {actual.run_count}")

# case 3
money = 99
actual = cashier_changer(money, coins)
check_coins(actual.coin_counts, coins, [9, 1, 4], money)
print(f"case 2: {actual.run_count}")
case 1: 2
case 2: 6
case 2: 14

The number of loops is dependent on the change owed, with an upper limit based on the denominations. In this case if we assume you wouldn't give out more than 99 cents in change then we would cap out at \( 9 \times 10 + 1 \times 5 + 4 \times 1\) = 14 coins/loops.

U.S. Denominations

This is a quick check using the U.S. coins most commonly used by cashiers. By adding a 25 cent piece we reduce the upper limit on the amount of coins needed to \(3 \times 25 + 2 \times 10 + 4 \times 1\) = 9 coins.

print(Denominations.US)
[25, 10, 5, 1]
money = 28
actual = cashier_changer(money, Denominations.US)
check_coins(actual.coin_counts, Denominations.US, [1, 0, 0, 3], money)

money = 14
actual = cashier_changer(money, Denominations.US)
check_coins(actual.coin_counts, Denominations.US, [0, 1, 0, 4], money)

Plotting the Coin Counts/Loops

PLOT_AMOUNTS = list(range(1, 100))
CASHIER_COUNTS = [cashier_changer(amount, Denominations.US).run_count
                  for amount in PLOT_AMOUNTS]

PLOT_FRAME = pandas.DataFrame({"Amount to Change": PLOT_AMOUNTS,
                               "Coins": CASHIER_COUNTS})

chart = altair.Chart(PLOT_FRAME).mark_bar().encode(
    x=altair.X("Amount to Change", axis=altair.Axis(tickMinStep=1)),
    y=altair.Y("Coins", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Coins"]).properties(
        title="Cashier's Change Coin Counts (Common U.S. Coins)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "cashiers-change-us-coins")

Figure Missing

The Double-Dime

Our cashier algorithm turns out to work for the specific set of coins that cashiers commonly use, but will it work for other coins as well?

According to Wikipedia, there was at one time a proposal in the United States for a twenty-cent piece (and at one time there were half-cent, two-cent, and three-cent coins).If we include the twenty cent piece amongst our denominations, we find that there are cases where the cashier algorithm will miss the optimal solution.

print(Denominations.double_dime)
[25, 20, 10, 5, 1]
money = 40
actual = cashier_changer(money, Denominations.double_dime)

try:
    check_coins(actual.coin_counts, Denominations.double_dime, [0, 2, 0, 0, 0], money)
except AssertionError as error:
    print(f"AssertionError: {error}")
AssertionError: 
expected: 3 to equal 2

Because the cashier algorithm always takes the largest possible coins first, it ends up using 25¢ + 10¢ + 5¢ as the solution instead of the optimal 20¢ + 20¢. While it might seem artificial, given the characterization of this as a solution to making change, it's important to note that generalizing the cashier algorithm beyond the curated denominations or even beyond coins specifically leaves it vulnerable to cases where it will fail.

A Greedy Version

As noted, the Cashier's Algorithm is a greedy algorithm, but let's translate it into a silghtly smarter version which uses a little arithmetic to get to the same point.

In Pseudocode

\begin{algorithm}
\caption{GreedyChange}
\begin{algorithmic}
\INPUT An integer ($money$) and an array of $d$ denominations (\(c = c_1, c_2, \ldots, c_d\)) in decreasing order of value (\(c_1 > c_2> \cdots > c_d\)).
\OUTPUT A list of $d$ integers \(i_1, i_2, \ldots, i_d\) such that \(c_1 \cdot i_1 + c_2 \cdot i_2 + \cdots + c_d \cdot i_d = money\)  and \(i_1 + i_2 + \cdots + i_d\) is as small as possible.
\PROCEDURE{CashiersChange}{$money, c$}
\STATE $remainder \gets money$
\STATE $d \gets $ \textsc{Length}($c$)
\FOR {$k \in \{1 \ldots d\}$}
  \STATE $i_k \gets \lfloor \frac{remainder}{c_k} \rfloor$
  \STATE $remainder \gets remainder - c_k \cdot i_k$
\ENDFOR
\RETURN $(i_1, i_2, \ldots, i_d)$
\ENDPROCEDURE
\end{algorithmic}
\end{algorithm}

In Python

def greedy_changer(money: int, denominations: list) -> ChangeCounts:
    """Make change using the fewest coins

    Args:
     money: the amount to change
     denominations: list of coin denominations in decreasing order

    Returns:
     Number of each denomination needed to make the change, loop-count
    """
    coins = [0] * len(denominations)
    remainder = money
    count = 0
    for location, denomination in enumerate(denominations):
        coins[location] = remainder // denomination
        remainder = remainder - denomination * coins[location]
        count += 1
    return ChangeCounts(coins, count)

Testing It Out

A First Test

# case 1
money = 2
coins = [10, 5, 1]
actual = greedy_changer(money, coins)
check_coins(actual.coin_counts, coins, [0, 0, 2], money)

# case 2
money = 28
actual = greedy_changer(money, coins)
check_coins(actual.coin_counts, coins, [2, 1, 3], money)

U.S. Denominations

money = 28
actual = greedy_changer(money, Denominations.US)
check_coins(actual.coin_counts, Denominations.US, [1, 0, 0, 3], money)

money = 14
actual = greedy_changer(money, Denominations.US)
check_coins(actual.coin_counts, Denominations.US, [0, 1, 0, 4], money)

The Double-Dime

Looking at the greedy-algorithm you can see that it only has one loop that traverses the denominations of coins - so it is a very quick algorithm, but while our greedy algorithm turns out to work for the specific set of coins that cashiers use, it also falls prey to sometimes missing the optimal solution.

money = 40
actual = greedy_changer(money, Denominations.double_dime)

try:
    check_coins(actual.coin_counts, Denominations.double_dime, [0, 2, 0, 0, 0], money)
except AssertionError as error:
    print(f"AssertionError: {error}")
AssertionError: 
expected: 3 to equal 2

Plotting

greedy_counts = [greedy_changer(amount, Denominations.US).run_count
                 for amount in PLOT_AMOUNTS]
GREEDY_FRAME = PLOT_FRAME.rename(columns={"Coins": "Cashier"})
GREEDY_FRAME["Greedy"] = greedy_counts
melted = GREEDY_FRAME.melt(id_vars=["Amount to Change"],
                           value_vars=["Greedy", "Cashier"],
                           var_name="Change Method",
                           value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Cashier's and Greedy Change Coin Counts (Common U.S. Coins)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "cashiers-greedy-change-us-coins")

Figure Missing

Since the Cashier works by repeated subtraction instead of division it goes up with the amount of change owed, and so generally does worse than the Greedy changer, except in the cases where it takes fewer coins to make the change than the number of denominations there are. If I had included the fifty cent piece it would have done a little better in the second half of the plot.

A Brute Force Changer

To get around the limitations of the greedy-style of change making we'll figure out all the ways we can make change for a particular amount and pick the solution with the fewest coins.

def brute_changer(money: int, denominations: list) -> ChangeCounts:
    """Make change using the fewest coins

    Args:
     money: the amount to change
     denominations: list of coin denominations in decreasing order

    Returns:
     Number of each denomination needed to make the change, loop_count
    """
    best = float("inf")
    number_of_denominations = len(denominations)
    counter = 0
    for first in range(number_of_denominations):
        remainder = money
        counts = [0] * number_of_denominations
        total = 0
        for next_location in range(first, number_of_denominations):
            denomination = denominations[next_location]
            count = remainder//denomination
            remainder = remainder - count * denomination
            counts[next_location] = count
            total += count
            counter += 1
        if total < best:
            best_counts = counts
            best = total
    return ChangeCounts(best_counts, counter)
# case 1
money = 2
coins = [10, 5, 1]
actual = brute_changer(money, coins)
check_coins(actual.coin_counts, coins, [0, 0, 2], money)

# case 2
money = 28
actual = brute_changer(money, coins)
check_coins(actual.coin_counts, coins, [2, 1, 3], money)

coins = [25, 10, 5, 1]

money = 28
actual = brute_changer(money, coins)
check_coins(actual.coin_counts, coins, [1, 0, 0, 3], money)

money = 14
actual = brute_changer(money, coins)
check_coins(actual.coin_counts, coins, [0, 1, 0, 4], money)

And now our double-dime case.

money = 40
actual = brute_changer(money, Denominations.double_dime)

check_coins(actual.coin_counts, Denominations.double_dime, [0, 2, 0, 0, 0], money)

print(actual)
ChangeCounts(coin_counts=[0, 2, 0, 0, 0], run_count=15)

The Brute-Force Changer fixes our double-dimes case, and has a constant runtime of:

\[ T(n) = \sum_{i = 1}^{n} = \frac{n(n + 1)}{2} \]

Where \(n\) is the number of denominations, not the input amount.

Let's make sure our functions all agree.

for amount in range(100):
    cashiers_change = cashier_changer(amount, Denominations.US).coin_counts
    greedy_change = greedy_changer(amount, Denominations.US).coin_counts
    brute_change = brute_changer(amount, Denominations.US).coin_counts

    check_coins(answer=cashiers_change, denominations=Denominations.US,
                expected_coins=greedy_change, expected_total=amount)
    check_coins(answer=brute_change, denominations=Denominations.US,
                expected_coins=greedy_change, expected_total=amount)

Plotting

Let's compare the three methods we've created so far using the typical U.S. Coins.

brute_counts = [brute_changer(amount, Denominations.US).run_count
                for amount in PLOT_AMOUNTS]
GREEDY_FRAME["Brute-Force"] = brute_counts
melted = GREEDY_FRAME.melt(id_vars=["Amount to Change"],
                           value_vars=["Greedy", "Cashier", "Brute-Force"],
                           var_name="Change Method",
                           value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Greedy Change & Brute-Force Coin Counts (Common U.S. Coins)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "brute-force-change-us-coins")

Figure Missing

The Greedy and Brute-Force methods both have a loop-count based on the number of coin denominations we have. Since we have four denominations (25, 10, 5, and 1) the Greedy method has four loops and the Brute-Force method has ten loops \(\left(^{4(4 + 1)}/_{2} = 10\right)\), so it does a little more calculating but not much, although in this case the benefit it has is wasted since we shouldn't find sub-optimal solutions with the greedy approach and using the coins that we did.

Memoized Changer

def memoized_changer(money: int, denominations: list, table: dict=None,
                     counter: int=0) -> ChangeCounts:
    """Make change using the fewest coins

    Args:
     money: the amount to change
     denominations: list of coin denominations in decreasing order
     table: memoization table (largest denomination, amount to change): best coin counts
     counter: number of times the function is called

    Returns:
     Number of each denomination needed to make the change, call_count
    """
    table = dict() if table is None else table
    counter += 1

    if money == 0:
        return ChangeCounts([0] * len(denominations), counter)

    if (denominations[0], money) in table:
        return ChangeCounts(table[(denominations[0], money)], counter)

    last_denomination = len(denominations) - 1
    best = float("inf")
    best_counts = None

    for current, denomination in enumerate(denominations):
        count = money//denomination
        remaining = money - count * denomination

        if current == last_denomination:
            if remaining > 0:
                count = float("inf")
            counts = [count]
            counter += 1
        else:
            counts, counter = memoized_changer(
                remaining, denominations[current + 1:], table, counter)
            counts = [count] + counts

        total_counts = sum(counts)

        if total_counts < best:
            best = total_counts
            best_counts = [0] * current + counts
        table[(denomination, money)] = counts
    return ChangeCounts(best_counts, counter)

Checking the Memoized Changer

def check_changers(changer: object, amount: int, denominations: list):
    """Check that the changer matches the brute-force solution

    Arguments:
     changer: change-function to compare to brute-force
     amount: cents to change
     denominations: descending list of coin-denominations

    Raises:
     AssertionError: there's a discrepancy somewhere
    """
    brute_counts = brute_changer(amount, denominations)
    check_counts = changer(amount, denominations)
    check_coins(check_counts.coin_counts, denominations, brute_counts.coin_counts, amount)
    return
def check_cases(changer: object) -> None:
    """Check the pre-picked cases are okay

    Arguments:
     changer: the dynamic-programming changer to check

    Raises:
     AssertionError if something doesn't check out
    """
    for amount in range(100):
        check_changers(changer, amount, Denominations.US)
        check_changers(changer, amount, Denominations.double_dime)
        check_changers(changer, amount, Denominations.obsolete)

        money = 2
        coins = [10, 5, 1]
        check_changers(memoized_changer, money,  coins)

        money = 28
        check_changers(memoized_changer, money,  coins)

        coins = [25, 10, 5, 1]
        check_changers(memoized_changer, money,  coins)

        money = 14
        check_changers(memoized_changer, money,  coins)

        # the double-dimes
        coins = [25, 20, 10, 5, 1]
        money = 40
        check_changers(memoized_changer, money,  coins)
        return
check_cases(memoized_changer)

Plotting

All the Methods So Far

memoized_counts = [memoized_changer(amount, Denominations.US, {}).run_count
                   for amount in PLOT_AMOUNTS]
GREEDY_FRAME["Memoized"] = memoized_counts
melted = GREEDY_FRAME.melt(id_vars=["Amount to Change"],
                           value_vars=["Greedy", "Cashier", "Brute-Force",
                                       "Memoized"],
                           var_name="Change Method",
                           value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Greedy, Brute-Force & Memoized Change Counts (Common U.S. Coins)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "memoized-change-us-coins")

Figure Missing

With a Brute-Force ceiling of 10 loops it's hard to see a marked improvement, given how much easier it is to write the Brute-Force method rather than the memoized one, but let's get rid of the two greedy methods and compare the brute force and memoized with more obsolete coins added in.

Obsolete Coins

First, we'll check to make sure that the brute-force and memoized methods agree on the best coins for making change with our new denominations, which include some that were once used in the United States but aren't any more.

print(Denominations.obsolete)
[25, 20, 10, 5, 3, 2, 1]

Now let's see how the run-times compare.

BRUTE_OBSOLETE = [brute_changer(amount, Denominations.obsolete).run_count
                  for amount in PLOT_AMOUNTS]
MEMOIZED_OBSOLETE = [memoized_changer(amount, Denominations.obsolete, {}).run_count
                     for amount in PLOT_AMOUNTS]

OBSOLETE_FRAME = pandas.DataFrame({"Amount to Change": PLOT_AMOUNTS,
                                   "Brute-Force": BRUTE_OBSOLETE,
                                   "Memoized": MEMOIZED_OBSOLETE})

melted = OBSOLETE_FRAME.melt(id_vars=["Amount to Change"],
                           value_vars=["Brute-Force",
                                       "Memoized"],
                           var_name="Change Method",
                           value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Brute-Force & Memoized Change Counts (Including Obsolete Coins)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "memoized-change-obsolete")

Figure Missing

This time we have seven coin denominations so the brute-force method takes \(^7(8)/_2 = 28\) loops. Now that we have more coins the memoized does slightly (very slightly) worse than the brute-force for larger amounts, but in the examples so far I've been creating a new memo-table for every amount - what happens if we build one table and re-use it as we find the change for new amounts?

OBSOLETE_TABLE = {}
MEMOIZED_OBSOLETE_2 = [memoized_changer(amount,
                                        Denominations.obsolete,
                                        OBSOLETE_TABLE).run_count
                       for amount in PLOT_AMOUNTS]

OBSOLETE_FRAME_2 = pandas.DataFrame({"Amount to Change": PLOT_AMOUNTS,
                          "Brute-Force": BRUTE_OBSOLETE,
                          "Memoized": MEMOIZED_OBSOLETE_2})

melted = OBSOLETE_FRAME_2.melt(id_vars=["Amount to Change"],
                               value_vars=["Brute-Force",
                                           "Memoized"],
                               var_name="Change Method",
                               value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Brute-Force & Memoized Change Counts (Obsolete Coins, Re-Used Table)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "memoized-change-obsolete-keep-table")

Figure Missing

Re-using the table allows us to avoid some re-calculating and the number of calls eventually drops down to the number of coins we have, the same as the number of loops for the greedy method, although in this case we can handle cases where the greedy method fails.

Even better, now that we have the table built with all our allowed values, we can just look things up and don't need to run the function (although I will here just to make sure nothing funky is going on), so it has a cost of one.

MEMOIZED_OBSOLETE_3 = [memoized_changer(amount,
                                        Denominations.obsolete,
                                        OBSOLETE_TABLE).run_count
                       for amount in PLOT_AMOUNTS]

OBSOLETE_FRAME_3 = pandas.DataFrame({"Amount to Change": PLOT_AMOUNTS,
                          "Brute-Force": BRUTE_OBSOLETE,
                          "Memoized": MEMOIZED_OBSOLETE_3})

melted = OBSOLETE_FRAME_3.melt(id_vars=["Amount to Change"],
                               value_vars=["Brute-Force",
                                           "Memoized"],
                               var_name="Change Method",
                               value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Brute-Force & Memoized Change Counts (Obsolete Coins, Pre-Filled Table)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "memoized-change-obsolete-pre-filled-table")

Figure Missing

Iterative Dynamic Programming

The idea behind this problem (making change) is to show how Dynamic Programming can eleminate redundant calculations, so this is the point where I'll switch to an iterative approach that uses Dynamic Programming. The iterative, top-down approach works in the opposite direction of the memoized approach, starting with the smallest values and then moving to ever increasing values, storing the outcomes as we go so that we can re-use the lower solutions as the values increase. This is where I was stumped for a while - since the memoized function I used is based on the remainders left over after subtracting a coin's value from it (e.g. 26 cents minus a quarter has a remainder of 1 cent so we use the stored 1-cent solution from the memo-table rather than re-calculating it) in order to create an iterative version we somehow have to store the values in the table before they are needed, but the scheme I used where we iterate over the coin-denominations doesn't work (or it makes it really convoluted to force it to work). From what I can tell this is because I didn't really make a dynamic programming solution with the memoized version. With Dynamic Programming our memo-table should be built around the change we need to give out, so you would have a table with all the possible amounts, not just the ones needed as remainders. This works with our depth-first version because we're starting at the leaf nodes and working back up the tree, but it doesn't work with the iterative version because we don't know ahead of time which remainders we need later on in the table. For example, if we can only use 1-cent and 10-cent coins and we need to make change for 11 cents, then we have to have an entry for 1-cent in the table before we get to figuring out the solution that includes the 10-cent piece. If we need to break 12 cents, though, we need to have an entry for 2 cents before we get to calculating the entry for 10 cents. But how do we know which values we need to enter into the table for the 1-cent piece before we get to the 10 cent piece?

The solution is to fill out the table completely from 1 to the amount to make change for each coin before moving on to the next larger denomination. So, for our 12-cent example we need entries for \(1 \ldots 12\) cents for the 1 cent piece filled out before we move on to calculating the entries for the 10 cent piece so that no matter what remainder we have when we reach the 10-cent piece we can look it up in the table (we actually only need up to 9 cents, but since the denominations aren't fixed doing the trimming once again adds to the complexity more than I'd like). So, I'll just fill out the table and we can see how it goes.

def centaur(amount: int, denominations: list, table: dict=None) -> ChangeCountsTable:
    """Make change using the fewest coins

    Warning:
     To keep the answers matching the other functions it's assumed that the denominations
     are in decreasing order and the solution will also be match a decreasing order

    Args:
     money: the amount to change
     denominations: list of coin denominations in decreasing order
     table: memoization table (largest denomination, amount to change): best coin counts

    Returns:
     Number of each denomination needed to make the change, call_count, lookup table
    """
    # the denominations need to be in ascending order, the opposite of the other methods
    denominations = list(sorted(denominations))
    counter = 0
    if not table:
        table = {denomination: [None] * (amount + 1) for denomination in denominations}
        for index, denomination in enumerate(denominations):
            table[denomination][0] = [0] * (index + 1)
    best = float("inf")
    best_counts = None

    for owed in range(amount + 1):
        for index, denomination in enumerate(denominations):
            counter += 1
            count = owed//denomination
            remainder = owed - count * denomination
            counts = [count]

            if index > 0:
                counts = table[denominations[index - 1]][remainder] + counts

            table[denomination][owed] = counts

            if amount == owed and sum(counts) < best:
                best = sum(counts)
                best_counts = counts + [0] * (len(denominations) - index - 1)
    # put solution in descending order to match other functions
    best_counts.reverse()
    return ChangeCountsTable(best_counts, counter, table)
check_cases(centaur)

Plotting

Since the iterative version works differently from the others the loop-counts aren't really easy to compare.

centaur_counts = [centaur(amount, Denominations.US).run_count
                   for amount in PLOT_AMOUNTS]
GREEDY_FRAME["Iterative"] = centaur_counts
melted = GREEDY_FRAME.melt(id_vars=["Amount to Change"],
                           value_vars=["Greedy", "Cashier", "Brute-Force",
                                       "Memoized", "Iterative"],
                           var_name="Change Method",
                           value_name="Loop Count")

chart = altair.Chart(melted).mark_line(point=True).encode(
    x="Amount to Change",
    y=altair.Y("Loop Count", axis=altair.Axis(tickMinStep=1)),
    tooltip=["Amount to Change", "Loop Count", "Change Method"],
    color="Change Method").properties(
        title="Coin Change Counts (Common U.S. Coins)",
        width=PlotParameters.width,
        height=PlotParameters.height,
    )

save_it(chart, "all-change-us-coins")

Figure Missing

Since I'm filling out the whole table for the iterative function the loop is dependent on the amount of change owed, not the number of coin-denominations as with the other functions so it increases linearly. There's something I'm missing here as to why either the memoized-version is incorrect or there is an advantage to using the iterative version, but I haven't figured it out yet. The solutions seem to be the same in either case, though, and the linear growth for the number of loops is still tractable so I'll move on for now.

Sources