El cierre de la lección anterior dejó una pregunta abierta: ¿qué hacemos cuando un problema tiene subestructura óptima pero la apuesta voraz falla — cuando pagar 6 € exige darse cuenta de que 3+3 gana a empezar por el 4? La respuesta es la programación dinámica (PD): en lugar de jugárselo todo a una decisión, se consideran todas las decisiones posibles... pero sin pagar el precio exponencial de la fuerza bruta, porque los subproblemas repetidos se calculan una sola vez y se guardan. Es exactamente la idea que apuntamos en 02-02 con construir_indice — gastar memoria para no repetir trabajo — elevada a estrategia de diseño. En esta lección la desarrollaremos en sus dos sabores (memoización y tabulación), la aplicaremos a dos problemas de RutaBus — el trayecto más barato con tarifas por tramos y la selección de mejoras de flota con presupuesto limitado — y aprenderemos no solo a calcular el valor óptimo, sino a reconstruir la solución que lo alcanza.
Contenido
- Los dos ingredientes: subproblemas solapados y subestructura óptima
- Fibonacci: el ejemplo mínimo del desastre y su cura
- Memoización (top-down): recordar lo ya calculado
- Problema RutaBus: el trayecto más barato con tarifas por tramos
- Tabulación (bottom-up): rellenar la tabla sin recursión
- Top-down vs bottom-up
- La mochila 0/1: mejoras de flota con presupuesto limitado
- Reconstruir la solución, no solo el valor
Los dos ingredientes: subproblemas solapados y subestructura óptima
La programación dinámica aplica cuando el problema tiene, a la vez:
- Subestructura óptima: la solución óptima del problema se construye a partir de soluciones óptimas de sus subproblemas. (La misma propiedad que exigía greedy en 03-02 — no es casualidad: greedy es, en cierto modo, una PD que se atreve a explorar una sola rama.)
- Subproblemas solapados: al descomponer el problema, los mismos subproblemas aparecen una y otra vez. Aquí está la diferencia clave con divide y vencerás (03-01), cuyos subproblemas eran independientes: si no hay solapamiento, guardar resultados no ahorra nada.
| Estrategia | Subestructura óptima | Subproblemas | Decisiones exploradas |
|---|---|---|---|
| Divide y vencerás | (no necesariamente de optimización) | Independientes | — |
| Greedy | Sí | Uno tras otro | Una (la voraz) |
| Programación dinámica | Sí | Solapados | Todas (guardando resultados) |
Fibonacci: el ejemplo mínimo del desastre y su cura
Antes de subir al autobús, el ejemplo más pequeño posible de subproblemas solapados: la sucesión de Fibonacci (1, 1, 2, 3, 5, 8, ...), donde cada término es la suma de los dos anteriores.
En 02-01 aprendimos a analizar recursivas contando llamadas. Contémoslas: fib(5) llama a fib(4) y fib(3); fib(4) llama a fib(3) — ¡otra vez! — y a fib(2)...
flowchart TD
A["fib(5)"] --> B["fib(4)"]
A --> C["fib(3)"]
B --> D["fib(3) (¡repetido!)"]
B --> E["fib(2)"]
C --> F["fib(2)"]
C --> G["fib(1)"]
D --> H["fib(2)"]
D --> I["fib(1)"]
fib(3) se calcula 2 veces aquí; en fib(50), fib(3) se calcularía miles de millones de veces. El árbol de llamadas crece como O(2ⁿ) — la peor categoría de la jerarquía de 01-03 — para un problema que solo tiene n subproblemas distintos: fib(1), fib(2), ..., fib(n). Todo el exceso es trabajo repetido. La cura: calcular cada uno una vez y apuntarlo.
Memoización (top-down): recordar lo ya calculado
Memoización = envolver la recursión con una "libreta" (normalmente un diccionario) donde se anota cada resultado la primera vez que se calcula; las veces siguientes se devuelve la anotación en O(1) — la consulta a diccionario cuyo coste estudiamos con construir_indice en 02-02.
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n in memo: # ¿ya lo calculamos? -> O(1)
return memo[n]
if n <= 2:
return 1
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]Análisis con las herramientas del Módulo 2:
- Tiempo: cada
fib_memo(k)se calcula de verdad una sola vez (después queda enmemo). Hay n subproblemas y cada uno hace trabajo O(1) fuera de las recursiones: O(n). De exponencial a lineal. - Espacio: el diccionario guarda n entradas y la pila de recursión alcanza profundidad n (02-02): O(n).
Este es, con nombre y apellidos, el trade-off tiempo ↔ espacio que anticipamos en 02-02: pagamos O(n) de memoria para desplomar el tiempo de O(2ⁿ) a O(n). Pocas veces en informática se compra tanto con tan poco.
Nota práctica: Python trae la memoización de serie con
from functools import lru_cachey el decorador@lru_cache(maxsize=None)sobre la función recursiva. Úsalo en producción; aquí escribimos la libreta a mano para ver el mecanismo.
Problema RutaBus: el trayecto más barato con tarifas por tramos
Ahora un problema real de RutaBus. En la línea L2 hay n + 1 paradas numeradas de 0 (Plaza Mayor) a n (Parque del Río). Desde cada parada i el pasajero puede:
- avanzar 1 parada pagando
tarifa1[i]céntimos, o - avanzar 2 paradas (servicio semidirecto) pagando
tarifa2[i]céntimos.
¿Cuál es el coste mínimo para ir de la parada 0 a la parada n?
# Ejemplo con n = 5 (paradas 0..5)
tarifa1 = [120, 140, 100, 130, 110] # tarifa1[i]: de i a i+1
tarifa2 = [210, 220, 260, 180] # tarifa2[i]: de i a i+2Paso 1 — definir el subproblema. Sea coste(i) = coste mínimo para llegar de la parada i hasta la n. Queremos coste(0).
Paso 2 — la recurrencia (subestructura óptima). Desde i solo hay dos primeras decisiones posibles; lo que venga después debe ser, a su vez, óptimo:
coste(n) = 0 (ya hemos llegado)
coste(n-1) = tarifa1[n-1] (solo cabe el salto de 1)
coste(i) = min(tarifa1[i] + coste(i+1),
tarifa2[i] + coste(i+2))Paso 3 — comprobar el solapamiento. coste(3) lo necesitan tanto coste(2) (saltando 1) como coste(1) (saltando 2): los mismos subproblemas aparecen por caminos distintos, igual que en Fibonacci. Sin memoria, la recursión sería exponencial; con ella, solo hay n + 1 subproblemas.
Versión top-down (memoización):
def coste_minimo_memo(tarifa1, tarifa2, i=0, memo=None):
n = len(tarifa1) # nº de tramos = nº de paradas - 1
if memo is None:
memo = {}
if i in memo:
return memo[i]
if i == n: # caso base: hemos llegado
return 0
if i == n - 1: # penúltima parada: solo salto de 1
return tarifa1[i]
saltar1 = tarifa1[i] + coste_minimo_memo(tarifa1, tarifa2, i + 1, memo)
saltar2 = tarifa2[i] + coste_minimo_memo(tarifa1, tarifa2, i + 2, memo)
memo[i] = min(saltar1, saltar2)
return memo[i]
print(coste_minimo_memo(tarifa1, tarifa2)) # 490Tiempo O(n) — cada parada se resuelve una sola vez — y espacio O(n) entre el memo y la pila de recursión.
Tabulación (bottom-up): rellenar la tabla sin recursión
La tabulación da la vuelta al cálculo: en lugar de partir de la pregunta grande y bajar (top-down), parte de los casos base y sube, rellenando una tabla con un bucle. Sin recursión, sin pila.
def coste_minimo_tab(tarifa1, tarifa2):
n = len(tarifa1)
coste = [0] * (n + 1) # coste[i]: mínimo desde i hasta n
coste[n] = 0 # casos base
coste[n - 1] = tarifa1[n - 1]
for i in range(n - 2, -1, -1): # de la parada n-2 hacia la 0
coste[i] = min(tarifa1[i] + coste[i + 1],
tarifa2[i] + coste[i + 2])
return coste
print(coste_minimo_tab(tarifa1, tarifa2))
# [490, 400, 280, 180, 110, 0]Rellenemos la tabla a mano, celda a celda y en el mismo orden que el bucle — verificar dos o tres celdas manualmente es la prueba unitaria más barata que existe para una PD:
| i | cálculo | coste[i] |
|---|---|---|
| 5 | caso base (destino) | 0 |
| 4 | caso base: tarifa1[4] = 110 |
110 |
| 3 | min(tarifa1[3]+coste[4], tarifa2[3]+coste[5]) = min(130+110, 180+0) = min(240, 180) |
180 |
| 2 | min(100+coste[3], 260+coste[4]) = min(100+180, 260+110) = min(280, 370) |
280 |
| 1 | min(140+coste[2], 220+coste[3]) = min(140+280, 220+180) = min(420, 400) |
400 |
| 0 | min(120+coste[1], 210+coste[2]) = min(120+520... ¡ojo, coste[1] es 400!) = min(120+400, 210+280) = min(520, 490) |
490 |
El coste mínimo es 490 céntimos. Observa la celda i = 3: ahí el semidirecto (180) gana al salto simple (240); en cambio en i = 2 conviene el salto simple. La tabla toma, para cada parada, la mejor decisión — nada de apuestas irrevocables.
Tiempo O(n), espacio O(n) para la tabla — y fíjate: como coste[i] solo consulta i+1 e i+2, bastarían dos variables, bajando el espacio auxiliar a O(1). Esa clase de reducciones de memoria se estudia en el Módulo 5 (05-02); aquí nos basta con saber que existe.
Top-down vs bottom-up
| Aspecto | Memoización (top-down) | Tabulación (bottom-up) |
|---|---|---|
| Forma | Recursión + diccionario | Bucles + tabla (lista/matriz) |
| Orden de cálculo | El que dicte la recursión | Explícito, de casos base hacia arriba |
| Subproblemas calculados | Solo los realmente necesarios | Todos los de la tabla |
| Pila de recursión | Sí — riesgo de RecursionError con n grande (02-02) |
No |
| Facilidad de escritura | Casi directa desde la recurrencia | Exige pensar el orden de llenado |
| Optimizar espacio | Difícil | Fácil (quedarse con las últimas filas) |
Regla práctica: diseña siempre la recurrencia primero (es el corazón de la PD); luego escribe la memoización para validarla rápido, y pásala a tabulación si necesitas rendimiento, control del espacio o evitar la pila.
La mochila 0/1: mejoras de flota con presupuesto limitado
El problema estrella de la PD, en versión RutaBus. Dirección aprueba un presupuesto de 9 (miles de euros) y el equipo técnico propone cuatro mejoras, cada una con su coste y su beneficio estimado (índice de mejora de servicio):
mejoras = [
("Wifi a bordo", 2, 3), # (nombre, coste, beneficio)
("Rampa accesibilidad", 3, 4),
("Paneles información", 4, 5),
("Climatización eco", 5, 8),
]
PRESUPUESTO = 9Cada mejora se financia entera o no se financia (de ahí "0/1": nada de medio wifi). Objetivo: maximizar el beneficio total sin exceder el presupuesto. El greedy por ratio beneficio/coste falla en general (es el cambio de monedas otra vez); la PD lo resuelve de forma exacta.
Subproblema: V[i][p] = beneficio máximo usando solo las i primeras mejoras con presupuesto p.
Recurrencia: para la mejora i-ésima, de coste c y beneficio b:
V[i][p] = V[i-1][p] si c > p (no cabe)
V[i][p] = max(V[i-1][p], (opción A: no financiarla)
V[i-1][p-c] + b) (opción B: financiarla)def mochila_mejoras(mejoras, presupuesto):
n = len(mejoras)
# tabla (n+1) x (presupuesto+1) inicializada a 0 (fila 0 = sin mejoras)
V = [[0] * (presupuesto + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
nombre, c, b = mejoras[i - 1]
for p in range(presupuesto + 1):
if c > p: # no cabe
V[i][p] = V[i - 1][p]
else:
V[i][p] = max(V[i - 1][p], # sin la mejora i
V[i - 1][p - c] + b) # con la mejora i
return V
V = mochila_mejoras(mejoras, PRESUPUESTO)
print(V[len(mejoras)][PRESUPUESTO]) # 13La tabla completa (filas: mejoras consideradas hasta el momento; columnas: presupuesto 0..9):
| p=0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |
|---|---|---|---|---|---|---|---|---|---|---|
| ∅ (sin mejoras) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| +Wifi (2, 3) | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
| +Rampa (3, 4) | 0 | 0 | 3 | 4 | 4 | 7 | 7 | 7 | 7 | 7 |
| +Paneles (4, 5) | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 | 12 |
| +Clima (5, 8) | 0 | 0 | 3 | 4 | 5 | 8 | 8 | 11 | 12 | 13 |
Leamos varias celdas para entender el mecanismo — esto es lo que convierte la tabla en conocimiento:
- Fila Wifi, p=2: por fin cabe el wifi (coste 2):
max(V[∅][2], V[∅][0] + 3) = max(0, 3) = 3. Toda la fila vale 3 de ahí en adelante: con una sola mejora disponible no hay más que rascar. - Fila Rampa, p=3:
max(V[wifi][3], V[wifi][0] + 4) = max(3, 4) = 4. Con presupuesto 3 conviene la rampa sola antes que el wifi solo. - Fila Rampa, p=5:
max(V[wifi][5], V[wifi][5−3] + 4) = max(3, 3 + 4) = 7. Financiar la rampa compensa porque con el presupuesto restante (2) aún cabe el wifi: la celdaV[wifi][2] = 3ya contenía esa mejor decisión previa. Cada celda reutiliza óptimos ya calculados: subestructura óptima en acción. - Fila Paneles, p=9:
max(V[rampa][9], V[rampa][9−4] + 5) = max(7, 7 + 5) = 12. Financiando los paneles (4) quedan 5, yV[rampa][5] = 7era "wifi + rampa": total wifi + rampa + paneles = 12. - Fila Clima, p=9 — la respuesta final:
max(V[paneles][9], V[paneles][9−5] + 8) = max(12, 5 + 8) = 13. Financiar la climatización deja presupuesto 4, cuyo óptimo previo eraV[paneles][4] = 5(los paneles solos). El máximo global es 13.
Coste del algoritmo: dos bucles anidados → O(n · P) en tiempo y espacio (n mejoras, P presupuesto), un análisis directo con las reglas de 02-01. Nota fina: ese coste depende del valor numérico P, no solo de cuántos elementos hay — la mochila por PD es eficiente para presupuestos moderados, no gratis en general.
Reconstruir la solución, no solo el valor
"El beneficio máximo es 13" no le sirve a dirección: quiere saber qué mejoras financiar. La tabla ya contiene esa información — basta recorrerla hacia atrás preguntando en cada fila "¿esta mejora cambió el valor?":
def reconstruir(mejoras, V, presupuesto):
seleccion = []
p = presupuesto
for i in range(len(mejoras), 0, -1): # de la última fila a la primera
if V[i][p] != V[i - 1][p]: # la mejora i fue decisiva
nombre, c, b = mejoras[i - 1]
seleccion.append(nombre)
p -= c # descontamos su coste
return list(reversed(seleccion))
print(reconstruir(mejoras, V, PRESUPUESTO))
# ['Paneles información', 'Climatización eco']Traza sobre la tabla anterior:
- Fila Clima, p=9:
V[4][9] = 13 ≠ V[3][9] = 12→ la climatización entra; presupuesto restante: 9 − 5 = 4. - Fila Paneles, p=4:
V[3][4] = 5 ≠ V[2][4] = 4→ los paneles entran; restante: 4 − 4 = 0. - Fila Rampa, p=0:
V[2][0] = 0 = V[1][0]→ la rampa no cambió nada: fuera. - Fila Wifi, p=0:
V[1][0] = 0 = V[0][0]→ fuera.
Selección óptima: paneles + climatización (coste 4 + 5 = 9, beneficio 5 + 8 = 13). Fíjate en el detalle anti-intuición: el wifi, la mejora con mejor ratio beneficio/coste (1,5), no está en el óptimo — un greedy por ratio la habría tomado primero y habría quedado atrapado. La PD lo vio venir porque exploró todas las combinaciones... pagando cada subproblema una sola vez.
El mismo truco de "mirar de qué celda vengo" reconstruye el trayecto barato de la L2: en cada parada i, si coste[i] == tarifa1[i] + coste[i+1] el óptimo saltó 1 parada; si no, saltó 2. Con nuestra tabla: 0 →(210)→ 2 →(100)→ 3 →(180)→ 5, total 490.
Cerramos con la mención prometida: la PD también opera sobre grafos completos — Floyd-Warshall calcula los caminos mínimos entre todos los pares de paradas de la red RutaBus rellenando una tabla de subproblemas indexada por parejas de paradas y nodos intermedios permitidos. Es PD pura sobre grafos y lo estudiaremos en 04-06.
Errores Comunes y Consejos
- Empezar a programar sin escribir la recurrencia. La PD se diseña en papel: subproblema (¿qué significa exactamente
V[i][p]?), recurrencia, casos base. El código es la traducción literal; sin recurrencia clara, saldrá una maraña de índices. - Definir mal el subproblema. Si no puedes expresar la recurrencia usando solo subproblemas "más pequeños", la definición no sirve. Reformúlala — a menudo añadiendo un parámetro, como el "usando solo las i primeras mejoras" de la mochila.
- No verificar celdas a mano. Elige 2-3 celdas de la tabla, recalcúlalas con la recurrencia y conviértelas en
assertde test. Los errores de índices (ivsi-1,pvsp-c) son silenciosos y esta es la red que los caza. - Olvidar los casos base o dejar la tabla sin inicializar.
coste[n] = 0y la fila ∅ de la mochila no son decoración: toda la tabla se apoya en ellos. - Usar memoización con listas como clave. Las claves de un diccionario deben ser inmutables (
int,tuple); una lista lanzaTypeError. Convierte el estado a tupla. - Recursión demasiado profunda. Para n de decenas de miles, la memoización agota la pila de Python (02-02): pasa a tabulación.
- Aplicar PD donde no hay solapamiento. Si cada subproblema aparece una sola vez, el memo no ahorra nada y solo gasta memoria: eso es divide y vencerás (03-01) y no necesita libreta.
Ejercicios
Ejercicio 1
Un pasajero en la parada 0 puede avanzar 1 o 2 paradas en cada paso (sin tarifas: aquí solo contamos caminos). Escribe formas_de_llegar(n) que cuente de cuántas maneras distintas puede llegar exactamente a la parada n, con memoización. Calcula a mano los valores para n = 1..5 y observa qué sucesión aparece. ¿Cuál es el coste temporal y espacial?
Ejercicio 2
Convierte formas_de_llegar a tabulación y reduce después su espacio auxiliar a O(1) conservando el tiempo O(n). (Pista: ¿cuántas celdas anteriores necesita cada celda?)
Ejercicio 3
Al problema de las mejoras de flota se añade una quinta opción: ("GPS de flota", 4, 6). Con presupuesto 9, calcula la nueva fila de la tabla y reconstruye la selección óptima. ¿Sigue estando la climatización en la solución? ¿Y los paneles?
Soluciones
Solución 1
def formas_de_llegar(n, memo=None):
if memo is None:
memo = {}
if n in memo:
return memo[n]
if n == 0:
return 1 # una única forma: el camino vacío
if n == 1:
return 1 # solo un salto de 1
memo[n] = formas_de_llegar(n - 1, memo) + formas_de_llegar(n - 2, memo)
return memo[n]Para llegar a n, el último salto vino de n−1 o de n−2, y ambos conjuntos de caminos son disjuntos: f(n) = f(n−1) + f(n−2). Valores: f(1)=1, f(2)=2, f(3)=3, f(4)=5, f(5)=8 — Fibonacci desplazado. Nuestro problema de transporte esconde la misma estructura que el ejemplo "de juguete": reconocer recurrencias conocidas bajo disfraces nuevos es una de las grandes destrezas de la PD. Tiempo O(n), espacio O(n).
Solución 2
def formas_de_llegar_tab(n):
if n <= 1:
return 1
anterior2, anterior1 = 1, 1 # f(0), f(1)
for _ in range(2, n + 1):
anterior2, anterior1 = anterior1, anterior1 + anterior2
return anterior1Cada celda solo necesita las dos anteriores, así que la "tabla" se comprime en dos variables: tiempo O(n), espacio auxiliar O(1) y sin pila de recursión. Es el trade-off tiempo↔espacio de 02-02 ajustado al mínimo imprescindible.
Solución 3
La nueva fila se calcula con V[5][p] = max(V[4][p], V[4][p−4] + 6) para p ≥ 4. En la celda final: V[5][9] = max(V[4][9], V[4][5] + 6) = max(13, 8 + 6) = 14. Reconstrucción: V[5][9] = 14 ≠ V[4][9] = 13 → el GPS entra, restante 5; V[4][5] = 8 ≠ V[3][5] = 7 → la climatización entra, restante 0; nada más cabe. Selección óptima: GPS + climatización (coste 4 + 5 = 9, beneficio 14). La climatización sigue, pero los paneles salen: añadir un candidato puede reorganizar toda la solución — exactamente lo que un greedy de decisiones irrevocables jamás podría hacer.
Conclusión
La programación dinámica es la estrategia para problemas con subestructura óptima y subproblemas solapados: se explora el espacio completo de decisiones, pero cada subproblema se paga una sola vez, cambiando memoria por tiempo — el trade-off que veníamos anunciando desde 02-02, ahora con nombre propio. Hemos aprendido su método de diseño (definir el subproblema → escribir la recurrencia → fijar los casos base), sus dos realizaciones — memoización top-down y tabulación bottom-up —, y la hemos aplicado al trayecto más barato de la L2 (490 céntimos, y sabemos por dónde) y a la mochila 0/1 de las mejoras de flota, incluida la reconstrucción de la solución recorriendo la tabla hacia atrás. Con divide y vencerás, greedy y PD tenemos ya tres formas de construir soluciones; nos falta la estrategia para cuando no queda más remedio que buscar: problemas de restricciones donde hay que probar combinaciones, detectar callejones sin salida y volver sobre los propios pasos. Esa exploración sistemática con marcha atrás — el backtracking — cierra el módulo en la próxima lección, donde además pondremos las cuatro estrategias frente a frente.
Curso de Análisis y Diseño de Algoritmos
Módulo 1: Introducción a los Algoritmos
Módulo 2: Análisis de Algoritmos
- Análisis de Complejidad Temporal
- Análisis de Complejidad Espacial
- Casos de Complejidad: Mejor, Peor y Promedio
Módulo 3: Estrategias de Diseño de Algoritmos
Módulo 4: Algoritmos Clásicos
- Búsqueda Binaria
- Ordenamiento por Inserción
- Ordenamiento por Mezcla (Merge Sort)
- Ordenamiento Rápido (Quick Sort)
- Algoritmo de Dijkstra
- Algoritmo de Floyd-Warshall
