Teoría y Complejidad Algorítmica
Practica 3
Eduardo Viciana Gámez
2
Contiene documentación y código fuente de la práctica
3
Descripción de la Práctica
En esta práctica se van a comparar dos algoritmos que resuelven el problema de la búsqueda de caminos de costo mínimo en grafos. El primer algoritmo es el algoritmo de Dijkstra, que ya ha sido estudiado en la práctica 2, y el segundo es el algoritmo de Floyd, que aplica la técnica de “programación dinámica” para tratar este problema de recorrido de grafos.
Hay que desarrollar un programa que implemente el algoritmo de Floyd tomando como entrada un fichero de texto que contenga el grafo, con el mismo formato que en la práctica 2. La salida será un fichero del mismo tipo que el de entrada que almacene los n caminos y el costo de cada uno de ellos.
Implementación
El algoritmo de Floyd se implementara como un método o función con los siguientes parámetros:
Entrada: tipo de dato donde se almacene el grafo leído del fichero.
Salida: las matrices de distancias (D) y de recorrido (P).
El programa creara un fichero de salida con estas características:
Para cada nodo origen i considerado, se incluirá una primera línea que indique el nodo del que se trata (i), y en las n líneas siguientes se almacenaran los n valores de las filas D[i] y P[i] (es decir, en cada línea dos valores: D[i,j] y P[i,j]), para 1 ≤ i, j ≤ n.
Para representar y usar el grafo se utilizaran los tipos de datos y funciones implementadas en la práctica 2.
En la implementación no se debe restringir el número de nodos ni de aristas.
Introducción a la ‘Programación dinámica’
En la Informática, la programación dinámica es un método para reducir el tiempo de ejecución de un algoritmo mediante la utilización de subproblemas superpuestos y subestructuras óptimas, como se describe a continuación.
El matemático Richard Bellman inventó la programación dinámica en 1953.
Una subestructura óptima significa que soluciones óptimas de subproblemas pueden ser usadas para encontrar las soluciones óptimas del problema en su conjunto. Por ejemplo, el camino más corto entre dos vértices de un grafo se puede encontrar calculando primero el camino más corto al objetivo desde todos los vértices adyacentes al de partida, y después usando estas soluciones para elegir el mejor camino de todos ellos. En general, se pueden resolver problemas con subestructuras óptimas siguiendo estos tres pasos:
Dividir el problema en subproblemas más pequeños.
Resolver estos problemas de manera óptima usando este proceso de tres pasos recursivamente.
Usar estas soluciones óptimas para construir una solución óptima al problema original.
Los subproblemas se resuelven a su vez dividiéndolos ellos mismos en subproblemas más pequeños hasta que se alcance el caso fácil, donde la solución al problema es trivial.
4
Decir que un problema tiene subproblemas superpuestos es decir que un mismo subproblema es usado para resolver diferentes problemas mayores. Por ejemplo, en la sucesión de Fibonacci, F3 = F1 + F2 y F4 = F2 + F3 — calcular cada término supone calcular F2. Como ambos F3 y F4 hacen falta para calcular F5, una mala implementación para calcular F5 acabará calculando F2 dos o más veces. Esto ocurre siempre que haya subproblemas superpuestos: una mala implementación puede acabar desperdiciando tiempo recalculando las soluciones óptimas a subproblemas que ya han sido resueltos anteriormente.
Esto se puede evitar guardando las soluciones que ya hemos calculado. Entonces, si necesitamos resolver el mismo problema más tarde, podemos obtener la solución de la lista de soluciones calculadas y reutilizarla. Este acercamiento al problema se llama memorización (en inglés "memoization"). Si estamos seguros de que no volveremos a necesitar una solución en concreto, la podemos descartar para ahorrar espacio. En algunos casos, podemos calcular las soluciones a problemas que sabemos que vamos a necesitar de antemano.
En resumen, la programación dinámica hace uso de:
Subproblemas superpuestos
Subestructuras óptimas
Memorización
La programación dinámica toma normalmente uno de los dos siguientes enfoques:
Top-down: El problema se divide en subproblemas, y estos subproblemas se resuelven recordando las soluciones en caso de que sean necesarias nuevamente. Es una combinación de memorización y recursión.
Bottom-up: Todos los subproblemas que puedan ser necesarios se resuelven de antemano y después son usados para resolver las soluciones a problemas mayores. Este enfoque es ligeramente mejor en consumo de espacio y llamadas a funciones, pero a veces resulta poco intuitivo encontrar todos los subproblemas necesarios para resolver un problema dado.
Originalmente, el término de programación dinámica se refería a la resolución de ciertos problemas y operaciones fuera del ámbito de la Ingeniería Informática, como también lo hacía la programación lineal. Aquel contexto no tiene relación con la programación en absoluto; el nombre es una coincidencia. El término también se usaba en los años 40 por Richard Bellman, un matemático americano, para describir el proceso de resolver problemas donde hace falta calcular la mejor solución consecutivamente.
Algunos lenguajes de programación funcionales, sobre todo Haskell, pueden usar la memorización automáticamente sobre funciones con un conjunto concreto de argumentos, para acelerar su proceso de evaluación. Esto sólo es posible en funciones que no tengan efectos secundarios, algo que ocurre en Haskell pero no tanto en otros lenguajes.
Principio de optimalidad
Cuando hablamos de optimizar nos referimos a buscar la mejor solución de entre muchas alternativas posibles. Dicho proceso de optimización puede ser visto como una secuencia de decisiones que nos proporcionan la solución correcta. Si, dada una subsecuencia de decisiones, siempre se conoce cuál es la decisión que debe tomarse a continuación para obtener la secuencia óptima, el problema es elemental y se resuelve trivialmente tomando una decisión detrás de otra, lo que se conoce como estrategia voraz.
5
A menudo, aunque no sea posible aplicar la estrategia voraz, se cumple el principio de optimalidad de Bellman que dicta que «dada una secuencia óptima de decisiones, toda subsecuencia de ella es, a su vez, óptima». En este caso sigue siendo posible el ir tomando decisiones elementales, en la confianza de que la combinación de ellas seguirá siendo óptima, pero será entonces necesario explorar muchas secuencias de decisiones para dar con la correcta, siendo aquí donde interviene la programación dinámica.
Contemplar un problema como una secuencia de decisiones equivale a dividirlo en subproblemas más pequeños y por lo tanto más fáciles de resolver como hacemos en Divide y Vencerás, técnica similar a la de Programación Dinámica. La programación dinámica se aplica cuando la subdivisión de un problema conduce a:
Una enorme cantidad de subproblemas.
Subproblemas cuyas soluciones parciales se solapan.
Grupos de subproblemas de muy distinta complejidad.
Desarrollo de la Práctica
Estudio de la implementación
Explicar los detalles de implementación y el coste de almacenamiento de:
Las estructuras de datos utilizadas para almacenar el grafo.
Las matrices de distancias y de recorrido.
Explicar los detalles de implementación de las estructuras de datos utilizadas para almacenar el grafo.
Como en la práctica anterior se han usado dos estructuras de almacenamiento para los grafos,
Matriz de adyacencias.
Lista de adyacencias.
Como en la práctica anterior se describieron estas estructuras, en esta ocasión describiremos su coste, en términos de espacio.
Lista de adyacencias
En esta estructura el espacio ocupado depende tanto de los vértices como del número de aristas.
Es difícil estimar el espacio necesario de la estructura en Java, ya que el uso extensivo de objetos aumenta enormemente el tamaño, y el número de estructuras internas usadas para mantener las listas.
Para poder dar una estimación teórica vamos a suponer que cada nodo de las listas de adyacencias consta de:
Nodo destino : Tipo int (4 bytes).
Peso de la arista : Tipo int (4 bytes).
Puntero a siguiente : Tipo Puntero (4 bytes en arquitecturas x86)
El array de listas de adyacencias será un array de punteros al primer nodo de cada lista, y por tanto su tamaño será de 4 bytes por nodo.
6
Con las anteriores suposiciones el tamaño de esta implementación será de:
Tamaño = Vértices · 4 bytes + Aristas · 12 bytes
Como ejemplos podremos decir que un grafo de 1000 vértices completo ocuparía 11,43 Mbyte
Matriz de adyacencias
En este caso el espacio solo depende del número de vértices, tomando los tamaños del los enteros del caso anterior, el espacio necesario seria:
Tamaño = Vértices * Vértices * 4 bytes
Como ejemplos podremos decir que un grafo de 1000 vértices ocuparía 3,81 Mbyte
Comparativa
Si trazamos en un grafico las 2 variantes, por ejemplo para 1000 vértices obtendremos:
Lo que significaría que en términos de espacio solo sería recomendable usar el método de lista de adyacencias cuando el grafo tenga aproximadamente menos del 30% de las aristas posibles.
Estudio teórico
El algoritmo de Floyd-Warshall, descrito en 1959 por Bernard Roy, es un algoritmo de análisis sobre grafos para encontrar el camino mínimo en grafos dirigidos ponderados. El algoritmo encuentra el camino entre todos los pares de vértices en una única ejecución. El algoritmo de Floyd-Warshall es un ejemplo de programación dinámica.
El funcionamiento del algoritmo se basa en:
Partimos de una matriz, que almacena las distancias entre cada par de vértices conectados, o INF en caso de que no exista conexión.
En cada iteración k, comprobamos si la distancia entre los vértices i→j almacenada en la tabla es mayor que la distancia entre i→k mas la distancia de k→j.
De ser así se actualiza la tabla de distancias, almacenando la nueva distancia. Si desea recuperar el recorrido, también será necesario actualizar la tabla de predecesores, para incorporar k en el camino de i→j.
Tras la iteración k = nVertices, la tabla de distancias contendrá la distancia mínima entre cada par de vértices.
0
2
4
6
8
10
12
14
10
00
0
50
00
0
90
00
0
13
00
00
17
00
00
21
00
00
25
00
00
29
00
00
33
00
00
37
00
00
41
00
00
45
00
00
49
00
00
53
00
00
57
00
00
61
00
00
65
00
00
69
00
00
73
00
00
77
00
00
81
00
00
85
00
00
89
00
00
93
00
00
97
00
00
Tam
año
en
Mb
Comparacion del tamaño necesario para el almacenamiento del grafo segun la implementacion usada
Matriz de adyacencias
Lista de adyacencias
7
Como ejemplo tomaremos el grafo representado por la siguiente matriz de adyacencias:
Realizando los pasos anteriores, la matriz ira evolucionando en cada iteración:
El pseudocódigo de este algoritmo es:
Para k = 0 hasta n hacer Para i = 0 hasta n hacer Para j = 0 hasta n hacer
D[i,j] = mínimo(D[i,j],D[i,k] + D[k,j]) Fin_Para
Fin_Para Fin_Para
Teniendo en cuenta que n es el número de vértices es fácil deducir que la complejidad del algoritmo es n3.
Comparación con algoritmo de Dijkstra
El tiempo de ejecución de Floyd depende únicamente del número de vértices, siendo O = n3.
En el algoritmo de Dijkstra los tiempos dependen tanto del número de vértices como del de aristas, así como de la implementación usada (lista o montículo), para compararlo con el de Floyd necesitaremos multiplicar el resultado por el numero de vértices, ya que Dijkstra solo calcula el camino mínimo desde un nodo a los demás y Floyd calcula la distancia de todos con todos.
Tiempo de ejecución usando listas: O = Vertices · Vertices2 = Vertices3
Tiempo de ejecución usando montículos: O = Vertices · ((Aristas + Vertices) · Log(Vertices)) O = (Aristas · Vertices + Vertices2) · Log(Vertices)
8
Aunque en el peor de los casos ambos algoritmos presentan un orden de O(n3) la constante implícita del algoritmo de Dijkstra es mucho mayor, por lo que en casos en los que el grafo es muy denso es mejor usar Floyd.
Estudio Experimental de los Algoritmos de Ordenación
a) Validación del algoritmo de Floyd: se realizara un programa de prueba que compare diferentes ejecuciones del algoritmo, tomando como entrada un fichero, y utilizando diferentes métodos de implementación del grafo. El alumno explicara las pruebas que ha realizado para comprobar que sus funciones trabajan correctamente.
b) Comparación de los algoritmos de Dijkstra y de Floyd: se hará un programa para permitir realizar las comparaciones que se crea oportunas entre los dos algoritmos. ´ Este debe poder ejecutar n veces Dijkstra y comparar los resultados y los tiempos con una sola ejecución de Floyd (pidiendo el fichero de entrada, el tipo de representación del grafo y el método de ordenación de aristas). Indicar las pruebas que han sido realizadas. Para estudiar experimentalmente estos métodos se incluirá en el programa la posibilidad de obtener tiempos de ejecución de los distintos métodos implementados.
Ambos apartados se han implementado en forma de un solo programa, que en su menú da la opción de realizar pruebas con cada uno de los algoritmos así como un modo test, que da resultados sobre los dos algoritmos.
c) Se contrastaran los resultados teóricos y los experimentales, comprobando si los experimentales confirman los teóricos, para los resultados teóricos obtenidos en el apartado 2. El alumno justificara los experimentos realizados, y en caso de discrepancia entre la teoría y los experimentos debe intentar buscar una explicación.
En particular hay que confirmar la forma en que crecen los tiempos de ejecución, que método de almacenamiento y ordenación de aristas es mejor, si las mejoras teóricas lo son en la práctica, como influye el número de aristas en estos métodos, etc,...
Para poder evaluar la validez de los datos obtenidos de manera teórica se ha decidido aplicar el algoritmo a grafos de distintas características, en concreto:
Los grafos tienen un número de vértices que aumenta de 10 a 210, en incrementos de 10.
Los grafos tienen un numero de aristas mínimo que asegura que sea conexo, mas una sobrecarga que varía de 0% a 100% (grafo completo) en incrementos de 5%.
Pruebas del algoritmo de Dijkstra
Ahora procederemos a mostrar los resultados obtenidos de manera práctica, tras haber ejecutado los test con los valores anteriores.
Los resultados obtenidos por el programa diseñado prueban este algoritmo sobre un solo vértice, aunque nos devuelven el tiempo necesario como si se hubiera realizado una vez por cada vértice, como sería necesario para poder ser comparado con Floyd.
En las siguientes graficas se ha tenido en cuenta esta circunstancia, por lo que se han ajustado los resultados.
9
El uso de montículos aumenta el rendimiento, en aproximadamente el 40%.
El factor de crecimiento sigue la tendencia esperada, según el estudio teórico, aunque es difícil de asegurar, pues el número de muestras es pequeño. Como en la práctica anterior sería necesario tomar muestras para un número más elevado de vértices y aristas, para poder trazar una línea de tendencia.
10
30
50
70
90
110
130
150170
190210
0
100
200
300
400
500
600
700
800
900
0%
25%
50%
75%
100%
Algoritmo de DIJKSTRAOrdenacion de vertices con lista
Almacenamiento del grafo mediante matriz de adyacencias
10
30
50
70
90
110
130
150170
190210
0
50
100
150
200
250
300
350
400
450
500
0%
25%
50%
75%
100%
Algoritmo de DIJKSTRAOrdenacion de vertices con monticulo
Almacenamiento del grafo mediante matriz de adyacencias
10
El uso de la implementación con el grafo almacenado en listas de adyacencia, supone un aumento muy considerable del tiempo necesario.
En este caso también se observa que la mejora debido al uso de montículos hace que el rendimiento mejore, como en el caso anterior, en aproximadamente un 40%.
10
30
50
70
90
110
130
150170
190210
0
50000
100000
150000
200000
250000
300000
350000
0%
25%
50%
75%
100%
Algoritmo de DIJKSTRAOrdenacion de vertices con lista
Almacenamiento del grafo mediante lista de adyacencias
10
30
50
70
90
110
130
150170
190210
0
20000
40000
60000
80000
100000
120000
140000
160000
0%
25%
50%
75%
100%
Algoritmo de DIJKSTRAOrdenacion de vertices con monticulo
Almacenamiento del grafo mediante lista de adyacencias
11
Pruebas del algoritmo de Floyd
En este algoritmo no se usa la ordenación de vértices, por lo que solo mostramos dos graficas, en la superior podemos ver que al usar matrices el tiempo se mantiene constante independientemente del número de aristas, confirmando la teoría, además la tendencia seguida se aproxima a la estudiada.
En el caso de usar listas de adyacencias los resultados cambian, no solo el tiempo aumenta bastante sino que además varia con el numero de aristas, lo que era de esperar pues a mayor numero de aristas mas se tarda en buscar la deseada dentro de la lista, no como en la matriz que el acceso es constante independientemente del número de aristas.
10
30
50
70
90
110
130
150170
190210
0
20
40
60
80
100
120
140
160
180
0%
25%
50%
75%
100%
Algoritmo de FLOYDAlmacenamiento del grafo mediante matriz de adyacencias
10
30
50
70
90
110
130
150170
190210
0
500
1000
1500
2000
2500
0%
25%
50%
75%
100%
Algoritmo de FLOYDAlmacenamiento del grafo mediante lista de adyacencias
12
Conclusiones
Como se ha visto el uso de montículos en la ordenación de vértices reduce el tiempo necesario para su ejecución, como ya se confirmo en la práctica anterior.
El uso de listas de adyacencia causa un gran impacto en el rendimiento de ambos algoritmos, que se hace notar más aun cuando el grafo es denso.
Aunque la complejidad de los 2 algoritmos estudiados es igual (en el caso de usar Dijkstra con ordenación con lista) en la práctica se observa que el tiempo necesario para su ejecución no es el mismo, Floyd obtiene unos tiempos mucho mejores, incluso en el caso de que el grafo sea poco denso. Esto se debe a su sencillez de implementación.
Como conclusiones finales podríamos decir que en los ordenadores actuales en los que la memoria no es demasiado problema, es recomendable el uso de matrices de adyacencias por su rendimiento muy superior al de las listas.
El algoritmo de Floyd es preferible al de Dijkstra cuando se necesitan calcular caminos de todos con todos los vértices, a no ser que el grafo tenga muy pocas aristas. En caso de que no se necesiten todas las distancias y caminos entre vértices es posible que Dijkstra pueda ofrecer un mayor rendimiento.
Relación de clases
En el proyecto se usan solo 5 clases:
Main : Es el encargado de lanzar la aplicación, contienen los menús y realiza los test.
Grafo : Implementa la clase abstracta Grafo, así como las dos clases derivadas, GrafoLista, y GrafoMatriz.
Dijkstra : Implementa el algoritmo de Dijkstra, tanto la versión con lista como con montículos.
Floyd : Implementa el algoritmo de Floyd, tanto la versión con lista como con montículos.
Uso del programa
El programa implementado es muy intuitivo, está basado en cuadros de dialogo, que preguntan en cada momento que se desea hacer.
Bibliografía
Los datos necesarios para la realización de la práctica han sido extraídos de:
Wikipedia (www.wikipedia.es)
Técnicas de Diseño de Algoritmos (Rosa Guerequeta y Antonio Vallecillo)