You are on page 1of 13

Programación Dinámica

1.1 Programación dinámica

En la programación de computadores, existen problemas cuyas soluciones pueden ser expresadas


directamente en términos matemáticos y solucionadas mediante un algoritmo recursivo. Sin
embargo, no necesariamente el tiempo de ejecución de un algoritmo recursivo es el adecuado en
términos de eficiencia. Lo anterior puede ser mejorado mediante el uso de la técnica de programación
dinámica.

La programación dinámica es una técnica que permite la optimización de código, evitando realizar
cálculos que previamente han sido realizados. Generalmente, esta técnica utiliza una tabla de
resultados que se van llevando a cabo en la medida que se van resolviendo los subproblemas.
Para (Guerequeta & Vallecillo, 2000), la técnica dinámica tiene sentido aplicarla, porque a través de
ella es posible resolver problemas de forma eficiente, en comparación con otras técnicas de
programación. Se debe mencionar que no siempre, la programación dinámica tiene ordenes de
complejidad inferiores con relación a otras técnicas de programación.

La programación dinámica, se fundamenta en el uso de una tabla en las que se almacenan


soluciones parciales del problema. En (Aldea, Blanco, & Garandal, 2016), se plantea que la tabla se
va llenando con las soluciones a los subcasos empezando por los subcasos más pequeños y
construyendo con ellos los grandes hasta llegar al caso que se desea resolver. No se no recalcula
resultados parciales (se sacan de la tabla). Con base en lo anterior, la programación dinámica,
resuelve un problema en subproblemas, cuyos resultados se almacenan para evitar recalcular los
mismos resultados.

Para (Jain et al., 2018), las siguientes son las dos propiedades principales de un problema que
sugieren que el problema se puede resolver usando la programación dinámica: subproblemas
superpuestos (Overlapping Subproblems) y subestructura óptima (Optimal Substructure). De lo
anterior se puede afirmar que la programación dinámica no es útil cuando no hay subproblemas
comunes (superpuestos) porque no tiene sentido almacenar las soluciones si no se necesitan de
nuevo. Por ejemplo, la búsqueda binaria no tiene subproblemas comunes (Jain et al., 2018).

A continuación, se presentan problemas en los cuales es posible la aplicación de la programación


dinámica.

1.1.1 Factorial de un número

El factorial de un valor positivo n, corresponde al producto de todos los números enteros positivos
desde 1 hasta n. La operación de factorial, tiene aplicación en diferentes áreas de las como la
simulación y la teoría combinatoria. La expresión n!, representa la notación matemática de factorial.
La función factorial se puede representar así: n! = 1 x 2 x 3 x … (n-1) x n
La expresión anterior, se puede expresar mediante el producto: n! = ∏𝑛𝑘=1 𝑘. La tabla 34 muestra los
primeros productos de la operación factorial.

Tabla 1. Factorial de número entero

N 0 1 2 3 4 5 6 7 8 9 10 11
n! 1 1 2 6 24 120 720 5040 40320 362880 3628800 39916800

El cálculo factorial se representa mediante la siguiente relación de recurrencia:

Con base en la anterior relación de recurrencia, el factorial para 3, se puede calcular así:

factorial (3) = 3 * factorial(2)


= 3 * (2 * factorial(1))
= 3 * (2 * 1)
=3*2
=6

A continuación, se presentan diferentes formas de implementar la operación factorial. Inicialmente


se presenta la versión iterativa, la cual fundamenta su operación en un ciclo en el cual se modifica
la variable factorial. El ciclo itera hasta un valor n predeterminado, por lo tanto, se puede afirmar
que el orden de complejidad del algoritmo es O(n).

public int factorial (int n)


{
int factorial = 1;
if(n==0 || n==1)
{
return factorial;
}
else
{
for (int i=1; i<=n; i++)
{
factorial = factorial * i;
}
}
return factorial;
}

La versión recursiva de factorial, se basa en su definición matemática recursiva. A continuación, se


presenta una implementación recursiva para la operación de factorial, en la cual se evidencia un
caso base y un caso inductivo. El caso inductivo se compone de una recursión directa, cuyo tamaño
de entrada se disminuye en términos de (n-1). Por el método de recurrencias por inducción se puede
afirmar que el método tiene un orden de complejidad O(n).
public int factorial( int n )
{
if (n==0 || n==1)
{
return 1;
}
else
{
return n * factorial (n-1);
}
}

