41. Algoritmos sobre grafos

Los algoritmos de grafos recorren conexiones, determinan alcanzabilidad, ordenan dependencias, encuentran rutas y seleccionan enlaces de bajo costo. Elegir el algoritmo adecuado depende de la dirección, los pesos y la pregunta que se quiere responder.

41.1 Introducción

Una representación de grafo guarda conexiones; un algoritmo sobre grafos extrae información de esas conexiones. Puede descubrir todos los nodos alcanzables, hallar una ruta corta, detectar un ciclo o planificar tareas.

Antes de elegir un algoritmo debemos preguntar: ¿el grafo es dirigido?, ¿tiene pesos?, ¿pueden ser negativos?, ¿se busca una ruta, un orden o una estructura de bajo costo? Estas condiciones determinan qué método es correcto.

41.2 Notación, representación y complejidad

Usaremos V para la cantidad de vértices y E para la cantidad de aristas. Con una lista de adyacencia, recorrer todos los vecinos de todos los vértices cuesta normalmente O(V + E).

V: vértices.
E: aristas.

Grafo disperso: E es mucho menor que V².
Grafo denso: E se acerca a V².

La representación elegida influye en el costo real.

La complejidad asintótica orienta la elección, pero también importan el tamaño de los datos, la memoria disponible y si se necesitan una o muchas consultas.

41.3 Búsqueda en anchura (BFS)

La búsqueda en anchura, BFS por breadth-first search, explora primero todos los vecinos del origen, luego los vértices a distancia dos y así sucesivamente. Usa una cola.

Nivel 0: origen.
Nivel 1: sus vecinos.
Nivel 2: vecinos aún no visitados de esos vecinos.

En un grafo no ponderado, BFS descubre caminos mínimos por cantidad de aristas.

BFS es apropiado para calcular saltos mínimos entre usuarios, buscar la salida más cercana en una grilla o descubrir una componente conexa.

41.4 Implementar BFS

function bfs(adyacentes, origen) {
  if (!adyacentes.has(origen)) throw new Error("origen inexistente");

  const distancia = new Map([[origen, 0]]);
  const anterior = new Map();
  const cola = [origen];
  let cabeza = 0;

  while (cabeza < cola.length) {
    const actual = cola[cabeza++];

    for (const vecino of adyacentes.get(actual) ?? []) {
      if (distancia.has(vecino)) continue;
      distancia.set(vecino, distancia.get(actual) + 1);
      anterior.set(vecino, actual);
      cola.push(vecino);
    }
  }
  return { distancia, anterior };
}

const red = new Map([
  ["A", new Set(["B", "C"])],
  ["B", new Set(["D"])],
  ["C", new Set(["D", "E"])],
  ["D", new Set(["E"])],
  ["E", new Set()]
]);

console.log(bfs(red, "A").distancia.get("E")); // 2

Marcar un vecino al encolarlo evita procesarlo varias veces. El índice cabeza evita el costo de shift(); con listas de adyacencia, BFS cuesta O(V + E).

41.5 Reconstruir un camino mínimo

El mapa de predecesores permite recuperar una ruta desde el destino hasta el origen y luego invertirla.

function reconstruirCamino(anterior, origen, destino) {
  if (origen === destino) return [origen];
  if (!anterior.has(destino)) return null;

  const camino = [destino];
  let actual = destino;

  while (actual !== origen) {
    actual = anterior.get(actual);
    camino.push(actual);
  }
  return camino.reverse();
}

const resultado = bfs(red, "A");
console.log(reconstruirCamino(resultado.anterior, "A", "E"));
// ["A", "C", "E"]

Puede haber varias rutas mínimas. La ruta concreta depende del orden en que estén almacenados los vecinos, pero su longitud coincide con la distancia calculada por BFS.

41.6 Búsqueda en profundidad (DFS)

La búsqueda en profundidad, DFS por depth-first search, sigue una rama tan lejos como puede antes de retroceder. Se implementa con recursión o una pila explícita.

BFS avanza por niveles con una cola.
DFS profundiza en una rama con una pila.

DFS no garantiza caminos mínimos, pero es útil para explorar estructura, componentes y ciclos.

En ambos recorridos se necesita una marca de visitado para terminar correctamente cuando existen ciclos.

41.7 Implementar DFS iterativo

function dfs(adyacentes, origen) {
  if (!adyacentes.has(origen)) throw new Error("origen inexistente");

  const visitados = new Set();
  const pila = [origen];
  const orden = [];

  while (pila.length > 0) {
    const actual = pila.pop();
    if (visitados.has(actual)) continue;

    visitados.add(actual);
    orden.push(actual);
    for (const vecino of adyacentes.get(actual) ?? []) {
      if (!visitados.has(vecino)) pila.push(vecino);
    }
  }
  return orden;
}

console.log(dfs(red, "A")); // un orden de exploración válido

El orden exacto puede variar con la lista de vecinos. La propiedad esencial es que cada vértice alcanzable se visita una vez, con costo O(V + E).

41.8 Comparación entre BFS y DFS

