Los problemas de optimización que presentan las siguientes 2 características se pueden resolver con programación dinámica de forma eficiente.
La solución óptima al problema se compone de soluciones óptimas a los subproblemas más chicos. Es fácil ver que esto vale por el argumento "cut and paste" del Cormen. Dada una solución que realmente es óptima, podemos descomponerla en las soluciones a los subproblemas, que a su vez también son óptimos. Si no lo fueran, podríamos "cortar y pegar" una solución óptima a un subproblema y así generar una mejor solución al problema original (absurdo pues dijimos que ya teníamos una solución óptima).
El espacio de búsqueda de soluciones es lo suficientemente chico como para que se superpongan los subproblemas. Es decir, a medida que vamos particionando el problema original en subproblemas, y luego estos nuevos problemas en más subproblemas (sub-subproblemas del problema original) llegamos al punto en donde se repiten los problemas a resolver. Si miramos el árbol de llamadas recursivas, veríamos que el mismo problema se resuelva muchas veces.
Si queremos calcular de forma recursiva el término 42 de Fibonacci
Programación dinámica resuelve esto calculando cada subproblema una única vez. Existen 2 formas para lograrlo.
Esta estrategia es la que resulta de forma directa a partir de una solución recursiva a un problema de optimización que cumple con las 2 características mencionadas: subestructura óptima y superposición de subproblemas.
Partiendo del problema original, se utiliza recursión para resolver los subproblemas y luego construir la solución óptima al problema original. Para evitar recalcular los mismos subproblemas más de una vez, se utiliza una matriz de memoización, en donde se guardan los resultados previamente calculados. De esta forma, cuando necesitamos resolver un subproblema, primero nos fijamos si ya fue calculado, y en tal caso devolvemos el valor memoizado. Caso contrario se procede a calcularlo y guardarlo en la matriz de memoización para futuros llamados recursivos por el mismo subproblema.
La matriz de memoización se "indexa" por el estado (subproblema) que estamos resolviendo. En el caso de Fibonacci, basta con una matriz de una sola dimensión del tamaño del término que queremos calcular. Antes de calcular un término
Para utilizar memoización es necesario inicializar la matriz antes de hacer la recursión. Generalmente se inicializa en
Esta estrategia construye la solución óptima partiendo de la solución de los subproblema más chicos y avanzando hasta llegar al problema original que queríamos resolver. En vez de recursión se utilizan bucles, y requiere un poco más de ingenio para determinar cuáles son los subproblemas que hay que resolver primero (no siempre es evidente a primera vista la dependencia entre los subproblemas). Los resultados se van guardando en una matriz o tabla para ser utilizados más adelante cuando se resuelven problemas más grandes.
A diferencia de top-down, esta estrategia calcula el resultado para todos los subproblemas posibles, porque a priori no sabemos cuáles vamos a necesitar para la solución final. En cambio top-down solamente calcula los subproblemas que realmente son necesarios.
Tenemos una vara de longitud
Este problema presenta una subestructura óptima: después de elegir dónde hacer el primer corte, resultan 2 varas las cuales una vamos a dejar fija sin más cortes, y la otra vamos a nuevamente optimizar sus cortes para maximizar la ganancia (utilizando el mismo algoritmo).
Definimos
def rod_cutting(n, p):
memo = [-1] * (n+1)
def r(n):
if n == 0:
memo[n] = 0
elif memo[n] == -1:
for i in range(1, n+1):
memo[n] = max(memo[n], p[i] + r(n-i))
return memo[n]
return r(n)
Inicializamos la matriz de memoización memo
en -1 lo cual indica que aún no calculamos ese resultado. La función r
calcula el resultado una sola vez para cada valor distinto de
La complejidad de esta implementación resulta
La versión bottom-up también utiliza una matriz (o tabla) para ir computando las soluciones para las varas de longitud
Luego de computar toda la tabla completa, obtenemos el resultado indexando en la longitud
def rod_cutting_bottom_up(n, p):
memo = [-1] * (n+1)
memo[0] = 0
# Por cada longitud j.
for j in range(1, n+1):
# Probamos todos los posibles cortes en 1 <= i <= j.
for i in range(1, j+1):
memo[j] = max(memo[j], p[i] + memo[j-i])
return memo[n]
La complejidad de esta versión resulta igual que top-down: