Python por ejemplo
Tugurium/Python

Python, por ejemplo

Grafos

6 Grafos

Un grafo es una estructura de datos no lineal compuesta por nodos o vértices, conectados entre si por arcos o aristas. Los nodos representan objetos y los arcos las relaciones entre ellos.

Los grafos son un tipo de estructuras de datos donde cada elemento puede tener más de un sucesor, como los árboles, pero también poseer más de un predecesor. Esto hace que los grafos sean las estructuras más adecuadas para representar relaciones arbitrarias entre los elementos que los componen.

Los grafos tienen una terminología específica:

  • Orden de un grafo: El número de nodos del grafo.
  • Nodos adyacentes: Los nodos conectados por un arco.
  • Grado de un nodo: En un grafo no dirigido: El número de arcos que inciden en el nodo. Eliminando los lazos. . La suma de los grados de los nodos es igual al doble del número de arcos.
  • Semigrado interior de un nodo: En un grafo dirigido: Número de arcos de los nodos precedentes.
  • Semigrado exterior de un nodo: En un grafo dirigido: Número de arcos a los nodos siguientes.
  • Camino o circuito: Sucesión de nodos unidos por arcos.
  • Camino simple: El camino en el que no se repite ningún arco.
  • Longitud de un camino: El número de arcos que componen el camino. Para un grafo ponderado, es la suma de los pesos del camino. Un nodo independiente tiene longitud 0.
  • Ciclo o camino cerrado o circuito: El camino en el que coinciden los nodos extremos. Y en el que no se repiten arcos.
  • Bucle : Arco cuyos vértices inicial y final coinciden.
  • Grafo conexo: Grafo no dirigido tal que para cualquier par de nodos existe al menos un camino que los une.
  • Grafo fuertemente conexo: Grafo dirigido tal que para cualquier par de nodos existe un camino que los une.
  • Grafo acíclico: Grafo que no tiene ciclos. Un grafo acíclico conectado se considera un árbol, y un grafo acíclico desconectado se denomina bosque.
  • Camino o circuito Hamiltoniano: Aquel en el cual aparecen todos los nodos del grafo, pasando solo una vez por cada uno.
  • Camino o circuito Euleriano: Aquel en el cual aparecen todos los arcos del grafo, sin importar que se visiten los nodos más de una vez.

Existen diferentes tipos de grafos, pero en nuestro caso vamos a trabajar tan solo con tres tipos:

  • Grafo no dirigido: Grafo que tiene un conjunto de nodos y un conjunto de arcos entre los nodos donde el orden de los nodos conectados no es importante. Los arcos se consideran bidireccionales.
  • Grafo dirigido o digrafo: Los arcos que conectan los nodos son unidireccionales. El arco se representa con una flecha, que indica la dirección del nodo origen al nodo destino.
  • Grafo ponderado: Grafo en el que a cada arista se le asocia un valor numérico o peso.

6.1 Recorridos

Los recorridos en grafos se aplican de manera similar a los recorridos en árboles, la idea es visitar todos los nodos que lo forman, moviéndose por los arcos, para lo cuál se debe llevar la cuenta de los nodos visitados y no visitados.

Hay dos enfoques básicos:

Recorrido primero en profundidad (Depth-First Search - DFS).

Es una generalización del recorrido en preorden de un árbol.

  • A partir de un nodo dado se localizan sus nodos adyacentes y para cada uno de ellos se inicia nuevamente el recorrido DFS.
  • Es necesario llevar la cuenta de los nodos visitados (y no visitados).
  • El recorrido no es único: depende del vértice inicial y del orden de visita de los vértices adyacentes.

Se hace uso de una pila para almacenar los nodos no visitados.

Recorrido primero en anchura o amplitud (Breadth-First Search - BFS).

Es una generalización del recorrido por niveles de un árbol.

El recorrido en anchura es una estrategia aplicable indistintamente al caso de grafos dirigidos y no dirigidos.

  • A partir de un nodo dado se visitan todos sus nodos adyacentes.
  • A continuación, los adyacentes a éstos.
  • Y así sucesivamente.

Se hace uso de una cola para almacenar los nodos no visitados.

6.1.1 Extensión de coste mínimo

En un grafo es fácil que haya caminos redundantes, así, podemos llegar por diferentes arcos de un nodo a otro. Si en un grafo conexo eliminamos los caminos redundantes, seguirá siendo conexo, si, además, nos quedamos con los caminos de menor coste habremos simplificado el grafo.

En el caso de los grafos ponderados resulta interesante encontrar el recorrido de peso mínimo entre dos nodos. El algoritmo de Dijkstra es el método general de búsqueda de este tipo de recorrido.

6.2 Representaciones

Los grafos se pueden representar mediante dos estructuras de datos distintas:

  • Matriz de adyacencia . Consiste en una tabla de tamaño VxV, en que la posición m[i][j] tendrá como valor 1 si existe un arco del nodo i al nodo j. En caso contrario, el valor será 0. Cuando se trata de grafos ponderados en lugar de 1 el valor que tomará será el peso del arco. Si el grafo es no dirigido debe marcarse con un 1 (o con el peso) tanto la entrada m[i][j] como la entrada m[j][i].
  • Lista de adyacencia . Cada nodo A tendrá una lista asociada con los nodos a los que está unido, así, habrá una referencia al nodo B si A y B tienen un arco que los une. Si el grafo es no dirigido, en la lista de B aparecerá la correspondiente referencia al nodo A. En el caso de que el grafo sea ponderado llevaran el peso asociado a cada arco.

La lista de adyacencia es un modelo dinámico, lo que permite cambiar el número de nodos sin necesidad de volver a crear una nueva matriz de adyacencia.

En los siguientes desarrollos vamos a emplear las listas de adyacencia en forma de diccionario de Python, donde las claves serán los nodos y los valores una lista de nodos adyacentes al nodo de la clave.

grafo = { <nodo>: [<nodos_adyacentes>]}

En el caso de grafos ponderados, cada nodo adyacente de la lista estará formado por una tupla de la forma:

(<nodo_adyacente>, <peso>)

El empleo de un diccionario nos va a facilitar las operaciones sobre el grafo, como listar nodos, obtener adyacencias, eliminar nodos, etc. Aunque debemos tener en cuenta que la implementación con listas de adyacencia determina el tratamiento del grafo, por lo que nos encontramos que un mismo grafo con un orden distinto de los arcos en la entrada producirá listas de adyacencia diferentes y por ello el orden en que los nodos se procesen producirá variaciones en los resultados.

Consideraremos el mínimo de operaciones a realizar sobre un TAD grafo:

  • Creación: Realizar operaciones de inicialización del objeto y obtener un nuevo objeto del tipo grafo.
  • Inserción: Introducir nuevos elementos en el objeto.
    • Añadir nodo: Añade el nuevo nodo si no estaba ya en el grafo.
    • Añadir arco: Añade un arco entre dos nodos del grafo. Si no existe alguno de los nodos se añadirá de forma implícita.
  • Borrado: Funciones para eliminar o alterar el contenido del objeto.
    • Eliminar nodo: Borra el nodo y los arcos del nodo eliminado.
    • Eliminar arco: Borra el arco que relacionaba los nodos.
  • Consulta: Realizar funciones que devuelven un valor en función de algún parámetro.
    • Buscar caminos mínimos
    • Comprobar si el grafo está vacío.
    • Comprobar si existe un nodo.
    • Comprobar si dos nodos son adyacentes.

En el caso de grafos ponderados debemos incluir el peso en la operación de Añadir arco.

6.3 Grafo no dirigido

Los grafos no dirigidos tienen un conjunto de nodos y un conjunto de arcos entre los nodos donde el orden de los nodos conectados no es importante.

Los arcos se consideran bidireccionales, así, en el caso de dos nodos unidos por un arco, es posible pasar de uno a otro y viceversa. Como vamos a representar los grafos en la implementación mediante una lista de adyacencias, cada par de nodos ayacentes llevarán en su lista la referencia al nodo al que está unido.

Grafo no dirigido

La representación del grafo de la imagen, como un diccionario de Python con sus listas de adyacencia, sería:

grafo = {'A': ['B', 'C', 'D'],
         'B': ['A', 'C'],
         'C': ['A', 'B', 'D'],
         'D': ['A', 'C']}

Al ser un grafo no dirigido vemos que el nodo A contiene en su lista el nodo B, y en el nodo B aparece también el nodo A.

Las operaciones que vamos a definir sobre los grafos no dirigidos son:

MétodoDescripción
g = Graph()Crea y devuelve un grafo vacío.
g.nodes()Devuelve los nodos del grafo como una lista.
g.edges()Devuelve los arcos del grafo como una lista de tuplas (orige, destino).
g.isolatedNodes()Devuelve una lista de los nodos aislados.
g.order()Devuelve el número de nodos del grafo.
g.size()Devuelve el número de aristas del grafo.
g.degree(node)Devuelve el número de aristas que conectan con el nodo indicado.
Los lazos se cuentan doblemente.
Si el grafo está vacío devuelve -1.
g.Delta()Devuelve el grado máximo.
g.addNode(node)Inserta un nodo en el grafo.
g.removeNode(node)Elimina un nodo del grafo.
g.addEdge(edge)Inserta un arco entre los nodos indicados. Si no existe alguno de los nodos se añade de forma implícita.

El arco es una lista con los nodos que une.

g.removeEdge(edge)Elimina una arista entre nodos.
g.DFS(node)Recorrido en profundidad desde el nodo indicado.

Equivale al recorrido en preorden de un árbol.

g.BFS(node)Recorrido en anchura desde el nodo indicado.

Equivalente al recorrido de un árbol por niveles.

g.findPaths(start, end)Devuelve todos los caminos entre dos nodos.
g.shortestPath(start, end)Devuelve el camino más corto entre dos nodos.
g.isEmpty()Devuelve True si el grafo está vacío, en caso contrario False.
g.existNode(node)Devuelve True si el nodo está en el grafo, en caso contrario False.
g.existEdge(edge)Devuelve True si existe el arco en el grafo (si los nodos son adyacentes), en caso contrario False.

Dispondremos además de los métodos especiales:

MétodoDescripción
__init__(self)Constructor, crea un grafo no dirigido vacío por defecto.
Se puede proporcionar un diccionario con el que se crea el grafo inicial.
__repr__(self)Devuelve una representación formal del contenido del grafo.
__iter__(self)Iterador para recorrer todos los nodos del grafo, con sus arcos, uno tras otro en el orden en que esté almacenado.

Crearemos una clase con los métodos necesarios para implementar nuestro grafo no dirigido.

grafo.py
# grafo no dirigido
# admite lazos 

