Emtix

Travesía de Grafos: BFS y DFS

Publicado el 1 de diciembre de 2025

Fundamentos de la Travesía de Grafos

La teoría de grafos representa un tema unificador dentro de la ciencia de la computación. Esta formalización abstracta nos permite modelar estructuras diversas: sistemas de transporte, interacciones sociales, redes de telecomunicaciones, circuitos eléctricos y más.


1. Definición: ¿Qué son los Grafos?

Grafo

Un grafo G=(V,E)G = (V, E) consiste en:

  • Un conjunto de vértices (o nodos): VV
  • Un conjunto de aristas (o edges): EE, que son pares de vértices

Las aristas pueden ser ordenadas (grafos dirigidos) o no ordenadas (grafos no dirigidos).

Ejemplos del Mundo Real

Éxito

Grafos en acción:

  • Red vial: Ciudades = vértices, carreteras = aristas
  • Red social: Personas = vértices, amistades = aristas
  • Circuito eléctrico: Componentes = vértices, conexiones = aristas
  • Web: Páginas = vértices, enlaces = aristas
  • Dependencias de software: Módulos = vértices, dependencias = aristas
Representación simple de un grafo
# Grafo simple: red social
grafo_simple = {
    'Alice': ['Bob', 'Charlie'],
    'Bob': ['Alice', 'David'],
    'Charlie': ['Alice', 'David', 'Eve'],
    'David': ['Bob', 'Charlie'],
    'Eve': ['Charlie']
}

# Visualización textual

print("Red Social:")
for persona, amigos in grafo_simple.items():
print(f"{persona} es amigo de: {', '.join(amigos)}")

2. Propósito: La Relevancia de los Algoritmos de Grafos

¿Por qué Grafos?

El principal propósito de utilizar grafos es proporcionar un lenguaje formal para describir las propiedades de las relaciones.

Muchos problemas complejos tienen una descripción y solución simple cuando se modelan en términos de grafos.

La Operación Fundamental: Travesía

Travesía de Grafos

La travesía (o recorrido) busca visitar sistemáticamente cada arista y cada vértice de un grafo.

Es la clave para operaciones básicas como:

  • Copiar estructuras
  • Imprimir grafos
  • Buscar caminos
  • Detectar ciclos
  • Encontrar componentes conectados
Quiz

¿Cuál es la operación más fundamental en la teoría de grafos?


3. Tipos de Grafos y Propiedades

Clasificación de Grafos

PropiedadDescripciónEjemplo
Dirigido vs No dirigido¿La arista (x,y)(x, y) implica (y,x)(y, x)?Red social (no dir.) vs Flujo de programa (dir.)
Ponderado vs No ponderado¿Cada arista tiene un peso/costo?Red vial con distancias vs Grafo de amistad
Esparso vs Denso¿Qué fracción de pares tiene aristas?Red de carreteras (esparso) vs Red completa (denso)
Cíclico vs Acíclico¿Contiene ciclos?Red social (cíclico) vs Árbol genealógico (acíclico)

Grafos Dirigidos vs No Dirigidos

Implementación de grafos dirigidos y no dirigidos
class Grafo:
    def __init__(self, dirigido=False):
        self.dirigido = dirigido
        self.adyacencia = {}

    def agregar_vertice(self, v):
        """Agregar un vértice al grafo"""
        if v not in self.adyacencia:
            self.adyacencia[v] = []

    def agregar_arista(self, v1, v2, peso=None):
        """Agregar una arista entre v1 y v2"""
        self.agregar_vertice(v1)
        self.agregar_vertice(v2)

        # Agregar arista v1 -> v2
        self.adyacencia[v1].append((v2, peso) if peso else v2)

        # Si no es dirigido, agregar v2 -> v1 también
        if not self.dirigido:
            self.adyacencia[v2].append((v1, peso) if peso else v1)

    def __repr__(self):
        lineas = [f"Grafo ({'Dirigido' if self.dirigido else 'No Dirigido'}):"]
        for vertice, vecinos in self.adyacencia.items():
            lineas.append(f"  {vertice} -> {vecinos}")
        return '\n'.join(lineas)

# Ejemplo: Grafo no dirigido (red social)
g_social = Grafo(dirigido=False)
g_social.agregar_arista('Alice', 'Bob')
g_social.agregar_arista('Alice', 'Charlie')
g_social.agregar_arista('Bob', 'David')
print(g_social)

print("\n" + "="*50 + "\n")

# Ejemplo: Grafo dirigido (flujo de programa)
g_flujo = Grafo(dirigido=True)
g_flujo.agregar_arista('inicio', 'proceso1')
g_flujo.agregar_arista('proceso1', 'decision')
g_flujo.agregar_arista('decision', 'proceso2')
g_flujo.agregar_arista('decision', 'fin')
print(g_flujo)

DAG: Grafos Acíclicos Dirigidos

