teoría y complejidad algorítmica -...

12
Teoría y Complejidad Algorítmica Practica 3 Eduardo Viciana Gámez

Upload: doanhanh

Post on 29-Sep-2018

218 views

Category:

Documents


0 download

TRANSCRIPT

Page 1: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

Teoría y Complejidad Algorítmica

Practica 3

Eduardo Viciana Gámez

Page 2: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

2

Contiene documentación y código fuente de la práctica

Page 3: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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.

Page 4: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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.

Page 5: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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.

Page 6: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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

Page 7: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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)

Page 8: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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.

Page 9: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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

Page 10: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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

Page 11: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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

Page 12: Teoría y Complejidad Algorítmica - read.pudn.comread.pudn.com/downloads148/sourcecode/java/638908/Practica 3... · Practica 3 Eduardo Viciana Gámez . 2 Contiene documentación

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)