class Graph():
    # Constructor, por defecto crea un diccionario vacío
    # El grafo se presenta como un diccionario de la forma
    # {nodo: [arcos]}
    # arcos es una lista de los nodos adyacentes 
    def __init__(self, graph={}):
        self.graph = graph

    # Devuelve una representación formal del contenido del grafo
    def __repr__(self):
        nodes = ''
        for node, edges in self.graph.items():
            nodes += f'{node}: {edges}\n'
        return nodes

    # Iterador para recorrer todos los nodos del grafo
    def __iter__(self):
        self.iter_obj = iter(self.graph)
        return self.iter_obj

    # Devuelve los nodos del grafo como una lista
    def nodes(self):
        return list(self.graph.keys())

    # Devuelve los arcos del grafo como una lista de tuplas
    # (nodo_origen, nodo_destino)
    def edges(self, node=None):
        if node:
            if self.existNode(node):
                return [(node, e) for e in self.graph[node]]
            else:
                return []
        else:
            return [(n, e) for n in self.graph.keys() for e in self.graph[n]]

    # Devuelve una lista de los nodos aislados
    def isolatedNodes(self):
        return [node for node in self.graph if not self.graph[node]]

    # Devuelve el número de nodos del grafo
    def order(self):
        return len(self.graph)

    # Devuelve el número de arcos del grafo
    def size(self):
        arcs = []
        for node, edges in self.graph.items():
            for edge in edges:
                sorted_edge = sorted([node, edge])
                if sorted_edge not in arcs:
                    arcs.append(sorted_edge)
        return len(arcs)
                   
    # Devuelve el número de arcos que conectan con el nodo
    # Los lazos se cuentan doblemente
    def degree(self, node):
        if self.graph == {}:
            return -1       # grafo vacío
        degree = len(self.graph[node])
        if node in self.graph[node]:
            degree += 1     # existe un lazo
        return degree

    # Devuelve el grado máximo
    def Delta(self):
        if self.graph == {}:
            return -1       # grafo vacío
        degrees = [self.degree(node) for node in self.graph]
        degrees.sort(reverse=True)
        return degrees[0]

    # Inserta un nodo en el grafo
    def addNode(self, node):
        # Si el nodo no está en el grafo,
        # se añade al diccionario con una lista vacía 
        if node not in self.graph:
            self.graph[node] = []   # nodo aislado

    # Elimina un nodo del grafo
    # Primero elimina todos los arcos del nodo
    def removeNode(self, node):
        if node in self.graph:
            edges = list(self.graph[node])
            for edge in edges:
                self.removeEdge((node, edge))
            self.graph.pop(node)

    # Inserta una arco entre los nodos indicados
    # El arco es una lista con los nodos que une
    def addEdge(self, edge):
        n1, n2 = tuple(edge)
        # Se crean arcos del nodo1 al nodo2 y viceversa
        for n, e in [(n1, n2), (n2, n1)]:
            if n in self.graph:
                if e not in self.graph[n]:
                    self.graph[n].append(e)
                    if n == e:
                        break           # es un lazo
            else:
                self.graph[n] = [e]     # no existe el nodo, se inserta

    # Elimina un arco entre nodos
    # El arco es una lista con los nodos que une
    def removeEdge(self, edge):
        n1, n2 = tuple(edge)
        for n, e in [(n1, n2), (n2, n1)]:
            if n in self.graph:
                if e in self.graph[n]:
                    self.graph[n].remove(e)

    # Recorrido en profundidad (Depth First Search)
    # comienza en un nodo del grafo y
    # comprueba a todo lo largo de cada arco antes de retroceder
    def DFS(self, node):
        visited = [node]                    # lista de visitados
        stack = [node]                      # pila a tratar
        while stack:                        # mientras haya nodos en la pila
            current = stack.pop()           # sacar nodo de la pila
            if current not in visited:      # si no ha sido visitado
                visited.append(current)     # añadir a visitados
            for e in self.graph[current]:   # para cada nodo adyacente
                if e not in visited:        # si no ha sido visitado
                    stack.append(e)         # añadir a la pila
        return visited                      # devolver el recorrido

    # Recorrido en anchura (Breadth-First Search)
    # comienza en un nodo del grafo y
    # comprueba los nodos adyacentes en el nivel actual
    # antes de pasar al siguiente nivel del grafo
    def BFS(self, node):
        visited = [node]                    # lista de visitados
        queue = [node]                      # cola a tratar
        while queue:                        # mientras haya nodos en la cola
            current = queue.pop(0)          # sacar nodo de la cola
            if current not in visited:      # si no ha sido visitado
                visited.append(current)     # añadir a visitados
            for e in self.graph[current]:   # para cada nodo adyacente
                if e not in visited:        # si no ha sido visitado
                    queue.append(e)         # añadir a la cola
        return visited                      # devolver el recorrido      

    # Devuelve todos los caminos entre dos nodos
    def findPaths(self, start, end, path = []):
        if start not in self.graph or end not in self.graph:
            return []
        path = path + [start]
        if start == end:
            return [path]
        paths = []
        for node in self.graph[start]:
            if node not in path:
                newpaths = self.findPaths(node, end, path)
                for subpath in newpaths:
                    paths.append(subpath)
        return paths

    # Devuelve el camino más corto entre dos nodos
    # camino más corto == menos nodos
    def shortestPath(self, start, end):
        path = sorted(self.findPaths(start, end), key = len)
        return path[0] if path else []

    # Consulta si el grafo está vacío
    def isEmpty(self):
        return self.graph == {}

    # Consulta si el nodo existe en el grafo
    def existNode(self, node):
        return node in self.graph.keys()

    # Consulta si el arco existe en el grafo
    def existEdge(self, edge):
        n1, n2 = tuple(edge)
        return (n1, n2) in self.edges(n1) or (n2, n1) in self.edges(n2)

Describimos a continuación los métodos de nuestra implementación.

__init__(self, graph={})
El constructor del grafo crea un grafo vacío por defecto. Se puede pasar un grafo como una lista de adyacencia en forma de diccionario de Python, donde las claves del diccionario son los nodos y los valores son los arcos como una lista de nodos a los que se enlaza desde el nodo clave.
__repr__(self)
El método de representación del grafo como una relación de nodos con una lista adjunta de sus nodos adyacentes.
__iter__(self)
El iterador para recorrer los nodos del grafo nos hace entrega de un nodo cada vez que se le llame.
nodes(self)
Devuelve una lista con los nodos del grafo.
edges(self, node=None)
Devuelve una lista de tuplas con los arcos del grafo.
Si se proporciona un nodo, devuelve los arcos que parten de ese nodo.
Si el nodo no existe, devuelve una lista vacía.
isoladedNodes(self)
Devuelve una lista con los nodos aislados del grafo.
order (self)
Devuelve el número de nodos del grafo.
size(self)
Devuelve el número de arcos del grafo.
degree(self, node)
Devuelve el número de arcos que conectan con el nodo.
Si hay lazos se cuenta por dos.
Si el grafo está vacío se devuelve -1.
Delta(self)
Devuelve el grado máximo del grafo.
addNode(self, node)
Inserta el nodo en el grafo.
Si el nodo no existe se añade al diccionario con una lista vacía de adyacencias.
Si el nodo ya existe, se ignora la orden.
removeNode(self, node)
Elimina un nodo del grafo.
Si el nodo existe, se eliminan primero todas las referencias al nodo en las listas de adyacencia de los demás nodos. A continuación se elimina el nodo del diccionario.
Si el nodo no existe, se ignora la orden.
addEdge(self, edge)
Inserta un arco entre los nodos indicados. El parámetro arco (edge) es una lista con los nodos que están relacionados.
Al ser un grafo no dirigido se crean arcos entre ambos nodos.
Si no existe alguno de los nodos se inserta en el diccionario con el otro nodo en su lista de adyacencia.
removeEdge(self, edge)
Elimina un arco entre los nodos indicados. El parámetro arco (edge) es una lista con los nodos que están relacionados.
Se elimina de la lista de adyacencia de cada nodo el otro nodo al que está conectado.
DFS(self, node)
Devuelve el recorrido en profundidad (Depth First Search) del grafo a partir del nodo indicado.
El recorrido comienza en un nodo del grafo y se comprueba a todo lo largo de cada arco antes de retroceder.
Se hace uso de una pila para almacenar los nodos pendientes de tratar.
BFS(self, node)
Devuelve el recorrido en anchura (Breadth First Search) del grafo a partir del nodo indicado.
Comienza en un nodo del grafo y comprueba los nodos adyacentes en el nivel del nodo, antes de pasar al siguiente nivel del grafo.
Se hace uso de una cola para almacenar los nodos pendientes de tratar.
findPaths(self, start, end, path = [])
Devuelve una lista de listas con todos los caminos existentes entre los nodos inicio (start) y fin (end).
shortestPath(self, start, end)
Devuelve el camino más corto entre dos nodos. Se entiende el camino más corto como el que pasa por menos nodos.
Se hace uso del método findPaths(). Si hay más un camino que cumple la condición se devuelve el primero que proporciona el procedimiento.
isEmpty(self)
Devuelve True si el grafo está vacío. En caso contrario False.
existNode(self, node)
Devuelve True si el nodo está en el grafo. En caso contrario False.
existEdge(self, edge)
Devuelve True si el arco existe en el grafo. En caso contrario False. Se comprueba la existencia de un arco en los dos sentidos.
El parámetro arco (edge) es una lista con los nodos que están relacionados.

