Grafos y búsqueda
"Listas y árboles son grafos disfrazados. Cuando aceptás eso, la mitad de tus problemas se vuelven 'una variante de BFS'."
Qué vas a aprender en este capítulo
Hasta acá viste estructuras lineales (listas) y jerárquicas (árboles). Los grafos son la generalización: cualquier nodo puede conectarse con cualquier otro, en cualquier dirección. Casi todo lo que en la vida real involucra "conexiones" — calles, amigos, dependencias entre tareas, rutas de microbús — se modela como un grafo.
En este capítulo vas a:
- Representar un grafo en código (lista de adyacencia vs. matriz).
- Recorrerlo con BFS (anchura) y DFS (profundidad), entendiendo cuándo cada uno.
- Encontrar el camino más corto sin pesos (BFS) y con pesos (Dijkstra).
- Detectar ciclos y obtener un orden topológico (esencial para schedulers, build systems, prerrequisitos de materias).
- Resolver problemas de entrevista clásicos.
Es la materia más rentable del libro. La mitad de las preguntas de entrevistas técnicas en empresas grandes son grafos disfrazados.
5.1 La idea: nodos conectados
💡 Intuición
Imaginate un mapa de microbuses de San Salvador. Cada parada es un nodo. Cada ruta directa entre dos paradas es una arista. Si querés ir de la UNIMO a Metrocentro y no hay ruta directa, el problema es: ¿por qué paradas tengo que pasar?.
Si todos los tramos cuestan lo mismo (un viaje = un boleto), buscás la ruta con menos transbordos — eso es BFS.
Si cada tramo tiene un costo distinto (tiempo, dinero, distancia), buscás la ruta de costo mínimo — eso es Dijkstra.
Los grafos no son una estructura nueva, son un lenguaje. En cuanto reconocés la forma "X tiene relaciones con Y", el patrón ya está.
📜 Historia
El estudio formal de grafos arranca en 1736, cuando Leonhard Euler resolvió el problema de los siete puentes de Königsberg: ¿se pueden recorrer los siete puentes de la ciudad pasando por cada uno exactamente una vez? Euler demostró que no, modelando la ciudad como un grafo y observando que todos los nodos tenían grado impar — condición incompatible con un recorrido euleriano.
De ese problema nació la teoría de grafos, que hoy modela: redes sociales, internet, redes neuronales, planificación, lingüística computacional. Cada vez que hacés git log --graph o seguís a alguien en Instagram, estás operando sobre un grafo.
5.2 Definiciones formales
📐 Fundamento
Un grafo es un par donde:
- es un conjunto de vértices (nodos).
- es un conjunto de aristas (edges).
Tipos:
| Tipo | Característica | Ejemplo |
|---|---|---|
| No dirigido | Aristas sin dirección: | red de amigos en Facebook |
| Dirigido (digrafo) | Aristas con dirección: | seguidores en Twitter, dependencias |
| Ponderado | Cada arista tiene peso | mapa con distancias |
| No ponderado | Aristas sin peso (o peso 1) | "¿son amigos?" sí/no |
| Cíclico | Tiene al menos un ciclo | mayoría de redes |
| Acíclico (DAG) | Sin ciclos. Solo dirigido | dependencias de tareas |
Términos clave:
- Grado de un vértice: número de aristas incidentes. En digrafos, se separa en
in-degreeyout-degree. - Camino: secuencia de vértices conectados por aristas, sin repetir.
- Ciclo: camino que empieza y termina en el mismo vértice.
- Conectado (no dirigido): existe camino entre cualquier par de vértices.
- Fuertemente conectado (dirigido): existe camino dirigido entre cualquier par.
- Componente conexo: subgrafo maximal conectado.
Tamaño: se denota por (número de vértices) y (número de aristas). En análisis de complejidad vamos a hablar de o — no son lo mismo.
5.3 Cómo representar un grafo
📐 Fundamento
Hay dos representaciones canónicas. Elegir la correcta cambia la complejidad de tus algoritmos.
Lista de adyacencia (la default)
Cada vértice tiene una lista de sus vecinos.
# Grafo no dirigido, no ponderado
grafo = {
'A': ['B', 'C'],
'B': ['A', 'D'],
'C': ['A', 'D'],
'D': ['B', 'C'],
}
# Grafo dirigido, ponderado: peso almacenado como tupla
grafo = {
'A': [('B', 4), ('C', 2)],
'B': [('D', 5)],
'C': [('B', 1), ('D', 8)],
'D': [],
}
- Espacio: .
- Iterar vecinos de : .
- ¿Existe arista ?: .
- Mejor cuando: (grafo disperso — la mayoría de los grafos reales).
Matriz de adyacencia
Una matriz donde si hay arista de a (o el peso si es ponderado).
# 4 vértices: A=0, B=1, C=2, D=3
matriz = [
[0, 1, 1, 0], # A: conectado a B, C
[1, 0, 0, 1], # B: conectado a A, D
[1, 0, 0, 1], # C: conectado a A, D
[0, 1, 1, 0], # D: conectado a B, C
]
- Espacio: .
- ¿Existe arista ?: .
- Iterar vecinos de : .
- Mejor cuando: (grafo denso) o necesitás chequear adyacencia constantemente.
¿Cuál usás?
- Por default, lista de adyacencia. La mayoría de los grafos reales son dispersos. Internet tiene billones de vértices y un promedio de ~10 aristas por vértice.
- Matriz solo si: (a) el grafo es chico (< 1000 vértices) y denso, o (b) el algoritmo requiere chequeo de adyacencia muchas veces (p. ej. Floyd-Warshall).
5.4 Recorrido en anchura (BFS)
💡 Intuición
BFS (Breadth-First Search) explora el grafo "en círculos concéntricos" desde el origen: primero todos los vecinos directos, después los vecinos de los vecinos, etc.
Es lo que hacés mentalmente cuando buscás la ruta con menos paradas entre dos puntos del microbús: "¿llego en una parada? ¿en dos? ¿en tres?".
📐 Fundamento
Idea: usar una cola (FIFO) de vértices "por visitar".
from collections import deque
def bfs(grafo, inicio):
"""Recorre todos los nodos alcanzables desde 'inicio'."""
visitados = {inicio}
cola = deque([inicio])
orden = []
while cola:
v = cola.popleft() # FIFO: el primero que entró
orden.append(v)
for vecino in grafo[v]:
if vecino not in visitados:
visitados.add(vecino)
cola.append(vecino)
return orden
Complejidad: — cada vértice se encola una sola vez, cada arista se examina una sola vez.
BFS para camino más corto (sin pesos)
def camino_mas_corto_bfs(grafo, inicio, destino):
"""Devuelve el camino más corto en aristas, o None si no existe."""
if inicio == destino:
return [inicio]
visitados = {inicio}
cola = deque([(inicio, [inicio])]) # (nodo_actual, camino_para_llegar)
while cola:
v, camino = cola.popleft()
for vecino in grafo[v]:
if vecino == destino:
return camino + [vecino]
if vecino not in visitados:
visitados.add(vecino)
cola.append((vecino, camino + [vecino]))
return None # no hay camino
Por qué BFS encuentra el más corto sin pesos: explora vértices a distancia antes que vértices a distancia . La primera vez que toca el destino, es por el camino más corto.
Casos de uso reales:
- Distancia social en redes (¿cuántos saltos entre vos y otra persona?).
- Web crawler que va capa por capa desde una URL semilla.
- "Resolvé el laberinto en menos pasos".
- Validar bipartición de un grafo.
5.5 Recorrido en profundidad (DFS)
💡 Intuición
DFS (Depth-First Search) "se mete a fondo" antes de probar otra rama: ve un vecino, lo sigue hasta no poder más, retrocede al último cruce sin explorar, y prueba otro.
Es lo que hacés cuando explorás una cueva con linterna: tomás un pasillo, lo seguís hasta un dead-end, retrocedés al último cruce y probás otro.
📐 Fundamento
Idea: usar una pila (LIFO) — o más natural, recursión.
def dfs_recursivo(grafo, inicio, visitados=None, orden=None):
if visitados is None:
visitados = set()
orden = []
visitados.add(inicio)
orden.append(inicio)
for vecino in grafo[inicio]:
if vecino not in visitados:
dfs_recursivo(grafo, vecino, visitados, orden)
return orden
def dfs_iterativo(grafo, inicio):
visitados = set()
pila = [inicio]
orden = []
while pila:
v = pila.pop() # LIFO: el último que entró
if v in visitados:
continue
visitados.add(v)
orden.append(v)
for vecino in grafo[v]:
if vecino not in visitados:
pila.append(vecino)
return orden
Complejidad: igual que BFS.
Casos de uso reales:
- Detección de ciclos.
- Orden topológico (DFS post-order invertido).
- Componentes conexos / fuertemente conexos (Kosaraju, Tarjan).
- Resolución de laberintos / puzzles donde "todos los caminos importan, no el más corto".
- Generadores de mazos / niveles en videojuegos.
📝 Ejemplo trabajado — BFS y DFS sobre el mismo grafo
Grafo:
A
/ \
B C
| |
D E
\ /
F
Como lista de adyacencia:
G = {
'A': ['B', 'C'],
'B': ['A', 'D'],
'C': ['A', 'E'],
'D': ['B', 'F'],
'E': ['C', 'F'],
'F': ['D', 'E'],
}
BFS desde A: A → B → C → D → E → F
(primero distancia 0, después 1, después 2, ...)
DFS desde A: A → B → D → F → E → C
(se mete a fondo por B antes de tocar C)
Orden distinto, ambos visitan los 6 vértices. El recorrido es determinístico dado el orden de los vecinos en la lista.
5.6 Camino más corto con pesos: Dijkstra
📐 Fundamento
Cuando las aristas tienen peso positivo distinto, BFS no sirve: una ruta con más saltos puede ser más barata. Dijkstra generaliza BFS reemplazando la cola FIFO por una cola de prioridad (min-heap) por costo acumulado.
import heapq
def dijkstra(grafo, inicio):
"""grafo: dict[nodo, list[(vecino, peso)]]
Devuelve dict[nodo, distancia_minima_desde_inicio]."""
distancias = {v: float('inf') for v in grafo}
distancias[inicio] = 0
heap = [(0, inicio)] # (distancia_acumulada, nodo)
while heap:
d, v = heapq.heappop(heap)
if d > distancias[v]:
continue # entrada obsoleta
for vecino, peso in grafo[v]:
nueva = d + peso
if nueva < distancias[vecino]:
distancias[vecino] = nueva
heapq.heappush(heap, (nueva, vecino))
return distancias
Complejidad: con heap binario; con Fibonacci heap (raro en práctica).
Restricción crítica: pesos no negativos. Con pesos negativos usás Bellman-Ford ().
Casos de uso reales:
- GPS y navegadores (Google Maps, Waze).
- Routing de paquetes en redes (OSPF).
- Costo mínimo en un juego (PathFinding).
- "Camino más rápido / más barato" en cualquier dominio.
5.7 DAGs y orden topológico
💡 Intuición
Un DAG (Directed Acyclic Graph) es un grafo dirigido sin ciclos. Aparece naturalmente cada vez que hay prerrequisitos:
- Materias del pénsum (Cálculo I antes de Cálculo II).
- Tareas de un proyecto (diseño antes de implementación).
- Compilación (instalar antes de configurar antes de iniciar).
- Pipeline de datos (extract → transform → load).
Un orden topológico es una permutación lineal de los vértices tal que toda arista va de antes a después. Con ese orden, podés ejecutar las tareas sin violar prerrequisitos.
📐 Fundamento
Algoritmo de Kahn (basado en in-degree):
def orden_topologico(grafo):
"""grafo: dict[nodo, list[nodo]] (dirigido)."""
in_deg = {v: 0 for v in grafo}
for v in grafo:
for w in grafo[v]:
in_deg[w] = in_deg.get(w, 0) + 1
cola = deque([v for v, d in in_deg.items() if d == 0])
orden = []
while cola:
v = cola.popleft()
orden.append(v)
for vecino in grafo[v]:
in_deg[vecino] -= 1
if in_deg[vecino] == 0:
cola.append(vecino)
if len(orden) != len(grafo):
raise ValueError("El grafo tiene un ciclo — no hay orden topológico.")
return orden
Complejidad: .
Resultado bonus: si al final len(orden) != len(grafo), detectaste un ciclo. Es el método más simple de detección de ciclos en digrafos.
Casos de uso reales:
make,npm,cargo,bazel— dependencias entre archivos/paquetes.- Schedulers de tareas (Airflow, Prefect).
- Orden de inicialización de servicios (
systemd). - Inferencia en redes neuronales (forward pass).
5.8 Aplicaciones — patrones de entrevista
🛠️ Aplicado
Casi todos los problemas de grafos en entrevistas son variantes de los algoritmos de arriba. Reconocer el patrón es el 80 % del trabajo.
Patrón 1: "Encontrar islas / componentes conexos"
Dado un grid 2D donde cada celda es agua o tierra, contar islas.
def contar_islas(grid):
if not grid: return 0
rows, cols = len(grid), len(grid[0])
visitado = [[False] * cols for _ in range(rows)]
islas = 0
def dfs(r, c):
if r < 0 or r >= rows or c < 0 or c >= cols: return
if visitado[r][c] or grid[r][c] == 0: return
visitado[r][c] = True
dfs(r + 1, c); dfs(r - 1, c); dfs(r, c + 1); dfs(r, c - 1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1 and not visitado[r][c]:
islas += 1
dfs(r, c)
return islas
Complejidad: .
Patrón 2: "Camino más corto en grid"
BFS sobre un grid 4-direcciones para encontrar la salida del laberinto en menos pasos.
Patrón 3: "Orden de cursos / tareas"
Dadas dependencias entre cursos, devolver un orden válido (LeetCode 207, 210).
Patrón 4: "Ruta más barata"
Dijkstra sobre vuelos con costos (LeetCode 787 — con K paradas máximas, agregás esa restricción al estado).
Patrón 5: "Bipartición / 2-coloreo"
BFS coloreando: vecino siempre del color opuesto. Si encontrás conflicto, no es bipartito.
5.9 Errores comunes
⚠️ Trampa común
Olvidar el set visitados en grafos cíclicos. En árboles podés recorrer sin marcar visitados (la estructura garantiza no volver). En grafos cíclicos, sin visitados entrás en bucle infinito en el primer ciclo.
Tip: poné el visitados al principio del recorrido siempre, incluso si creés que el grafo es acíclico — un bug en otra parte puede meter un ciclo y tu BFS/DFS revienta sin diagnóstico.
⚠️ Trampa común
Usar Dijkstra con pesos negativos. Si una arista tiene peso negativo, Dijkstra puede dar un resultado incorrecto sin avisar: el algoritmo asume que cuando saca un nodo del heap su distancia ya es óptima, lo cual deja de ser cierto si más adelante aparece un atajo negativo.
Tip: si tu dominio puede tener pesos negativos (descuentos, recompensas, deltas), usá Bellman-Ford. Si garantizás , Dijkstra es correcto y mucho más rápido.
⚠️ Trampa común
Confundir "grafo no dirigido" con "grafo dirigido en ambos sentidos". En la representación, son lo mismo: cada arista aparece dos veces. Pero al modelar, un grafo dirigido con aristas en ambos sentidos representa explícitamente "puedo ir y puedo volver"; un no dirigido representa una única relación simétrica. La diferencia importa para algoritmos como SCC (componentes fuertemente conexos), que solo aplican a digrafos.
Tip: definí en tu código un tipo Graph y un tipo DiGraph distintos, aunque internamente compartan la lista de adyacencia. Te ahorra bugs sutiles.
5.10 Resumen visual
| Algoritmo | Complejidad | Sirve para |
|---|---|---|
| BFS | recorrido por niveles, camino mínimo en aristas | |
| DFS | recorrido a fondo, ciclos, topológico, SCC | |
| Dijkstra | camino mínimo con pesos | |
| Bellman-Ford | camino mínimo con pesos negativos (sin ciclos negativos) | |
| Floyd-Warshall | todos los pares (small graphs) | |
| Kahn (topológico) | DAGs, scheduling | |
| Kruskal / Prim (MST) | árbol generador mínimo |
5.11 Ejercicios
- Modelá tu pénsum como un DAG y obtené un orden topológico válido. ¿Cuántos órdenes válidos existen?
- Implementá BFS pero sin la cola: usá dos sets (capa actual, capa siguiente). ¿Cuándo conviene?
- Dado el grafo de microbuses de tu ciudad (con tiempo promedio de cada tramo), encontrá la ruta más rápida entre dos paradas con Dijkstra.
- Implementá detección de ciclo en un grafo no dirigido con DFS. (Pista: una "arista hacia atrás" indica ciclo, pero la arista al padre no cuenta.)
- Demostrá por qué BFS produce un árbol de caminos mínimos desde el nodo origen.
5.12 Para profundizar
- CLRS, Introduction to Algorithms, parte VI (Graph Algorithms).
- Sedgewick & Wayne, Algorithms — capítulos 4.1-4.5 (gratis online en princeton.edu).
- NetworkX — librería Python:
pip install networkx. Tiene DFS, BFS, Dijkstra y todo lo del libro listo, además de visualización. - LeetCode, etiqueta "Graph" — más de 200 problemas para practicar patrones.
5.13 Mini-proyecto integrador
🏗️ Proyecto final — Tu propio Google Maps
Alcance: modelá un mapa real (tu colonia, tu campus universitario, una línea de microbús) como grafo ponderado y construí un buscador de rutas.
Entregables:
- Datos — al menos 20 nodos (paradas/intersecciones) con coordenadas. Aristas con un peso real (tiempo en minutos o distancia en metros).
- Carga — leer desde JSON o CSV y construir lista de adyacencia.
- Algoritmos — implementá BFS (menos saltos), Dijkstra (tiempo mínimo), y comparalos sobre 10 pares origen-destino.
- Visualización — un script que dibuje el grafo y el camino encontrado, con
matplotlib+networkxo exportando a GraphViz. - Bonus — agregá un nodo "tráfico" en una arista (peso × 3) y verificá que la ruta cambia.
Criterio de éxito: otro estudiante usa tu CLI (python rutas.py UNIMO Metrocentro) y obtiene el camino + tiempo estimado en menos de 50 ms.
Tiempo estimado: 1-2 semanas. Es proyecto de portafolio: combina estructuras de datos + lectura de datos + visualización.
Definiciones nuevas: grafo, vértice, arista, grafo dirigido (digrafo), grafo ponderado, DAG, grado, in-degree, out-degree, camino, ciclo, conexividad, lista de adyacencia, matriz de adyacencia, BFS, DFS, Dijkstra, Bellman-Ford, orden topológico, algoritmo de Kahn, componente conexo, MST.