23. Detección de ciclos

Detectar un ciclo consiste en determinar si es posible partir de un vértice, seguir las aristas y regresar al punto inicial. El método cambia según el grafo sea dirigido o no dirigido.

23.1 Introducción

Un ciclo representa un recorrido cerrado. En una red de caminos puede ofrecer una ruta alternativa; en una jerarquía de dependencias puede indicar una contradicción; en un árbol, demuestra que la estructura dejó de ser acíclica.

La detección de ciclos aparece en compiladores, administradores de paquetes, sistemas de archivos, redes, planificación de tareas y validación de árboles. No existe un único procedimiento para todos los grafos: debemos considerar la dirección de las aristas y la forma en que recibimos los datos.

23.2 Qué es un ciclo

Un ciclo es una secuencia de vértices que comienza y termina en el mismo nodo y utiliza aristas válidas entre elementos consecutivos.

v₀ → v₁ → v₂ → ... → vₖ → v₀

En un ciclo simple no se repiten vértices intermedios. En grafos simples no dirigidos, un ciclo utiliza al menos tres vértices. Un lazo v → v constituye por sí mismo un ciclo cuando el modelo admite lazos.

23.3 La dirección cambia el problema

En un grafo no dirigido podemos recorrer una arista en ambos sentidos. Por eso, al usar DFS, regresar inmediatamente al padre no debe confundirse con un ciclo.

En un grafo dirigido las aristas solo pueden recorrerse en su orientación. Tener A → B y A → C no permite regresar a A; para formar un ciclo debe existir un camino dirigido que cierre la vuelta.

Tipo de grafoSeñal durante DFS
No dirigidoArista hacia un visitado que no es el padre
DirigidoArista hacia un vértice que continúa en la pila activa

23.4 Ciclos en grafos no dirigidos

Al recorrer un grafo no dirigido con DFS guardamos, además del vértice actual, el nodo desde el que llegamos. Si encontramos un vecino visitado distinto del padre, existe otra ruta que conecta ambos puntos y, por lo tanto, se ha formado un ciclo.

vecino no visitado → continuar DFS y registrar actual como padre
vecino visitado y vecino ≠ padre → ciclo detectado

Es necesario iniciar una búsqueda desde cada vértice todavía no visitado para cubrir grafos desconectados.

23.5 Simulación interactiva: construye y detecta ciclos

Pulsa cerca de una arista para seleccionarla o quitarla. El detector analiza cada componente del grafo no dirigido y resalta en rosa las aristas de un ciclo encontrado.

La selección no contiene ciclos.
Aristas seleccionadas0
Componentes7
CicloNo
Longitud del ciclo

Las líneas grises son aristas disponibles, las verdes están seleccionadas y las rosas forman un ciclo detectado. Puede haber otros ciclos además del resaltado.

23.6 DFS para un grafo no dirigido

function tieneCicloNoDirigido(grafo) {
  const visitados = new Set();

  function dfs(actual, padre) {
    visitados.add(actual);

    for (const vecino of grafo[actual]) {
      if (!visitados.has(vecino)) {
        if (dfs(vecino, actual)) return true;
      } else if (vecino !== padre) {
        return true;
      }
    }
    return false;
  }

  for (const vertice of Object.keys(grafo)) {
    if (!visitados.has(vertice) && dfs(vertice, null)) {
      return true;
    }
  }
  return false;
}

23.7 Por qué debemos recordar al padre

Supongamos que DFS recorre A — B. Desde B observa a A como vecino ya visitado, pero esa es la misma arista utilizada para llegar a B. No representa una ruta alternativa ni cierra un ciclo.

Encontrar al padre = regreso por la misma arista
Encontrar otro visitado = cierre mediante una ruta diferente

Esta regla supone un grafo simple. Si se permiten aristas paralelas entre los mismos vértices, dos aristas distintas pueden formar un ciclo de longitud dos y deben identificarse individualmente.

23.8 Ciclos en grafos dirigidos

En un grafo dirigido no alcanza con encontrar cualquier vértice visitado. Una arista puede apuntar a un nodo terminado de otra rama sin formar un ciclo. El ciclo aparece cuando la arista vuelve hacia un vértice que todavía pertenece al camino DFS activo.

Se suelen utilizar tres colores o estados:

  • Blanco: todavía no visitado.
  • Gris: descubierto y presente en la pila activa.
  • Negro: completamente procesado.
Arista hacia un vértice gris = arista de retroceso = ciclo dirigido

23.9 DFS para un grafo dirigido

function tieneCicloDirigido(grafo) {
  const estado = {}; // 0: blanco, 1: gris, 2: negro

  function dfs(actual) {
    estado[actual] = 1;

    for (const vecino of grafo[actual]) {
      if (estado[vecino] === 1) return true;
      if (!estado[vecino] && dfs(vecino)) return true;
    }

    estado[actual] = 2;
    return false;
  }

  for (const vertice of Object.keys(grafo)) {
    if (!estado[vertice] && dfs(vertice)) return true;
  }
  return false;
}

23.10 Pila de recursión y aristas

Durante DFS dirigido, las aristas pueden interpretarse según el estado del destino:

DestinoInterpretación¿Prueba un ciclo?
BlancoArista de descubrimientoNo
GrisArista hacia un ancestro activo
NegroArista hacia un nodo ya terminadoNo por sí sola