Construimos un script para probar nuestro módulo de tratamiento de grafos no dirigidos. Los comentarios en cada sentencia son suficientemente descriptivos.

import grafo as GR


# Grafo ejemplo con listas de adyacencia
grafo = {'A': ['B', 'C', 'D'],
         'B': ['A', 'C'],
         'C': ['A', 'B', 'D'],
         'D': ['A', 'C']}

def testGrafo():
    g = GR.Graph(grafo)         # Crear el grafo con el diccionario de ejemplo

    print('Grafo')
    print(g)                    # Visualizar el grafo
    print('Iteración')
    for n in g:                 # Iterar sobre los nodos y arcos del grafo
        print(n)
        print(g.edges(n))
    print('Nodos:', g.nodes())  # Visualizar todos los nodos
    print('Arcos:', g.edges())  # Visualizar todos los arcos
    print('Arcos desde el nodo C:', g.edges('C'))   # Arcos desde un nodo

    print('\nComprobaciones')
    print('Vacío:', g.isEmpty())                    # Comprobar si grafo vacío
    print('Existe nodo A:', g.existNode('A'))       # Comprobar si existe nodo
    print('Existe nodo Z:', g.existNode('Z'))
    print('Existe arco A-C:', g.existEdge(['A', 'C']))  # Comprobar arco
    print('Existe arco A-Z:', g.existEdge(['A', 'Z']))

    print('\nAñadir nodos y arcos')
    g.addNode('E')              # Añadir nodo
    g.addEdge(['E', 'A'])       # Añadir arco
    g.addNode('F')
    g.addEdge(['F', 'F'])
    g.addEdge(['A', 'B'])
    g.addEdge(['G', 'B'])
    g.addEdge(['B', 'B'])
    g.addEdge(('B', 'F'))
    print('Grafo resultante')
    print(g)

    print('Medidas')
    print('Grado de B:', g.degree('B'))     # Obtener grado de un nodo
    print('Grado de D:', g.degree('D'))
    print('Delta:', g.Delta())              # Obtener Delta del grafo
    print('Orden:', g.order())              # Obtener orden
    print('Tamaño:', g.size())              # Obtener tamaño

    print('\nRecorridos')
    print("DFS('A'):", g.DFS('A'))          # Recorrido DFS
    print("BFS('A'):", g.BFS('A'))          # Recorrido BFS

    # Obtener todos los caminos posibles entre dos nodos
    print('Caminos de A a C:', g.findPaths('A', 'C'))
    # Obtener el camino más corto entre dos nodos
    print('Más corto de A a C:', g.shortestPath('A', 'C'))
    
    print('\nEliminar arcos y nodos')
    g.removeEdge(['B', 'B'])    # Eliminar arco
    g.removeNode('B')           # Eliminar nodo
    g.removeEdge(['G', 'A'])
    g.removeNode('Z')           # Eliminar nodo inexistente
    g.removeEdge(['B', 'Z'])    # Eliminar arco inexistente
    print('Grafo resultante')
    print(g)
    print('Nodos aislados:', g.isolatedNodes()) # Visualizar nodos aislados


if __name__ == '__main__':
    testGrafo()

El resultado de la ejecución del programa de prueba es:

Grafo
A: ['B', 'C', 'D']
B: ['A', 'C']
C: ['A', 'B', 'D']
D: ['A', 'C']

Iteración
A
[('A', 'B'), ('A', 'C'), ('A', 'D')]
B
[('B', 'A'), ('B', 'C')]
C
[('C', 'A'), ('C', 'B'), ('C', 'D')]
D
[('D', 'A'), ('D', 'C')]
Nodos: ['A', 'B', 'C', 'D']
Arcos: [('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B'), ('C', 'D'), ('D', 'A'), ('D', 'C')]
Arcos desde el nodo C: [('C', 'A'), ('C', 'B'), ('C', 'D')]

Comprobaciones
Vacío: False
Existe nodo A: True
Existe nodo Z: False
Existe arco A-C: True
Existe arco A-Z: False

Añadir nodos y arcos
Grafo resultante
A: ['B', 'C', 'D', 'E']
B: ['A', 'C', 'G', 'B', 'F']
C: ['A', 'B', 'D']
D: ['A', 'C']
E: ['A']
F: ['F', 'B']
G: ['B']

Medidas
Grado de B: 6
Grado de D: 2
Delta: 6
Orden: 7
Tamaño: 10