En el contexto de la programación dinámica existe dos estrategias para almacenar valores que se
pueden reutilizar: Memorización (Top Down) y Tabulación ((Bottom Up). Mediante la estrategia de
tabulación, la tabla se construye de abajo hacia arriba y devuelve la última entrada de la tabla. En
cada posición se va almacenando el correspondiente valor.

public int factorialDinamico(int n, int arreglo[ ])


{
int i;
arreglo[0]=1;
arreglo[1]=1;
for (i = 2; i <=n; i++)
{
arreglo[i] = arreglo[i-1] * i;
}
return arreglo [i-1];
}
La implementación por tabulación se fundamenta en un ciclo que itera hasta un valor n
predeterminado, por lo tanto, se puede afirmar que el método tiene un orden de complejidad O(n).

Mediante la memorización, el programa es similar a la implementación recursiva, con una


modificación que busca en una tabla de búsqueda para calcular las soluciones. Esta modificación
consiste en añadir una estructura (tipo tabla), en la cual se van almacenando los resultados parciales.
Para este caso, la implementación de la tabla se realiza mediante un arreglo de tipo entero. Cada
vez que se necesite la solución a un subproblema, se busca en la tabla. Si el valor calculado está
allí, entonces se regresa el valor, de lo contrario, se calcula el valor y se coloca en la tabla de
búsqueda para que pueda reutilizarse más adelante.

public int factorialDinamico(int n, int arreglo[ ])


{
if (n==0)
{
arreglo[n]=1;
return arreglo[n];
}
else
{
arreglo[n]=factorialDinamico(n-1,arreglo) * n;
return arreglo[n];
}
}
Con base en las anteriores implementaciones, se puede formular la pregunta: ¿cuál considera que
es la solución más eficiente en términos computacionales? La respuesta puede ser dada en términos
del orden de complejidad de cada implementación. Todas las implementaciones tienen el mismo
orden de complejidad, por tanto, a nivel de eficiencia, todas son iguales. Es en estos casos cuando
se debe evaluar la complejidad en el diseño del algoritmo y con base en esa valoración, determinar
cuál es la implementación más sencilla.

1.1.2 Sucesión de Fibonacci

La sucesión de Fibonacci tiene aplicación en las ciencias de la computación, la naturaleza, la teoría


de juegos, las matemáticas, la teoría de la simulación, entre otras. La sucesión fue introducida por
Leonardo de Pisa. Cada número de la secuencia se obtiene sumando los dos anteriores. Por
definición, los dos primeros valores son 0 y 1 respectivamente. Los otros números de la sucesión se
calculan sumando los dos números que le preceden. En la tabla 35, se presentan 11 números de la
sucesión.
Tabla 2. Sucesión de Fibonacci

n 0 1 2 3 4 5 6 7 8 9 10 11
n! 0 1 1 2 3 5 8 13 21 34 55 89

Es posible representar la sucesión de Fibonacci, mediante la siguiente relación de recurrencia:

En la figura 12, se presenta la representación de la sucesión, cuando el valor es n=4.

Figura 1. Representación de la sucesión de Fibonacci

Un ejemplo muy utilizado en esta sucesión, está relacionado con la secuencia en la reproducción de
conejos. Este ejemplo ha sido previamente presentado en (Cardona, Jaramillo, & Triviño, 2011). Con
base en la literatura, se puede afirmar que la reproducción de conejos sigue las siguientes reglas:

a) Cada pareja de conejos fértiles tiene una pareja de conejitos cada mes.
b) Cada pareja de conejos comienza a ser fértil a partir del segundo mes de vida.
c) Ninguna pareja de conejos muere ni deja de reproducirse (Bohórquez, 2006).
La tabla 36 muestra la cantidad conejos que debe haber al cabo de 6 meses

Tabla 3. Crecimiento de acuerdo a la sucesión de Fibonacci

Mes 0 1 2 3 4 5
Cantidad de conejos 1 1 2 3 5 8

Se inicia con una pareja de conejos (mes 0). Esta pareja (A), al inicio del mes dos se reproduce con
lo cual quedaría la pareja de padres y la pareja de hijos (B). Al tercer mes la pareja B aun no es fértil
pues solo tiene un mes de vida, pero sus padres continuación siendo fértiles dando lugar a una nueva
pareja (C). Es decir, en el mes tres ya se tendrían 3 parejas. En el mes cuatro, la pareja A tiene un
nuevo hijo (D), al igual que la pareja B, mientras que la pareja C aun no es fértil pues solo tiene un
mes de vida, con lo cual quedaría cinco parejas. Esta relación de recurrencia se expresa de la
siguiente forma:

