Descarga la aplicación para disfrutar aún más
Vista previa del material en texto
Grafos en general El origen de la palabra grafo es griego y su significado etimológico es "trazar". aparece con gran frecuencia como respuesta a problemas de la vida cotidiana, algunos ejemplos podrían ser los siguientes:un gráfico de una serie de tareas a realizar indicando su secuenciación (un organigrama), grafos matemáticos que representan las relaciones binarias, una red de carreteras, la red de enlaces ferroviarios o aéreos o la red eléctrica de una ciudad, (ver la figura 1). En cada caso es conveniente representar gráficamente el problema dibujando un grafo como un conjunto de puntos (nodos o vértices) con líneas conectándolos (arcos). De aquí se podría deducir que un grafo es básicamente un objeto geométrico aunque en realidad sea un objeto combinatorio, es decir, un conjunto de puntos y un conjunto de líneas tomado de entre el conjunto de líneas que une cada par de vértices. Por otro lado, debido a su generalidad y a la gran diversidad de formas que pueden usarse, resulta complejo tratar con todas las ideas relacionadas con un grafo. Para facilitar el estudio de este tipo de dato, a continuación, se realizará un estudio de la teoría de grafos desde el punto de vista de las ciencias de la computación, considerando que dicha teoría es compleja y amplia, aquí sólo se realizará una introducción a la misma, describiéndose el grafo como un tipo de dato y mostrándose los problemas típicos y los algoritmos que permiten solucionarlos usando un ordenador. Los grafos son estructuras de datos no lineales que tienen una naturaleza generalmente dinámica, u estudio podría dividirse en dos grandes bloques; grafos dirigidos y grafos no dirigidos (pueden ser considerados un caso particular de los anteriores). Un ejemplo de grafo dirigido lo constituye la red de aguas de una ciudad ya que cada tubería sólo admite que el agua la recorra en un único sentido, por el contrario, la red de carreteras de un país representa en general un grafo no dirigido, puesto que una misma carretera puede ser recorrida en ambos sentidos. No obstante, podemos dar unas definiciones generales para ambos tipos. A continuación, daremos definiciones de los dos tipos de grafos y de los conceptos que llevan asociados. Notación y conceptos Un grafo G es un conjunto en el que hay definida una relación binaria, es decir, G=(V,A) tal que V es un conjunto de objetos a los que denominaremos vértices o nodos y A V x V es una relación binaria a cuyos elementos denominaremos arcos o aristas. Dados x, y V, puede ocurrir que: (x, y) A, en cuyo caso diremos que x e y están unidos mediante un arco y, (x, y) A, en cuyo caso diremos que no lo están. Si las aristas tienen asociada una dirección (las aristas (x,y) y (y,x) no son equivalentes) diremos que el grafo es dirigido, en otro caso ((x,y)=(y,x)) diremos que el grafo es no dirigido. Veamos algunos conceptos sobre grafos: Diremos que un grafo es completo si A=VxV,o sea,si para cualquier pareja de vértices existe una arista que los une(en ambos sentidos si el grafo es no dirigido).El número de aristas será: Para grafos dirigidos: Para grafos no dirigidos: donde n=|V| . Un grafo dirigido es simétrico si para toda arista (x, y) A también aparece la arista (y, x) A y es antisimétrico si dada una arista (x, y) A implica que (y, x) A. Tanto a las aristas como a los vértices les puede ser asociada información, a esta información se le llama etiqueta, si la etiqueta que se asocia es un número se le llama peso, costo o longitud, un grafo cuyas aristas o vértices tienen pesos asociados recibe el nombre de grafo etiquetado o ponderado. El número de elementos de V se denomina orden del grafo, un grafo nulo es un grafo de orden cero. Se dice que un vértice x es incidente a un vértice y si existe un arco que vaya de x a y ((x, y) A),a x se le denomina origen del arco y a y extremo del mismo, de igual forma se dirá que y es adyacente a x, en el caso de que el grafo sea no dirigido si x es adyacente (resp. incidente) a y entonces y también es adyacente (resp. incidente) a x. Se dice que dos arcos son adyacentes cuando tienen un vértice común que es a la vez origen de uno y extremo del otro. Se denomina camino (algunos autores lo llaman cadena si se trata de un grafo no dirigido)en un grafo dirigido a una sucesión de arcos adyacentes: C={(v1,v2),(v2,v3),...,(vn-1,vn), vi V}. La longitud del camino es el número de arcos que comprende y en el caso en el que el grafo sea ponderado se calculará como la suma de los pesos de las aristas que lo constituyen. Un camino se dice simple cuando todos sus arcos son distintos y se dice elemental cuando no utiliza un mismo vértice dos veces. Por tanto, todo camino elemental es simple y el recíproco no es cierto. Un camino se dice Euleriano si es simple y además contiene a todos los arcos del grafo. Un circuito (o ciclo para grafos no dirigidos) es un camino en el que coinciden los vértices inicial y final, un circuito se dice simple cuando todos los arcos que lo forman son distintos y se dice elemental cuando todos los vértices por los que pasa son distintos. La longitud de un circuito es el número de arcos que lo componen. Un bucle es un circuito de longitud 1 (están permitidos los arcos de la forma (i,i) y notemos que un grafo antisimétrico carecería de ellos). Un circuito elemental que incluye a todos los vértices de un grafo lo llamaremos circuito Hamiltoniano. Un grafo se denomina simple si no tiene bucles y no existe más que un camino para unir dos nodos. Diremos que un grafo no dirigido es bipartido si el conjunto de sus vértices puede ser dividido en dos subconjuntos (disjuntos) de tal forma que cualquiera de las aristas que componen el grafo tiene cada uno de sus extremos en un subconjunto distinto, un grafo no dirigido será bipartido si y sólo si no contiene ciclos con un número de aristas par. Dado un grafo G=(V,A),diremos que G'=(V,A' ) con A' A es un grafo parcial de G y un subgrafo de G es todo grafo G'=(V',A') con V' V y A' A donde A' será el conjunto de todas aquellas aristas que unían en el grafo G dos vértices que están en V'., se podrían combinar ambas definiciones dando lugar a lo que llamaremos subgrafo parcial . Se denomina grado de entrada de un vértice x al número de arcos incidentes en él, se denota e (x). Se denomina grado de salida de un vértice x al número de arcos adyacentes a él, se denota s (x). Para grafos no dirigidos tanto el grado de entrada como el de salida coinciden y hablamos entonces de grado y lo notamos por (x). A todo grafo no dirigido se puede asociar un grafo denominado dual construido de la siguiente forma: G (V, A) ------> G' (V', A' ) donde A' está construido de la siguiente forma: si e1,e2 a A son adyacentes - -> (e1,e2) a A' con e1,e2 a V', en definitiva, para construir un grafo dual se cambian vértices por aristas y viceversa. Dado un grafo G, diremos que dos vértices están conectados si entre ambos existe un camino que los une. Llamaremos componente conexa a un conjunto de vértices de un grafo tal que entre cada par de vértices hay al menos un camino y si se añade algún otro vértice esta condición deja de verificarse. Matemáticamente se puede ver como que la conexión es una relación de equivalencia que descompone a V en clases de equivalencia,cada uno de los subgrafos a los que da lugar cada una de esas clases de equivalencia constituiría una componente conexa. Un grafo diremos que es conexo si sólo existe unacomponente conexa que coincide con todo el grafo. Búsqueda de caminos mínimos en grafos Supongamos que tenemos un grafo dirigido sencillo etiquetado G = {V, A} de grado n, V = {1,..., n} y con etiquetas en los arcos no negativas. Entre un vértice y todos los demás vértices del grafo Nos planteamos el problema de dado un vértice determinar el camino de costo mínimo de ese vértice a todos los demás vértices de V. Para resolver el problema aplicaremos un algoritmo debido a Dijkstra que esencialmente era a partir de un conjunto S de vértices (S V) cuya distancia más corta desde el origen es conocida y en cada paso se agrega un nuevo vértice v a S cuya distancia a su vez desde el origen sea la más corta posible. Si la suposición que hacíamos de que las etiquetas son siempre no negativas se cumple, puede comprobarse que siempre es posible encontrar un camino más corto desde el origen y un vértice v que sólo pase a través de los vértices de S (camino "inherente"). Si se utiliza además un vector D donde se almacena las longitudes de los caminos inherentes más cortos a cada vértice, una vez que S incluya a todos los vértices, todos los caminos son inherentes de forma que D contendrá la distancia más corta del origen a cada vértice. Notación: Origen = vértice 1 (obviamente esto no es una condición) Sea S un vector de n componentes representando el conjunto de vértices cuya distancia más corta desde el origen ya es conocida. D es un vector de n componentes donde D [i] indica en cada paso la distancia más corta entre el origen y el vértice i: a. bien mediante el camino directo si existe el arco (i,j). b. bien a través de los vértices de S (de los que se conoce su distancia más corta al origen), Al final D contendrá el costo del camino mínimo entre el origen y cada vértice. C es la matriz de costos del grafo. C [1,i] representa el costo del arco (1,i). Si el arco no existe, se le asigna un valor fuera de rango () P es un vector de dimensión n a través del cual reconstruiremos el camino más corto del origen al resto de los vértices. Así P[i] contiene el vértice inmediato anterior a i en el camino más corto. Inicialmente es evidente que S = {1} y i D[i] = C[1 i] con P[i] = 1 Con estas premisas el algoritmo de Dijkstra se puede esquematizar así: Algoritmo Dijkstra() 1. S = {1} 2. for (i = 2; i<=n; i++ ) { D[i] = C[I,i] P[i] = 1 } 3. while ( S V ) { elegir w V-S / D[w] sea mínimo S= S U{w} for (cada vértice v V -S) if (D[v] > D[w] + C[w, v]) { D[v] = D[w] + C[w, v] P[v] = w } } Veamos un ejemplo del algoritmo, supongamos que queremos encontrar los caminos mínimos del vértice 1 al resto en el grafo siguiente: En principio: S= {1} D [2] = 60; D [3] = 10; D [4] = 100; D [5] = P[i] = 1 i Iteraci6n 1: V -S = {2,3,4,5} w = 3 -> S = {1,3} -> V -S = {2,4,5} D [2] = min(D [2] , D [3] + C [3,2]) = min(60, 50) = 50 -> P [2] = 3 D [4] = min(D [4] , D [3] + C [3,4]) = min(100, 40) = 40 -> P [4] = 3 D [5] = min(D [5] , D [3] + C [3,5]) = min (,) = Así D [2] = 50; D [4] = 40; D [5] = 00; P [2] = 3; P [4] = 3; P [5] = 1 Iteraci6n 2: V -S = {2,4,5} w = 4 -> S = {1,3,4} -> V- S = {2,5} D [2] = min(D [2] , D [4] + C [4,2]) = min(50, 00) = 50 -> P [2] = 3 D [5] = min(D [5] , D [4] + C [4,5]) = min( 00,60) = 60 -> P [5] = 4 Así D [2] = 50; D [5] = 60; p [2] = 3; P [5] = 4 Iteraci6n 3: V -S = {2,5} w = 2 -> S = {1,3,4,2} -> V- S = {5} D [5] = min(D [5] , D[2] + C [2,5]) = min(60, 55) = 55 -> P [5] = 2 Así D [5] = 55; P[5] = 2 Finalmente w = 5 -> S = {1,3,4,2,5} -> FIN DEL ALGORlTMO Para reconstruir el camino más corto del origen a cada vértice, se asignan los predecesores en orden inverso. Por ejemplo, si se quiere conocer el camino desde el origen al vértice S, se, tiene que: P [5] = 2-+ P [2] = 3-+ P [3] = 1 siendo por tanto el camino (1,3,2,5) con costo 55 . Aunque la implementación de este algoritmo es simple si la realizamos en base a una matriz de adyacencia, en la práctica se utiliza normalmente una implementación en base a listas de adyacencia. La razón de esta elección es que en la primera la eficiencia es O(n2) para cualquier grafo; sin embargo la mayoría de los grafos que nos encontramos en la práctica tiene un número de aristas bastante pequeño (grafos que podemos denominar dispersos o no densos) y por tanto el uso de listas de adyacencia se presenta como una solución más eficiente. Para conseguir una mejor eficiencia en esta implementación del algoritmo de Dijkstra se ha echado mano de una estructura de datos formada por un APO que tiene como etiqueta los vértices del grafo y como clave el coste de ir desde el vértice inicial en el problema a ese vértice de tal forma que obtener el vértice con mínimo coste sería O (log n). Entre cada par de vértices del grafo En lugar de buscar los caminos mínimos de un vértice a los demás nos podemos plantear buscar el camino más corto entre cualquier pareja de vértices, es decir, dado un grafo dirigido etiquetado G = {V, A} en el que las etiquetas son no negativas encontrar el camino de longitud " más corta entre dos vértices cualesquiera de ese grafo. Podría pensarse, para resolver el problema, en aplicar el algoritmo de Dijkstra n veces, una por vértice, pero en lugar de eso, aplicaremos un nuevo algoritmo creado por Floyd que va encontrando los caminos de forma iterativa. Notación: V = {1, ..., n} conjunto de vértices. A es una matriz de tamaño n x n en la que se calculará en cada Aij la longitud más corta del camino que va de i a j. P es una matriz de tamaño n x n que utilizaremos para recuperar los caminos más cortos. C es una matriz de dimensión n x n conteniendo los costos de los arcos. Si no existe arco de un vértice i a otro j el correspondiente valor C [i,j] = . Inicialmente A [i,j] = { C[i,j] si i j, 0 si i = j }. A continuación se itera sobre A n veces de forma que tras hacer la k iteración A[i,j] tiene un valor correspondiente a la longitud más pequeña de cualquier camino de i a j que no pase por un vértice de índice mayor que k, es decir, cualquier vértice intermedio entre i y j (extremos del camino) ha de ser menor o igual que k. Por tanto en cada iteración k se usará la siguiente fórmula: Ak [i,j] = min(Ak-1 [i,j] ,Ak-1 [i,k] +Ak-1l [k,j])3 es decir, cada Ak [i,j] se obtiene comparando Ak-1 [i,j], el coste de ir de i a j sin pasar por k o cualquier vértice de índice mayor, con Ak-1 [i,k] +Ak-1 [k,j], el costo de ir primero de i a k y después de k a j sin pasar por un vértice de índice mayor que k de forma que si el paso por el vértice k produce un camino i más corto que el indicado por Ak-1 [i,j], se elige ese coste para Ak [i,j] .Así mismo, cada iteración P [i,j] contendrá el vértice k que permitió al algoritmo de Floyd encontrar el valor más pequeño de A[i, j] .Inicialmente P[i,j] = 0, puesto que inicialmente el camino más corto de i a j es el propio arco. El algoritmo sería el siguiente: Algoritmo Floyd() 1. for (i = 1; i <= n; i++ ) for (j = 1; j <=n ; j++) { A[i, j]= C[i, j] P[i, j]= 0 } 2. for (i = 1; i <= n; i++) A[i, i]= 0 3. for (k = 1; k <= n; k++) for (i = 1; i <= n; i++) for (j = l; j <=n ; j++) if ((i j) && (A[i,k]+A[k,j] < A[i, j])) { A[i, j]= A[i, k]+A[k, j] P[i, j]= k } Veamos un ejemplocon el siguiente grafo: A0 = P0 = A1 = P1 = A2 = P2 = A3 = P3 = 0 7 * 5 * 0 2 * 6 5 * 0 * * * 3 0 * * 7 * 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 7 * 5 * 0 2 * 6 5 [12] 0 10 * * 3 0 * * 7 * 0 0 0 0 0 0 0 0 0 0 0 0 [1] 0 [1] 0 0 0 0 0 0 0 0 0 0 0 0 7 [9] 5 [13] 0 2 * 6 5 12 0 10 [18] * 3 0 * * 7 * 0 0 0 [2] 0 2 0 0 0 0 0 0 1 0 1 [2] 0 0 0 0 0 0 0 0 0 0 0 7 9 5 13 [7] 0 2 [12] 6 5 12 0 10 18 A4 = P4 = A5 = P5 = A5 =A4 con la que el algoritmo termina. Con objeto de recuperar los caminos de cualquier vértice i a cualquier otro j puede usarse el siguiente procedimiento: Algoritmo recuperar_camino (i, j de tipo vértice) 1.k= P[i, j] 2. if (k 0) { recuperar_camino (i, k) escribir (k) recuperar_camino (k, j) [8] [15] 3 0 [21] [12] [19] 7 [17] 0 0 0 2 0 2 3 0 0 3 0 0 1 0 1 2 [3] [3] 0 0 [3] [3] [3] 0 [3] 0 0 7 [8] 5 13 7 0 2 12 6 5 12 0 10 18 8 15 3 0 21 12 19 7 17 0 0 0 [4] 0 2 3 0 0 3 0 0 1 0 1 2 3 3 0 0 3 3 3 0 3 0 0 7 8 5 13 7 0 2 12 6 5 12 0 10 18 8 15 3 0 21 12 19 7 17 0 0 0 4 0 2 3 0 0 3 0 0 1 0 1 2 3 3 0 0 3 3 3 0 3 0 } Por ejemplo, el camino más corto entre los vértices 2 y 4 se determinaría llamando a: recuperar-camino (2,4) k = 3 -> recuperar-camino (2,3) -> 0 [3] recuperar-camino (3,4) -> k = 1 -> recuperar-camino (3,1) -> 0 [1] recuperar-camino (1,4) -> 0 con la que el camino es (2,3,1,4) con costo 12. Primitivas del grafo Dentro del TDA Grafo hemos propuesto las siguientes primitivas: Grafo ( ) -> constructor del grafo, reserva los recursos e inicializa el grafo a vacío. Nodo LocalizaLabel (const Tbase e) -> localiza la etiqueta, devuelve el nodo asociado a la etiqueta e. PARÁMETROS : e -> etiqueta asociada al nodo a encontrar. Bool ExisteArco (nodo o, nodo d) -> nos dice si existe un arco, devuelve true si existe el arco o false en otro caso. PARÁMETROS: o -> nodo origen del arco. d -> nodo destino del arco. Int GrafoVacio () -> devuelve 0 si el grafo este vacío, si no devuelve 1 en otro caso. Float EtiqArco (nodo o, nodo d) -> devuelve la etiqueta asociada a un arco, es decir el peso del arco. PARÁMETROS: o -> nodo origen del arco. d -> nodo destino del arco. Void InsertarNodo (const Tbase dato) -> inserta un nodo nuevo en el grafo. PARÁMETROS: dato -> etiqueta del nuevo nodo. Void InsertarArco (nodo o, nodo d, int valor) -> inserta un arco entre el nodo o y el d, asociado al arco le podemos dar un valor o peso. PARÁMETROS: o -> nodo origen del arco. d -> nodo destino del arco. valor -> peso del del arco. Void BorrarArco (nodo o, nodo d) -> borra el arco existente entre los nodos o y d. PARÁMETROS: o -> nodo origen del arco. d -> nodo destino del arco. Void DesconectarNodo (nodo a_eliminar) -> elimina un nodo del grafo receptor, todos los arcos que entran o salen del nodo a eliminar tambien desaparecen. PARÁMETROS: a_eliminar -> nodo a eliminar del grafo. Void CopiarGrafo (Grafo gr) -> copia el grafo gr en el receptor. PARÁMETROS : gr -> grafo a copiar. ~Grafo () -> destructor, destruye el grado liberando todos los recursos. Ejemplos de uso Imprimir un grafo Esta función nos imprime en pantalla el contenido de un grafo con etiquetas enteras. Grafo<int> g; Void Imprimir () { nodo n1, n2; arco a; int cont = 1; printf (“Arcos : \n”); for (n1=g.inicio; n1 != 0; n = n->sig) { if (n1->etiqueta != 0) { for (a = n1->ady; a != 0; a = a->sig) { n2 = a->destino; printf (“%3d -> %3d (%d)”, n1->etiqueta, n2->etiqueta, a- >valor); } if (cont % 4) printf (“,”); else printf (“ \n ”); cont++; } } if ((cont % 4) != 0) printf (“ \n ”); } Implementación del Grafo Existen diversas representaciones de naturaleza muy diferente que resultan adecuadas para manejar un grafo,y en la mayoría de los casos no se puede decir que una sea mejor que otra siempre ya que cada una puede resultar más adecuada dependiendo del problema concreto al que se desea aplicar, así,si existe una representación que es peor que otra para todas las operaciones excepto una es posible que aún así nos decantemos por la primera porque precisamente esa operación es la única en la que tenemos especial interés en que se realice de forma eficiente. A continuación veremos dos de las representaciones más usuales: Matriz de adyacencia Grafos dirigidos G=(V,A) un grafo dirigido con |V|=n .Se define la matriz de adyacencia o booleana asociada a G como Bnxn con : b i,j = { 1 si (i,j) A, 0 en otro caso } Como se puede ver se asocia cada fila y cada columna a un vértice y los elementos bi,j de la matriz son 1 si existe el arco (i,j) y 0 en caso contrario. Grafos no dirigidos G=(V,A) un grafo no dirigido con |V|=n .Se define la matriz de adyacencia o booleana asociada a G como Bnxn con: b i,i = { 1 si (i,j) A, 0 en otro caso } La matriz B es simetrica con 1 en las posiciones ij y ji si existe la arista (i,j). Veamos ahora un ejemplo: Si el grafo es etiquetado, entonces tanto bi,j como bi,j representan al coste o valor asociado al arco (i,j) y se suelen denominar matrices de coste. Si el arco (i,j) no pertenece a A entonces se asigna bi,j o bi,j un valor que no puede ser utilizado como una etiqueta valida. La principal ventaja de la matriz de adyacencia es que el orden de eficiencia de las operaciones de obtención de etiqueta de un arco o ver si dos vértices están conectados son independientes del número de vértices y de arcos, por el contrario, existen dos grandes inconvenientes: Es una representación orientada hacia grafos que no modifica el número de sus vertices ya que una matriz no permite que se le o supriman filas o columnas. Se puede producir un gran derroche de memoria en grafos poco densos (con gran número de vértices y escaso número de arcos). Para evitar estos inconvenientes se introduce otra representación: las listas de adyacencia. Lista de adyacencia En esta estructura de datos la idea es asociar a cada vertice i del grafo una lista que contenga todos aquellos vértices j que sean adyacentes a él. De esta forma sóllo reservará memoria para los arcos adyacentes a i y no para todos los posibles arcos que pudieran tener como origen i, el grafo, por tanto, se representa por medio de un vector de n componentes (si |V|=n) donde cada componente va a ser una lista de adyacencia correspondiente a cada uno de los vertices del grafo. Cada elemento de la lista consta de un campo indicando el vértice adyacente. En caso de que el grafo sea etiquetado, habrá que añadir un segundo campo para mostrar el valor de la etiqueta. Veamos un ejemplo a continuación: Esta representación requiere un espacioproporcional a la suma del número de vértices, más el número de arcos, y se suele usar cuando el número de arcos es mucho menor que el número de arcos de un grafo completo. Una desventaja es que puede llevar un tiempo O(n) determinar si existe un arco del vértice i al vértice j, ya que puede haber n vértices en la lista de adyacencia asociada al vértice i. Mediante el uso del vector de listas de adyacencias sólo se reserva memoria para los arcos existentes en el grafo con el consiguiente ahorro de la misma, sin embargo, no permite que haya vértices que puedan ser añadidos o suprimidos del grafo, debido a que la dimensión del grafo debe ser predeterminado y fija. Para solucionar esto se puede usar una lista de listas de adyacencia, sólo los vértices del grafo que sean origen de algún arco aparecerán en la lista, de esta forma se pueden añadir y suprimir arcos sin desperdicio de memoria ya que simplemente habrá que modificar la lista de listas para reflejar los cambios. Como puede verse en el ejemplo de las figuras anteriores tanto el vector de listas de adyacencias como en la lista de listas se ha razonado en función de los vértices que actúan como orígenes de los arcos. Análogamente se podía haber hecho con los vértices destino, y combinando ambas representaciones podría pensarse en utilizar dos vectores de listas de adyacencia o dos listas de listas de adyacencia. Nuestra implementación es con listas de adyacencia donde sólo usamos los arcos adyacentes y no los incidentes, es una simplificación considerable ya que no tenemos mucho tiempo. Usamos una estructura nodo que simula lo que sería un vértice o nodo del grafo, contiene un elemento de tipo Tbase que es la etiqueta, un entero nodo que identifica unívocamente al nodo, un puntero sig al siguiente nodo de la lista y un puntero ady a la lista de adyacencia del nodo en cuestión. Podría tener como hemos comentado antes un puntero a la lista de arcos incidentes, pero hemos simplificado el problema. También tenemos otra estructura para simular el comportamiento de un arco o arista del grafo, esta estructura tiene un valor que es el peso que tiene el arco tratado, un puntero sig al siguiente arco de la lista, un puntero al nodo origen del arco y otro al destino del mismo. El grafo se compone de un nodo inicio que es el pirmer nodo del grafo, del cual cuelgan el resto de los nodos y del nnodos que nos da el número de nodos del grafo. Para el grafo de la siguiente figura: La representación según nuestra implementación sería: Vamos a ver la especificación tanto de la función de abstracción como de la invariante de la representación del grafo: Función de Abstracción Dado el objeto del tipo rep r, r = {inicio, nnodos}, el objeto abstracto al que representa es: g = < r.inicio, r.inicio->sig, r.inicio->sig->sig ..., r.inicio->sig... nnodos...->sig > Invariante de Representación 0 <= r.nnodos < 5 De manera que la clase Grafo en C++ tendría el siguiente aspecto: Class Grafo { Public: struct nodo { Tbase etiqueta; int nodo; struct nodo *sig; struct arco *ady; } struct arco { int valor; struct nodo *origen; struct nodo *destino; struct arco *sig; } struct nodo *inicio; int nnodos; Grafo (); nodo LocalizaLabel (const Tbase e); bool ExisteArco (nodo o, nodo d); int GrafoVacio (); float EtiqArco (nodo o, nodo d); void InsertarNodo (const Tbase dato); void InsertarArco (nodo o, nodo d, int valor); void BorrarArco (nodo o, nodo d); void DesconectarNodo (nodo a_eliminar); void CopiarGrafo (Grafo gr); ~Grafo(); } Debemos aclarar un par de cosas sobre la implementación en C++; sólo tenemos miembros públicos para facilitar el acceso a los mismos de forma rápido, aunque esta no sea la filosofía más apropiada tratándose de un lenguaje con orientación a objetos, esto se ha hecho así para simplificar en gran medida tanto las primitivas como el código asociado a ellas ya que en la confección de este tutorial no hemos tenido tiempo suficiente para hacer una clase Grafo como dios manda. Si se quiere hacer una clase grafo donde tengamos miembros privados como inicio y nnodos, debemos elaborar primitivas que nos devuelvan cada elemento de una estructura nodo y arco en este caso, serían muchas primitivas pequeñas, luego el resto (las que hemos elegido aquí) habría que modificarlas en base a las nuevas primitivas elaboradas. Pasemos ahora a ver la implementación de cada una de las primitivas enunciadas con anterioridad en C++: template <class Tbase> Grafo<Tbase>::Grafo() { inicio = new nodo; inicio->etiqueta = 0; inicio->nodo = 1; inicio->ady = 0; inicio->sig = 0; } template <class Tbase> nodo Grafo<Tbase>::LocalizaLabel(const Tbase e) { nodo n; int enc=0; for (n=this.inicio; n!=0 && !enc; ) { if (n->etiqueta == e) enc = 1; else n = n->sig; } return n; } template <class Tbase> bool Grafo<Tbase>::ExisteArco(nodo o, nodo d) { arco a; a=o->ady; while (a!=0) { if ((a->origen==o) && (a->destino==d)) return true; else a = a->sig; } return false; } template <class Tbase> int Grafo<Tbase>::GrafoVacio() { return(this.inicio == 0); } template <class Tbase> float Grafo<Tbase>::EtiqArco(nodo o, nodo d) { arco a; a=o->ady; while (a!=0) { if ((a->origen == o) && (a->destino == d)) return (a->valor); else a = a->sig; } return 0; } template <class Tbase> void Grafo<Tbase>::InsertarNodo(const Tbase dato) { nodo aux,p; aux = new nodo; p=this.inicio; while(p->sig != 0) p = p->sig; aux->etiqueta = dato; aux->nodo = (p->nodo)+1; aux->ady = 0; aux->sig = 0; p->sig = aux; (this.nnodos)++; } template <class Tbase> void Grafo<Tbase>::InsertarArco (nodo , nodo d, int valor) { arco aux; aux = new arco; aux->origen = o; aux->destino = d; aux->valor = valor; aux->sig= o->ady; o->ady = aux; } template <class Tbase> void Grafo<Tbase>::BorrarArco(nodo o, nodo d) { arco a,ant; int enc=0; if (o->ady==0) return; else if (o->ady->destino==d) { a = o->ady; o->ady = a->sig; delete a; } else { ant = o->ady; a = ant->sig; while (!enc && (a!=0)) { if (a->destino==d) enc=1; else { a = a->sig; ant = ant->sig; } } if (a==0) return; else { ant->sig = a->sig; delete a; } } } template <class Tbase> Void Grafo<Tbase>::DesconectarNodo(nodoa_eliminar) { Grafo g_nd; nodo n,org,dst,o,d; arco a; g_nd = new Grafo; for (n=this.inicio; n!=0; n=n->sig) g_nd.InsertarNodo(n->etiqueta); for (n=this.inicio; n!=0; n=n->sig) for (a=n->ady; a!=0; a=a->sig) { org = a->origen; dst = a->destino; o = g_nd.LocalizaLabel(org->etiqueta); d = g_nd.LocalizaLabel(dst->etiqueta); if ((org!=a_eliminar) && dst!=a_eliminar)) g_nd.InsertarArco(o,d,a->valor); } this.CopiarGrafo(g_nd); } template <class Tbase> Void Grafo<Tbase>::CopiarGrafo(Grafo gr) { nodo n,org,dest,o,d; arco a; this.Destruir(); this = new Grafo; for (n=gr.inicio; n!=0; n=n->sig) this.InsertarNodo(n->etiqueta); for (n=gr.inicio; n!=0; n=n->sig) for (a=n->ady; a!=0; a=a->sig) { org = a->origen; dest = a->destino; o = g_nd.LocalizaLabel(org->etiqueta); d = g_nd.LocalizaLabel(dest->etiqueta); this.InsertarArco(o,d,a->valor); } } template <class Tbase> Grafo<Tbase>::~Grafo() { nodo n; arco aux; while ((this.inicio)->sig != 0) { n = (this.inicio)->sig; while (n->ady != 0) { aux = n->ady; n->ady = aux->sig; delete aux; } (this.inicio)->sig = n->sig; delete n; } delete (this.inicio); } Algoritmos de Búsqueda en Anchura (BFS) y Búsqueda en Profundidad (DFS) En anteriores publicaciones pudimos aprender algunas cosas elementales acerca de la teoría de grafos. En esta oportunidad aprenderemos a realizar Algoritmos de Búsqueda en Anchura (BFS, por sus siglas en inglés: https://www.bibliadelprogramador.com/2014/04/algoritmos-de-busqueda-en-anchura-bfs-y.html https://www.bibliadelprogramador.com/2014/04/algoritmos-de-busqueda-en-anchura-bfs-y.html “Breadth-First Search”) y de Búsqueda en Profundidad (DFS, por sus siglas en inglés: “Depth-First Search”). Ambas técnicas constituyen métodos sistemáticos para visitar todos los vértices y arcos del grafo, exactamente una vez y en un orden específico predeterminado, por lo cual podríamos decir que estos algoritmos simplemente nos permiten hacer recorridos controlados dentro del grafo con algún propósito. Siendo la búsqueda una de las operaciones más sencillas y elementales en cualquier estructura de datos, se han estandarizado el uso de estos algoritmos para ello, por lo que se conocen como algoritmos de búsqueda. Sin embargo, es importante resaltar que pueden utilizarse para muchísimas otras operaciones con grafos que no necesariamente incluyan la búsqueda de algún elemento dentro del grafo. Manejo de grafos El recorrido que se hace con este tipo de algoritmos es a cíclico por lo que se dice que el recorrido es de árbol, sin embargo la entrada sigue siendo un grafo. Un árbol es un grafo conexo a cíclico, y para cada par de vértices distintos sólo existirá un único camino. Un árbol está conformado por vértices (nodos) y arcos (aristas), al igual que un grafo. Si existe un camino de longitud 1 entre dos nodos en un árbol uno de ellos será el padre y el otro será el hijo. Los vértices pueden ser de tres tipos: Raíz: Es el único nodo que no tiene padre. Nodo Interno: Es aquel que posee al menos un hijo. Nodo Externo: Es aquel que no tiene hijos, también se le denomina hoja. Haremos nuestra implementación en el lenguaje de programación C++, utilizando Visual Studio 10.0. Lo primero que hay que hacer tanto en BFS como en DFS es crear toda la estructura base que nos permitirá manejar el grafo. Utilizaremos listas de adyacencia para representar el grafo, en este caso trabajaremos con un grafo no dirigido y no ponderado. En el tutorial antes mencionado se explica en detalle las consideraciones base para manejar el https://4.bp.blogspot.com/-pDgfo8XsGQw/U2A7Z8OvP6I/AAAAAAAAA2Q/7idoU7Ykmw4/s1600/foto1.png grafo, puedes consultarlo como referencia para la creación de esta primera parte del código. En ambos casos el recorrido se inicia desde un nodo raíz, elegido arbitrariamente (En el caso de que el grafo sea un árbol, la raíz ya está determinada). También se debe indicar el elemento que ha de ser buscado. Es fundamental marcar cada uno de los nodos que han sido visitados, de lo contrario podremos repetir los nodos una y otra vez, con lo cual no podremos salir nunca de nuestro algoritmo. Esta característica es lo que garantiza precisamente que el recorrido sea a cíclico. Para ello usaremos un vector de booleanos, que inicializamos en falso, y la declararemos como una variable global. vector<bool> mark; mark = vector<bool>(graph.V, false); La diferencia entre BFS y DFS es el orden en que ha de recorrerse el grafo, para algunos problemas no tiene ninguna relevancia cuál de los dos algoritmos ha de aplicarse, pero en otros casos esta elección es crucial. Algoritmo de Búsqueda en Anchura (BFS) La búsqueda en anchura supone que el recorrido se haga por niveles. Para entender más fácilmente de que se trata, hemos indicado en la siguiente imagen un grafo ejemplo en donde cada color representa un nivel, tomando como raíz o nodo inicial el que tiene el número 1. El recorrido se hará en orden numérico de forma consecutiva hasta llegar al nodo número 7. La estrategia que usaremos para garantizar este recorrido es utilizar una cola que nos permita almacenar temporalmente todos los nodos de un nivel, para ser procesados antes de pasar al siguiente nivel hasta que la cola esté vacía. queue<State> q; q.push(State(raiz)); mark[raiz] = true; https://3.bp.blogspot.com/-9P7rZ69o7M4/U2A8E9IfsvI/AAAAAAAAA2Y/A6twySfWy4k/s1600/foto2.png Inmediatamente después de declarar nuestra estructura de cola, agregamos el nodo raíz para poder iniciar el proceso de búsqueda. Esto se hace porque necesitamos tener al menos un elemento en nuestra cola, dado que la condición de salida es que la cola esté vacía. Luego marcamos el nodo raíz como visitado. while(!q.empty()) { State st = q.front(); q.pop(); if (st.node == nodo){ printf("'%d'n",nodo); return; }else printf("%d ",st.node); int T = (int)graph.G[st.node].size(); for(int i = 0; i < T; ++i) { if (!mark[graph.G[st.node][i].node]) { mark[graph.G[st.node][i].node] = true; q.push(State(graph.G[st.node][i].node)); } } } Cada vez que visitamos un nodo, lo desencolamos e imprimimos por pantalla el valor del nodo para ir indicando el recorrido. Luego agregamos a la cola todos los nodos del siguiente nivel y los marcamos como visitados antes de comenzar el ciclo de nuevo, en el que procesaremos estos nuevos nodos que hemos agregado a la cola. Si encontramos el elemento buscado, la función BFS retornará al “main” e imprimirá entre comillas simples el valor del elemento buscado. Podríamos hacer otro tipo de mensaje para indicar que el elemento ha sido encontrado, calcular el número de nodos que fueron visitados o incluso generar un mensaje especial en el caso de que el elemento no haya sido encontrado. Por el momento la función ha quedado lo más sencilla posible para enfocarnos únicamente en cómo hacer el recorrido. Algoritmo de Búsqueda en Profundidad (DFS) En el caso de la búsqueda en profundidad lo que se quiere es recorrer desde la raíz hasta los nodos extremos u hojas por cada una de las ramas. En este casolos niveles de cada nodo no son importantes. En la siguiente imagen podemos ver el orden en que se hace el recorrido desde el nodo raíz, indicado con el número uno, hasta el nodo número siete. La forma más intituiva de hacer este algoritmo es de forma recursiva, de lo contrario tendríamos que usar en lugar de una cola una pila, pero con la recursión nos ahorramos la necesidad de utilizar esta estructura explícitamente y en lugar de ello nos valemos de la pila de recursión. En este caso pasaremos por parámetro el nodo a buscar y el nodo actual (El nodo que está siendo visitado en cada ambiente de recursión), que en la primera llamada será el nodo raíz. void DFS(const int nodo, int nodoAct){ mark[nodoAct] = true; if(mark[nodo]==true) return; if (nodoAct == nodo){ printf("'%d'n",nodo); return; }else if(mark[nodo]==true) return; else{ printf("%d ",nodoAct); int T = (int)graph.G[nodoAct].size(); for(int i = 0; i < T; ++i) { if (!mark[graph.G[nodoAct][i].node]) DFS(nodo, graph.G[nodoAct][i].node); } } } En cada llamada recursiva marcaremos el nodo actual como visitado y luego verificamos si es el nodo buscado para salir de la recursión, este será nuestro caso base. De no ser el nodo requerido, se hace la llamada recursiva con todos los nodos hijos del nodo actual, pero en este caso, a diferencia del recorrido BFS, no se visitarán todos los hijos de forma consecutiva, sino que el algoritmo recorrerá en profundidad hasta llegar a un nodo extremo o nodo hoja, antes de retornar al ambiente de recursión en donde se encuentran los otros nodos hijos. https://3.bp.blogspot.com/-GFKwcaroQBE/U2A8cGPg8yI/AAAAAAAAA2g/2qc-I7I8EgE/s1600/foto+3.png El orden en que se eligen las ramas en un recorrido DFS está determinado por el tipo de recorrido de procesamiento de árbol que se haya elegido, estos pueden ser: Pre-orden: Se procesa primero la raíz, luego la rama izquierda y luego las ramas siguientes hasta llegar a la que se encuentra más a la derecha. Post-orden: Se procesa el árbol desde las ramas izquierdas hasta la que se encuentra más a la derecha. Finalmente se procesa el nodo raíz Simétrico o In-orden: Se procesa la rama de la izquierda, luego el nodo raíz y luego la rama derecha. En este caso el recorrido es Pre-orden. Tips: 1. La búsqueda en anchura se recomienda cuando lo que se necesita es buscar el camino más corto en grafos no ponderados. 2. La búsqueda en profundidad se puede utilizar para detectar ciclos en un grafo, determinar si un grafo es conexo o no y cuántas componentes conexas tiene, determinar puntos de articulación y biconexión de grafos, entre otras cosas. 3. Cuando no tiene importancia el orden en que visitemos los nodos y aristas del grafo, se puede usar cualquiera de los dos algoritmos. Un grafo de ejemplo Supongamos que tenemos el siguiente grafo leído: Queremos ver el orden en que cada algoritmo ha de recorrer el grafo. Llamaremos a la función BFS en el main y posteriormente la función DFS. Tomaremos el nodo ’2′ como nuestro nodo raíz para ambos algoritmos y la salida obtenida será: https://4.bp.blogspot.com/-yQwWm9EhG2s/U2A9Ld9t7DI/AAAAAAAAA2s/9hThcsu6PyM/s1600/foto4.png https://1.bp.blogspot.com/-yH0DatZdVfM/U2BAreVJeDI/AAAAAAAAA3I/4l3Pl_cMGu4/s1600/foto5.png Conclusiones Los algoritmos de búsqueda BFS y DFS son una de las herramientas básicas a la hora de trabajar con grafos. No sólo podremos usarlos para recorrer grafos o buscar elementos, sino que también podemos adaptarlos y mejorarlos para resolver de manera eficiente cualquier tipo de situaciones que podamos moldear como un grafo o un árbol. En los próximos tutoriales aprenderemos otras técnicas de programación muy útiles que podrás aplicar a diferentes situaciones. Estaremos atentos para resolver todas tus dudas. No olvides seguirnos y manifestar cualquier inquietud, queja o sugerencia. Para más información, dejo un vídeo explicativo por si tienen más dudas sobre el tema. Bibliografias https://www.bibliadelprogramador.com/2018/06/implementado-algoritmos-de-grafos.html http://decsai.ugr.es/~jfv/ed1/c++/cdrom4/paginaWeb/grafos.htm https://www.bibliadelprogramador.com/2014/04/algoritmos-de-busqueda-en-anchura-bfs- y.html https://www.bibliadelprogramador.com/2018/06/implementado-algoritmos-de-grafos.html http://decsai.ugr.es/~jfv/ed1/c++/cdrom4/paginaWeb/grafos.htm https://www.bibliadelprogramador.com/2014/04/algoritmos-de-busqueda-en-anchura-bfs-y.html https://www.bibliadelprogramador.com/2014/04/algoritmos-de-busqueda-en-anchura-bfs-y.html
Compartir