Recorridos
DFS('A'): ['A', 'E', 'D', 'C', 'B', 'F', 'G']
BFS('A'): ['A', 'B', 'C', 'D', 'E', 'G', 'F']
Caminos de A a C: [['A', 'B', 'C'], ['A', 'C'], ['A', 'D', 'C']]
Más corto de A a C: ['A', 'C']

Eliminar arcos y nodos
Grafo resultante
A: ['C', 'D', 'E']
C: ['A', 'D']
D: ['A', 'C']
E: ['A']
F: ['F']
G: []

Nodos aislados: ['G']

6.4 Grafo dirigido o digrafo

Un grafo dirigido o dígrafo es aquel en el que los arcos que unen nodos tienen un único sentido. El arco va desde el nodo origen al nodo destino. Decimos que el nodo origen precede al nodo destino, mientras que el nodo destino sucede al nodo origen. El arco se representa gráficamente con una flecha, que indica la dirección del nodo origen al nodo destino

Grafo dirigido

La representación del grafo dirigido de la imagen, como un diccionario de Python, donde los nodos precedentes mantienen la lista de los nodos destino, es:

grafo = {'A': ['B', 'D'],
         'B': ['C'],
         'C': ['A', 'D'],
         'D': []}

Al ser un grafo dirigido vemos que el nodo A contiene en su lista el nodo B, ya que la flecha nos indica que el nodo A es el precedente del nodo B, mientras que en el nodo B no aparece el nodo A.

Definiremos dos operaciones específicas para los grafos dirigidos.

MétodoDescripción
outDegree(node)Devuelve el semigrado exterior del nodo indicado. El número de arcos a los nodos siguientes.
inDegree(node)Devuelve el semigrado interior del nodo indicado. El número de arcos de los nodos precedentes.

Vamos realizar la implementación empleando la clase Graph() que creamos en el punto anterior. Haremos uso de la herencia de los métodos correspondientes de la clase Graph(), reescribiendo aquellos métodos que hacian uso de la posibilidad de bireccionalidad de los arcos, que ahora no tenemos.

grafo_dirigido.py
# Grafo dirigido

import grafo as GR

class DirectedGraph(GR.Graph):
    # Constructor, por defecto crea un diccionario vacío
    # El grafo se presenta como un diccionario de la forma
    # {nodo: [arcos]}
    # arcos es una lista de los nodos siguientes al nodo
    def __init__(self, graph={}):
        super().__init__(graph)

    # Elimina un nodo del grafo
    # Elimina todos los arcos desde otros nodos
    def removeNode(self, node):
        if node in self.graph:
            nodes = list(self.graph)
            for n in nodes:
                if node in self.graph[n]:
                    self.graph[n].remove(node)
            self.graph.pop(node)

    # Inserta una arco entre los nodos indicados
    # La arco es una lista con el nodo origen y destino
    # Si no existe alguno de los nodos lo inserta
    def addEdge(self, edge):
        start, end = tuple(edge)
        if end not in self.graph:
            self.graph[end] = []            # no existe nodo destino, se crea
        if start in self.graph:
            if end not in self.graph[start]:
                self.graph[start].append(end)
        else:
            self.graph[start] = [end]       # no existe nodo origen, se crea

    # Elimina un arco entre nodos
    # El arco es una lista con los nodos que une
    def removeEdge(self, edge):
        start, end = tuple(edge)
        if start in self.graph:
            if end in self.graph[start]:
                self.graph[start].remove(end)

    # Devuelve una lista de los nodos aislados
    def isolatedNodes(self):
        return [n for n, e in self.graph.items() if self.inDegree(n) == 0]

    # Semigrado exterior
    def outDegree(self, node):
        return len(self.graph[node])

    # Semigrado interior 
    def inDegree(self, node):
        return len([n for n, e in self.graph.items() if node != n and node in e])

Describimos a continuación los métodos de nuestra implementación. Los métodos que no aparecen serán los correspondientes a la clase base Graph().

__init__(self, graph={})
Es el constructor del grafo.
Llamamos al constructor de la clase base para la creación del grafo.
removeNode(self, node)
Elimina un nodo del grafo.
Si el nodo existe, se eliminan primero todas las referencias al nodo en las listas de adyacencia de los nodos precedentes a él. A continuación se elimina el nodo del diccionario.
Si el nodo no existe, se ignora la orden.
addEdge(self, edge)
Inserta un arco entre los nodos indicados. El parámetro arco (edge) es una lista con el nodo origen y el nodo destino.
Al ser un grafo dirigido solo se añade el nodo destino en la lista de adyacencia del nodo origen.
Si no existe alguno de los nodos se inserta en el diccionario de la forma:
Si no existe el nodo destino se inserta con una lista de adyacencia vacía.
Si no existe el nodo origen se inserta con el nodo destino en su lista de adyacencia.
removeEdge(self, edge)
Elimina un arco entre los nodos indicados. El parámetro arco (edge) es una lista con los nodos origen y destino, que están relacionados.
Se elimina de la lista de adyacencia del nodo origen el nodo de destino.
Si no existe alguno de los nodos, se ignora la orden.
isoladedNodes(self)
Devuelve una lista con los nodos aislados del grafo.
Se considera aislado aquel nodo que no tiene referencias desde otros nodos, que no tiene arcos de entrada.
outDegree(self, node)
Devuelve el número de arcos a los nodos siguientes. La longitud de su lista de adyacencia.
inDegree(self, node)
Devuelve el número de arcos de los nodos precedentes. La longitud de la lista de nodos que contienen el nodo en su lista de adyacencia.

Construimos un script para probar nuestro módulo de tratamiento de grafos dirigidos. Los comentarios en cada sentencia son suficientemente descriptivos.

import grafo_dirigido as GRD


# Grafo ejemplo con listas de adyacencia
grafo = {'A': ['B', 'D'],
         'B': ['C'],
         'C': ['A', 'D'],
         'D': []}

