Docsity
Docsity

Prepara tus exámenes
Prepara tus exámenes

Prepara tus exámenes y mejora tus resultados gracias a la gran cantidad de recursos disponibles en Docsity


Consigue puntos base para descargar
Consigue puntos base para descargar

Gana puntos ayudando a otros estudiantes o consíguelos activando un Plan Premium


Orientación Universidad
Orientación Universidad


grafos, Apuntes de Informática

Asignatura: Estructura de datos, Profesor: maty maty, Carrera: I. T. Infor. Sistemas, Universidad: UCA

Tipo: Apuntes

2014/2015

Subido el 01/03/2015

cpargela
cpargela 🇪🇸

4.5

(2)

1 documento

1 / 25

Toggle sidebar

Esta página no es visible en la vista previa

¡No te pierdas las partes importantes!

bg1
pf3
pf4
pf5
pf8
pf9
pfa
pfd
pfe
pff
pf12
pf13
pf14
pf15
pf16
pf17
pf18
pf19

Vista previa parcial del texto

¡Descarga grafos y más Apuntes en PDF de Informática solo en Docsity!

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 ≤ ik. 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

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;

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.

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); }

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); }

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); }

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; }

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.

Á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;

Á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 , bn −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 ≤ xn −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.

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.

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; }

Á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