DAG - Directed Acyclic Graph

Un DAG es un grafo dirigido que no contiene ciclos.

Aplicaciones cruciales:

  • Dependencias de compilación
  • Programación de tareas con precedencias
  • Control de versiones (Git)
  • Estructuras de herencia
  • Pipelines de procesamiento de datos
Ejemplo: Dependencias de módulos
# Sistema de dependencias de módulos
dependencias = {
    'main.py': ['utils.py', 'config.py'],
    'utils.py': ['logger.py'],
    'config.py': ['logger.py'],
    'logger.py': []
}

# Visualizar

print("Sistema de Dependencias (DAG):")
print("="\*50)
for modulo, deps in dependencias.items():
if deps:
print(f"{modulo} depende de: {', '.join(deps)}")
else:
print(f"{modulo} (sin dependencias)")

# Este es un DAG válido porque no hay ciclos:

# main.py -> utils.py -> logger.py

# main.py -> config.py -> logger.py

# Ningún módulo depende transitivamente de sí mismo

Ciclo inválido (NO es DAG):

A depende de B
B depende de C
C depende de A  ← ¡Ciclo! No es DAG

Este tipo de dependencia circular causaría problemas en compilación.

Quiz

¿Qué tipo de grafo se usa típicamente para representar dependencias de compilación?


4. Estructuras de Datos para Grafos

Matriz de Adyacencia vs Lista de Adyacencia

Elección Crítica

La elección de la estructura de datos tiene un impacto directo en el rendimiento de los algoritmos de grafos.

Matriz de Adyacencia

Representación: Matriz n×nn \times n donde M[i][j] = 1 si existe arista de ii a jj

Ventajas:

  • ✅ Verificación rápida: ¿Existe arista (i,j)(i,j)? → O(1)O(1)
  • ✅ Simple de implementar
  • ✅ Buena para grafos densos

Desventajas:

  • ❌ Espacio: O(n2)O(n^2) siempre, incluso para grafos esparsos
  • ❌ Recorrido de vecinos: O(n)O(n) (debe revisar toda la fila)

Lista de Adyacencia

Representación: Array/diccionario de listas enlazadas (cada vértice tiene lista de vecinos)

Ventajas:

  • ✅ Espacio eficiente: O(n+m)O(n + m) donde mm = número de aristas
  • ✅ Recorrido rápido de vecinos: O(grado(v))O(grado(v))
  • ✅ Ideal para grafos esparsos

Desventajas:

  • ❌ Verificación de arista: O(grado(v))O(grado(v)) en lugar de O(1)O(1)

Comparación Práctica

OperaciónMatriz de AdyacenciaLista de Adyacencia
EspacioO(n2)O(n^2)O(n+m)O(n + m)
¿Existe arista (u,v)(u,v)?O(1)O(1)O(grado(u))O(grado(u))
Listar vecinos de vvO(n)O(n)O(grado(v))O(grado(v))
Agregar aristaO(1)O(1)O(1)O(1)
Eliminar aristaO(1)O(1)O(grado(v))O(grado(v))
Travesía completaO(n2)O(n^2)O(n+m)O(n + m)

Recomendación

Por defecto, usar listas de adyacencia para la mayoría de las aplicaciones:

  • Son más rápidas para recorrer el grafo
  • Usan menos memoria en grafos esparsos (la mayoría de los grafos reales)
  • Complejidad óptima para travesía: O(n+m)O(n + m)
Implementación de ambas estructuras
class GrafoMatriz:
    """Grafo usando Matriz de Adyacencia"""
    def __init__(self, n_vertices):
        self.n = n_vertices
        self.matriz = [[0] * n_vertices for _ in range(n_vertices)]

    def agregar_arista(self, u, v):
        self.matriz[u][v] = 1
        self.matriz[v][u] = 1  # No dirigido

    def existe_arista(self, u, v):
        return self.matriz[u][v] == 1

    def vecinos(self, v):
        return [i for i in range(self.n) if self.matriz[v][i] == 1]

    def mostrar(self):
        print("Matriz de Adyacencia:")
        for fila in self.matriz:
            print(fila)

class GrafoLista:
    """Grafo usando Lista de Adyacencia"""
    def __init__(self):
        self.lista = {}

    def agregar_vertice(self, v):
        if v not in self.lista:
            self.lista[v] = []

    def agregar_arista(self, u, v):
        self.agregar_vertice(u)
        self.agregar_vertice(v)
        self.lista[u].append(v)
        self.lista[v].append(u)  # No dirigido

    def existe_arista(self, u, v):
        return v in self.lista.get(u, [])

    def vecinos(self, v):
        return self.lista.get(v, [])

    def mostrar(self):
        print("Lista de Adyacencia:")
        for vertice, vecinos in self.lista.items():
            print(f"{vertice}: {vecinos}")