def testDirectedGraph():
    g = GRD.DirectedGraph(grafo)    #Crear el grafo con el diccionario de ejemplo

    print('Grafo')
    print(g)                    # Visualizar el grafo
    print('Iteración')
    for n in g:                 # Iterar sobre los nodos y arcos del grafo
        print(n)
        print(g.edges(n))
    print('Nodos:', g.nodes())  # Visualizar todos los nodos
    print('Arcos:', g.edges())  # Visualizar todos los arcos
    print('Arcos desde el nodo C:', g.edges('C'))   # Arcos desde un nodo

    print('\nComprobaciones')
    print('Vacío:', g.isEmpty())                    # Comprobar si grafo vacío
    print('Existe nodo A:', g.existNode('A'))       # Comprobar si existe nodo
    print('Existe nodo Z:', g.existNode('Z'))
    print('Existe arco A-C:', g.existEdge(['A', 'C']))  # Comprobar arco
    print('Existe arco A-Z:', g.existEdge(['A', 'Z']))
    
    print('\nAñadir nodos y arcos')
    g.addNode('E')              # Añadir nodo
    g.addEdge(('A', 'E'))       # Añadir arco
    g.addNode('F')
    g.addEdge(('B', 'G'))
    g.addEdge(('B', 'F'))
    print('Grafo resultante')
    print(g)

    print('Medidas')
    print('Grado de B:', g.degree('B'))     # Obtener grado de un nodo
    print('Grado de D:', g.degree('D'))
    print('Grado de salida A:', g.outDegree('A'))   #Semigrado exterior 
    print('Grado de entrada A:', g.inDegree('A'))   #semigrado interior 
    print('Delta:', g.Delta())              # Obtener Delta del grafo
    print('Orden:', g.order())              # Obtener orden
    print('Tamaño:', g.size())              # Obtener tamaño

    print('\nRecorridos')
    print("DFS('A'):", g.DFS('A'))          # Recorrido DFS
    print("BFS('A'):", g.BFS('A'))          # Recorrido BFS

    # Obtener todos los caminos posibles entre dos nodos
    print('Caminos de A a C:', g.findPaths('A', 'C'))
    # Obtener el camino más corto entre dos nodos
    print('Más corto de A a C:', g.shortestPath('A', 'C'))

    print('\nEliminar arcos y nodos')
    g.removeNode('C')           # Eliminar nodo
    g.removeEdge(['A', 'D'])    # Eliminar arco
    g.removeNode('Z')           # Eliminar nodo inexistente
    g.removeEdge(['B', 'Z'])    # Eliminar arco inexistente
    print('Grafo resultante')
    print(g)
    print('Nodos aislados:', g.isolatedNodes()) # Visualizar nodos aislados


if __name__ == '__main__':
    testDirectedGraph()

El resultado de la ejecución del programa de prueba es:

Grafo
A: ['B', 'D']
B: ['C']
C: ['A', 'D']
D: []

Iteración
A
[('A', 'B'), ('A', 'D')]
B
[('B', 'C')]
C
[('C', 'A'), ('C', 'D')]
D
[]
Nodos: ['A', 'B', 'C', 'D']
Arcos: [('A', 'B'), ('A', 'D'), ('B', 'C'), ('C', 'A'), ('C', 'D')]
Arcos desde el nodo C: [('C', 'A'), ('C', 'D')]

Comprobaciones
Vacío: False
Existe nodo A: True
Existe nodo Z: False
Existe arco A-C: True
Existe arco A-Z: False

Añadir nodos y arcos
Grafo resultante
A: ['B', 'D', 'E']
B: ['C', 'G', 'F']
C: ['A', 'D']
D: []
E: []
F: []
G: []

Medidas
Grado de B: 3
Grado de D: 0
Grado de salida A: 3
Grado de entrada A: 1
Delta: 3
Orden: 7
Tamaño: 8

Recorridos
DFS('A'): ['A', 'E', 'D', 'B', 'F', 'G', 'C']
BFS('A'): ['A', 'B', 'D', 'E', 'C', 'G', 'F']
Caminos de A a C: [['A', 'B', 'C']]
Más corto de A a C: ['A', 'B', 'C']

Eliminar arcos y nodos
Grafo resultante
A: ['B', 'E']
B: ['G', 'F']
D: []
E: []
F: []
G: []

Nodos aislados: ['A', 'D']

6.5 Grafo ponderado

Los grafos ponderados son grafos con las aristas etiquetadas con un valor numérico o peso. Esa etiqueta puede ser una distancia, un tiempo o cualquier otro parámetro que resulte interesante definir en el grafo.

Grafo ponderado

Para la representación del grafo ponderado de la imágen, como un diccionario de Python, incluimos en las listas de adyacencia el peso de cada arco como una tupla de la forma nodo destino, peso, así:

grafo = {'A': [('B', 1), ('C', 2), ('D', 3)],
         'B': [('A', 1), ('C', 4)],
         'C': [('A', 2), ('B', 4), ('D', 5)],
         'D': [('A', 3), ('C', 5)]}

Como los arcos en los grafos ponderados son bidireccionales, al igual que en los grafos no dirigidos, vemos que el nodo A contiene en su lista el nodo B, y en el nodo B aparece también el nodo A.

Vamos realizar la implementación empleando la clase Graph() que creamos al principio de este capítulo. Haremos uso de la herencia de los métodos correspondientes de la clase Graph(), reescribiendo aquellos métodos que no tenían en cuenta la existencia del peso asociado a los arcos.

grafo_ponderado.py
# Grafo ponderado
# los arcos son bidireccionales, con peso único

import grafo as GR


