75.29 - TEORÍA DE ALGORITMOS 1 TRABAJO PRÁCTICO Nº: 2

Anuncio
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
Descargar