fib (0) = 1
fib (1) = 1
fib(n)= fib (n-1) + fib (n-2)

A continuación, se presenta una versión iterativa de la sucesión Fibonacci. Esta implementación se


fundamenta en la iteración de un ciclo. Para el peor caso, se puede afirmar que la versión iterativa,
tiene orden de complejidad O(n).
public int fibonacci (int n)
{
int penultimo = 0;
int ultimo = 1;
int siguiente = 0;
for (int i = 1; i < n; i++)
{
siguiente = penultimo + ultimo;
penultimo = ultimo;
ultimo = siguiente;
}
return siguiente;
}

Dada la relación de recurrencia para la sucesión de Fibonacci, una implementación es la relacionada


con la estrategia divide y vencerás, pues está dada en términos de la solución recursiva. El algoritmo
recursivo, tiene un orden de complejidad O(2n). El caso inductivo para esta implementación es: T(n)
= b+2T(n-1), por lo tanto, es de orden exponencial.

public int fibonacci (int n)


{
if ((n == 0) || (n == 1))
{
return 1;
}
else
{
return calcularFibonacci(n-1) + calcularFibonacci(n-2);
}
}
}
Mediante programación dinámica es posible que el tiempo de ejecución para la sucesión de la serie
de Fibonacci sea de orden O(n). Para ello se utiliza un arreglo en el cual se almacenan los valores
previamente obtenidos, para posteriormente reutilizarlos.

Fib(0) Fib(1) Fib(2) .... Fib(n)

La figura 13, muestra cómo se van almacenando los valores anteriores, mediante la programación
dinámica.

Figura 2. Almacenamiento para la sucesión de Fibonacci

Esta primera implementación mediante programación dinámica, se realiza mediante la técnica de


memorización.

public int fibonacciDinamico(int n)


{
if (n <= 1)
{
datos[n] = n;
}
else
{
datos[n]=fibonacciDinamico(n-1)+fibonacciDinamico(n-2);
}
return datos[n];
}

La siguiente, es la implementación de la sucesión de Fibonacci usando programación dinámica,


mediante la estrategia de tabulación:

public int fibonacciDinamico (int n, int datos[])


{
if (n==0)
{
return 0;
}
if (n<=1)
{
return 1;
}
else
{
datos[0]=0;
datos[1]=1;
for (int i=2; i <= n; i++)
{
datos[i] = datos[i-1] + i;
}
}
return datos[n];
}

La implementación mediante el método de tabulación tiene orden de complejidad O(n), a partir de lo


cual se puede afirmar que es una solución más eficiente con relación al método de memorización.

1.1.3 Calculo de coeficientes binomiales

En algunos problemas matemáticos y como se ha mencionado anteriormente, es posible que la


implementación recursiva, sea la expresión que define de forma directa y simple la solución. En
algunos casos, la implementación en programación dinámica puede resultar más compleja, como
por ejemplo cuando se deban usar estructuras bidimensionales para conservar los resultados
parciales.

Para mostrar una situación en la cual se deben conservar resultados parciales en un arreglo
bidimensional, se estudia el problema de los coeficientes binomiales. Los coeficientes binomiales o
combinaciones, indican combinaciones en las que se pueden extraer subconjuntos a partir de un
conjunto dado. Los números combinatorios satisfacen la siguiente propiedad:

Esta fórmula recursiva, permite calcular los números combinatorios sin multiplicar ni dividir, mediante
un procedimiento visual conocido hoy en día como “Triángulo de Pascal” de la figura 14, el cual se
puede representar mediante la combinatoria de los valores n y k.

Figura 3. Representación del triángulo de pascal

Por ejemplo, si se calcula:

C(1,1)= C(0,0)+C(0,1) = 1
La implementación del algoritmo recursivo, puede resultar de un orden de complejidad exponencial,
dada la cantidad de llamados a la función recursiva. El algoritmo recursivo, tiene un orden de
complejidad O(2n). El caso inductivo para esta implementación es: T(n) = b+2T(n-1), por lo tanto, es
de orden exponencial.

public int binomial(int n, int k)


{
if(k==0||k==n)
{
return 1;
}
else
{
if(k>0&&k<n)
{
return binomial (n-1, k-1)+ binomial (n-1, k);
}
else
{
return 0;
}
}
}