class WeightedGraph(GR.Graph):
    # Constructor, por defecto crea un diccionario vacío
    # El grafo se presenta como un diccionario de la forma
    # {nodo: [arcos]}
    # arcos es una lista de tuplas de la forma (nodo_destino, peso) 
    def __init__(self, graph={}):
        super().__init__(graph)

    # Devuelve el número de arcos del grafo
    def size(self):
        arcs = []
        for node, edges in self.graph.items():
            for edge, w in edges:
                sorted_edge = sorted([node, edge])
                if sorted_edge not in arcs:
                    arcs.append(sorted_edge)
        return len(arcs)

    # Elimina un nodo del grafo
    # Primero elimina todos los arcos del nodo
    def removeNode(self, node):
        if node in self.graph:
            edges = list(self.graph[node])
            for edge, w in edges:
                self.removeEdge((node, edge))
            self.graph.pop(node)

    # Inserta una arco entre los nodos indicados
    # El arco es una lista con los nodos que une
    # Si no existe el nodo lo inserta
    def addEdge(self, edge, weight):
        n1, n2 = tuple(edge)
        for n, e in [(n1, n2), (n2, n1)]:
            if n in self.graph:
                if e not in self.graph[n]:
                    self.graph[n].append((e, weight))
                    if n == e:
                        break       # es un lazo
            else:
                self.graph[n] = [(e, weight)]

    # Elimina una arco entre nodos
    # El arco es una lista con los nodos que une
    def removeEdge(self, edge):
        n1, n2 = tuple(edge)
        for n, e in [(n1, n2), (n2, n1)]:
            if n in self.graph:
                for node, weight in self.graph[n]:
                    if node == e:
                        self.graph[n].remove((node, weight))

    # Recorrido en profundidad (Depth First Search)
    def DFS(self, node):
        visited = [node]
        stack = [node]
        while stack:
            current = stack.pop()
            if current not in visited:
                visited.append(current)
            for e, _ in self.graph[current]:    # para cada nodo adyacente
                if e not in visited:
                    stack.append(e)
        return visited

    # Recorrido en anchura (Breadth-First Search)
    def BFS(self, node):
        visited = [node]
        queue = [node]
        while queue:
            current = queue.pop(0)
            if current not in visited:
                visited.append(current)
            for e, _ in self.graph[current]:    # para cada nodo adyacente
                if e not in visited:
                    queue.append(e)
        return visited

    # Devuelve todos los caminos entre dos nodos
    def findPaths(self, start, end, path = []):
        if start not in self.graph or end not in self.graph:
            return []
        path = path + [start]
        if start == end:
            return [path]
        paths = []
        for node, _ in self.graph[start]:       # para cada nodo adyacente
            if node not in path:
                newpaths = self.findPaths(node, end, path)
                for subpath in newpaths:
                    paths.append(subpath)
        return paths

    # Devuelve el camino más corto entre dos nodos
    # camino más corto == el de menor peso
    # Algoritmo de Dijkstra para grafos ponderados
    # https://www.teachyourselfpython.com/challenges.php?a=01_Solve_and_Learn&t=7-Sorting_Searching_Algorithms&s=02c_Dijkstras_Algorithm
    def shortestPath(self, start, end):
        INF = float('inf')
        # Diccionario de nodos con un peso infinito
        unvisited = {node: INF for node in self.graph.keys()}
        # Diccionario de predecesores
        predecessor = {node: node for node in self.graph.keys()} 
        visited = {}
        current = start
        currentWeight = 0
        unvisited[current] = currentWeight      # nodo origen peso 0
        while True:
            for node, weight in self.graph[current]:
                if node not in unvisited:
                    continue                    # nodo ya tratado
                newWeight = currentWeight + weight
                if unvisited[node] > newWeight:
                    # Tomar el nodo con el menor peso
                    unvisited[node] = newWeight
                    predecessor[node] = current # predecesor con el menor peso
            visited[current] = currentWeight    # visitado con el menor peso 
            unvisited.pop(current)              # eliminar de los no visitados
            if not unvisited:
                break       # Terminar el bucle si no hay nodos por visitar
            # Tomar el nodo con el menor peso de los no visitados
            candidates = [(n, w) for n, w in unvisited.items() if w != INF]
            current, currentWeight = sorted(candidates, key = lambda x: x[1])[0]
        # Reconstrucción del camino de longitud mínima
        # Se parte del nodo final al inicial
        path = []
        node = end
        while True:
            path.append(node)
            if(node == predecessor[node]):
                break
            node = predecessor[node]
        # Devuelve una tupla con el camino y el peso total
        return (path[::-1], visited[end])

Describimos a continuación los métodos de nuestra implementación. Los métodos que no aparecen serán los correspondientes a la clase base Graph().

__init__(self, graph={})
Es el constructor del grafo.
Llamamos al constructor de la clase base para la creación del grafo.
size(self)
Devuelve el número de arcos del grafo.
Debemos tener en cuenta que la estructura de la lista de adyacencia del grafo incluye también el peso del arco.
removeNode(self, node)
Elimina un nodo del grafo.
Debemos tener en cuenta que la estructura de la lista de adyacencia del grafo incluye también el peso del arco.
Si el nodo existe, se eliminan primero todas las referencias al nodo en las listas de adyacencia de los demás nodos. A continuación se elimina el nodo del diccionario.
Si el nodo no existe, se ignora la orden.
addEdge(self, edge, weight)
Inserta un arco con su peso (weight) entre los nodos indicados. El parámetro arco (edge) es una lista con los nodos que están relacionados.
Al ser un grafo no dirigido se crean arcos entre ambos nodos.
Si no existe alguno de los nodos se inserta en el diccionario con el otro nodo en su lista de adyacencia.
removeEdge(self, edge)
Elimina un arco entre los nodos indicados. El parámetro arco (edge) es una lista con los nodos que están relacionados.
Debemos tener en cuenta que la estructura de la lista de adyacencia del grafo incluye también el peso del arco.
Se elimina de la lista de adyacencia de cada nodo el otro nodo al que está conectado.
DFS(self, node)
Devuelve el recorrido en profundidad (Depth First Search) del grafo a partir del nodo indicado.
Debemos tener en cuenta que la estructura de la lista de adyacencia del grafo incluye también el peso del arco.
El recorrido comienza en un nodo del grafo y se comprueba a todo lo largo de cada arco antes de retroceder.
Se hace uso de una pila para almacenar los nodos pendientes de tratar.
BFS(self, node)
Devuelve el recorrido en anchura (Breadth First Search) del grafo a partir del nodo indicado.
Debemos tener en cuenta que la estructura de la lista de adyacencia del grafo incluye también el peso del arco.
Comienza en un nodo del grafo y comprueba los nodos adyacentes en el nivel del nodo, antes de pasar al siguiente nivel del grafo.
Se hace uso de una cola para almacenar los nodos pendientes de tratar.
findPaths(self, start, end, path = [])
Devuelve una lista de listas con todos los caminos existentes entre los nodos inicio (start) y fin (end).
Debemos tener en cuenta que la estructura de la lista de adyacencia del grafo incluye también el peso del arco.
shortestPath(self, start, end)
Devuelve el camino de menor peso entre los nodos inicio (start) y fin (end). Se entiende por menor peso el camino cuya suma de pesos de los arcos sea menor.
Reescribiremos el método implementando el algoritmo de Dijkstra. La idea subyacente en este algoritmo consiste en ir explorando todos los caminos más cortos que parten del nodo origen y que llevan a todos los demás nodos. El método devuelve una tupla con el camino entre el nodo origen y el destino, y el peso resultante para ese camino.
El algoritmo de Dijkstra fue concebido por el informático Edsger W. Dijkstra en 1958 y publicado tres años después. Este algoritmo no funciona en grafos ponderados con arcos de coste negativo.

