Grafos (2014)

Apunte Español
Universidad Universidad de Cádiz
Grado Ingeniería Informática - 2º curso
Asignatura Estructura de Datos No Lineales
Año del apunte 2014
Páginas 24
Fecha de subida 27/11/2014
Descargas 3
Subido por

Descripción

Resumen con códigos del Tema 6 Grafos

Vista previa del texto

TEMA 6: GRAFOS 1. INTRODUCCIÓN Un grafo G = (V, A) consta de un conjunto de vértices o nodos, V, y un conjunto de aristas o arcos A ⊆ (V×V) que define una relación binaria en V. Cada arista es, por tanto, un par de vértices (v, w) ∈ A.
Si cada arista (v, w) ∈ A es un par ordenado, es decir, si (v, w) y (w, v) no son equivalentes, entonces el grafo es dirigido y la arista (v, w) se representa como una flecha de v a w. El vértice v se dice que es incidente sobre el vértice w y w es adyacente a v.
Si, por el contrario, cada arista es un par no ordenado de vértices y por tanto (v, w) = (w, v), entonces el grafo es no dirigido y la arista (v, w) se representa como un segmento entre v y w. En este caso, se dice que v y w son adyacentes y la arista (v, w) es incidente sobre v y w.
Una arista puede tener un valor asociado, llamado peso, que representa un tiempo, una distancia, un coste, etc. Un grafo cuyas aristas tienen pesos asociados recibe el nombre de grafo ponderado.
2. CONCEPTOS BÁSICOS Grado: El grado de un vértice en un grafo no dirigido es el número de arcos del vértice. Si el grafo es dirigido, se distingue entre grado de entrada (número de arcos incidentes en el vértice) y grado de salida (número de arcos adyacentes al vértice).
Camino: Una sucesión de vértices de un grafo n1, n2, ..., nk, tal que (ni, ni+1) es una arista para 1 ≤ i ≤ k. La longitud de un camino es el número de arcos que comprende, en este caso k-1. Si el grafo es ponderado la longitud de un camino se calcula como la suma de los pesos de las aristas que lo constituyen.
Camino simple: Un camino cuyos arcos son todos distintos. Si además todos los vértices son distintos, se llama camino elemental.
Ciclo: Es un camino en el que coinciden los vértices inicial y final. Si el camino es simple, el ciclo es simple y si el camino es elemental, entonces el ciclo se llama elemental. Se permiten arcos de un vértice a sí mismo; si un grafo contiene arcos de la forma (v, v), lo cual no es frecuente, estos son ciclos de longitud 1; de lo contrario y como caso especial, un vértice v por sí mismo denota un camino de longitud 0 125 TEMA 6: GRAFOS Grafo conexo: Grafo no dirigido en el que hay al menos un camino entre cualquier par de vértices.
Grafo fuertemente conexo: Grafo dirigido en el que hay al menos un camino entre cualquier par de vértices. Si un grafo dirigido no es fuertemente conexo, pero el grafo no dirigido subyacente (sin dirección en los arcos) es conexo, entonces es débilmente conexo.
Grafo completo: Aquel en el cual existe una arista entre cualquier par de vértices (en ambos sentidos si el grafo es dirigido).
Subgrafo: Dado un grafo G = (V, A), diremos que G’ = (V’, A’), donde V’ ⊆ V y A’ ⊆ A, es un subgrafo de G si A’ contiene sólo las aristas de A que unen dos vértices de V’.
Un componente conexo de un grafo no dirigido G es un subgrafo conexo maximal, es decir, un subgrafo conexo que no es subgrafo de ningún otro subgrafo conexo de G. Análogamente se define componente fuertemente conexo de un grafo dirigido.
3. REPRESENTACIONES DE GRAFOS 3.1 Matriz de adyacencia Dado un grafo G = (V, A) con n vértices, se define la matriz de adyacencia asociada a G como una matriz Mn×n donde Mi,j = 1 si (i, j) ∈ A Mi,j = 0 si (i, j) ∉ A y Si G es un grafo no dirigido, M es una matriz simétrica ya que (i, j) = (j, i) para cualesquiera vértices i, j.
#define N 100 /* Número máximo de vértices */ typedef enum {FALSE, TRUE} boolean; typedef int vertice; /* un valor entre 0 y numVert-1 */ typedef struct { boolean Ady[N][N]; int numVert; } tGrafo; typedef tGrafo *Grafo; 126 REPRESENTACIONES DE GRAFOS 3.2 Matriz de costes Dado un grafo G = (V, A) con n vértices, se define la matriz de costes asociada a G como una matriz Cn×n donde Ci,j = p si (i, j) ∈ A, siendo p = peso asociado a (i, j) Ci,j = peso_ilegal si (i, j) ∉ A, (peso_ilegal es un valor no válido como peso de un arco).
#define N 100 /* Número máximo de vértices */ typedef int vertice; /* un valor entre 0 y numVert-1 */ typedef unsigned tCoste; /* valor asociado a un arco */ typedef struct { tCoste Costes[N][N]; int numVert; } tGrafo; typedef tGrafo *Grafo; 3.3 Listas de adyacencia La idea es asociar a cada vértice i del grafo una lista que almacene todos los vértices adyacentes a i.
3.3.1 Vector de listas de adyacencia: typedef int vertice; /* índice del vector entre 0 y numVert-1 */ typedef struct { ListaAdy *adyacentes; /* vector de listas */ int maxVert; int numVert; } tGrafo; typedef tGrafo *Grafo; 127 TEMA 6: GRAFOS tElemento en el TAD ListaAdy se define como sigue: a) Grafos no ponderados: typedef vertice tElemento; b) Grafos ponderados: typedef struct { vertice vert; tCoste coste; } tElemento; 3.3.2 Lista de listas de adyacencia: typedef ListaVert Grafo; tElemento en el TAD ListaVert se define como sigue: typedef struct { vertice vert; ListaAdy adyacentes; } tElemento; Ventajas e inconvenientes: ƒ Las matrices de adyacencia y costes son muy eficientes para comprobar si existe una arista entre un vértice y otro.
ƒ Pueden desaprovechar gran cantidad de memoria si el grafo no es completo.
ƒ Tiene limitación para el número máximo de vértices, con lo cual, cuando el número real de vértices es inferior al máximo, se puede desaprovechar una cantidad considerable de memoria.
ƒ La representación mediante listas de adyacencia aprovecha mejor el espacio de memoria, pues sólo se representan los arcos existentes en el grafo.
ƒ Cuando se utiliza una lista de listas es posible añadir y suprimir vértices.
ƒ Las listas de adyacencia son poco eficientes para determinar si existe una arista entre dos vértices del grafo.
128 RECORRIDOS DE GRAFOS 4. RECORRIDOS DE GRAFOS En el caso de los recorridos en un grafo surge un problema nuevo que no había aparecido en ninguna estructura anterior. Básicamente, dado que no existen reglas definidas en la conexión entre nodos de un grafo (no existe secuencialidad, como en el caso de las listas, ni existen jerarquías, como en el caso de los árboles), nada nos impide, por tanto, meternos en un ciclo. No existen reglas para evitar que esto ocurra, por lo que la solución para evitar recorrer en más de una ocasión el mismo nodo, pasa por marcarlo de alguna forma como visitado. La idea no es nueva, ya surge en la literatura infantil con el cuento de Pulgarcito. Por suerte en la memoria del ordenador no hay pájaros, como en el cuento, que se coman nuestras “migas de pan” (marcas).
typedef enum {NO_VISITADO,VISITADO} visitas; void Profundidad (Grafo G) { visitas *marcas; vertice i; marcas = calloc(G->numVert, sizeof(visitas)); if (marcas == NULL) ERROR(”...: No hay memoria"); for (i = 0; i < G->numVert; i++) if (marcas[i] == NO_VISITADO) Profun(i, G, marcas); free(marcas); } void Profun(vertice v,Grafo G,visitas *marcas) { vertice w; marcas[v] = VISITADO; printf("%d ", v); /* Procesar v */ for (w = 0; w < G->numVert; w++) if (G->Ady[v][w] == TRUE && marcas[w] == NO_VISITADO) Profun(w, G, marcas); } 129 TEMA 6: GRAFOS void Profundidad2 (Grafo G) { visitas *marcas; Pila P; /* Pila de vértices */ vertice i, v, w; P = CrearPila(); marcas = calloc(G->numVert, sizeof(visitas)); if (marcas == NULL) ERROR("Profundidad2(): No hay memoria"); for (i = 0; i < G->numVert; i++) if (marcas[i] == NO_VISITADO) { Push(i, P); do { v = Tope(P); Pop(P); if (marcas[v] == NO_VISITADO) { /* Marcar y procesar v */ marcas[v] = VISITADO; printf("%d ", v); /* Meter en la pila los adyacentes no visitados */ for (w = 0; w < G->numVert; w++) if (G->Ady[v][w] == TRUE && marcas[w] == NO_VISITADO) Push(w, P); } } while (!Vacia(P)); } /* for if */ free(marcas); DestruirPila(P); } 130 RECORRIDOS DE GRAFOS void Anchura (Grafo G) { visitas *marcas; Cola C; /* Cola de vértices */ vertice i, v, w; C = CrearCola(); marcas = calloc(G->numVert, sizeof(visitas)); if (marcas == NULL) ERROR("Anchura(): No hay memoria"); for (i = 0; i < G->numVert; i++) if (marcas[i] == NO_VISITADO) { ColaPush(i, C); do { v = Frente(C); ColaPop(C); if (marcas[v] == NO_VISITADO) { /* Marcar y procesar v */ marcas[v] = VISITADO; printf("%2d ", v); /* Meter en la cola los adyacentes no visitados */ for (w = 0; w < G->numVert; w++) if (G->Ady[v][w] == TRUE && marcas[w] == NO_VISITADO) ColaPush(w, C); } } while (!ColaVacia(C)); } /* for if */ free(marcas); DestruirCola(C); } 131 TEMA 6: GRAFOS 5. ALGORITMOS DEL CAMINO MÁS CORTO Un problema muy común en las aplicaciones de grafos consiste en determinar el coste o longitud del camino más corto entre dos vértices de un grafo. En realidad, este problema es tan difícil como determinar el coste de los caminos de coste mínimo desde un vértice origen a todos los demás vértices del grafo y después siempre podemos seleccionar el valor correspondiente al vértice destino que nos interese, o bien, si lo preferimos podemos terminar el algoritmo en el momento en que se encuentre el coste del camino que nos interesa. Por tanto, el siguiente algoritmo que recibe el nombre de su autor Dijkstra, resuelve el problema más general de encontrar el coste mínimo de los caminos desde un vértice origen hasta todos los vértices de un grafo ponderado void Dijkstra (vertice origen, Grafo G, tCoste **D, vertice **P) /* Calcula los caminos de coste mínimo entre origen y todos los vértices del grafo G.
Salida: - Un vector *D de tamaño G->numVert con estos costes mínimos.
- Un vector *P de tamaño G->numVert tal que (*P)[i] es el último vértice del camino de origen a i.
*/ { boolean *S; int i; vertice v, w; tCoste CosteMin, Owv; S = calloc(G->numVert, sizeof(boolean)); if (S == NULL) ERROR("Dijkstra(): No hay memoria"); S[origen] = TRUE; /* Incluir origen en S */ *D = calloc(G->numVert, sizeof(tCoste)); if (*D == NULL) ERROR("Dijkstra(): No hay memoria"); 132 ALGORITMOS DEL CAMINO MÁS CORTO *P = calloc(G->numVert, sizeof(vertice)); if (*P == NULL) ERROR("Dijkstra(): No hay memoria"); /* Inicializar *D y *P */ for (v = 0; v < G->numVert; v++) { (*D)[v] = G->Costes[origen][v]; (*P)[v] = origen; } for (i = 0; i < G->numVert-1; i++) { /* Localizar vértice w no incluido en S con coste mínimo desde origen */ CosteMin = INFINITO; for (v = 0; v < G->numVert; v++) if (!S[v] && (*D)[v] < CosteMin) { CosteMin = (*D)[v]; w = v; } S[w] = TRUE; /* Incluir w en S */ /* Recalcular coste hasta cada v no incluido en S a través de w. */ for (v = 0; v < G->numVert; v++) { Owv = Suma((*D)[w], G->Costes[w][v]); if (!S[v] && (*D)[v] > Owv) { (*D)[v] = Owv; (*P)[v] = w; } } } free(S); } 133 TEMA 6: GRAFOS void Caminoi (vertice orig, vertice i, vertice *P) /* Reconstruye el camino de orig a i a partir de un vector P obtenido mediante la función Dijkstra(). */ { if (P[i] != orig) { Caminoi(orig, P[i], P); printf("%2d ", P[i]); } } En ciertos casos es necesario determinar el coste de los caminos de coste mínimo entre cualquier par de vértices del grafo. Este es el problema de los caminos de coste mínimo entre todos los pares. Se puede resolver utilizando el algoritmo de Dijkstra con cada vértice del grafo, pero existe un método más directo mediante el algoritmo de Floyd.
void Floyd (Grafo G, tCoste A[][N], vertice P[][N]) /*Devuelve una matriz de costes mínimos A de tamaño NxN y una matriz de vértices P de tamaño NxN, tal que P[i][j] es el vértice por el que pasa el camino de coste mínimo de i a j, o bien es -1 si este camino es directo*/ { vertice i, j, k; tCoste ikj; for (i = 0; i < G->numVert; i++) for (j = 0; j < G->numVert; j++) { A[i][j] = G->Costes[i][j]; P[i][j] = -1; } 134 ALGORITMOS DEL CAMINO MÁS CORTO for (i = 0; i < G->numVert; i++) A[i][i] = 0; for (k = 0; k < G->numVert; k++) for (i = 0; i < G->numVert; i++) for (j = 0; j < G->numVert; j++) { ikj = Suma(A[i][k], A[k][j]); if (A[i][j] > ikj) { A[i][j] = ikj; P[i][j] = k; } } } void Camino (vertice i, vertice j, vertice P[][N]) /* Reconstruye el camino de i a j a partir de una matriz P obtenida mediante la función Floyd(). */ { vertice k; k = P[i][j]; if (k != -1) { Camino(i, k, P); printf("%2d ", k); Camino(k, j, P); } } Para otros problemas es suficiente conocer si existe un camino entre cualquier par de vértices. Esto se puede conseguir con una pequeña modificación del algoritmo de Floyd, dando lugar al algoritmo de Warshall.
135 TEMA 6: GRAFOS void Warshall (Grafo G, boolean A[][N]) /* Determina si hay un camino entre cada par de vértices del grafo G. Devuelve una matriz booleana A de tamaño NxN, tal que A[i][j] == TRUE si existe al menos un camino entre el vértice i y el vértice j, y A[i][j] == FALSE si no existe ningún camino entre los vértices i y j. */ { vertice i, j, k; /* Inicializar A con la matriz de adyacencia de G */ for (i = 0; i < G->numVert; i++) for (j = 0; j < G->numVert; j++) A[i][j] = G->Ady[i][j]; /* Comprobar camino entre cada par de vértices i, j a través de cada vértice k */ for (k = 0; k < G->numVert; k++) for (i = 0; i < G->numVert; i++) for (j = 0; j < G->numVert; j++) if (!A[i][j]) A[i][j] = A[i][k] && A[k][j]; } 6. ÁRBOLES GENERADORES DE COSTE MÍNIMO Un problema característico de grafos se plantea en el diseño de redes de comunicación, donde los vértices representan nodos de la red y las aristas, las líneas de comunicación entre los mismos. El peso asociado a cada arista representa el coste de establecer esa línea de la red.
La cuestión es seleccionar el conjunto de líneas que permitan la comunicación entre todos los nodos de la red, tal que el costo total de la red diseñada sea mínimo.
136 ÁRBOLES GENERADORES DE COSTE MÍNIMO La solución de este problema se puede obtener hallando un árbol generador de coste mínimo para el grafo que comprenda todas las líneas posibles de comunicación de la red.
Dado un grafo no dirigido y conexo G = (V, A), se define un árbol generador de G como un árbol que conecta todos los vértices de V; su coste es la suma de los costes de las aristas del árbol. Un árbol es un grafo conexo acíclico.
Existen dos algoritmos muy conocidos para construir un árbol de extensión de coste mínimo a partir de un grafo ponderado. Estos se deben a Prim y Kruskall.
6.1. Algoritmo de Prim void Prim (Grafo G, arista **T) /* Devuelve en un vector *T el conjunto de aristas que forman un árbol generador de coste mínimo de un grafo conexo G. */ { boolean *U; vertice j, k; int i; arista a; tCoste CosteMin; *T = calloc(G->numVert-1, sizeof(arista)); if (*T == NULL) ERROR("Prim(): No hay memoria"); U = calloc(G->numVert, sizeof(boolean)); if (U == NULL) ERROR("Prim(): No hay memoria"); U[0] = TRUE; for (i = 0; i < G->numVert-1; i++) { /* Buscar una arista a=(u, v) de coste mínimo, tal que u está ya en el conjunto U y v no está en U. */ CosteMin = INFINITO; 137 TEMA 6: GRAFOS for (j = 0; j < G->numVert; j++) for (k = 0; k < G->numVert; k++) if (U[j] && !U[k]) if (G->Costes[j][k] <= CosteMin){ CosteMin = G->Costes[j][k]; a.orig = j; a.dest = k; } /* Incluir a en *T y v en U */ (*T)[i] = a; U[a.dest] = TRUE; } } 6.2. Algoritmo de Kruskall TAD Partición Una partición de un conjunto C de elementos de un cierto tipo es un conjunto de subconjuntos disjuntos cuya unión es el conjunto total C.
Partiendo de esta definición, nuestro objetivo es crear un TAD general que nos permita trabajar con particiones de cualquier conjunto finito C. En lugar de crear directamente este TAD general, crearemos un TAD para representar particiones solamente del conjunto de los números enteros en el intervalo [0, n−1] (donde n es el número de elementos de C). Este segundo TAD lo podremos utilizar para representar particiones de cualquier conjunto C de n elementos, simplemente definiendo una aplicación entre los elementos de C y el rango de enteros [0, n−1], tal que cada elemento se aplique en un único número y cada número corresponda a un solo elemento. Esta aplicación estará implementada mediante dos funciones externas al TAD cuyas especificaciones son las siguientes: int IndiceElto (tElemento x); Pre: x ∈ C.
Post: Devuelve el índice del elemento x en el rango [0, n−1].
tElemento NombreElto (int i); Pre: 0 ≤ i ≤ n−1 Post: Devuelve el elemento de C cuyo índice es i.
Una relación de equivalencia sobre los elementos de un conjunto C define una partición de C y, viceversa, cualquier partición de C define una relación de equivalencia sobre sus elementos, de tal forma que cada miembro de la partición es una clase de equivalencia. Así pues, para cada subconjunto o clase podemos elegir cualquier elemento como representante canónico de todos sus miembros.
138 ÁRBOLES GENERADORES DE COSTE MÍNIMO A continuación se da la especificación del TAD Partición teniendo en cuenta las consideraciones anteriores.
Definición: Una partición del conjunto de enteros C = {0, 1,…, n−1} es un conjunto de subconjuntos disjuntos cuya unión es el conjunto total C.
Operaciones: Particion CrearParticion (int n); Post: Construye y devuelve una partición del intervalo de enteros [0, n−1] colocando un solo elemento en cada subconjunto.
void Union (int a, int b, Particion P); Pre: La partición P está inicializada y 0 ≤ a, b ≤ n−1 (a y b son los representantes de sus clases).
Post: Une el subconjunto del elemento a y el del elemento b en uno de los dos subconjuntos arbitrariamente. La partición P queda con un miembro menos.
int Encontrar (int x, Particion P); Pre: La partición P está inicializada y 0 ≤ x ≤ n−1.
Post: Devuelve el representante del subconjunto al que pertenece el elemento x.
void DestruirParticion (Particion P); Post: Destruye la partición P, liberando el espacio ocupado en memoria.
Para la implementación del TAD Partición analizaremos diferentes estructuras de datos alternativas.
1. Vector de pertenencia La estructura de datos más sencilla que se puede utilizar para representar una partición P del conjunto C = {0, 1,…, n−1} es un vector de enteros de tamaño n, tal que en la posición i-ésima se almacena el representante de la clase a la que pertenece i. Obviamente, la operación Encontrar() es O(1), mientras que CrearParticion() y Union() son O(n). La eficiencia de la operación constructora no es posible mejorarla, ya que debe crear n subconjuntos unitarios, sin embargo sí que podemos modificar la estructura de datos para hacer la unión más eficiente.
139 TEMA 6: GRAFOS 2. Listas de elementos El punto débil de la unión es que hay que recorrer todo el vector en busca de todos los elementos de la clase de b para asignarles el representante de la clase de a, o viceversa. Una posibilidad para evitar este recorrido es enlazar todos los miembros de una clase en una lista cuyo principio sea el representante de la clase, añadiendo un campo a cada celda del vector para almacenar el siguiente elemento de la lista (utilizamos −1 para indicar el final de una lista). Ahora, en vez de recorrer el vector completo, basta recorrer la lista de los elementos de una clase para asignarles el representante de la otra y enlazar ambas listas en una sola.
Sería deseable, además, recorrer siempre la lista más corta, pero para eso necesitamos conocer el tamaño de ambas listas, así que podemos añadir otro campo más a cada celda para guardar el tamaño de la clase a la que pertenece el elemento. Pero entonces, hay que recorrer las dos listas para actualizar este valor con la suma de los tamaños de las clases que se unen. Por lo tanto, para evitar el recorrido de ambas listas conviene guardar la longitud de cada lista solamente en el elemento representante (los valores almacenados para los demás elementos son irrelevantes).
Con esta estructura de datos conseguimos reducir el tiempo de ejecución de la unión de dos conjuntos. Si unimos dos listas de elementos en una de ellas, entonces el tiempo será proporcional al número de elementos de la lista recorrida.
Sin tener en cuenta la longitud, puede ocurrir que la lista recorrida sea la más larga. El caso peor se dará cuando se combine una clase unitaria con otra en la que estén el resto de los elementos del conjunto total, el tiempo será casi el mismo que cuando se utilice simplemente un vector de pertenencia. En todos los demás casos, la ganancia de tiempo será algo mayor, pero a costa de emplear un campo más por cada elemento para almacenar las listas.
Por otra parte, si consideramos la longitud de la listas a unir, la peor situación siempre se dará cuando ambas tengan la misma longitud. Entonces el caso extremo se presentará cuando la partición conste de dos subconjuntos con la mitad de los elementos cada uno. En tal caso habrá que recorrer cualquiera de las dos listas, pero se tardará la mitad de tiempo que en recorrer el vector entero (la mejora es mayor que antes). Además, en este caso, se necesita el mismo tiempo que sin considerar la longitud de las listas, pero en todos los demás casos el tiempo de ejecución nunca será mayor, porque siempre se recorre la lista más corta.
3. Bosque de árboles La causa por la que la operación Unión() no se ejecuta en un tiempo constante es que para cada elemento se almacena el representante de su clase y esto obliga a modificar el representante de todos los elementos de uno de los cojuntos unidos. Podemos cambiar la estructura de datos para conseguir que la unión sea O(1), pero a costa de empeorar el tiempo de ejecución de la operación Encontrar(), ya que no existe una estructura de datos que permita ejecutar simultáneamente estas dos operaciones en un tiempo constante.
140 ÁRBOLES GENERADORES DE COSTE MÍNIMO La idea consiste en formar un árbol con todos los elementos de una clase y elegir la raíz como representante1. Así pues, una partición se representa como un bosque de árboles, cada uno de los cuales es un subconjunto de la partición. Un árbol se puede representar simplemente enlazando cada nodo con su padre y, por tanto, podemos almacenarlo en un vector de enteros en el que la posición i-ésima guarda el padre del elemento i o −1, si i es la raíz. De esta forma, la unión de conjuntos se convierte en la fusión de dos árboles, para lo cual no es necesario ningún recorrido, basta enlazar las dos raíces haciendo que una sea hija de la otra, con lo cual la operación Unión() claramente es O(1). Sin embargo, determinar la clase a la que pertenece un elemento implica ascender por el árbol hasta la raíz y devolver ésta, por lo que el tiempo de la operación Encontrar() será proporcional a la profundidad del nodo que representa al elemento. Como la estrategia de unión descrita puede producir un árbol de máxima profundidad, es decir, con todos los nodos en una rama (esta situación se presenta cuando repetidas veces se unen dos conjuntos, uno de ellos unitario y éste siempre se convierte en raíz), entonces Encontrar() será O(n) en el peor caso.
/*--------------------------------------*/ /* particion.h */ /*--------------------------------------*/ #ifndef _PARTICION_ #define _PARTICION_ typedef struct { int *padre; int nEltos; /* Tamaño cjto */ } tipoParticion; typedef tipoParticion *Particion; Particion CrearParticion (int n); void Union(int a, int b, Particion P); int Encontrar (int x, Particion P); void DestruirParticion (Particion P); #endif 1 Obsérvese que esta estructura es muy parecida a la anterior, la cual podemos ver como un bosque de árboles degenerados en listas con raíz en el primer elemento.
141 TEMA 6: GRAFOS /*-------------------------------------*/ /* particion1.c */ /*-------------------------------------*/ #include <stdlib.h> #include "error.h" #include "particion.h" Particion CrearParticion (int n) { Particion P; int i; P = (Particion) malloc(sizeof(tipoParticion)); if (P == NULL) ERROR("CrearParticion: No hay memoria"); P->padre = (int *) malloc(n * sizeof(int)); if (P->padre == NULL) ERROR("CrearParticion: No hay memoria"); for (i = 0; i < n; i++) P->padre[i] = -1; P->nEltos = n; return P; } void Union (int a, int b, Particion P) { P->padre[b] = a; } 142 ÁRBOLES GENERADORES DE COSTE MÍNIMO int Encontrar (int x, Particion P) { while (P->padre[x] != -1) x = P->padre[x]; return x; } void DestruirParticion (Particion P) { free(P->padre); free(P); } Conseguir reducir el tiempo de la búsqueda requiere modificar el procedimiento de unión, procurando que la altura de los árboles se mantenga lo más pequeña posible en todo momento. Existen dos enfoques para lograrlo: a) unión por tamaño: el árbol con menos nodos se convierte en subárbol del que tiene mayor número de nodos; b) unión por altura: el árbol menos alto se convierte en un subárbol del otro. Estos dos algoritmos realizan la unión de dos conjuntos en un tiempo O(1) y permiten localizar al representante de una clase en un tiempo O(log n) en el peor caso. Veámoslo para la unión por tamaño.
Inicialmente cada nodo está a profundidad 0 y como un nodo sólo puede descender de nivel cuando su clase se une a otra mayor o igual, entonces su profundidad nunca podrá ser mayor que log n (número máximo de veces que se puede unir la clase de un elemento x con otra de igual tamaño hasta obtener una partición con un único conjunto). Se puede hacer un análisis similar para la unión por altura y llegar a la misma conclusión. Por tanto, en el peor de los casos la operación Encontrar() es O(log n).
Para implementar esta estrategia es necesario guardar el tamaño o la altura de cada árbol. Podemos hacerlo almacenando en la raíz de cada árbol el valor correspondiente en negativo, así no es necesario utilizar espacio adicional y podemos seguir identificando los nodos raíces en la operación Encontrar(). No obstante, en la unión por altura almacenaremos el opuesto de la altura menos 1, para distinguir un árbol de altura 0 de un nodo cuyo padre es el nodo 0.
La implementación del TAD Partición con unión por tamaño o altura es bastante aceptable para la mayoría de las aplicaciones, pero aún podemos ganar eficiencia modificando también la operación Encontrar() para reducir la altura del árbol a la vez que se asciende hasta la raíz. El fundamento de esta idea es que la próxima vez que haya que determinar la clase a la que pertenece un elemento, algunos nodos del árbol estarán a menor profundidad y por tanto el tiempo requerido para alcanzar la raíz desde ellos será menor. Dicho de otra forma, el objetivo tras varias búsquedas es acercarnos lo máximo posible a la situación ideal 143 TEMA 6: GRAFOS en la que todos los árboles tienen altura 1 y por tanto las posteriores operaciones de búsqueda tardarán un tiempo casi constante. Esta técnica, que es independiente del método de unión que se utilice, se conoce como compresión de caminos y se implementa haciendo que todos los nodos por los que se pasa durante una búsqueda se transformen en hijos de la raíz del árbol al que pertenecen.
/*-------------------------------------*/ /* particion.c */ /*-------------------------------------*/ /* TAD Paticion mediante unión por altura y búsqueda con compresión de caminos */ #include <stdlib.h> #include "error.h" #include "particion.h" Particion CrearParticion (int n) { Particion P; int i; P = (Particion) malloc(sizeof(tipoParticion)); if (P == NULL) ERROR("CrearParticion: No hay memoria"); P->padre = (int *) malloc(n * sizeof(int)); if (P->padre == NULL) ERROR("CrearParticion: No hay memoria"); /* Inicialmente hay n árboles de altura 0 representada con -1 */ for (i = 0; i < n; i++) P->padre[i] = -1; 144 ÁRBOLES GENERADORES DE COSTE MÍNIMO P->nEltos = n; return P; } void Union (int a, int b, Particion P) { if (P->padre[b] < P->padre[a]) P->padre[a] = b; else { if (P->padre[a] = P->padre[b]) P->padre[a]--; /* el árbol resultante tiene un nivel más */ P->padre[b] = a; } } int Encontrar (int x, Particion P) { int raiz, y; raiz = x; while (P->padre[raiz] > -1) raiz = P->padre[raiz]; /* los nodos del camino de x a raiz se hacen hijos de raiz */ while (P->padre[x] > -1) { y = P->padre[x]; P->padre[x] = raiz; x = y; } return raiz; } 145 TEMA 6: GRAFOS void DestruirParticion (Particion P) { free(P->padre); free(P); } Implementación del algoritmo de Kruskall Previamente definiremos la estructura struct aris empleada en el algoritmo: struct aris { tCoste c; arista a; } void Kruskall (Grafo G, arista **T) /* Devuelve en un vector *T el conjunto de aristas que forman un árbol generador de coste mínimo de un grafo conexo G. */ { int a, i, j; Particion P; /* Partición del conjunto de vértices de G */ vertice u, v; arista e; struct aris *H; /* Vector de aristas de G ordenadas por costes */ *T = calloc(G->numVert-1, sizeof(arista)); if (*T == NULL) ERROR("Kruskall(): No hay memoria"); H = calloc(G->numVert * G->numVert, sizeof(struct aris)); if (H == NULL) ERROR("Kruskall(): No hay memoria"); 146 ÁRBOLES GENERADORES DE COSTE MÍNIMO P = CrearParticion(G->numVert); a = 0; for (u = 0; u < G->numVert; u++) { for (v = u+1; v < G->numVert; v++) if (G->Costes[u][v] != INFINITO) { H[a].a.orig = u; H[a].a.dest = v; H[a].c = G->Costes[u][v]; a++; } } qsort((void *) H, a, sizeof(struct aris), CompAris); i = 0; a = 0; while (i < G->numVert-1) { e = H[a].a; u = Encontrar(e.orig, P); v = Encontrar(e.dest, P); if (u != v) { /* Los extremos de e pertenecen a clases distintas */ Union(u, v, P); /* Incluir e en *T */ (*T)[i] = e; i++; } a++; } /* while */ DestruirParticion(P); free(H); } 147 TEMA 6: GRAFOS int CompAris (const void *a, const void *b) { if (((struct aris *) a)->c < ((struct aris *) b)->c) return -1; else if (((struct aris *) a)->c == ((struct aris *) b)->c) return 0; else return 1; } 148 ...