Apuntes DAM
Volver al inicio

Programación dinámica

AlgoritmosTécnicas de diseñoNivel avanzadoTambién: dynamic programming, memoización, tabulación

Resuelve un problema a partir de sus subproblemas guardando cada solución para no calcularla dos veces. Convierte recursividades exponenciales en tablas que se rellenan en tiempo polinómico.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Programación dinámica: el cambio óptimo

Escribe las monedas y la cantidad: dp[v] guarda el mínimo de monedas para pagar v, y cada casilla se calcula con las anteriores.

De 1 a 5 valores distintos
De 1 a 16
  • en la zona de trabajo
  • la que se calcula
  • fuera de juego

Paso 1

dp[0] = 0: pagar 0 no necesita ninguna moneda. Todas las demás empiezan en ∞ (aún no se sabe pagarlas).

1static int minMonedas(int[] monedas, int cantidad) {
2    int[] dp = new int[cantidad + 1];     // dp[v] = mínimo de monedas para pagar v
3    Arrays.fill(dp, Integer.MAX_VALUE);
4    dp[0] = 0;  // v = 0, dp[0] = 0
5    for (int v = 1; v <= cantidad; v++)
6        for (int m : monedas)
7            if (m <= v && dp[v - m] != Integer.MAX_VALUE)
8                dp[v] = Math.min(dp[v], dp[v - m] + 1);
9    return dp[cantidad] == Integer.MAX_VALUE ? -1 : dp[cantidad];
10}

Variables

v
0
dp[0]
0

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Algunos problemas se resuelven bien con recursividad pero repiten muchísimo trabajo: el Fibonacci ingenuo calcula fib(30) más de un millón de veces la misma fib(2). La programación dinámica se basa en una idea sencilla: si un subproblema ya se ha resuelto, se guarda su resultado y la próxima vez se consulta en vez de recalcularlo.

Funciona cuando el problema cumple dos condiciones. Subestructura óptima: la mejor solución del problema se construye con las mejores soluciones de sus subproblemas (el mejor cambio para 6 es una moneda más el mejor cambio para lo que queda). Y subproblemas solapados: los mismos subproblemas aparecen una y otra vez (si no se repitieran, sería divide y vencerás).

Se puede escribir de dos formas. De arriba abajo (memoización): la recursividad natural, más un mapa o un array donde se apunta cada resultado. De abajo arriba (tabulación): se rellena una tabla empezando por los casos más pequeños, de modo que cuando se calcula una casilla, las que necesita ya están. La tabulación evita la recursividad y suele permitir ahorrar memoria.

La parte difícil no es programarla sino plantearla: decidir qué significa cada casilla («dp[v] = mínimo de monedas para pagar v»), cuál es la recurrencia que la relaciona con las anteriores y cuáles son los casos base. Una vez escrito eso en una frase, el código sale casi solo.

Cuándo usarlo

  • Problemas de optimización o de contar formas en los que la solución se construye con soluciones de subproblemas que se repiten: cambio de monedas, mochila 0/1, caminos en una cuadrícula, subir escaleras.
  • Comparar secuencias: distancia de edición (correctores, diff), subsecuencia común más larga, alineamiento de ADN.
  • Cuando un voraz no da el óptimo y probar todas las combinaciones es exponencial.

Cuándo no

  • Si los subproblemas no se repiten: basta con divide y vencerás (o recursividad simple).
  • Si existe un voraz demostrado óptimo: es más simple y más rápido.
  • Si la tabla es demasiado grande (la mochila con una capacidad de miles de millones).

Paso a paso

  1. Definir el estado. Qué guarda cada casilla, en una frase: «dp[v] es el mínimo de monedas para pagar exactamente v».
  2. La recurrencia. Cómo se calcula una casilla con otras más pequeñas: dp[v] = min(dp[v − m] + 1) para cada moneda m que cabe.
  3. Los casos base. Las casillas que se saben sin calcular: dp[0] = 0.
  4. El orden. Rellenar la tabla en un orden en que las casillas necesarias ya estén calculadas (de 0 hacia arriba), o memoizar la recursividad. Si hace falta la solución y no solo su valor, se apunta en cada casilla qué decisión se tomó y se reconstruye al final.

El código

Subir una escalera: memoización y tabulación