Una implementación iterativa debe conservar explícitamente suficiente información para saber cuándo comienza y termina el procesamiento de cada vértice.

23.11 Detección mediante ordenamiento topológico

El algoritmo de Kahn también detecta ciclos dirigidos. Elimina sucesivamente vértices con grado de entrada cero. Si el proceso se detiene y todavía quedan vértices, estos participan en dependencias cíclicas o dependen de ellas.

orden.length < cantidad de vértices ⇒ el grafo dirigido contiene un ciclo

Este método es conveniente cuando, además de validar el grafo, necesitamos producir un orden topológico si las dependencias son correctas.

23.12 Union-Find para aristas no dirigidas

Si las aristas de un grafo no dirigido llegan una por una, podemos usar una estructura de conjuntos disjuntos, también llamada Union-Find.

  1. Cada vértice comienza en su propio conjunto.
  2. Para una arista u — v, se buscan los representantes de u y v.
  3. Si son diferentes, se unen los conjuntos.
  4. Si ya son iguales, u y v estaban conectados por otro camino: la nueva arista crea un ciclo.
find(u) === find(v) antes de unirlos ⇒ la arista cierra un ciclo

23.13 Implementación con Union-Find

function tieneCicloUnionFind(vertices, aristas) {
  const padre = Object.fromEntries(vertices.map(v => [v, v]));
  const rango = Object.fromEntries(vertices.map(v => [v, 0]));

  function find(x) {
    if (padre[x] !== x) padre[x] = find(padre[x]);
    return padre[x];
  }

  function union(a, b) {
    let ra = find(a), rb = find(b);
    if (ra === rb) return false;
    if (rango[ra] < rango[rb]) [ra, rb] = [rb, ra];
    padre[rb] = ra;
    if (rango[ra] === rango[rb]) rango[ra]++;
    return true;
  }

  for (const [a, b] of aristas) {
    if (!union(a, b)) return true;
  }
  return false;
}

La compresión de caminos y la unión por rango hacen que las operaciones sean casi constantes en la práctica. Este enfoque no se traslada directamente a ciclos dirigidos.

23.14 Reconstruir el ciclo

A veces no basta con responder sí o no: necesitamos mostrar la dependencia problemática. Durante DFS podemos guardar el padre de cada vértice.

Al hallar una arista hacia un ancestro, se recorre la cadena de padres desde el vértice actual hasta ese ancestro. Esa cadena, junto con la arista de retroceso, reconstruye el ciclo.

actual → padre[actual] → ... → ancestro → actual

En sistemas reales conviene devolver esta secuencia para ofrecer un diagnóstico comprensible al usuario.

23.15 Complejidad y elección del método

MétodoTipo de grafoTiempoCuándo usarlo
DFS con padreNo dirigidoO(V + E)Grafo completo en memoria
DFS con coloresDirigidoO(V + E)Analizar rutas y reconstruir ciclos
KahnDirigidoO(V + E)Validar y ordenar dependencias
Union-FindNo dirigidoCasi O(E)Aristas agregadas incrementalmente

DFS requiere O(V) de espacio adicional para visitados, padres y pila. Kahn utiliza O(V) para grados y cola. Union-Find utiliza O(V) para sus conjuntos.

23.16 Aplicaciones

  • Detectar dependencias circulares entre módulos o paquetes.
  • Validar que una estructura pretendidamente arbórea no contenga ciclos.
  • Evitar bucles en redes y conexiones físicas.
  • Comprobar prerrequisitos académicos o tareas de un proyecto.
  • Encontrar referencias circulares entre archivos o celdas.
  • Determinar si agregar una arista conservará un bosque.

23.17 Errores comunes

  • Aplicar la misma condición DFS a grafos dirigidos y no dirigidos.
  • Considerar al padre como evidencia de ciclo en un grafo no dirigido.
  • Creer que cualquier arista hacia un visitado forma un ciclo dirigido.
  • Explorar solo desde un vértice y omitir componentes desconectadas.
  • Marcar un vértice dirigido como terminado antes de procesar sus vecinos.
  • Usar Union-Find sin advertir que las aristas son dirigidas.
  • Ignorar lazos o aristas paralelas cuando el modelo los permite.

23.18 Qué debes recordar de este tema

  • Un ciclo es un recorrido cerrado que regresa al punto de partida.
  • En grafos no dirigidos, DFS debe distinguir al padre.
  • En grafos dirigidos, una arista hacia un vértice gris revela un ciclo.
  • Kahn detecta ciclos cuando no puede procesar todos los vértices.
  • Union-Find detecta si una nueva arista no dirigida conecta nodos ya unidos.
  • Es posible guardar padres para reconstruir y mostrar el ciclo.
  • DFS y Kahn trabajan en O(V + E).

23.19 Conclusión

Detectar ciclos permite distinguir árboles de grafos generales y validar relaciones de dependencia. La clave es respetar la naturaleza del grafo: padre en el caso no dirigido, pila activa en el dirigido y conjuntos disjuntos cuando las conexiones no dirigidas llegan incrementalmente.

En el próximo tema estudiaremos las componentes fuertemente conexas, regiones de un grafo dirigido donde todos los vértices pueden alcanzarse mutuamente.