# Ejemplo comparativo
print("=== Grafo con 4 vértices (0, 1, 2, 3) ===\n")

# Usando Matriz
print("MATRIZ DE ADYACENCIA:")
g_matriz = GrafoMatriz(4)
g_matriz.agregar_arista(0, 1)
g_matriz.agregar_arista(0, 2)
g_matriz.agregar_arista(1, 3)
g_matriz.mostrar()
print(f"Vecinos de 0: {g_matriz.vecinos(0)}")
print(f"¿Existe arista (0,1)? {g_matriz.existe_arista(0, 1)}")

print("\n" + "="*50 + "\n")

# Usando Lista
print("LISTA DE ADYACENCIA:")
g_lista = GrafoLista()
g_lista.agregar_arista(0, 1)
g_lista.agregar_arista(0, 2)
g_lista.agregar_arista(1, 3)
g_lista.mostrar()
print(f"Vecinos de 0: {g_lista.vecinos(0)}")
print(f"¿Existe arista (0,1)? {g_lista.existe_arista(0, 1)}")

# Comparación de espacio
print("\n" + "="*50)
print("COMPARACIÓN DE ESPACIO:")
print(f"Matriz: Siempre 4x4 = 16 posiciones (O(n²))")
print(f"Lista: Solo almacena 4 aristas x 2 = 8 entradas (O(n+m))")
print(f"Ahorro: 50% en este caso (mejor en grafos más esparsos)")
Quiz

¿Cuándo es preferible usar una matriz de adyacencia sobre una lista de adyacencia?


5. Algoritmos de Travesía: BFS y DFS

Dos Métodos Fundamentales

Existen dos métodos primarios de travesía que difieren en el orden en que exploran los vértices:

  • BFS (Breadth-First Search): Búsqueda en Amplitud
  • DFS (Depth-First Search): Búsqueda en Profundidad

Estados de los Vértices

Durante la travesía, cada vértice pasa por tres estados:

EstadoDescripciónColor típico
UndiscoveredEstado inicial, no visitadoBlanco
DiscoveredEncontrado, pero vecinos no exploradosGris
ProcessedVisitados todos los vecinosNegro

BFS vs DFS: Diferencia Clave

La Estructura de Datos Define el Orden

La diferencia fundamental entre BFS y DFS radica en la estructura de datos usada para almacenar los vértices descubiertos pero no procesados:

  • BFS: Usa una COLA (FIFO) → Explora por niveles
  • DFS: Usa una PILA (LIFO) o recursión → Explora en profundidad

6. Búsqueda en Amplitud (BFS)

BFS - Breadth-First Search

Estrategia: Explorar todos los vecinos de un vértice antes de avanzar al siguiente nivel.

Resultado: Las exploraciones “irradian” lentamente desde el vértice inicial, como ondas en el agua.

Algoritmo BFS

Implementación completa de BFS
from collections import deque

def bfs(grafo, inicio):
"""
Búsqueda en Amplitud (BFS)

    Retorna:
        visitados: conjunto de vértices visitados
        padres: diccionario de padres (para reconstruir caminos)
        distancias: diccionario de distancias desde inicio
    """
    # Inicializar estructuras
    visitados = set()
    padres = {inicio: None}
    distancias = {inicio: 0}
    cola = deque([inicio])

    print(f"Iniciando BFS desde {inicio}\n")

    while cola:
        # Sacar el primer elemento (FIFO)
        actual = cola.popleft()

        if actual in visitados:
            continue

        # Procesar vértice actual
        visitados.add(actual)
        print(f"Procesando: {actual} (distancia: {distancias[actual]})")

        # Explorar vecinos
        for vecino in grafo.get(actual, []):
            if vecino not in visitados and vecino not in padres:
                padres[vecino] = actual
                distancias[vecino] = distancias[actual] + 1
                cola.append(vecino)
                print(f"  Descubierto: {vecino} (padre: {actual})")

    return visitados, padres, distancias

def reconstruir_camino(padres, inicio, fin):
"""Reconstruir el camino más corto de inicio a fin"""
if fin not in padres:
return None

    camino = []
    actual = fin
    while actual is not None:
        camino.append(actual)
        actual = padres[actual]

    camino.reverse()
    return camino

# Ejemplo: Red de ciudades

ciudades = {
'Madrid': ['Barcelona', 'Valencia', 'Sevilla'],
'Barcelona': ['Madrid', 'Valencia', 'Zaragoza'],
'Valencia': ['Madrid', 'Barcelona', 'Sevilla'],
'Sevilla': ['Madrid', 'Valencia', 'Málaga'],
'Zaragoza': ['Barcelona', 'Bilbao'],
'Málaga': ['Sevilla'],
'Bilbao': ['Zaragoza']
}

print("="*60)
print("BÚSQUEDA EN AMPLITUD (BFS)")
print("="*60 + "\n")

visitados, padres, distancias = bfs(ciudades, 'Madrid')

