15. Cálculo numérico elemental
16.3. Recorriendo un grafo
Así como es importante «recorrer» una lista (por ejemplo para encontrar el máximo o la suma), también es importante recorrer un grafo, «visitando» todos sus vértices en forma ordenada, evitando visitar vértices ya visitados, y siempre «caminando» por las aristas del grafo. Exactamente qué hacer cuando se visita un vértice dependerá del problema, y en general podemos pensar que «visitar» es sinónimo de «procesar».
En una lista podemos considerar que los «vecinos» de un elemento son su antecesor y su sucesor (excepto el primero y el último), y empezando desde el primer elemento podemos recorrer linealmente la lista, mirando al sucesor de turno. En cambio, en un grafo un vértice puede tener varios vecinos, y el recorrido es más complicado.
No habiendo un «primer vértice» como en una lista, en un grafo elegimos un vértice en donde empezar el recorrido, llamado raíz, y luego visitamos a los vecinos, luego a los vecinos de los vecinos, etc., conservando información sobre cuáles vértices ya fueron considerados a fin de no visitarlos nuevamente. Con este fin, normalmente usaremos una listapadrede modo quepadre[v]nos dice desde qué vértice hemos venido a visitarlo. Para indicar el principio del recorrido, ponemospadre[raiz] = raiz,(2)y
cualquier otro vértice tendrápadre[v]6=v.
16.3. Recorriendo un grafo Pág. 131
Algoritmo recorrido Entrada: un grafo G= (V, E ) y un vértice r ∈ V (la raíz)
Salida: la lista de vértices que se pueden alcanzar desde r «visitados» Poner Q= {r }
mientras Q6= ;: sea i∈ Q sacar i de Q «visitar» i
para todo j adyacente a i : si j no está «visitado» y j /∈Q:
agregar j a Q poner padre(j ) = i
Cuadro 16.1: Esquema del algoritmorecorrido.
Como los vértices se visitarán secuencialmente, uno a la vez, tenemos que pensar cómo organizarnos para hacerlo, para lo cual apelamos al concepto de cola (como la del supermercado).
Inicialmente ponemos la raíz en la cola. Posteriormente vamos sacando vértices de la cola para visitarlos y agregando los vecinos de los que estamos visitando. En el cuadro 16.1mostramos un esquema informal, donde la cola se llama Q y la raíz r .
Hay distintos tipos de cola, y a nosotros nos interesarán las siguientes:
Cola lifo (last in, first out) o pila: el último elemento en ingresar (last in) es primero en salir (first out). También la llamamos pila porque se parece a las pilas de platos. Cola fifo (first in, first out): el primer elemento ingresado (first in) es el primero en
salir (first out). Son las colas que normalmente llamamos... colas, como la del supermercado.
Cola con prioridad: Los elementos van saliendo de la cola de acuerdo a cierto orden de prioridad. Por ejemplo, las mamás con bebés se atienden antes que otros clientes. Las colas tienen ciertas operaciones comunes: inicializar, agregar un elemento y quitar un elemento:
• Inicializamos la cola con:
cola = [] # la cola vacía
• Para simplificar, vamos a agregar elementos siempre al final de la cola:
cola.append(x)
• Qué elemento sacar de la cola depende del tipo de cola:
x = cola.pop() # para colas lifo
x = cola.pop(0) # para colas fifo
Más adelante veremos cómo elegir el elemento a sacar en las colas de prioridad que usaremos.
Volviendo al recorrido de un grafo, una primera versión es la funciónrecorrido(en el módulo
grafos
). Esta función toma como argumentos la lista de vecinos (recordando que los índices empiezan desde 1) y un vértice raíz, retornando los vértices para los cuales existe un camino desde la raíz.Observar el uso depadreen la funciónrecorrido. Inicialmente el valor esNone para todo vértice, y al terminar el valor esNoneen un vértice si y sólo si no se puede llegar a él desde la raíz.
También podemos ver que la cola se ha implementado como pila pues sale el último en ingresar.
Recorrido con cola LIFO - Paso 5: árbol y cola cola hijo - padre árbol elegido arista sacada n orden de agregado 1 2 3 4 5 6 1 3 2 4
Figura 16.2: Recorrido lifo del grafo delejemplo 16.2tomando raíz 1.
Ejercicio 16.9 (recorrido de un grafo).
a) Estudiar la funciónrecorridoy comprobar el comportamiento para el grafo delejemplo 16.2tomando distintos vértices como raíz, por ejemplo 1 y 5. - Observar que si la raíz es 1, se «visitan» todos los vértices excepto 5. Lo inverso
sucede tomando raíz 5: el único vértice visitado es 5.
- En la página del libro hay una«película»(archivo pdf ) de cómo se va construyendo el árbol en este caso. Lafigura 16.2muestra la última página de ese archivo, donde podemos observar el árbol en azul, las flechas indican el sentido hijo-
padre, y en las aristas está recuadrado el orden visitados los vértices y aristas
correspondientes, en este caso: 1 (raíz), 3 (usando{1, 3}), 6 (usando {3, 6}), 4 (usando{3, 4}) y 2 (usando {1, 2}).
b) Al examinar vecinos del vértice que se visita, hay un lazo que comienza confor v in vecinos[u]....
¿Sería equivalente cambiar esas instrucciones por
lista = [v in vecinos[u] if padre[v] == None] cola.extend[lista]
for v in lista:
padre[v] = u ? ¡
Ejercicio 16.10. Agregar instrucciones a la funciónrecorridode modo que al final se impriman los vértices en el orden en que se incorporaron a la cola, así como el orden en que fueron «visitados» (es decir, el orden en que fueron sacados de la cola).
Por ejemplo, aplicada al grafo delejemplo 16.2cuando la raíz es 1 se imprimiría
Orden de incorporación a la cola:
1 2 3 4 6
Orden de salida de la cola:
1 3 6 4 2
Sugerencia: agregar dos listas, digamosentradaysalida, e ir incorporando a cada
una los vértices que entran o salen de la cola. ¡
Ejercicio 16.11 (componentes). En elejercicio 16.9vimos que no siempre existen caminos desde la raíz a cualquier otro vértice. Lo que hace exactamente la función
recorridoes construir (y retornar) los vértices de la componente conexa que contiene a la raíz.
a) Agregar al grafo delejemplo 16.2las aristas{3, 5} y {4, 5}, de modo que ahora es conexo. Verificarlo corriendo la funciónrecorridopara distintas raíces sobre el nuevo grafo.
16.3. Recorriendo un grafo Pág. 133
b) En general, para ver si un grafo es conexo basta comparar la longitud (cardinal)
de una de sus componentes con la cantidad de vértices. Hacer una función que tomando el número de vértices y la lista de aristas, decida si el grafo es conexo o no (retornandoTrueoFalse).
c) Usando la funciónrecorrido, hacer una función que retorne una lista de las componentes de un grafo. En el grafo original delejemplo 16.2el resultado debe
ser algo como[[1, 2, 3, 4, 6], [5]]. ¡
En lafigura 16.2podemos ver que las aristas elegidas por la funciónrecorrido forman un árbol. Usando la raíz 1 en el grafo delejemplo 16.2, las aristas del árbol son {1, 2}, {1, 3}, {3, 4} y {3, 6}, como ya mencionamos. En cambio, el árbol se reduce a la raíz cuando ésta es 5, y no hay aristas.
Ejercicio 16.12. Agregar instrucciones arecorrido, de modo que en vez de retornar los vértices del árbol obtenido, se retorne la lista de aristas que forman el árbol. ¡ Ejercicio 16.13. Hacer sendas funciones para los siguientes apartados dado un grafo
G :
a) Ingresando la lista de vecinos y los vértices s y t , s6= t , se exhiba un camino s –t o se imprima un cartel diciendo que no existe tal camino.
Sugerencia: usarrecorridocon raíz t y si al finalizar resultapadre[s]6=
None, construir el camino siguiendopadrehasta llegar at.
b) Ingresando la cantidad de vértices y la lista de aristas, se imprima una (y sólo
una) de las siguientes:
i) G no es conexo, ii) G es un árbol,
iii) G es conexo y tiene al menos un ciclo.
Sugerencia: recordar elteorema 16.1y elejercicio 16.11.b).
c) Dados el número de vértices, la lista de aristas y la arista{u , v }, se imprima si hay o no un ciclo en G que la contiene, y en caso afirmativo, imprimir uno de esos ciclos.
Ayuda: si hay un ciclo que contiene a la arista{u , v }, debe haber un camino
u –v en el grafo que se obtiene borrando la arista{u , v } del grafo original. ¡ Ejercicio 16.14 (ciclo de Euler I). Un célebre teorema de Euler dice que un grafo tiene un ciclo que pasa por todas las aristas exactamente una vez, llamado ciclo de Euler, si y sólo si el grafo es conexo y el grado de cada vértice es par.
Recordando elejercicio 16.6, hacer una función que tomando como datos la canti- dad de vértices y la lista de aristas, decida (retornandoTrueoFalse) si el grafo tiene o no un ciclo de Euler.
- Un problema muy distinto es construir un ciclo de Euler en caso de existir (ver el ejercicio 16.21).
¦ Euler (1707–1783) fue uno de los más grandes y prolíficos matemáticos de todos los tiempos, haciendo contribuciones en todas las áreas de las matemáticas. Fue tan grande su influencia que se unificaron notaciones que el ideó, como la deπ (= 3.14159...) (del griego perypheria o circunferencia), i (=p−1) (por imaginario), y e (= 2.71828 . . .) (del
alemán einhalt o unidad).
Entre otras tantas, Euler fue quien originó el estudio de teoría de grafos y la topología al resolver en 1736 el famoso problema de los puentes de Königsberg (hoy Kaliningrad, en Rusia), donde el río Pregel se bifurcaba dejando dos islas (y dos costas), y las islas y las costas se unían por siete puentes. Euler resolvió el problema, demostrando que no se podían recorrer todos los puentes pasando una única vez por ellos, demostrando el
teorema que mencionamos en el problema. ¡
Como ya observamos, la cola en la funciónrecorridoes una pila. Así, si el grafo fuera ya un árbol, primero visitaremos toda una rama hasta el fin antes de recorrer otra, lo que hace que este tipo de recorrido se llame en profundidad o de profundidad
Recorrido con cola FIFO - Paso 5: árbol y cola cola hijo - padre árbol elegido arista sacada n orden de agregado 1 2 3 4 5 6 1 2 3 4
Figura 16.3: Recorrido fifo del grafo delejemplo 16.2tomando raíz 1.
primero. Otra forma de pensarlo es que vamos caminando por las aristas (a partir de la
raíz) hasta llegar a una hoja, luego volvemos por una arista (o las necesarias) y bajamos hasta otra hoja, y así sucesivamente.
- En algunos libros se llama recorrido en profundidad a «visitar» primero todos los vecinos antes de visitar al vértice. La programación es bastante más complicada en ese caso, y podríamos ponerla como:
while len(cola) > 0:
u = cola[-1] # tomar el último de la cola while vecinos[u] != []: # si queda algún vecino
v = vecinos[u].pop() # sacarlo de vecinos[u] # y no considerar u como vecino de v
vecinos[v].remove(u)
if padre[v] == None: # si v nunca estuvo en cola.append(v) # la cola, incorporarlo padre[v] = u # guardar de dónde vino break # y salir de este lazo if u != cola[-1]: # si no se agregaron vecinos
cola.pop() # eliminarlo de la cola
En el caso del grafo delejemplo 16.2tomando raíz 1 en esta versión el orden de visita(1,3,6,4,2). En la página del libro hay una«película»que ilustra el algoritmo.
El algoritmo es un poco más ineficiente que usar una cola lifo porque tenemos que sacar audevecinos[v](recorriendo la listavecinos[v]hasta encontraru), y tenemos que acceder avecinos[u]varias veces hasta vaciarlo (mientras que en nuestra presentación se accede una única vez).
- El orden en que se recorren los vértices —tanto en el recorrido en profundidad como el recorrido a lo ancho que veremos a continuación— está determinado también por la numeración de los vértices. En la mayoría de las aplicaciones, la numeración dada a los vértices no es importante:si lo fuera, hay que sospechar del modelo y mirarlo con cuidado.
Si en la funciónrecorrido, en vez de implementar la cola como lifo (pila) la imple- mentamos como fifo, visitamos primero la raíz, luego sus vecinos, luego los vecinos de los vecinos, etc. Si el grafo es un árbol, visitaremos primero la raíz, después todos sus hijos, después todos sus nietos, etc., por lo que se el recorrido se llama a lo ancho. Ejercicio 16.15 (recorrido a lo ancho).
a) Modificar la funciónrecorridode modo que la cola sea ahora fifo.
Sugerencia: cambiarpop()apop(0)en el lugar adecuado.
- En Python es mucho más eficiente usar colas lifo, modificando el final con lista.append(x)yx = lista.pop(), que agregar o sacar en cualquier po-
16.4. Grafos con pesos Pág. 135