← Todos los algoritmos

Coin Change

Voraz · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Greedy

El problema

Dar cambio de una cantidad usando la menor cantidad posible de monedas/billetes a partir de un conjunto dado de denominaciones. Probar todas las combinaciones para encontrar el mínimo verdadero es exponencial. Greedy ofrece un atajo mucho más barato — pero el problema es que no siempre acierta, y saber exactamente cuándo deja de acertar es el verdadero objetivo de este módulo.

La solución

Greedy: ordenar las denominaciones y luego tomar repetidamente tantas unidades como quepan de la denominación más grande, pasando a la siguiente más pequeña solo cuando la actual ya no quepa. Una sola pasada sobre una lista de denominaciones de tamaño fijo — O(denominaciones), completamente independiente de la cantidad.

Esa es la cantidad mínima de monedas verdadera solo para un sistema de denominaciones canónico, en el que ninguna combinación de monedas más pequeñas supera nunca a una más grande que greedy habría elegido. Los sistemas monetarios reales — incluidos los billetes y monedas del real brasileño — son canónicos, y por eso precisamente greedy es lo que en realidad ejecuta cada cajero automático. Pero no todo conjunto de denominaciones es canónico, y para uno que no lo es, greedy puede comprometerse temprano con una moneda grande que una combinación más pequeña habría evitado, llegando a una respuesta válida que no es la mejor. classic/CoinChange.minCoinsDP resuelve el mismo problema con programación dinámica — O(cantidad × denominaciones), más lento, pero correcto para cualquier conjunto de denominaciones positivas — específicamente para poder mostrar la brecha entre ambos, no solo afirmarla.

Ejemplo clásico

classic/CoinChange implementa greedyCoinCount y minCoinsDP lado a lado. CoinChangeTest demuestra directamente el contraejemplo clásico de los libros de texto: con las denominaciones {1, 3, 4} y la cantidad 6, greedy toma primero un 4 y termina con 4 + 1 + 13 monedas. El método de DP encuentra 3 + 32 monedas. Mismas entradas, mismo problema, dos respuestas distintas, porque uno de los dos métodos solo es correcto bajo una suposición que el otro no necesita. El mismo archivo de pruebas confirma que ambos coinciden en un conjunto canónico (monedas de EE. UU., {1, 5, 10, 25}), y que los dos métodos fallan de forma ruidosa (en lugar de subcontar en silencio) cuando una cantidad genuinamente no se puede formar con las monedas dadas.

Ejemplo aplicado: quiosco de retiro de efectivo de cajero automático bancario

applied/CashDispenser modela el quiosco de autoservicio de retiro de un banco heredado, que decide cuántos billetes/ monedas de cada denominación entregar. Las denominaciones del real brasileño (desde R$200 hasta 1 centavo) son canónicas, así que greedy da aquí la cantidad mínima verdadera de billetes/ monedas — justo lo que necesita un quiosco con capacidad de casete limitada por denominación. Las cantidades se manejan en centavos como long específicamente para mantener la aritmética monetaria exacta y evitar el redondeo de punto flotante. CashDispenserTest cubre un retiro mixto realista, una coincidencia exacta con un solo billete, un retiro de cero, y las salvaguardas (cantidades negativas, cantidades que superan el límite por transacción del quiosco).

Benchmark

./gradlew :greedy:coin-change:jmh

Ejecución real en esta máquina (JMH 1.37, JDK 26.0.2, 2 iteraciones de calentamiento + 3 de medición, 1 fork). Ambos métodos se ejecutan contra el mismo conjunto canónico de denominaciones en centavos de BRL, así que siempre coinciden en la respuesta — esto mide el costo de llegar a ella, no la corrección:

Costo 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

Greedy se mantiene estable a lo largo de tres órdenes de magnitud de la cantidad, exactamente como predice O(denominaciones) — está haciendo la misma pasada fija sobre 13 denominaciones sin importar cuán grande sea la cantidad. DP sigue su predicción de O(amount) con claridad en el extremo más limpio del rango: pasar de amount=500,000 a amount=5,000,000 es un aumento de 10x en la cantidad, y el costo medido de DP creció 10.16x — una coincidencia cercana. (El paso más pequeño, 10,000 → 500,000, presenta márgenes de error mucho más amplios en esta ejecución — probablemente ruido de calentamiento del JIT en ese extremo del rango — por lo que la confirmación más limpia del 10x en los tamaños mayores es la que vale la pena confiar.) En amount=5,000,000, DP es ~942,157x más lento que greedy para una respuesta que greedy ya tenía.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúcelo tú mismo:

./gradlew :greedy:coin-change:jacocoTestReport

Informe en greedy/coin-change/build/reports/jacoco/test/html/index.html.

Lectura complementaria

Pruebas unitarias

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 informe completo de cobertura JaCoCo →