print("\n" + "="*60)
print("RESULTADOS:")
print("="*60)
print(f"\nVértices visitados: {visitados}")
print(f"\nDistancias desde Madrid:")
for ciudad, dist in sorted(distancias.items(), key=lambda x: x[1]):
print(f" {ciudad}: {dist} pasos")

print(f"\nCamino más corto de Madrid a Bilbao:")
camino = reconstruir_camino(padres, 'Madrid', 'Bilbao')
print(f" {' -> '.join(camino)}")
print(f" Distancia: {len(camino) - 1} pasos")

Propiedades de BFS

Garantías de BFS

En grafos NO ponderados:

  1. BFS encuentra el camino más corto (menor número de aristas)
  2. Define un árbol de expansión BFS con la raíz en el inicio
  3. Los vértices se visitan por niveles de distancia
  4. Complejidad: O(n+m)O(n + m) con lista de adyacencia

Advertencia

BFS solo garantiza el camino más corto en grafos NO ponderados.

Para grafos ponderados, usar Dijkstra o A*.

Grafo de ejemplo:

         A --- B --- D
         |     |
         C --- E

Ejecución de BFS desde A:

Paso 0: Iniciar
  Cola: [A]
  Visitados: {}

Paso 1: Procesar A
  Cola: [B, C]
  Visitados: {A}
  Descubiertos: B (padre: A), C (padre: A)

Paso 2: Procesar B
  Cola: [C, D, E]
  Visitados: {A, B}
  Descubiertos: D (padre: B), E (padre: B)

Paso 3: Procesar C
  Cola: [D, E]
  Visitados: {A, B, C}
  E ya descubierto, no se agrega

Paso 4: Procesar D
  Cola: [E]
  Visitados: {A, B, C, D}

Paso 5: Procesar E
  Cola: []
  Visitados: {A, B, C, D, E}

¡Finalizado!

Árbol BFS resultante:

            A
           / \
          B   C
         / \
        D   E

Distancias desde A:

  • A: 0
  • B, C: 1
  • D, E: 2

7. Búsqueda en Profundidad (DFS)

DFS - Depth-First Search

Estrategia: Explorar tan profundo como sea posible antes de retroceder.

Resultado: Las exploraciones avanzan rápidamente a lo largo de un camino, retrocediendo solo cuando no hay más vecinos no descubiertos.

Algoritmo DFS

Implementación completa de DFS (iterativa y recursiva)
def dfs_recursivo(grafo, inicio, visitados=None, tiempo=[0],
                  tiempo_entrada=None, tiempo_salida=None, padres=None):
    """
    DFS Recursivo con tiempos de entrada y salida

    Los tiempos son cruciales para:
    - Ordenación topológica
    - Detección de ciclos
    - Clasificación de aristas
    """
    if visitados is None:
        visitados = set()
        tiempo_entrada = {}
        tiempo_salida = {}
        padres = {inicio: None}

    # Marcar como visitado y registrar tiempo de entrada
    visitados.add(inicio)
    tiempo[0] += 1
    tiempo_entrada[inicio] = tiempo[0]
    print(f"Descubriendo {inicio} (tiempo: {tiempo_entrada[inicio]})")

    # Explorar vecinos
    for vecino in grafo.get(inicio, []):
        if vecino not in visitados:
            padres[vecino] = inicio
            dfs_recursivo(grafo, vecino, visitados, tiempo,
                         tiempo_entrada, tiempo_salida, padres)

    # Marcar como procesado y registrar tiempo de salida
    tiempo[0] += 1
    tiempo_salida[inicio] = tiempo[0]
    print(f"Finalizando {inicio} (tiempo: {tiempo_salida[inicio]})")

    return visitados, tiempo_entrada, tiempo_salida, padres

def dfs_iterativo(grafo, inicio):
    """
    DFS Iterativo usando una pila explícita
    """
    visitados = set()
    pila = [inicio]
    padres = {inicio: None}
    orden = []

    print(f"Iniciando DFS desde {inicio}\n")

    while pila:
        # Sacar el último elemento (LIFO)
        actual = pila.pop()

        if actual in visitados:
            continue

        # Procesar vértice
        visitados.add(actual)
        orden.append(actual)
        print(f"Procesando: {actual}")

        # Agregar vecinos a la pila (en orden inverso para mantener orden)
        vecinos = list(grafo.get(actual, []))
        for vecino in reversed(vecinos):
            if vecino not in visitados:
                pila.append(vecino)
                if vecino not in padres:
                    padres[vecino] = actual
                print(f"  Apilando: {vecino}")

    return visitados, orden, padres

# Ejemplo: Sistema de dependencias
dependencias = {
    'main': ['module_a', 'module_b'],
    'module_a': ['utils', 'config'],
    'module_b': ['utils'],
    'utils': ['logger'],
    'config': ['logger'],
    'logger': []
}

