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 consiste en:
- Un conjunto de vértices (o nodos):
- Un conjunto de aristas (o edges): , 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
# 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
¿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
| Propiedad | Descripción | Ejemplo |
|---|---|---|
| Dirigido vs No dirigido | ¿La arista implica ? | 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
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
# 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 DAGEste tipo de dependencia circular causaría problemas en compilación.
¿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 donde M[i][j] = 1 si existe arista de a
Ventajas:
- ✅ Verificación rápida: ¿Existe arista ? →
- ✅ Simple de implementar
- ✅ Buena para grafos densos
Desventajas:
- ❌ Espacio: siempre, incluso para grafos esparsos
- ❌ Recorrido de vecinos: (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: donde = número de aristas
- ✅ Recorrido rápido de vecinos:
- ✅ Ideal para grafos esparsos
Desventajas:
- ❌ Verificación de arista: en lugar de
Comparación Práctica
| Operación | Matriz de Adyacencia | Lista de Adyacencia |
|---|---|---|
| Espacio | ||
| ¿Existe arista ? | ||
| Listar vecinos de | ||
| Agregar arista | ||
| Eliminar arista | ||
| Travesía completa |
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:
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)") ¿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:
| Estado | Descripción | Color típico |
|---|---|---|
| Undiscovered | Estado inicial, no visitado | Blanco |
| Discovered | Encontrado, pero vecinos no explorados | Gris |
| Processed | Visitados todos los vecinos | Negro |
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
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:
- BFS encuentra el camino más corto (menor número de aristas)
- Define un árbol de expansión BFS con la raíz en el inicio
- Los vértices se visitan por niveles de distancia
- Complejidad: 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 --- EEjecució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 EDistancias 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
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 Arista | Descripción | Indicador |
|---|---|---|
| Tree Edge | Arista del árbol DFS | Lleva a vértice no descubierto |
| Back Edge | Arista hacia ancestro | Lleva a vértice ancestro en pila |
| Forward Edge | Arista hacia descendiente | Lleva a descendiente ya visitado |
| Cross Edge | Entre subárboles | Lleva 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.
¿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
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 |
\ /
\ /
AcabadosDependencias:
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 pilaPila (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
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 es una articulación si y solo si:
-
Caso raíz DFS: es la raíz del árbol DFS y tiene más de un hijo
-
Caso no-raíz: tiene un hijo tal que ningún vértice en el subárbol de tiene una arista de retorno (back edge) a un ancestro de
Formalización:
Para cada vértice , definimos:
entry_time[v]: Tiempo de descubrimiento en DFSlow[v]: Tiempo de entrada más temprano alcanzable desde el subárbol de (incluyendo back edges)
Condición:
(no-raíz) es articulación si existe hijo tal que:
Esto significa que no puede “escapar” hacia arriba de .
Ejemplo visual:
1 (raíz)
/ \
/ \
2 5
/ \ \
3 4 61es articulación: raíz con 2+ hijos2es articulación:low[3] = 2 >= entry_time[2] = 25es articulación:low[6] = 6 >= entry_time[5] = 5
¿Qué indica si encontramos una Back Edge durante DFS en un grafo dirigido?
10. Análisis de Complejidad
Complejidad Temporal
| Operación | Matriz de Adyacencia | Lista de Adyacencia |
|---|---|---|
| BFS | ||
| DFS | ||
| Ordenación Topológica | ||
| Articulaciones |
Donde:
- = número de vértices
- = número de aristas
Complejidad Óptima
Con listas de adyacencia, los algoritmos de travesía son lineales:
Esto es óptimo porque debemos visitar cada vértice y cada arista al menos una vez.
Complejidad Espacial
| Estructura | Espacio |
|---|---|
| Matriz de Adyacencia | |
| Lista de Adyacencia | |
| BFS (cola) | |
| DFS (pila/recursión) |
11. Resumen y Comparación
Estructuras de Datos
Matriz de Adyacencia:
- ✅ Verificación de arista:
- ❌ Espacio:
- ❌ Travesía:
- Usar cuando: Grafo denso, verificación frecuente de aristas
Lista de Adyacencia:
- ✅ Espacio:
- ✅ Travesía:
- ❌ Verificación de arista:
- 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:
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:
Aplicaciones Clave
| Problema | Algoritmo | Complejidad |
|---|---|---|
| Camino más corto (no ponderado) | BFS | |
| Ordenación topológica | DFS | |
| Detección de ciclos (dirigido) | DFS | |
| Vértices de articulación | DFS | |
| Componentes conectados | BFS o DFS |
Tipos de Grafos
- Dirigido vs No dirigido
- Ponderado vs No ponderado
- Esparso vs Denso
- DAG vs Cíclico
Reglas de Oro
- Por defecto, usar listas de adyacencia (mayoría de grafos son esparsos)
- BFS para distancias/niveles en grafos no ponderados
- DFS para estructura temporal (ciclos, topología, articulaciones)
- Complejidad óptima: es el objetivo para travesía
¿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 ( vs )
- 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:
- 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: espacio
- Lista de adyacencia: 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:
✓ DFS (Búsqueda en Profundidad):
- Usa pila (LIFO) o recursión
- Explora en profundidad
- Tiempos de entrada/salida
- Clasificación de aristas
- Complejidad:
✓ 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:
- 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.