← Todos os algoritmos

Coin Change

Guloso · ver código-fonte no GitHub

Leia em: English · Português · Español

Categoria: Greedy

O problema

Dar troco de um valor usando o menor número possível de moedas/cédulas a partir de um conjunto dado de denominações. Tentar todas as combinações para encontrar o mínimo verdadeiro é exponencial. O greedy oferece um atalho muito mais barato — mas a pegadinha é que ele nem sempre está certo, e saber exatamente quando ele deixa de estar certo é o verdadeiro propósito deste módulo.

A solução

Greedy: ordenar as denominações e, então, pegar repetidamente o máximo possível da maior denominação que ainda cabe, passando para a próxima menor apenas quando a atual não couber mais. Uma única passada sobre uma lista de denominações de tamanho fixo — O(denominações), completamente independente do valor.

Essa é a contagem mínima de moedas verdadeira apenas para um sistema de denominações canônico, no qual nenhuma combinação de moedas menores jamais supera uma maior que o greedy teria escolhido. Sistemas de moeda reais — incluindo as cédulas e moedas do real brasileiro — são canônicos, e é exatamente por isso que o greedy é o que todo caixa eletrônico de fato executa. Mas nem todo conjunto de denominações é canônico, e, para um que não é, o greedy pode travar cedo demais em uma moeda grande que uma combinação menor teria evitado, chegando a uma resposta válida que não é a melhor. classic/CoinChange.minCoinsDP resolve o mesmo problema com programação dinâmica — O(valor × denominações), mais lento, porém correto para qualquer conjunto de denominações positivas — especificamente para que a diferença entre os dois possa ser demonstrada, não apenas alegada.

Exemplo clássico

classic/CoinChange implementa greedyCoinCount e minCoinsDP lado a lado. CoinChangeTest comprova diretamente o contraexemplo clássico dos livros-texto: com as denominações {1, 3, 4} e o valor 6, o greedy pega um 4 primeiro e termina com 4 + 1 + 13 moedas. O método de DP encontra 3 + 32 moedas. Mesmas entradas, mesmo problema, duas respostas diferentes, porque um dos dois métodos só está correto sob uma suposição de que o outro não precisa. O mesmo arquivo de teste confirma que os dois concordam em um conjunto canônico (moedas dos EUA, {1, 5, 10, 25}), e que ambos os métodos falham ruidosamente (em vez de subcontar silenciosamente) quando um valor genuinamente não pode ser formado a partir das moedas dadas.

Exemplo aplicado: quiosque de saque de dinheiro de caixa eletrônico bancário

applied/CashDispenser modela o quiosque de autoatendimento de saque de um banco legado, decidindo quantas cédulas/moedas de cada denominação dispensar. As denominações do real brasileiro (de R$200 até 1 centavo) são canônicas, então o greedy fornece aqui a contagem mínima verdadeira de cédulas/moedas — exatamente o que um quiosque com capacidade de cassete limitada por denominação precisa. Os valores são tratados em centavos como long especificamente para manter a aritmética monetária exata e evitar arredondamento de ponto flutuante. CashDispenserTest cobre um saque misto realista, uma correspondência exata com uma única cédula, um saque de valor zero, e as proteções de segurança (valores negativos, valores acima do limite por transação do quiosque).

Benchmark

./gradlew :greedy:coin-change:jmh

Execução real nesta máquina (JMH 1.37, JDK 26.0.2, 2 iterações de aquecimento + 3 de medição, 1 fork). Ambos os métodos rodam contra o mesmo conjunto canônico de denominações em centavos de BRL, então sempre concordam quanto à resposta — isto mede o custo de chegar até ela, não a correção:

Custo amount=10,000 amount=500,000 amount=5,000,000
greedy 0.164 µs 0.158 µs 0.159 µs
DP 198.98 µs 14,734.68 µs 149,802.96 µs

O greedy permanece estável ao longo de três ordens de grandeza do valor, exatamente como O(denominações) prevê — ele está executando a mesma passada fixa sobre 13 denominações, independentemente de quão grande seja o valor. O DP acompanha sua previsão de O(amount) com nitidez na ponta mais limpa do intervalo: ir de amount=500,000 para amount=5,000,000 é um aumento de 10x no valor, e o custo medido do DP cresceu 10.16x — uma correspondência bem próxima. (O passo menor, 10,000 → 500,000, carrega margens de erro bem mais amplas nesta execução — provavelmente ruído de aquecimento do JIT nessa ponta do intervalo — então a confirmação mais limpa de 10x nos tamanhos maiores é a que vale a pena confiar.) Em amount=5,000,000, o DP é ~942,157x mais lento do que o greedy para uma resposta que o greedy já tinha.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