print("="*60)
print("BÚSQUEDA EN PROFUNDIDAD (DFS) - VERSIÓN RECURSIVA")
print("="*60 + "\n")

visitados, entrada, salida, padres = dfs_recursivo(dependencias, 'main')

print("\n" + "="*60)
print("Tiempos de entrada y salida:")
print("="*60)
for modulo in sorted(entrada.keys(), key=lambda x: entrada[x]):
    print(f"{modulo}: entrada={entrada[modulo]}, salida={salida[modulo]}")

print("\n" + "="*60)
print("BÚSQUEDA EN PROFUNDIDAD (DFS) - VERSIÓN ITERATIVA")
print("="*60 + "\n")

visitados_iter, orden_iter, padres_iter = dfs_iterativo(dependencias, 'main')

print("\n" + "="*60)
print(f"Orden de visita: {' -> '.join(orden_iter)}")

Propiedades Clave de DFS

Tiempos de Entrada y Salida

DFS organiza los vértices por tiempo de entrada y tiempo de salida.

Estos tiempos son cruciales para:

  • Ordenación topológica
  • Detección de ciclos
  • Clasificación de aristas
  • Identificación de componentes fuertemente conectados

Clasificación de Aristas en DFS

Durante DFS, las aristas se clasifican en:

Tipo de AristaDescripciónIndicador
Tree EdgeArista del árbol DFSLleva a vértice no descubierto
Back EdgeArista hacia ancestroLleva a vértice ancestro en pila
Forward EdgeArista hacia descendienteLleva a descendiente ya visitado
Cross EdgeEntre subárbolesLleva a vértice en otro subárbol

Detección de Ciclos

Si durante DFS en un grafo dirigido encontramos una Back Edge, esto indica un ciclo dirigido.

Por lo tanto, el grafo NO es un DAG.

Quiz

¿Cuál es la diferencia principal entre BFS y DFS en términos de estructura de datos?


8. Aplicación: Ordenación Topológica

Ordenación Topológica

La ordenación topológica es la operación más importante en los DAGs.

Ordena los vértices de modo que todas las aristas dirigidas vayan de izquierda a derecha.

Propósito

Éxito

Define un horario factible cuando las aristas representan restricciones de precedencia:

  • Orden de compilación de módulos
  • Secuencia de tareas con dependencias
  • Currículo de cursos con prerrequisitos
  • Pipeline de procesamiento de datos

Algoritmo con DFS

Ordenación Topológica usando DFS
def ordenacion_topologica(grafo):
    """
    Ordenación Topológica usando DFS
    
    Algoritmo: Etiquetar vértices en orden INVERSO a su tiempo de finalización
    """
    visitados = set()
    pila_ordenada = []
    tiene_ciclo = [False]
    
    def dfs_topologico(v, en_pila):
        """DFS modificado para ordenación topológica y detección de ciclos"""
        if v in en_pila:
            # Back edge encontrada - hay un ciclo
            tiene_ciclo[0] = True
            return
        
        if v in visitados:
            return
        
        visitados.add(v)
        en_pila.add(v)
        
        # Explorar vecinos
        for vecino in grafo.get(v, []):
            dfs_topologico(vecino, en_pila)
        
        en_pila.remove(v)
        # CLAVE: Agregar a la pila cuando se FINALIZA el vértice
        pila_ordenada.append(v)
    
    # Ejecutar DFS desde todos los vértices no visitados
    for vertice in grafo:
        if vertice not in visitados:
            dfs_topologico(vertice, set())
    
    if tiene_ciclo[0]:
        return None  # No hay ordenación topológica (grafo tiene ciclo)
    
    # La pila contiene los vértices en orden inverso al de finalización
    # Invertir para obtener el orden topológico
    return list(reversed(pila_ordenada))

# Ejemplo 1: Cursos con prerrequisitos

cursos = {
'Cálculo 1': [],
'Cálculo 2': ['Cálculo 1'],
'Álgebra Lineal': ['Cálculo 1'],
'Ecuaciones Diferenciales': ['Cálculo 2'],
'Machine Learning': ['Álgebra Lineal', 'Programación'],
'Programación': [],
'Estructuras de Datos': ['Programación'],
'Algoritmos': ['Estructuras de Datos', 'Cálculo 2']
}

print("="*60)
print("ORDENACIÓN TOPOLÓGICA: Cursos con Prerrequisitos")
print("="*60 + "\n")

orden = ordenacion_topologica(cursos)

if orden:
print("Orden válido para tomar los cursos:\n")
for i, curso in enumerate(orden, 1):
prereqs = cursos[curso]
prereq_str = ', '.join(prereqs) if prereqs else 'Ninguno'
print(f"{i}. {curso}")
print(f" Prerrequisitos: {prereq_str}\n")
else:
print("ERROR: El grafo contiene un ciclo (dependencia circular)")

