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:

  1. Representar un grafo en código (lista de adyacencia vs. matriz).
  2. Recorrerlo con BFS (anchura) y DFS (profundidad), entendiendo cuándo cada uno.
  3. Encontrar el camino más corto sin pesos (BFS) y con pesos (Dijkstra).
  4. Detectar ciclos y obtener un orden topológico (esencial para schedulers, build systems, prerrequisitos de materias).
  5. 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 G=(V,E)G = (V, E) donde:

  • VV es un conjunto de vértices (nodos).
  • EV×VE \subseteq V \times V es un conjunto de aristas (edges).

Tipos:

Tipo Característica Ejemplo
No dirigido Aristas sin dirección: (u,v)=(v,u)(u, v) = (v, u) red de amigos en Facebook
Dirigido (digrafo) Aristas con dirección: (u,v)(v,u)(u, v) \neq (v, u) seguidores en Twitter, dependencias
Ponderado Cada arista tiene peso w(u,v)w(u, v) 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-degree y out-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 V=n|V| = n (número de vértices) y E=m|E| = m (número de aristas). En análisis de complejidad vamos a hablar de O(V+E)O(V + E) o O(V2)O(V^2) — 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: O(V+E)O(V + E).
  • Iterar vecinos de vv: O(deg(v))O(\text{deg}(v)).
  • ¿Existe arista (u,v)(u, v)?: O(deg(u))O(\text{deg}(u)).
  • Mejor cuando: EV2E \ll V^2 (grafo disperso — la mayoría de los grafos reales).

Matriz de adyacencia

Una matriz n×nn \times n donde M[i][j]=1M[i][j] = 1 si hay arista de ii a jj (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: O(V2)O(V^2).
  • ¿Existe arista (u,v)(u, v)?: O(1)O(1).
  • Iterar vecinos de vv: O(V)O(V).
  • Mejor cuando: EV2E \approx V^2 (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 O(1)O(1) 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: O(V+E)O(V + E) — 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 kk antes que vértices a distancia k+1k+1. 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: O(V+E)O(V + E) 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: O((V+E)logV)O((V + E) \log V) con heap binario; O(VlogV+E)O(V \log V + E) con Fibonacci heap (raro en práctica).

Restricción crítica: pesos no negativos. Con pesos negativos usás Bellman-Ford (O(VE)O(VE)).

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 (u,v)(u, v) 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: O(V+E)O(V + E).

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: O(RC)O(R \cdot C).

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 w0w \geq 0, 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 O(V+E)O(V + E) recorrido por niveles, camino mínimo en aristas
DFS O(V+E)O(V + E) recorrido a fondo, ciclos, topológico, SCC
Dijkstra O((V+E)logV)O((V+E) \log V) camino mínimo con pesos 0\geq 0
Bellman-Ford O(VE)O(VE) camino mínimo con pesos negativos (sin ciclos negativos)
Floyd-Warshall O(V3)O(V^3) todos los pares (small graphs)
Kahn (topológico) O(V+E)O(V + E) DAGs, scheduling
Kruskal / Prim (MST) O(ElogV)O(E \log V) árbol generador mínimo

5.11 Ejercicios

  1. Modelá tu pénsum como un DAG y obtené un orden topológico válido. ¿Cuántos órdenes válidos existen?
  2. Implementá BFS pero sin la cola: usá dos sets (capa actual, capa siguiente). ¿Cuándo conviene?
  3. 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.
  4. 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.)
  5. Demostrá por qué BFS produce un árbol de caminos mínimos desde el nodo origen.

5.12 Para profundizar

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:

  1. Datos — al menos 20 nodos (paradas/intersecciones) con coordenadas. Aristas con un peso real (tiempo en minutos o distancia en metros).
  2. Carga — leer desde JSON o CSV y construir lista de adyacencia.
  3. Algoritmos — implementá BFS (menos saltos), Dijkstra (tiempo mínimo), y comparalos sobre 10 pares origen-destino.
  4. Visualización — un script que dibuje el grafo y el camino encontrado, con matplotlib + networkx o exportando a GraphViz.
  5. 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.