./gradlew :greedy:coin-change:jacocoTestReport

Relatório em greedy/coin-change/build/reports/jacoco/test/html/index.html.

Leitura complementar

Testes unitários

src/test/java/com/algorithms/greedy/coinchange/classic/CoinChangeTest.java
package com.algorithms.greedy.coinchange.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CoinChangeTest {

    private static final int[] US_COINS = {1, 5, 10, 25};
    private static final int[] NON_CANONICAL = {1, 3, 4};

    @Test
    void greedyMatchesTheDpOptimumOnACanonicalDenominationSet() {
        assertThat(CoinChange.greedyCoinCount(US_COINS, 41)).isEqualTo(CoinChange.minCoinsDP(US_COINS, 41));
        assertThat(CoinChange.greedyCoinCount(US_COINS, 41)).isEqualTo(4); // 25 + 10 + 5 + 1
    }

    @Test
    void greedyIsProvablySuboptimalOnANonCanonicalDenominationSet() {
        // {1, 3, 4} for amount 6: greedy locks in 4 first (4 + 1 + 1 = 3 coins), but 3 + 3 = 2
        // coins is strictly better. This is the textbook counterexample to "greedy coin change
        // is always optimal" — it only holds for canonical denomination systems.
        assertThat(CoinChange.greedyCoinCount(NON_CANONICAL, 6)).isEqualTo(3);
        assertThat(CoinChange.minCoinsDP(NON_CANONICAL, 6)).isEqualTo(2);
    }

    @Test
    void zeroAmountNeedsNoCoins() {
        assertThat(CoinChange.greedyCoinCount(US_COINS, 0)).isZero();
        assertThat(CoinChange.minCoinsDP(US_COINS, 0)).isZero();
    }

    @Test
    void greedyFailsLoudlyWhenTheAmountCannotBeMade() {
        assertThatThrownBy(() -> CoinChange.greedyCoinCount(new int[] {2}, 3))
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    void dpFailsLoudlyWhenTheAmountCannotBeMade() {
        assertThatThrownBy(() -> CoinChange.minCoinsDP(new int[] {2}, 3))
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    void rejectsInvalidDenominationsAndAmounts() {
        assertThatThrownBy(() -> CoinChange.greedyCoinCount(null, 10)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> CoinChange.greedyCoinCount(new int[0], 10)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> CoinChange.greedyCoinCount(new int[] {1, 0}, 10)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> CoinChange.greedyCoinCount(US_COINS, -1)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> CoinChange.minCoinsDP(null, 10)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> CoinChange.minCoinsDP(new int[] {-5}, 10)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> CoinChange.minCoinsDP(US_COINS, -1)).isInstanceOf(IllegalArgumentException.class);
    }
}
src/test/java/com/algorithms/greedy/coinchange/applied/CashDispenserTest.java
package com.algorithms.greedy.coinchange.applied;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CashDispenserTest {

    private final CashDispenser dispenser = new CashDispenser();

    @Test
    void dispensesTheMinimumNoteAndCoinCountForAWithdrawal() {
        // R$237.85: 200 + 20 + 10 + 5 + 2 + 0.50 + 0.25 + 0.10 = 8 notes/coins.
        assertThat(dispenser.dispenseNoteCount(23_785L)).isEqualTo(8);
    }

    @Test
    void zeroWithdrawalNeedsNoNotes() {
        assertThat(dispenser.dispenseNoteCount(0L)).isZero();
    }

    @Test
    void aSingleLargeNoteCoversAnExactMatch() {
        assertThat(dispenser.dispenseNoteCount(20_000L)).isEqualTo(1);
    }

    @Test
    void rejectsANegativeWithdrawal() {
        assertThatThrownBy(() -> dispenser.dispenseNoteCount(-1L)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsAWithdrawalBeyondTheKiosksPerTransactionLimit() {
        assertThatThrownBy(() -> dispenser.dispenseNoteCount((long) Integer.MAX_VALUE + 1))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

Ver relatório completo de cobertura JaCoCo →