# Ejemplo 2: Sistema con ciclo (inválido)

print("\n" + "="*60)
print("EJEMPLO CON CICLO (No válido para ordenación topológica)")
print("="*60 + "\n")

sistema_ciclico = {
'A': ['B'],
'B': ['C'],
'C': ['A'] # ¡Ciclo!
}

orden_ciclico = ordenacion_topologica(sistema_ciclico)
if orden_ciclico is None:
print("⚠️ ERROR: Dependencia circular detectada")
print("A -> B -> C -> A (ciclo)")
print("\nNo es posible una ordenación topológica válida.")

DAG de ejemplo (tareas de construcción):

                  Cimientos
                      |
                   Paredes
                   /     \
                  /       \
               Techo   Instalación eléctrica
                 |             |
              Pintura          |
                  \           /
                   \         /
                    Acabados

Dependencias:

tareas = {
    'Cimientos': [],
    'Paredes': ['Cimientos'],
    'Techo': ['Paredes'],
    'Instalación eléctrica': ['Paredes'],
    'Pintura': ['Techo'],
    'Acabados': ['Pintura', 'Instalación eléctrica']
}

Ejecución DFS con tiempos de finalización:

DFS desde 'Cimientos':
  Descubre: Cimientos (tiempo 1)
  Finaliza: Cimientos (tiempo 2) ← Agregar a pila

DFS desde 'Paredes':
  Descubre: Paredes (tiempo 3)
  Finaliza: Paredes (tiempo 4) ← Agregar a pila

DFS desde 'Techo':
  Descubre: Techo (tiempo 5)
  Finaliza: Techo (tiempo 6) ← Agregar a pila

DFS desde 'Instalación eléctrica':
  Descubre: Instalación eléctrica (tiempo 7)
  Finaliza: Instalación eléctrica (tiempo 8) ← Agregar a pila

DFS desde 'Pintura':
  Descubre: Pintura (tiempo 9)
  Finaliza: Pintura (tiempo 10) ← Agregar a pila

DFS desde 'Acabados':
  Descubre: Acabados (tiempo 11)
  Finaliza: Acabados (tiempo 12) ← Agregar a pila

Pila (orden de finalización): [Cimientos, Paredes, Techo, Instalación eléctrica, Pintura, Acabados]

Ordenación topológica (invertir pila): [Cimientos, Paredes, Instalación eléctrica, Techo, Pintura, Acabados]

o también válido:

[Cimientos, Paredes, Techo, Instalación eléctrica, Pintura, Acabados]

(Puede haber múltiples ordenaciones válidas)


9. Aplicación: Vértices de Articulación

Vértices de Articulación

Un vértice de articulación (o cut-node) es un punto único de falla en un grafo.

Su eliminación desconecta un componente conectado.

Propósito

Peligro

Identificar vulnerabilidades en redes:

  • Redes de computadoras: Puntos críticos cuya falla aísla segmentos
  • Redes de transporte: Intersecciones críticas
  • Redes sociales: Personas que conectan grupos aislados
  • Infraestructura: Componentes cuyo fallo causa desconexión

Algoritmo con DFS

Encontrar vértices de articulación
def encontrar_articulaciones(grafo):
    """
    Encuentra vértices de articulación usando DFS
    
    Usa dos arrays:
    - entry_time: tiempo de descubrimiento
    - low: ancestro más temprano alcanzable
    """
    tiempo = [0]
    visitados = set()
    entry_time = {}
    low = {}
    padres = {}
    articulaciones = set()
    
    def dfs_articulacion(v, padre=None):
        hijos = 0
        visitados.add(v)
        tiempo[0] += 1
        entry_time[v] = low[v] = tiempo[0]
        
        for vecino in grafo.get(v, []):
            if vecino == padre:
                continue
            
            if vecino in visitados:
                # Back edge: actualizar low
                low[v] = min(low[v], entry_time[vecino])
            else:
                # Tree edge
                hijos += 1
                padres[vecino] = v
                dfs_articulacion(vecino, v)
                
                # Actualizar low del padre
                low[v] = min(low[v], low[vecino])
                
                # Verificar si v es articulación
                if padre is None:
                    # Caso raíz: es articulación si tiene más de 1 hijo
                    if hijos > 1:
                        articulaciones.add(v)
                else:
                    # Caso no-raíz: es articulación si algún hijo
                    # no puede alcanzar un ancestro más arriba
                    if low[vecino] >= entry_time[v]:
                        articulaciones.add(v)
    
    # Ejecutar DFS desde todos los componentes
    for vertice in grafo:
        if vertice not in visitados:
            dfs_articulacion(vertice)
    
    return articulaciones, entry_time, low

# Ejemplo: Red de servidores

red_servidores = {
'A': ['B', 'C'],
'B': ['A', 'D'],
'C': ['A', 'E'],
'D': ['B', 'F'],
'E': ['C', 'F'],
'F': ['D', 'E', 'G'],
'G': ['F']
}