Las dos formas de la programación dinámica para el mismo problema (que es Fibonacci disfrazado).

Java
1import java.util.Arrays;
2
3public class Main {
4    static long[] memo;
5
6    /** De arriba abajo (memoización): la recursividad de siempre, apuntando cada resultado. */
7    static long formas(int n) {
8        if (n <= 1) return 1;                          // 0 o 1 escalón: una sola forma
9        if (memo[n] != 0) return memo[n];
10        return memo[n] = formas(n - 1) + formas(n - 2);   // el último paso fue de 1 o de 2
11    }
12
13    /** De abajo arriba (tabulación): se rellena la tabla desde los casos pequeños, sin recursividad. */
14    static long[] tabla(int n) {
15        long[] dp = new long[n + 1];
16        dp[0] = 1;
17        dp[1] = 1;
18        for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
19        return dp;
20    }
21
22    public static void main(String[] args) {
23        memo = new long[11];
24        System.out.println("Formas de subir 10 escalones de 1 en 1 o de 2 en 2: " + formas(10));
25        System.out.println("La tabla, de 0 a 10 escalones: " + Arrays.toString(tabla(10)));
26    }
27}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Formas de subir 10 escalones de 1 en 1 o de 2 en 2: 89
La tabla, de 0 a 10 escalones: [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

El cambio óptimo, reconstruyendo las monedas

Además del mínimo, se apunta qué moneda mejoró cada casilla: recorriendo esos apuntes desde el final sale la solución.

Java
1import java.util.*;
2
3public class Main {
4    /** El mínimo de monedas para pagar cantidad, y cuáles. dp[v] = mínimo para pagar v. */
5    static String cambio(int[] monedas, int cantidad) {
6        int[] dp = new int[cantidad + 1], ultima = new int[cantidad + 1];
7        Arrays.fill(dp, Integer.MAX_VALUE);
8        dp[0] = 0;
9        for (int v = 1; v <= cantidad; v++)
10            for (int m : monedas)
11                if (m <= v && dp[v - m] != Integer.MAX_VALUE && dp[v - m] + 1 < dp[v]) {
12                    dp[v] = dp[v - m] + 1;
13                    ultima[v] = m;                     // se apunta la moneda para poder reconstruir
14                }
15        if (dp[cantidad] == Integer.MAX_VALUE) return cantidad + ": imposible";
16        StringJoiner sj = new StringJoiner(" + ");
17        for (int v = cantidad; v > 0; v -= ultima[v]) sj.add(String.valueOf(ultima[v]));
18        return cantidad + " = " + sj + " (" + dp[cantidad] + " monedas)";
19    }
20
21    public static void main(String[] args) {
22        System.out.println(cambio(new int[] {1, 3, 4}, 6));
23        System.out.println(cambio(new int[] {1, 5, 10, 25}, 63));
24        System.out.println(cambio(new int[] {5, 2}, 3));
25    }
26}

Salida al ejecutarlo (la misma en los 5 lenguajes)

6 = 3 + 3 (2 monedas)
63 = 1 + 1 + 1 + 10 + 25 + 25 (6 monedas)
3: imposible

Traza: dp del cambio de 6 con monedas de 1, 3 y 4

vdp[v]Moneda elegidaCandidatos
00—caso base
111dp[0] + 1 = 1
221dp[1] + 1 = 2
313dp[2] + 1 = 3, dp[0] + 1 = 1
414dp[3] + 1 = 2, dp[1] + 1 = 2, dp[0] + 1 = 1
521dp[4] + 1 = 2, dp[2] + 1 = 3, dp[1] + 1 = 2
623dp[5] + 1 = 3, dp[3] + 1 = 2, dp[2] + 1 = 3

dp[6] = 2: se reconstruye desde el 6 (moneda 3) al 3 (moneda 3) al 0. El voraz habría dado 4 + 1 + 1.

Complejidad

ProblemaFuerza brutaProgramación dinámica
Fibonacci, escalerasO(2ⁿ)O(n)
Cambio mínimo (k monedas, cantidad C)ExponencialO(C · k)
Mochila 0/1 (n objetos, capacidad W)O(2ⁿ)O(n · W)
Distancia de edición (textos de n y m)ExponencialO(n · m)

El coste es (número de casillas) × (trabajo por casilla). La memoria es el tamaño de la tabla, aunque muchas veces basta con guardar la última fila o las últimas casillas.

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(n)
  • Caso medio: O(n)
  • Peor caso: O(n)

El cambio óptimo rellena cantidad + 1 casillas probando cada moneda: O(cantidad · monedas), lineal en la cantidad. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Los correctores ortográficos y git diff calculan distancias de edición y subsecuencias comunes con tablas de programación dinámica.
  • La bioinformática alinea secuencias de ADN y proteínas (Needleman-Wunsch, Smith-Waterman).
  • El reconocimiento de voz (Viterbi), el ajuste de texto en párrafos de TeX y los planificadores de rutas usan programación dinámica.
  • Los optimizadores de consultas de las bases de datos eligen el orden de los JOIN con programación dinámica.

Errores típicos

  • Plantear la tabla sin definir con precisión qué significa una casilla: de ahí salen casi todos los errores de la recurrencia.
  • Rellenar en un orden en que se usan casillas que aún no se han calculado.
  • Olvidar los casos base o inicializarlos mal (∞ frente a 0, 1 forma de subir 0 escalones).
  • Desbordar enteros al contar formas: el número de formas crece muy deprisa (usar long o BigInteger).
  • Confundir «sumar las formas» (contar) con «quedarse con la mejor» (optimizar): la recurrencia cambia de + a min/max.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Formas de subir una escalera

La primera línea es el número de escalones (1 a 60) y la segunda, los tamaños de paso permitidos (por ejemplo 1 2 o 1 3 5). ¿De cuántas formas distintas se puede subir la escalera? Importa el orden: subir 3 escalones como 1 + 2 es distinto que 2 + 1. Usa una tabla en la que dp[i] son las formas de subir i escalones. Completa formas.

  • Entrada: 10 y luego 1 2.
  • Salida: Formas de subir 10 escalones con pasos de 1, 2: 89 (los pasos se escriben ordenados y sin repetir).
  • Errores: Escalones no válidos: «…» (de 1 a 60) y Pasos no válidos: «…» (enteros de 1 a 10).
☕JavaFormas de subir una escaleraMedio

Ejemplo

Entrada (lo que se escribe por teclado)
10
1 2
Salida esperada
Formas de subir 10 escalones con pasos de 1, 2: 89
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
0/6 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    /** Formas de subir n escalones dando pasos de los tamaños permitidos (el orden de los pasos cuenta).
5        dp[i] = suma de dp[i − p] para cada paso p ≤ i, con dp[0] = 1. */
6    static long formas(int n, int[] pasos) {
7        long[] dp = new long[n + 1];
8        dp[0] = 1;
9        for (int i = 1; i <= n; i++)
10            for (int p : pasos)
11                if (p <= i) dp[i] += dp[i - p];
12        return dp[n];
13    }
14
15    public static void main(String[] args) {
16        Scanner sc = new Scanner(System.in);
17        String primera = sc.hasNextLine() ? sc.nextLine().trim() : "";
18        String segunda = sc.hasNextLine() ? sc.nextLine().trim() : "";
19        if (!primera.matches("[1-9]\\d?") || Integer.parseInt(primera) > 60) {
20            System.out.println("Escalones no válidos: «" + primera + "» (de 1 a 60)");
21            return;
22        }
23        int n = Integer.parseInt(primera);
24        if (!segunda.matches("([1-9]|10)(\\s+([1-9]|10))*")) {
25            System.out.println("Pasos no válidos: «" + segunda + "» (enteros de 1 a 10)");
26            return;
27        }
28        int[] pasos = Arrays.stream(segunda.split("\\s+")).mapToInt(Integer::parseInt).distinct().sorted().toArray();
29        StringJoiner sj = new StringJoiner(", ");
30        for (int p : pasos) sj.add(String.valueOf(p));
31        System.out.println("Formas de subir " + n + " escalones con pasos de " + sj + ": " + formas(n, pasos));
32    }
33}