Construimos un script para probar nuestro módulo de tratamiento de grafos ponderados. Los comentarios en cada sentencia son suficientemente descriptivos.

import grafo_ponderado as GRP


# Grafo ejemplo con listas de adyacencia y pesos asociados
grafo = {'A': [('B', 1), ('C', 2), ('D', 3)],
         'B': [('A', 1), ('C', 4)],
         'C': [('A', 2), ('B', 4), ('D', 5)],
         'D': [('A', 3), ('C', 5)]}


def testGrafo():
    g = GRP.WeightedGraph(grafo)    # Crear el grafo con el diccionario de ejemplo

    print('Grafo')
    print(g)                    # Visualizar el grafo
    print('Iteración')
    for n in g:                 # Iterar sobre los nodos y arcos del grafo
        print(n)
        print(g.edges(n))
    print('Nodos:', g.nodes())  # Visualizar todos los nodos
    print('Arcos:', g.edges())  # Visualizar todos los arcos
    print('Arcos desde el nodo C:', g.edges('C'))   # Arcos desde un nodo

    print('\nComprobaciones')
    print('Vacío:', g.isEmpty())                    # Comprobar si grafo vacío
    print('Existe nodo A:', g.existNode('A'))       # Comprobar si existe nodo
    print('Existe nodo Z:', g.existNode('Z'))
    print('Existe arco A-C:', g.existEdge(['A', 'C']))  # Comprobar arco
    print('Existe arco A-Z:', g.existEdge(['A', 'Z']))

    print('\nAñadir nodos y arcos')
    g.addNode('E')              # Añadir nodo
    g.addEdge(('A', 'E'), 6)    # Añadir arco con el peso
    g.addEdge(('B', 'F'), 4)
    g.addEdge(('B', 'G'), 5)
    print('Grafo resultante')
    print(g)

    print('Medidas')
    print('Grado de B:', g.degree('B'))     # Obtener grado de un nodo
    print('Grado de D:', g.degree('D'))
    print('Delta:', g.Delta())              # Obtener Delta del grafo
    print('Orden:', g.order())              # Obtener orden
    print('Tamaño:', g.size())              # Obtener tamaño

    print('\nRecorridos')
    print("DFS('A'):", g.DFS('A'))          # Recorrido DFS
    print("BFS('A'):", g.BFS('A'))          # Recorrido BFS
    
    # Obtener todos los caminos posibles entre dos nodos
    print(g.findPaths('A', 'C'))
    # Obtener el camino más corto entre dos nodos
    path, weight = g.shortestPath('D', 'G')
    print(f'Dijkstra: ruta D-G:{path} peso:{weight}')
    
    print('\nEliminar arcos y nodos')
    g.removeEdge(['D', 'A'])    # Eliminar arco
    g.removeNode('C')           # Eliminar nodo
    print('Grafo resultante')
    print(g)
    print('Nodos aislados:', g.isolatedNodes()) # Visualizar nodos aislados


if __name__ == '__main__':
    testGrafo()

El resultado de la ejecución del programa de prueba es:

Grafo
A: [('B', 1), ('C', 2), ('D', 3)]
B: [('A', 1), ('C', 4)]
C: [('A', 2), ('B', 4), ('D', 5)]
D: [('A', 3), ('C', 5)]

Iteración
A
[('A', ('B', 1)), ('A', ('C', 2)), ('A', ('D', 3))]
B
[('B', ('A', 1)), ('B', ('C', 4))]
C
[('C', ('A', 2)), ('C', ('B', 4)), ('C', ('D', 5))]
D
[('D', ('A', 3)), ('D', ('C', 5))]
Nodos: ['A', 'B', 'C', 'D']
Arcos: [('A', ('B', 1)), ('A', ('C', 2)), ('A', ('D', 3)), ('B', ('A', 1)), ('B', ('C', 4)), ('C', ('A', 2)), ('C', ('B', 4)), ('C', ('D', 5)), ('D', ('A', 3)), ('D', ('C', 5))]
Arcos desde el nodo C: [('C', ('A', 2)), ('C', ('B', 4)), ('C', ('D', 5))]

Comprobaciones
Vacío: False
Existe nodo A: True
Existe nodo Z: False
Existe arco A-C: False
Existe arco A-Z: False

Añadir nodos y arcos
Grafo resultante
A: [('B', 1), ('C', 2), ('D', 3), ('E', 6)]
B: [('A', 1), ('C', 4), ('F', 4), ('G', 5)]
C: [('A', 2), ('B', 4), ('D', 5)]
D: [('A', 3), ('C', 5)]
E: [('A', 6)]
F: [('B', 4)]
G: [('B', 5)]

Medidas
Grado de B: 4
Grado de D: 2
Delta: 4
Orden: 7
Tamaño: 8

Recorridos
DFS('A'): ['A', 'E', 'D', 'C', 'B', 'G', 'F']
BFS('A'): ['A', 'B', 'C', 'D', 'E', 'F', 'G']
[['A', 'B', 'C'], ['A', 'C'], ['A', 'D', 'C']]
Dijkstra: ruta D-G:['D', 'A', 'B', 'G'] peso:9

Eliminar arcos y nodos
Grafo resultante
A: [('B', 1), ('E', 6)]
B: [('A', 1), ('F', 4), ('G', 5)]
D: []
E: [('A', 6)]
F: [('B', 4)]
G: [('B', 5)]

Nodos aislados: ['D']

Grafos

  • Grafos
    • Recorridos
      • Extensión de coste mínimo
    • Representaciones
    • Grafo no dirigido
    • Grafo dirigido o digrafo
    • Grafo ponderado