print("="*60)
print("VÉRTICES DE ARTICULACIÓN: Red de Servidores")
print("="*60 + "\n")

print("Red de servidores:")
for servidor, conexiones in red_servidores.items():
print(f" {servidor} conectado a: {', '.join(conexiones)}")

articulaciones, entry, low = encontrar_articulaciones(red_servidores)

print("\n" + "="*60)
print("PUNTOS CRÍTICOS DETECTADOS:")
print("="*60 + "\n")

if articulaciones:
print(f"⚠️ Vértices de articulación: {', '.join(sorted(articulaciones))}\n")
print("ADVERTENCIA: La falla de estos servidores causará desconexión de la red.\n")

    for v in sorted(articulaciones):
        print(f"Servidor {v}:")
        print(f"  - Tiempo de entrada: {entry[v]}")
        print(f"  - Ancestro más temprano alcanzable: {low[v]}")
        print()

else:
print("✓ No hay vértices de articulación.")
print(" La red es robusta - ningún servidor único causa desconexión.\n")

# Análisis adicional

print("="*60)
print("ANÁLISIS DE VULNERABILIDAD:")
print("="*60 + "\n")

for v in grafo.keys():
if v in articulaciones:
print(f"❌ {v}: CRÍTICO - Punto único de falla")
else:
print(f"✓ {v}: Seguro - No es punto único de falla")

Teorema:

Un vértice vv es una articulación si y solo si:

  1. Caso raíz DFS: vv es la raíz del árbol DFS y tiene más de un hijo

  2. Caso no-raíz: vv tiene un hijo ww tal que ningún vértice en el subárbol de ww tiene una arista de retorno (back edge) a un ancestro de vv

Formalización:

Para cada vértice vv, definimos:

  • entry_time[v]: Tiempo de descubrimiento en DFS
  • low[v]: Tiempo de entrada más temprano alcanzable desde el subárbol de vv (incluyendo back edges)

Condición:

vv (no-raíz) es articulación si existe hijo ww tal que:

low[w]entry_time[v]low[w] \geq entry\_time[v]

Esto significa que ww no puede “escapar” hacia arriba de vv.

Ejemplo visual:

           1 (raíz)
          / \
         /   \
        2     5
       / \     \
      3   4     6
  • 1 es articulación: raíz con 2+ hijos
  • 2 es articulación: low[3] = 2 >= entry_time[2] = 2
  • 5 es articulación: low[6] = 6 >= entry_time[5] = 5
Quiz

¿Qué indica si encontramos una Back Edge durante DFS en un grafo dirigido?


10. Análisis de Complejidad

Complejidad Temporal

OperaciónMatriz de AdyacenciaLista de Adyacencia
BFSO(n2)O(n^2)O(n+m)O(n + m)
DFSO(n2)O(n^2)O(n+m)O(n + m)
Ordenación TopológicaO(n2)O(n^2)O(n+m)O(n + m)
ArticulacionesO(n2)O(n^2)O(n+m)O(n + m)

Donde:

  • nn = número de vértices
  • mm = número de aristas

Complejidad Óptima

Con listas de adyacencia, los algoritmos de travesía son lineales: O(n+m)O(n + m)

Esto es óptimo porque debemos visitar cada vértice y cada arista al menos una vez.

Complejidad Espacial

EstructuraEspacio
Matriz de AdyacenciaO(n2)O(n^2)
Lista de AdyacenciaO(n+m)O(n + m)
BFS (cola)O(n)O(n)
DFS (pila/recursión)O(n)O(n)

11. Resumen y Comparación

Estructuras de Datos

Matriz de Adyacencia:

  • ✅ Verificación de arista: O(1)O(1)
  • ❌ Espacio: O(n2)O(n^2)
  • ❌ Travesía: O(n2)O(n^2)
  • Usar cuando: Grafo denso, verificación frecuente de aristas

Lista de Adyacencia:

  • ✅ Espacio: O(n+m)O(n + m)
  • ✅ Travesía: O(n+m)O(n + m)
  • ❌ Verificación de arista: O(grado)O(grado)
  • Usar cuando: Grafo esparso (mayoría de casos reales)

Algoritmos de Travesía

BFS (Búsqueda en Amplitud):

  • Usa cola (FIFO)
  • Explora por niveles
  • Encuentra caminos más cortos (grafos no ponderados)
  • Ideal para: distancias, niveles, conectividad
  • Complejidad: O(n+m)O(n + m)

DFS (Búsqueda en Profundidad):

  • Usa pila (LIFO) o recursión
  • Explora en profundidad
  • Genera tiempos de entrada/salida
  • Ideal para: ciclos, ordenación topológica, articulaciones
  • Complejidad: O(n+m)O(n + m)

Aplicaciones Clave