También es posible implementar un algoritmo con un orden de complejidad O(n 2), Para ello, es
posible apoyarse en una matriz o arreglo bidimensional en la que se van almacenando valores
intermedios. En la tabla 37, se presenta la tabla, por filas de arriba hacia abajo y de izquierda a
derecha. La tabla 37 se fundamenta de (Guerequeta & Vallecillo, 2000)
Tabla 4. Representación del triángulo de pascal

La implementación del algoritmo usando programación dinámica, se presenta a continuación.

public int binomial (int n, int k)


{
int matriz [][]= new int [ n+1 ] [ k+1 ];
if(n==k || k == 0)
{
return 1;
}
else {
for (int i=0; i<=n;i++)
{
matriz [i][0] = 1;
}
for (int i=1; i<=n;i++)
{
matriz [i][1] = i;
}
for (int i=2; i<=k;i++)
{
matriz [i][i] = 1;
}
for (int i=3; i<=n;i++)
{
for (int j=2; j<i;j++)
{
if (j<=k)
{
matriz[i][j]= matriz[i-1][j-1]+
matriz[i-1][j];
}

}
}
}
return matriz [n][k];
}

Esta implementación usa fundamentalmente un arreglo bidimensional de elementos. El algoritmo


del coeficiente binomial en programación dinámica, tiene un orden de complejidad O(n2), teniendo
en cuenta que tiene dos ciclos anidados, los cuales, para el peor caso, iteran hasta el valor n.

1.1.4 Función de Ackermann

La función de Ackermann, es una función de crecimiento asintótico exponencial, la cual es estudiada


a nivel teórico, dada su poca aplicación práctica. La función de Ackermann según (Guerequeta &
Vallecillo, 2000), se define mediante la siguiente relación de recurrencia.

𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜 (0, 𝑛) = 𝑛 + 1, 𝑠𝑖 𝑚 = 0
𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜(𝑚, 𝑛) = { 𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜 (𝑚, 0) = 𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜 (𝑚 − 1, 1), 𝑠𝑖 𝑚 > 0
𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜(𝑚, 𝑛) = 𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜(𝑚 − 1, 𝑀𝑖𝑠𝑡𝑒𝑟𝑖𝑜(𝑚, 𝑛 − 1)), 𝑠𝑖 𝑚, 𝑛 > 0

En la tabla 38, se presentan algunos valores asociados a la función de Ackerman. En la tabulación


se observa que, para valores relativamente pequeños, la función genera valores de orden
polinómico, cuyo tratamiento a nivel de programación, puede requerir del tipo de datos diferentes a
los usados tradicionalmente.
Tabla 5. Valores para la función de Ackerman

m\n 0 1 2 3 4 n

0 1 2 3 4 5 n+1

1 2 3 4 5 6 n+2

2 3 5 7 9 11 2n + 3

3 5 13 29 61 125

A(3,265536-
4 13 65533 A(3,A(4,3))
3)
(n + 3 términos)

5 65533 A(4,65533) A(4,A(5,1)) A(4,A(5,2)) A(4,A(5,3))

La implementación de la función de Ackerman se puede realizar mediante una técnica de


programación recursiva directa. De forma experimental se observa que, para valores pequeños de
los parámetros, la función genera valores pequeños. Cuando los parámetros son superiores a
valores a 4, se observa que los valores son exponenciales y por tanto el almacenamiento de los
resultados requiere del uso de estructuras en las cuales se puedan guardar los datos.

public int ackermann (int m, int n)


{
if(m == 0)
{
return n+1;
}
else if(m>0 && n==0)
{
return ackerman(m-1,1);
}
else
{
return ackerman(m-1,ackerman(m,n-1));
}
}

Cuando el parámetro m, llegue a cero, el algoritmo retorna directamente el valor resultado de n+1.
Para el condicional if(m>0 && n==0), se observa que existe una llamada recursiva en la cual solo
se modifica el primer parámetro y el parámetro n, queda convertido en un valor constante 1. En caso
contrario, en la función ackerman(m-1,ackerman(m,n-1)) , se observa que el segundo
parámetro invoca a sí mismo una llamada recursiva, y por tanto, el crecimiento de la función se puede
expresar de la siguiente manera.

…𝑚
22
2
Cuando el primer argumento sea 0, el resultado es b+1 y se va guardando. Mientras más resultados
estén almacenados en la matriz, menos cálculos realizará el método. Inicialmente, la matriz se
encuentra vacía, el programa aplicará recursividad para realizar los cálculos. En caso contrario, el
programa utilizará la recursividad para realizar los cálculos correspondientes.

public int ackerman (int m, int n)


{
if (m == 0)
{
return n+1;
}
if (n == 0)
{
return ackerman(m-1,1);
}
if (arreglo[m][n-1] == 0)
{
arreglo[m][n-1] = ackerman(m,n-1);
}
if (arreglo[m][n] == 0)
{
arreglo[m][n] = ackerman(m-1,arreglo[m][n-1]);
}
return arreglo[m][n];
}

En este tipo de problemas, es frecuente encontrar problemas de gestión de recursos de memoria,


dado que se incurre en excesivas llamadas a la pila de la recursión, que deben ser controlados
mediante manejo de excepciones.

1.1.5 Algoritmo de Floyd

El algoritmo de Floyd, se aplica en la teoría de grafos y permite encontrar los caminos más cortos en
un grafo, en el cual las aristas tienen un valor en la distancia entre los nodos. El algoritmo permite
encontrar el camino más corto entre los pares de vértices, sin entrar en detalles de los caminos. Para
usar el algoritmo, es necesario que el grafo sea dirigido y ponderado.

El algoritmo de Floyd permite estimar el menor camino entre los vértices de un grafo. Esa estimación
parte de una matriz de adyacencia que representa la estructura interna del grado. El cálculo genera
como resultante una matriz que contiene la distancia del camino que une pares de vértices. Para el
algoritmo de Floyd es posible establecer la siguiente relación de recurrencia y con la cual se estima
el camino mínimo, la cual es propuesta por (Guerequeta & Vallecillo, 2000) .
El uso de una matriz permite hacer caso omiso a repetir cálculos. Es por eso que, en la
implementación, se declara una matriz auxiliar, la cual almacena un valor entero que determina la
longitud del camino mínimo de cada par de vértices. La matrizAux toma para cada posición toma
el valor mínimo entre la matrizAux [i][j] y la suma de matrizAux [i][k] y matrizAux [k][j]. La
siguiente es una posible implementación del algoritmo de Floyd.

public void floyd ( int matrizA [][], int n )


{
int i, j, k;
for ( i=0; i<n; i++ )
{
for ( j=0; j<n; j++ )
{
matrizAux [i][j] = matrizA [i][j];
}
}
for ( k=0; k<n; k++ )
{
for ( i=0; i<n; i++ )
{
for ( j=0; j<n; j++ )
{
matrizAux [i][j] = minimo ( matrizD [i][j],
matrizAux [i][k] + matrizAux [k][j] );
}
}
}
}

El método mini, retorna el mínimo de los valores enviados por parámetro.

public int mini (int x, int y )


{
if ( x<y )
return x;
return y;
}
La implementación del método de Floyd tiene un orden de complejidad de O(n3), el cual está
determinado por los tres ciclos anidados. La matriz representa el costo de ir de un nodo a otro del
grafo sin pasar por nodos intermedios. En cada ciclo del algoritmo se añade un nodo a través del
cual se pueden establecer caminos para ir de un nodo a otro, así, al final de la k-ésima iteración,
D[i][j] indica el menor costo de cualquier camino entre el nodo i y el nodo j que pase por nodos
con número menor o igual que k.

1.1.6 Algoritmo de Dijkstra

Es un algoritmo que tiene como propósito calcular el camino mínimo entre los vértices de un grafo
ponderado. La esencia del algoritmo se fundamenta en analizar cada uno de los caminos que salen
de un vértice y que llegan a otro determinado vértice. Ese análisis determina ese camino más corto
desde un vértice hasta todos los vértices que hacen parte del grafo. A continuación, se presenta una
implementación del algoritmo.
public void AlgoritmoDijkstra()
{
float menor;
for (int i=0;i<(n-1);i++)
{
S[i+1]=false;
D[i]=L[0][i+1];
}
S[0]=true;
for (int i=0;i<(n-2);i++)
{
menor= Menor();
S[pos]=true;
for (int j=0;j<(n-1);j++)
{
if (!S[j+1])
{
D[j]=Math.min(D[j],D[pos-1]+L[pos][j+1]);
}
}
}
}

public float Menor()


{
float menor=Integer.MAX_VALUE;
int pos=0;
for (int i=0;i<(n-1);i++)
{
if (!S[i+1])
{
if (D[i]<menor)
{
menor = D[i];
pos=i+1;
}
}
}
return menor;
}

You might also like