TL;DR
- El Problema: Optimización de complejidad temporal y espacial para estructuras de datos clave.
- El Enfoque: Problema estilo CTCI 8.11 para principiantes: cuenta combinaciones que suman n centavos con monedas de 25, 10, 5 y 1. DP bottom-up de cambio de monedas, el orden no importa, Java claro.
- Complejidad: Relación óptima de tiempo y espacio con gestión de casos límite.
Tienes monedas infinitas de unos pocos valores fijos. Alguien pregunta: ¿cuántos montones distintos suman exactamente n centavos? No el menor número de monedas. El conteo de combinaciones. Ese es el problema clásico Coins: monedas de 25, 10, 5 y 1, y un importe objetivo.
Este post es enseñanza original para principiantes en Java. Misma familia de preguntas de combinaciones de cambio en entrevista, no una copia de libro. Parte de la serie CTCI en Java. Capítulo 8, recursión y programación dinámica, problema 8.11.
1. Analogía cotidiana
Piensa en una máquina expendedora que solo acepta 25, 10, 5 y 1. Debes pagar exactamente 30 centavos. No importa qué moneda cae primero. Tres monedas de 10 son una forma. Una de 25 y una de 5 son otra. Seis de 5 son otra. El orden en la ranura no crea una forma nueva.
Si el orden importara, tres monedas de 10 explotarían en muchas permutaciones de las mismas tres monedas. En entrevistas casi siempre quieren combinaciones: el mismo multiconjunto de monedas es una sola forma.
Una tabla pequeña de "cuántas formas hay de hacer cada importe" es más fácil que inventar cada montón a mano. Esa tabla es programación dinámica.
2. Enunciado en palabras simples
Entrada: un entero no negativo n (centavos a formar). Opcionalmente una lista de denominaciones; el conjunto clásico es {25, 10, 5, 1}.
Salida: el número de combinaciones distintas de esas monedas que suman exactamente n. Las monedas del mismo valor son idénticas. Puedes usar tantas de cada tipo como quieras (suministro ilimitado).
Ejemplos con monedas {25, 10, 5, 1}:
| n | Formas (idea) | Conteo |
|---|---|---|
| 0 | montón vacío | 1 |
| 1 | un céntimo | 1 |
| 5 | cinco céntimos; un nickel | 2 |
| 10 | ver recorrido abajo | 4 |
| 30 | muchas mezclas de 25/10/5/1 | 18 |
Formas para n = 10 (cada línea es una combinación):
10×1
1×5 + 5×1
2×5
1×10
Eso es 4. No cuentas 5 luego 5 como distinto de 5 luego 5 al revés; los nickels son idénticos.
Aclara antes de codificar:
- ¿Combinaciones o permutaciones? Combinaciones (el orden no importa).
- ¿Suministro ilimitado de cada denominación? Sí, salvo que digan lo contrario.
- ¿Qué es
ways(0)? Suele ser 1 (una combinación vacía). Dilo en voz alta. - ¿
nnegativo? Devuelve 0, o asumen >= 0. - ¿Tipo de retorno?
intvale para tamaños de entrevista; mencionalongsinpuede crecer. - ¿Monedas fijas o array genérico? Codifica el array genérico; demuéstralo con
{25, 10, 5, 1}.
3. Piensa primero
Recursión a fuerza bruta
Elige un tipo de moneda a la vez para que el orden no se cuele. Para el índice de moneda i y el resto rem:
- Si
rem == 0, cuenta 1. - Si
rem < 0o se acabaron los tipos, cuenta 0. - Si no, prueba 0, 1, 2, ... copias de
coins[i], y recurre al siguiente tipo con lo que quede.
Eso explora cada combinación una vez. Sin memoización es lento: muchos subproblemas solapados como "formas con monedas desde el índice 2 y rem = 40".
Recursión con memo
Cachea en (coinIndex, remaining). Misma lógica, mucho más rápido. Sigue siendo un estado de dos dimensiones.
DP bottom-up (respuesta por defecto en entrevista)
Construye un array ways[0 .. n] donde ways[a] significa "número de combinaciones que suman a".
ways[0] = 1
for each coin c in coins:
for a from c to n:
ways[a] += ways[a - c]
Por qué importa el orden de los bucles:
| Bucle externo | Bucle interno | Qué cuentas |
|---|---|---|
| monedas, luego importes | como arriba | combinaciones (cada multiconjunto una vez) |
| importes, luego monedas | intercambia los bucles | permutaciones (el orden importa) |
Quieres la primera tabla. Cada moneda se "introduce" del todo antes de pasar a la siguiente, así que secuencias que solo difieren en el orden colapsan en un solo camino por el array.
Intuición de un paso: cuando la moneda c está disponible, cada forma antigua de hacer a - c se convierte en una forma de hacer a añadiendo una c más. Puedes añadir varias c en actualizaciones sucesivas del mismo array porque el bucle interno sube.
Recorrido pequeño: n = 10, coins = [1, 5, 10]
Inicio: ways = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Tras la moneda 1 (solo céntimos): cada importe tiene 1 forma.
Tras la moneda 5:
- importe 5:
ways[5] += ways[0]→ 2 - importe 6:
ways[6] += ways[1]→ 2 - ...
- importe 10: se acumulan las formas con nickels
Tras la moneda 10: ways[10] += ways[0] añade la combinación de un dime puro. Final ways[10] = 4.
Por qué no el DP de "mínimas monedas"
El problema famoso de "pocas monedas" guarda una longitud mínima. Este guarda un conteo. Misma forma de bucles anidados, distinta recurrencia:
- min:
dp[a] = min(dp[a], dp[a - c] + 1) - ways:
ways[a] += ways[a - c]
No los mezcles en la cabeza durante la entrevista.
Boceto en la pizarra
- Escribe denominaciones
25, 10, 5, 1. - Dibuja
ways[0]=1, el resto a cero. - Procesa una moneda cada vez (mentalmente) para un
npequeño como 10. - Rodea el orden de bucles (moneda fuera) para no caer en permutaciones.
- Codifica el método genérico y llámalo con el array clásico.
4. Solución en Java
/**
* Number of combinations that sum to n using unlimited coins from denominations.
* Order does not matter. ways(0) == 1.
*/
int makeChange(int n, int[] coins) {
if (n < 0) {
return 0;
}
int[] ways = new int[n + 1];
ways[0] = 1;
for (int coin : coins) {
if (coin <= 0) {
continue; // skip bad denominations if any slip in
}
for (int amount = coin; amount <= n; amount++) {
ways[amount] += ways[amount - coin];
}
}
return ways[n];
}
/** Classic CTCI denominations: quarters, dimes, nickels, pennies. */
int makeChange(int n) {
return makeChange(n, new int[] {25, 10, 5, 1});
}
Variante recursiva + memo (misma respuesta)
Útil si piden top-down primero:
int makeChangeMemo(int n, int[] coins) {
if (n < 0) {
return 0;
}
Integer[][] memo = new Integer[coins.length][n + 1];
return waysFrom(0, n, coins, memo);
}
private int waysFrom(int index, int remaining, int[] coins, Integer[][] memo) {
if (remaining == 0) {
return 1;
}
if (index == coins.length) {
return 0;
}
if (memo[index][remaining] != null) {
return memo[index][remaining];
}
int ways = 0;
int coin = coins[index];
for (int count = 0; count * coin <= remaining; count++) {
ways += waysFrom(index + 1, remaining - count * coin, coins, memo);
}
memo[index][remaining] = ways;
return ways;
}
El array bottom-up es más corto de escribir con reloj en contra. Conoce ambos.
Recorrido: n = 5, coins = [1, 5]
| Paso | Estado de ways[0..5] |
|---|---|
| init | [1, 0, 0, 0, 0, 0] |
| tras 1 | [1, 1, 1, 1, 1, 1] |
| tras 5 | [1, 1, 1, 1, 1, 2] |
Respuesta 2: cinco céntimos, o un nickel.
Pruebas mínimas
public static void main(String[] args) {
int[] coins = {25, 10, 5, 1};
System.out.println(makeChange(0, coins)); // 1
System.out.println(makeChange(1, coins)); // 1
System.out.println(makeChange(5, coins)); // 2
System.out.println(makeChange(10, coins)); // 4
System.out.println(makeChange(30, coins)); // 18
}
5. Tabla de complejidad
| Enfoque | Tiempo | Espacio extra | Notas |
|---|---|---|---|
| Recursión sin memo | exponencial | O(d) pila | d = número de denominaciones; demasiado lento |
| Memo en (índice, resto) | O(d · n · ...) según bucles | O(d · n) | bien; más código |
Bottom-up ways[] |
O(d · n) | O(n) | respuesta preferida en entrevista |
| Bottom-up con solo 4 monedas fijas | O(n) | O(n) | misma idea, d es constante |
Con las cuatro monedas clásicas, el tiempo es lineal en n. Aun así di O(d · n) para sonar general.
6. Casos límite y errores frecuentes
Los entrevistadores pinchan estos:
n = 0→ devuelve 1 (una combinación vacía). No 0.nnegativo → 0, o rechaza la entrada.- Solo céntimos → exactamente una forma para todo
nno negativo. - No se puede formar
n(por ejemplo monedas{2, 4}yn = 3) →ways[n]se queda en 0. - Denominaciones duplicadas en el array → contarías de más; asume valores únicos o deduplica.
- Moneda mayor que
n→ el bucle interno no corre; no pasa nada. - Desbordamiento de entero → con
ngrande y muchas monedas,intpuede envolver. Mencionalongsi crecen las cotas.
Errores comunes:
- Intercambiar el orden de los bucles y contar permutaciones. Tres céntimos se sobrecontarían como órdenes distintos.
- Poner
ways[0] = 0. Entonces todo importe se queda en cero. - Usar una tabla 2D sin necesidad y fallar en los índices. 1D basta para combinaciones con monedas ilimitadas.
- Resolver mínimas monedas en lugar del conteo. Otra recurrencia.
- Mutar el array
coinsu ordenar sin necesidad. Ordenar no hace daño, pero el DP de combinaciones no lo exige si procesas un tipo completo cada vez.
7. Recap para contárselo a un amigo
Coins pregunta: con 25/10/5/1 ilimitados, ¿cuántas combinaciones distintas suman exactamente n centavos?
- El orden no importa. Tres monedas de 10 son una forma, no seis permutaciones.
ways[0] = 1. Puedes hacer cero centavos de una forma: no usar nada.- Por cada moneda, recorre importes desde esa moneda hasta
ny hazways[a] += ways[a - c]. - Bucle externo de monedas da combinaciones. Externo de importes da permutaciones. Di cuál quieres.
- Tiempo O(d · n), espacio O(n). Para n = 10 la respuesta es 4; para n = 30 es 18 con el conjunto clásico.
Si puedes rellenar ways para n = 10 a mano y explicar por qué el orden de bucles mata las permutaciones, dominas el problema 8.11.
Serie
- Guía: guía de la serie CTCI
- Anterior: Paint Fill
- Siguiente: Eight Queens