AspectoBFSDFS
Estructura principalcolapila o recursión
Exploraciónpor nivelespor ramas
Camino mínimo no ponderadono en general
Aplicacionesdistancias, cercanía, saltosciclos, componentes, ordenación
Complejidad usualO(V + E)O(V + E)

La diferencia no es cuál visita más vértices, sino el orden de visita y la información que ese orden permite deducir.

41.9 Detección de ciclos dirigidos

En DFS de un digrafo, una arista hacia un vértice que aún está en la ruta de exploración revela un ciclo dirigido. Para detectarlo se distinguen tres estados: no visitado, en proceso y terminado.

Blanco: no visitado.
Gris: en la pila de DFS actual.
Negro: exploración terminada.

Una arista hacia un vértice gris indica un ciclo.

Esta técnica permite identificar dependencias circulares. No basta con saber que un vértice fue visitado: se debe saber si aún pertenece a la cadena activa.

41.10 Orden topológico

Un orden topológico es una lista de los vértices de un DAG tal que toda arista u → v coloca a u antes que v. Es útil para prerequisitos y tareas dependientes.

Compilar A antes que B: A → B.

Un orden válido puede ser A, C, B, D.
Puede haber varios si algunas tareas son independientes.
Si hay un ciclo, no existe orden topológico.

La dirección de las aristas debe definirse con cuidado. Si una arista significa «depende de», se interpreta en sentido contrario al de una arista que significa «habilita».

41.11 Orden topológico con Kahn

El algoritmo de Kahn comienza con vértices de grado de entrada cero. Los elimina conceptualmente y reduce el grado de entrada de sus vecinos. Cada vértice que queda en cero pasa a estar disponible.

function ordenTopologico(adyacentes) {
  const gradoEntrada = new Map();

  for (const vertice of adyacentes.keys()) gradoEntrada.set(vertice, 0);
  for (const vecinos of adyacentes.values()) {
    for (const vecino of vecinos) {
      gradoEntrada.set(vecino, (gradoEntrada.get(vecino) ?? 0) + 1);
    }
  }

  const cola = [...gradoEntrada].filter(([, grado]) => grado === 0).map(([v]) => v);
  const orden = [];
  let cabeza = 0;

  while (cabeza < cola.length) {
    const actual = cola[cabeza++];
    orden.push(actual);

    for (const vecino of adyacentes.get(actual) ?? []) {
      const nuevoGrado = gradoEntrada.get(vecino) - 1;
      gradoEntrada.set(vecino, nuevoGrado);
      if (nuevoGrado === 0) cola.push(vecino);
    }
  }
  return orden.length === gradoEntrada.size ? orden : null;
}

Si el resultado es null, quedaron vértices con grado de entrada positivo: existe un ciclo. Con listas de adyacencia, el algoritmo cuesta O(V + E).

41.12 Caminos mínimos con pesos

Cuando cada arista tiene un costo, el camino con menos aristas no necesariamente es el más barato. El problema de camino mínimo busca minimizar la suma de pesos.

A—B cuesta 10.
A—C cuesta 2 y C—B cuesta 3.

A—B usa una arista pero cuesta 10.
A—C—B usa dos aristas pero cuesta 5.

BFS es correcto si todas las aristas tienen el mismo costo. Para pesos no negativos se utiliza habitualmente el algoritmo de Dijkstra.

41.13 Algoritmo de Dijkstra

Dijkstra mantiene la mejor distancia conocida desde un origen. En cada paso elige el vértice pendiente con distancia menor y relaja sus aristas, intentando mejorar la distancia de cada vecino.

Relajar u → v con peso w:
si distancia[u] + w < distancia[v], actualizar distancia[v].

La elección codiciosa es válida solo con pesos no negativos.

Una cola de prioridad implementa eficientemente la elección del mínimo. La versión siguiente busca el mínimo por escaneo para mostrar con claridad la lógica del algoritmo.

41.14 Implementar Dijkstra

function dijkstra(adyacentes, origen) {
  if (!adyacentes.has(origen)) throw new Error("origen inexistente");

  const distancia = new Map([...adyacentes.keys()].map(v => [v, Infinity]));
  const anterior = new Map();
  const pendientes = new Set(adyacentes.keys());
  distancia.set(origen, 0);

  while (pendientes.size > 0) {
    let actual = null;
    for (const vertice of pendientes) {
      if (actual === null || distancia.get(vertice) < distancia.get(actual)) actual = vertice;
    }
    if (distancia.get(actual) === Infinity) break;
    pendientes.delete(actual);

    for (const { destino, peso } of adyacentes.get(actual) ?? []) {
      if (peso < 0) throw new Error("Dijkstra no admite pesos negativos");
      const alternativa = distancia.get(actual) + peso;
      if (alternativa < distancia.get(destino)) {
        distancia.set(destino, alternativa);
        anterior.set(destino, actual);
      }
    }
  }
  return { distancia, anterior };
}

La función supone que cada vértice aparece como clave del mapa, incluso si no tiene salidas. Esta versión cuesta O(V² + E); con una cola de prioridad suele obtenerse O((V + E) log V).