ProblemaAlgoritmoComplejidad
Camino más corto (no ponderado)BFSO(n+m)O(n + m)
Ordenación topológicaDFSO(n+m)O(n + m)
Detección de ciclos (dirigido)DFSO(n+m)O(n + m)
Vértices de articulaciónDFSO(n+m)O(n + m)
Componentes conectadosBFS o DFSO(n+m)O(n + m)

Tipos de Grafos

  • Dirigido vs No dirigido
  • Ponderado vs No ponderado
  • Esparso vs Denso
  • DAG vs Cíclico

Reglas de Oro

  1. Por defecto, usar listas de adyacencia (mayoría de grafos son esparsos)
  2. BFS para distancias/niveles en grafos no ponderados
  3. DFS para estructura temporal (ciclos, topología, articulaciones)
  4. Complejidad óptima: O(n+m)O(n + m) es el objetivo para travesía
Quiz

¿Qué complejidad tiene BFS con lista de adyacencia?


12. Cierre: Perspectivas sobre Algoritmos de Grafos

Fundamentos Universales

Los procesos de travesía BFS y DFS proporcionan la base para la mayoría de los algoritmos de grafos simples y eficientes.

Dominando estos fundamentos, se desbloquea la capacidad de resolver problemas complejos en:

  • Redes de computadoras
  • Sistemas de recomendación
  • Rutas y navegación
  • Análisis de redes sociales
  • Compiladores y dependencias
  • Infraestructura crítica

Lecciones Clave para el Desarrollo

Elección de Estructura de Datos

La selección de la estructura de datos adecuada es crítica:

  • Listas de adyacencia sobre matrices: mejora asintótica crucial (O(n+m)O(n+m) vs O(n2)O(n^2))
  • Para grafos densos (raros): considerar matriz de adyacencia
  • La elección incorrecta puede degradar rendimiento de lineal a cuadrático

Escalabilidad y Eficiencia

Complejidad Asintótica

En el desarrollo de software moderno, la atención a las complejidades asintóticas es vital:

  • Las demandas de procesamiento crecen con el tiempo
  • Programas con grandes volúmenes de datos deben ejecutarse en tiempo lineal o casi lineal
  • Poco margen para diseños deficientes en sistemas de producción
  • Determinar la configuración óptima antes de implementar es crítico

Aplicaciones Avanzadas

Los algoritmos de travesía son fundamentales incluso en sistemas complejos:

  • Criptografía: Autenticación con clave pública/privada (grafos de confianza)
  • Blockchain: Verificación de transacciones (DAGs)
  • Machine Learning: Redes neuronales (grafos computacionales)
  • Bases de datos: Consultas de grafos, grafos de conocimiento
  • Sistemas distribuidos: Topologías de red, detección de fallos

Dominio Esencial

El dominio de BFS y DFS es esencial para cualquier programador que aspire a:

  • Diseñar sistemas escalables
  • Optimizar algoritmos críticos
  • Resolver problemas complejos de manera elegante
  • Entender y aplicar algoritmos avanzados de grafos

Resumen Final

✓ Conceptos Básicos:

  • Definición de grafo: G=(V,E)G = (V, E)
  • Vértices y aristas
  • Grafos dirigidos vs no dirigidos
  • Grafos ponderados vs no ponderados
  • DAG (Grafo Acíclico Dirigido)

✓ Estructuras de Datos:

  • Matriz de adyacencia: O(n2)O(n^2) espacio
  • Lista de adyacencia: O(n+m)O(n+m) espacio
  • Cuándo usar cada una

✓ BFS (Búsqueda en Amplitud):

  • Usa cola (FIFO)
  • Explora por niveles
  • Encuentra caminos más cortos (no ponderados)
  • Complejidad: O(n+m)O(n+m)

✓ DFS (Búsqueda en Profundidad):

  • Usa pila (LIFO) o recursión
  • Explora en profundidad
  • Tiempos de entrada/salida
  • Clasificación de aristas
  • Complejidad: O(n+m)O(n+m)

✓ Aplicaciones:

  • Ordenación topológica (DAGs)
  • Detección de ciclos
  • Vértices de articulación
  • Componentes conectados
  • Caminos más cortos (BFS)

✓ Análisis:

  • Complejidad óptima: O(n+m)O(n+m)
  • Elección de estructura de datos
  • Trade-offs espacio-tiempo

¡Felicitaciones!

Has completado los fundamentos de travesía de grafos. Ahora comprendes:

  • La importancia de los grafos en ciencia de la computación
  • Cómo elegir la estructura de datos adecuada
  • Los algoritmos fundamentales BFS y DFS
  • Aplicaciones prácticas: ordenación topológica, articulaciones
  • Análisis de complejidad y optimización

Próximo paso: Explorar algoritmos avanzados de grafos como caminos más cortos (Dijkstra, Bellman-Ford), árboles de expansión mínima (Kruskal, Prim), y flujo máximo.