Con pasos de 1 y 2 es la sucesión de Fibonacci: cada forma de subir i escalones termina en un paso de 1 (desde i − 1) o en uno de 2 (desde i − 2).

La recursividad sin memoria tardaría siglos con n = 60; la tabla hace 60 × pasos sumas.

2. El ladrón de casas

En una calle hay casas en fila con cierto dinero cada una, y la alarma salta si se entra en dos casas vecinas. ¿Cuánto se puede robar como máximo y en qué casas? Con dp[i] = lo mejor usando las casas 0..i, cada casa se roba (y entonces la anterior no) o no se roba. Para decir qué casas, se reconstruye desde el final: si dp[i] es igual a dp[i − 1], la casa i no hace falta; si no, se roba y se salta a la i − 2. Completa tabla y casas.

  • Entrada: una línea con el dinero de cada casa (enteros de 0 a 9999999), por ejemplo 2 7 9 3 1.
  • Salida: Máximo: 12 y Casas: 1 (2), 3 (9), 5 (1) (posición desde 1 y dinero), o Casas: ninguna.
  • Errores: No hay casas y Valor no válido: «x».
☕JavaEl ladrón de casasDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
2 7 9 3 1
Salida esperada
Máximo: 12
Casas: 1 (2), 3 (9), 5 (1)
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
0/6 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    /** El ladrón de casas: el máximo que se puede robar sin entrar en dos casas vecinas.
5        dp[i] = lo mejor con las casas 0..i = max(dp[i − 1], dp[i − 2] + v[i]). */
6    static long[] tabla(int[] v) {
7        long[] dp = new long[v.length];
8        for (int i = 0; i < v.length; i++) {
9            long sin = i >= 1 ? dp[i - 1] : 0;                      // no robar la casa i
10            long con = (i >= 2 ? dp[i - 2] : 0) + v[i];             // robarla (y no la anterior)
11            dp[i] = Math.max(sin, con);
12        }
13        return dp;
14    }
15
16    /** Qué casas se roban (posiciones desde 0, de menor a mayor), reconstruyendo desde el final:
17        si dp[i] == dp[i − 1], la casa i no hace falta; si no, se roba y se salta a i − 2. */
18    static List<Integer> casas(int[] v, long[] dp) {
19        List<Integer> r = new ArrayList<>();
20        int i = v.length - 1;
21        while (i >= 0) {
22            if (i >= 1 && dp[i] == dp[i - 1]) i--;
23            else {
24                r.add(0, i);
25                i -= 2;
26            }
27        }
28        return r;
29    }
30
31    public static void main(String[] args) {
32        Scanner sc = new Scanner(System.in);
33        String linea = sc.hasNextLine() ? sc.nextLine().trim() : "";
34        if (linea.isEmpty()) {
35            System.out.println("No hay casas");
36            return;
37        }
38        String[] t = linea.split("\\s+");
39        int[] v = new int[t.length];
40        for (int i = 0; i < t.length; i++) {
41            if (!t[i].matches("\\d{1,7}")) {
42                System.out.println("Valor no válido: «" + t[i] + "»");
43                return;
44            }
45            v[i] = Integer.parseInt(t[i]);
46        }
47        long[] dp = tabla(v);
48        List<Integer> r = casas(v, dp);
49        StringJoiner sj = new StringJoiner(", ");
50        for (int i : r) sj.add((i + 1) + " (" + v[i] + ")");
51        System.out.println("Máximo: " + dp[v.length - 1]);
52        System.out.println("Casas: " + (r.isEmpty() ? "ninguna" : sj.toString()));
53    }
54}

Cada casilla resume todas las combinaciones válidas de las casas anteriores en un solo número: por eso la tabla es lineal aunque haya 2ⁿ formas de elegir casas.

La reconstrucción repite la decisión de cada casilla al revés: si no robar la casa i daba lo mismo, no se roba (es la regla que fija qué solución se escribe cuando hay varias igual de buenas).

Test

Test: Programación dinámica

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Qué dos propiedades necesita un problema para resolverlo con programación dinámica?

  2. 2.¿Qué diferencia hay entre memoización y tabulación?

  3. 3.Con monedas de 1, 3 y 4, ¿cuánto vale dp[6] (mínimo de monedas para pagar 6)?

  4. 4.¿Cuál es el coste del cambio mínimo con programación dinámica para una cantidad C y k monedas?

  5. 5.Para saber QUÉ monedas usar (y no solo cuántas), ¿qué hace falta?

Relacionado