41.15 Pesos negativos

Dijkstra puede fallar con una arista de peso negativo, porque una distancia ya considerada definitiva podría mejorar más tarde. Bellman-Ford es una alternativa que admite esos pesos.

Bellman-Ford relaja todas las aristas repetidamente.
Admite pesos negativos.
Puede detectar ciclos negativos alcanzables.

Complejidad: O(VE).

Un ciclo de peso negativo permite reducir el costo sin límite recorriéndolo repetidamente, por lo que no existe un camino mínimo finito en el sentido habitual.

41.16 Árbol de expansión mínima

En un grafo no dirigido, conexo y ponderado, un árbol de expansión mínima conecta todos los vértices con n - 1 aristas y el menor peso total posible.

Árbol de expansión: conecta todos los vértices sin ciclos.
Árbol de expansión mínima: entre todos esos árboles, el de menor costo total.

No es lo mismo que caminos mínimos desde un origen.

Este problema aparece al planificar redes de cable, tuberías o enlaces cuando se busca conectividad global con un presupuesto mínimo.

41.17 Kruskal y conjuntos disjuntos

Kruskal ordena las aristas de menor a mayor peso e incorpora una arista solo si sus extremos pertenecen a componentes distintas. Así nunca crea un ciclo.

1. Ordenar aristas por peso.
2. Tomar la siguiente más barata.
3. Agregarla si une dos componentes diferentes.
4. Detenerse al tener n - 1 aristas.

Union-find permite comprobar componentes eficientemente.

La complejidad está dominada por ordenar las aristas: O(E log E). Si el grafo está desconectado, el resultado es un bosque de expansión mínima.

41.18 Implementar Kruskal

function kruskal(vertices, aristas) {
  const padre = new Map([...vertices].map(v => [v, v]));

  function encontrar(v) {
    if (padre.get(v) !== v) padre.set(v, encontrar(padre.get(v)));
    return padre.get(v);
  }

  function unir(a, b) {
    const raizA = encontrar(a);
    const raizB = encontrar(b);
    if (raizA === raizB) return false;
    padre.set(raizA, raizB);
    return true;
  }

  const arbol = [];
  for (const arista of [...aristas].sort((x, y) => x.peso - y.peso)) {
    if (unir(arista.origen, arista.destino)) arbol.push(arista);
  }
  return arbol;
}

const aristas = [
  { origen: "A", destino: "B", peso: 4 },
  { origen: "A", destino: "C", peso: 2 },
  { origen: "B", destino: "C", peso: 1 }
];
console.log(kruskal(["A", "B", "C"], aristas));

El resultado usa A—C de peso 2 y B—C de peso 1. La estructura de conjuntos disjuntos evita agregar A—B, ya que entonces sus extremos ya están conectados por las aristas elegidas.

41.19 Selección del algoritmo

ProblemaAlgoritmo habitualCondición
Vértices alcanzablesBFS o DFSgrafo general
Menos aristas desde un origenBFSno ponderado
Ordenar prerequisitosorden topológicoDAG dirigido
Menor costo desde un origenDijkstrapesos no negativos
Pesos negativosBellman-Fordsin ciclo negativo útil
Conectar al menor costo totalKruskal o Primno dirigido ponderado

Los objetivos no son intercambiables: un árbol de expansión mínima no garantiza rutas más cortas desde un vértice concreto.

41.20 Verificación y casos límite

Las pruebas de algoritmos de grafos deben incluir vértices aislados, destinos inalcanzables, ciclos, aristas duplicadas, pesos cero y direcciones invertidas.

BFS: distancia 0 al origen y ausencia de distancia para inalcanzables.
Topológico: probar un DAG y un ciclo.
Dijkstra: rutas alternativas y rechazo de pesos negativos.
Kruskal: un grafo conexo y uno desconectado.

La representación debe contener explícitamente los vértices aislados cuando el algoritmo necesita distinguirlos de vértices inexistentes.

41.21 Errores frecuentes

  • Usar DFS esperando obtener el camino con menos aristas.
  • Usar Dijkstra con pesos negativos.
  • Confundir un árbol de expansión mínima con un conjunto de caminos mínimos.
  • No marcar visitados y quedar atrapado en ciclos.
  • Aplicar orden topológico a un grafo con ciclos sin tratar el error.
  • Invertir el significado de una arista de dependencia.

41.22 Qué debes recordar y conclusión

  • BFS explora por niveles y encuentra distancias mínimas en grafos no ponderados.
  • DFS explora en profundidad y ayuda a analizar componentes y ciclos.
  • Un orden topológico existe solo en DAG y respeta dependencias dirigidas.
  • Dijkstra encuentra caminos mínimos con pesos no negativos.
  • Kruskal construye un árbol de expansión mínima evitando ciclos.
  • Dirección, pesos, representación y objetivo determinan qué algoritmo corresponde.

Los algoritmos convierten grafos en respuestas operativas: rutas, órdenes, costos y conexiones esenciales. En el próximo tema veremos aplicaciones concretas de grafos en programación y sistemas reales.