75.29 - TEORÍA DE ALGORITMOS 1 TRABAJO PRÁCTICO Nº: 2 INTEGRANTES Padrón 84672 PARA USO DE LA CÁTEDRA Primera entrega 26/10/2011 Corrector Observaciones Segunda entrega Corrector Observaciones Nombre y Apellido Juan Martín Muñoz Facorro Email juan.facorro@gmail.com ÍNDICE Enunciado ........................................................................................................................................................... 3 Implementación de Grafos ................................................................................................................................. 4 Lista de Adyacencia ........................................................................................................................................ 4 Algoritmo de Dijkstra .................................................................................................................................. 5 Algoritmo de Prim ...................................................................................................................................... 6 Algoritmo de Kruskal .................................................................................................................................. 7 Matriz de Adyacencia ..................................................................................................................................... 8 Comparación de Implementaciones .............................................................................................................. 9 Estructuras de Datos ........................................................................................................................................ 10 Cola de Prioridad: Heap ............................................................................................................................... 10 Lista Enlazada: List ....................................................................................................................................... 10 Tabla de Hash: HashTable ............................................................................................................................ 10 Union-Búsqueda: UnionFind ........................................................................................................................ 11 Conclusiones ..................................................................................................................................................... 12 Apéndice A – Estructuras de Datos .................................................................................................................. 13 Apéndice B – Implementación de Grafos ......................................................................................................... 14 Referencias ....................................................................................................................................................... 15 2 ENUNCIADO El objetivo de este trabajo es implementar el TDA grafo utilizando dos estrategias: matriz de adyacencias y listas enlazadas. En ambos casos el TDA deberá soportar las siguientes operaciones: Método AgregarArista AgregarVertice ObtenerCantidadDeVertices ObtenerCantidadDeAristas CalcularTendidoMinimoPorKruskal CalcularTendidoMinimoPorPrim CalcularCaminoMinimoPorDijkstra Parámetros vérticeOrigen, verticeDestino, pesoDeLaArista Nombredelvertice verticeOrigen , verticeDestino Devuelve - Devuelve el objetoVertice Entero Entero Lista de vértices Lista de vértices Lista de vértices Para cada implementación se deben ejecutar pruebas cronometrando el tiempo de ejecución para las operaciones CalcularCaminoMinimoPorDijkstra, CalcularTendidoMinimoPorPrim, CalcularTendidoMinimoPorKruskal para grafos ralos y para grafos densos de distintos tamaños. Con esta información se deberá realizar un análisis comparando estos resultados con los correspondientes órdenes calculados en forma teórica. Con esto pretendemos identificar que implementación resulta más conveniente para cada tipo de grafo (ralo o denso) Se deberá entregar: - Un informe escrito acorde a las normas de la cátedra conteniendo el análisis comparativo de las implementaciones antes mencionado El código fuente de ambas implementaciones y el correspondiente set de pruebas automáticas ejecutadas para realizar las mediciones de tiempo. Los archivos de prueba utilizados. 3 IMPLEMENTACIÓN DE GRAFOS Para la resolución del enunciado se llevaron a cabo dos implementaciones para representar y manipular grafos. Dado que uno de los requerimientos incluía no utilizar ninguna de las estructuras de datos que se ofrecen con el lenguaje de programación a utilizar, se realizó también la implementación de las siguientes estructuras de datos: 1. 2. 3. 4. Heap: cola de prioridad. List: lista enlazada doble. HashTable: tabla de hash. UnionFind: unión-buscar. El detalle de cada una de estas implementaciones puede encontrarse en la sección Estructuras de Datos. LISTA DE ADYACENCIA La implementación del grafo utilizando listas de adyacencia mantiene una lista de los vértices, donde cada uno a su vez contiene una lista con los vértices adyacentes a él. En el siguiente gráfico se ofrece una ilustración de la estructura utilizada: Donde Edge es un objeto con la siguiente información: 4 ALGORITMO DE DIJKSTRA A continuación se presenta el pseudocódigo que representa el algoritmo de esta sección: Dijkstra(G(V, E), origen, destino): anterior = vacío # anterior[v] contiene el vértice # anterior a v en el camino S es el conjunto de vértices agregados Para todo v que pertenece a V: costo[v] = ∞ costo[origen] = 0 Insertar el vértice origen a la cola de prioridad Q con su costo Mientras S != V: u = Extraer el vértice con mínimo costo de Q Agregar u a S Para todos los vértices v adyacentes a u: Siendo peso el peso de la arista (u, v) Si (costo[u] + peso) es menor a costo[v] Actualizar el costo[v] con el valor calculado Establecer anterior[v] con u Insertar el vértice v a Q con su nuevo costo Calcular el camino desde el origen al destino usando el vector anterior Dado que el ciclo mientras agrega un vértice a S en cada iteración, este ciclo se repetirá |V| veces, que equivale a la cantidad de vértices que tiene G. En cada una de estas iteraciones se procesarán además las aristas que tienen como origen al vértice agregado u, por lo que luego de que se agreguen todos los vértices a S se habrán procesado todas las aristas que pertenecen a E. La implementación del grafo con la lista de prioridad realiza un barrido que incluye solo los vértices que son adyacentes a u, gracias a que mantiene estos en una lista doblemente enlazada. Este barrido de vértices adyacentes responde a un orden O (|V|), es decir, cuando un vértice tiene a todos los otros como adyacentes, pero si el grafo es disperso, el caso promedio tendrá un costo mucho menor. Dentro del ciclo se actualiza el costo acumulado desde el origen a cada vértice adyacente a u, si este costo es menor al que tiene asignado, se inserta en la cola de prioridad. Dicha operación de inserción tiene asociado un costo O (log n) donde n, en el peor de los casos, es |V|2. Esto último se debe a que no se actualiza la clave de los vértices en la cola de prioridad sino que se inserta nuevamente el vértice con su costo menor. En un grafo denso con aristas que comunican todos los vértices con todos los otros, en cada iteración se recorrerán todos los demás vértices dad que son adyacentes, pero como peor caso se agregará a la cola sólo la cantidad de vértices para los cuales no se ha encontrado una mínima distancia. Esta progresión estará dada por: |𝑉| |𝑉| |𝑉| ∑|𝑉| − 𝑖 = ∑|𝑉| − ∑ 𝑖 = |𝑉|. |𝑉| − 𝑖=1 𝑖=1 𝑖=1 |𝑉|2 + |𝑉| |𝑉|2 − |𝑉| = = 𝑂(|𝑉|2 ) 2 2 Como se mencionó anteriormente, por más que el ciclo mientras se ejecuta |V| veces, cada arista se procesa una única vez, con su posible inserción en la cola de prioridad, a un costo O (log |V|2). Por lo tanto llegamos a la conclusión que el orden logrado por el algoritmo es O (|E| log |V|2) (donde |E| equivale a la cantidad de aristas en el grafo) lo cual es equivalente a O (|E| log |V|), por la propiedad del logaritmo. El orden teórico que se debe lograr mediante la utilización de una cola de prioridad es O (|E| log |V|), por lo que podemos concluir que la implementación es aceptable. 5 ALGORITMO DE PRIM El análisis del orden de este algoritmo es análogo al realizado con Dijkstra. Para mayor claridad se ofrece a continuación el pseudocódigo correspondiente: Prim(G(V, E)): anterior = vacío # anterior[v] contiene el vértice # anterior a v en el camino S es el conjunto de vértices agregados Para todo v que pertenece a V: costo[v] = ∞ Definimos el vértice origen arbitrariamente costo[origen] = 0 Insertar el vértice origen a la cola de prioridad Q con su costo Mientras S != V: u = Extraer el vértice con mínimo costo de Q Agregar u a S Para todos los vértices v adyacentes a u: Siendo peso el peso de la arista (u, v) Si peso es menor a costo[v] Actualizar el costo[v] con el valor calculado Establecer anterior[v] con u Insertar el vértice v a Q con su nuevo costo Calcular cada arista recorriendo el vector anterior Como en el análisis del algoritmo de Dijkstra, vemos que en cada iteración del ciclo mientras se procesa un vértice nuevo por lo que el ciclo será ejecutado |V| veces. Cada arista a lo largo de la ejecución del algoritmo será procesada una sola vez y, en caso que cumpla la condición de disminuir el costo para incluir a su vértice destino, será insertada a la cola de prioridad Q con un costo de orden O (log |V|) (ver justificación de este orden en la sección de Dijsktra). En el peor de los casos se realiza una inserción de cada arista del grafo en Q, lo que determina que el ciclo mientras responde a un orden de O (|E| log |V|), donde |E| es el número de aristas del grafo. Como puede observarse la implementación es muy similar a la del algoritmo de Dijkstra con la diferencia esencial que el costo que debe ser mínimo no entre un vértice y todos los otros, sino entre un vértice y otro adyacente. 6 ALGORITMO DE KRUSKAL La estrategia de este algoritmo se basa en buscar entre todas las aristas la de menor peso, verificar que sus vértices no pertenecen a la misma componente conexa y en este caso agregar la arista a la solución. A continuación se presenta el pseudocódigo de dicho algoritmo: Kruskal(G(V, E)): aristas = vacío # contiene las aristas del árbol encontrado Para toda arista que pertenece a E: Insertar la arista a la cola de prioridad Q con su peso Mientras |aristas| < |V| - 1: arista = Extraer la arista con mínimo peso de Q Si la arista une dos vértices en diferentes componentes: Agregar arista a aristas Unir las componentes de los vértices de arista Devolver aristas Con el fin de encontrar la arista con el mínimo peso entre todas las existentes, se utiliza una cola de prioridad en la cual se insertan inicialmente todas las aristas que perteneces a E. La operación de inserción en la cola de prioridad tiene un costo asociado de O (log |E|) donde |E| es la cantidad de aristas en el grafo y dado que como máximo podemos tener |V|2 aristas en un grafo, el costo se traduce a O (log |V|). Este costo se multiplica por la cantidad de aristas insertadas, cuyo valor responde a |E|, entonces el costo de inicialización de la cola de prioridad es O (|E| log |V|). En el ciclo mientras se realiza la extracción de la arista con el mínimo peso, dicha operación tiene un costo de O (log |V|). Para averiguar si dos vértices se encuentran en la misma componente se utilizó la estructura UnionFind la cual realiza la búsqueda de la componente de un vértice con un costo O (log n), donde n es la cantidad de elementos que contiene la estructura, en este caso |V|. Por cada iteración debemos encontrar la componente para el vértice origen y destino de la arista, en el peor de los casos, tendremos que hacer la búsqueda 2*|E| veces, dos búsquedas por cada arista. La misma estructura se utiliza para realizar la unión de dos componentes, en este caso la operación tiene un costo O (1), por lo tanto el costo asociado a la corrida del ciclo mientras estará determinado por las operaciones de búsqueda. Por lo expuesto anteriormente llegamos a la conclusión que el algoritmo responde a un orden de O (|E| log |V|). 7 MATRIZ DE ADYACENCIA La implementación utilizando la matriz de adyacencia del grafo, utiliza un vector de vectores, en el cual la posición [i, j] guarda el objeto Edge (arista) que contiene sus vértices de origen y destino, así como el peso asociado al mismo. El análisis de los algoritmos implementados con esta estructura responde a la misma naturaleza que el realizado para la implementación con las listas de adyacencia. A pesar de esto es importante resaltar una diferencia elemental que resulta de utilizar una matriz para encontrar los vértices adyacentes a un determinado vértices. Esta diferencia es el impacto que tiene esta búsqueda sobre el desempeño de los algoritmos, dado que a pesar de que la búsqueda responde a un orden O (|V|), como lo hacía con las listas de adyacencia, para un vértice dado siempre realiza un recorrido de todos los otros vértices para verificar si existe una arista, lo cual en un grafo disperso significa que se estará realizando una cantidad de trabajo de más considerable. Por el contrario, si el grafo es denso, la diferencia entre una y otra implementación en relación al trabajo de encontrar los adyacentes disminuye, favoreciendo más a las matrices de adyacencia, dado que es más rápido recorrer un vector que una lista enlazada. Esta afirmación se basa en la prueba realizada por medio del método list_vs_array() en el archivo main.py, donde se obtuvo que recorrer un vector con 100.000 elementos es alrededor de 75 veces más rápido que recorrer una lista con la misma cantidad de elementos. En el siguiente gráfico se ofrece una ilustración de la estructura utilizada: 8 COMPARACIÓN DE IMPLEMENTACIONES Para realizar una evaluación de la efectividad de una y otra implementación de grafos, se creó un generador de grafos, al cual se le indica la cantidad de nodos que debe tener y el factor de densidad, que establece la proporción de aristas que tendrá el grafo, con respecto a la máxima cantidad de aristas que puede tener. Utilizando este generador se crearon 10 grafos densos y 10 grafos dispersos, con una cantidad de nodos de 10 a 100, incrementando de a 10. Una vez que se dispuso de estos grafos de prueba, se corrieron los tres algoritmos implementados (Dijkstra, Prim y Kruskal) con cada una de las dos implementaciones de grafos, midiendo los tiempos de corrida con el módulo timeit de pyton, el cual facilita el cronometraje de porciones de código. 9 ESTRUCTURAS DE DATOS Las siguientes estructuras de datos fueron implementadas utilizando sólo como estructura base los vectores del lenguaje de programación utilizado, en este caso, python. COLA DE PRIORIDAD: HEAP Esta estructura se implementó utilizando un vector para representar el árbol binario, donde cada posición contiene un nodo del mismo. Debido a la que los índices de un vector comienzan en cero, se tuvieron que realizar algunas modificaciones para determinar el padre y los hijos de un nodo determinado. izquierdo = (2 * índice) + 1 derecho = (2 * índice) + 2 padre = { 0 : si el índice es 0, índice / 2 - 1 : si el índice es múltiplo de 2, índice / 2 : si índice es múltiplo de 2 } Las operaciones para extraer el mínimo o agregar un elemento a la cola de prioridad se implementaron de forma tal que tengan asociado un costo O (log n), donde n es la cantidad de elemento en la cola. Como estructura base se utilizó la lista de python que sería el vector en otros lenguajes como C. Agregar un elemento en una lista tiene un costo O (1), lo cual puede verificarse en la referencia [1], con lo cual se tiene la flexibilidad de no limitar la cantidad de elementos que pueden ser agregados a la cola, dado que el costo de quitar el último elemento de una lista es también O (1), ver referencia [3]. LISTA ENLAZADA: LIST La implementación realizada de la lista enlazada utiliza mantiene cuatro valores: Referencia al primer nodo de la lista. Referencia al último nodo de la lista. Una referencia al nodo actual, utilizado para recorrer la lista. La cantidad de elementos en la lista. Los costos asociados a las operaciones de esta estructura son los siguientes: Agregado de elemento al final o al principio: O (1) Eliminación de elemento: O (1) Obtención de un elemento al final o al principio: O (1) Recorrido: O (n) Obtención de un elemento que no está al final o principio: O (n) TABLA DE HASH: HASHT ABLE Para la implementación de la tabla de hash se investigaron las funciones de hash disponibles y se utilizó finalmente la que rindió mejores resultados para la aplicación de este trabajo. La función utilizada se llama FNV (diminutivo para Fowler, Noll y Vo, nombres de sus creadores) y se obtuvo de la referencia [2]. 10 La estructura de la implementación tiene por defecto una lista de python con 50 posiciones cada una de las cuales puede contener un Bucket que a su vez tiene capacidad para 5 elementos. No se implementó la operación de re-hashing en caso que un Bucket sobrepase su capacidad, pero se informa por medio de una excepción si es que esto ocurre. El Bucket contiene a sus ítems de tipo BucketItem en una lista de python. Las dos operaciones de la tabla de hash (asignación y obtención de un valor dada una clave) se realizan con un orden O (1). Esto se logra con la utilización de la función de hash, con la cual se obtiene la posición del Bucket (orden O (1)) donde se debe asignar o ir a buscar el valor correspondiente a la clave especificada. Una vez encontrado el Bucket, se realiza la búsqueda dentro de los ítems que contiene, encontrando el que corresponde a la clave. Dado que cada Bucket tiene una capacidad máxima determinada, esta última búsqueda tiene orden O (1). Es así que toda operación realizada con la tabla de hash tiene el mismo orden O (1). UNION-BÚSQUEDA: UNIONFIND La implementación de esta estructura se realizó siguiendo la sección 4.6 Implementing Kruskal’s Algorithm: The Union-Find Data Structure del libro de la referencia [4]. La estructura utiliza una tabla de hash para poder encontrar los vértices por nombre, cada posición de la tabla contiene un objeto del tipo UnionFindNode el cual posee una referencia a su padre, el nombre del vértice que representa y el tamaño del grupo al que pertenece. Este último valor se utiliza con el fin de determinar al realizar una unión de dos componentes, cual es tiene más cantidad de elementos; de esta forma el nombre del componente que tenga más elementos será el nombre de la unión. Con esta optimización se logra que la búsqueda del componente al que pertenece un vértice sea de orden O (log n), mientras que la unión de dos componentes será O (1). 11 CONCLUSIONES 12 APÉNDICE A – ESTRUCTURAS DE DATOS HASHTABLE.PY HEAP.PY LIST.PY UNIONFIND.PY 13 APÉNDICE B – IMPLEMENTACIÓN DE GRAFOS GRAPH.PY LISTGRAPH.PY MATRIXGRAPH.PY 14 REFERENCIAS [1] [2] [3] [4] TimeComplexity - http://wiki.python.org/moin/TimeComplexity The Art of Hashing - http://www.eternallyconfuzzled.com/tuts/algorithms/jsw_tut_hashing.aspx Python list implementation - http://www.laurentluce.com/posts/python-list-implementation/ Algorithm Design - by John Kleinberg and Éva Tardos 15