Ir al contenido
wannacode

DFS: islas y ciclos

En resumen: DFS (búsqueda en profundidad) sigue un camino hasta donde llega y luego retrocede, con recursión o con una pila; encuentra componentes conexas y ciclos en un grafo en O(V + E).

Problema

Una ciudad cortada por el río

La ciudad cierra los dos puentes por obras. Los mensajeros ya no pueden cruzar el río, y quien coordina el reparto tiene que saber en cuántas partes se parte la ciudad y qué paradas hay en cada parte: cada parte la atenderá una furgoneta distinta.

De la lección de BFS sabes cómo encontrar lo que se alcanza desde el almacén. Pero puede haber muchas partes, y cada una hay que encontrarla entera. Necesitas un recorrido que empiece en cada parada que aún no tenga parte y marque todo lo que alcanza.

En la misma ciudad hay un segundo problema. El plan de reparto tiene reglas «A se entrega antes que B». Alguien añadió una regla equivocada y ahora no hay ningún orden que las cumpla todas. ¿Qué regla tiene la culpa?

La idea

Hasta donde llega la calle

DFS (búsqueda en profundidad) camina como una persona en un laberinto: toma la primera calle hacia una parada desconocida, desde ahí la primera hacia la siguiente desconocida, y así hasta llegar a una parada alrededor de la cual todo se ha visto. Entonces vuelve atrás una parada y prueba la siguiente calle de esa parada.

Con recursión, volver atrás ocurre solo: visit de una parada llama a visit para su primer vecino desconocido, y cuando ese termina, el bucle sigue con el siguiente. La pila de llamadas recuerda hasta dónde ha llegado cada parada.

  • Cada parada recibe el número de su isla en cuanto se entra en ella. Eso es también el conjunto de las paradas vistas: una parada con número no se visita por segunda vez.
  • Una isla (una componente conexa) es todo lo que alcanza un visit. El bucle sobre todas las paradas lanza un visit nuevo desde cada parada sin número.

Míralo

Hacia abajo y de vuelta

function visit(graph, stop, island, islandOf) {
islandOf.set(stop, island);
for (const next of graph.get(stop)) {
if (!islandOf.has(next)) {
visit(graph, next, island, islandOf);
}
}
}
function islands(graph, stops) {
const islandOf = new Map();
let count = 0;
for (const stop of stops) {
if (islandOf.has(stop)) continue;
count++;
visit(graph, stop, count, islandOf);
}
return islandOf;
}

function visit(graph, stop, island, islandOf) {
islandOf.set(stop, island);
for (const next of graph.get(stop)) {
if (!islandOf.has(next)) {
visit(graph, next, island, islandOf);
}
}
}
function islands(graph, stops) {
const islandOf = new Map();
let count = 0;
for (const stop of stops) {
if (islandOf.has(stop)) continue;
count++;
visit(graph, stop, count, islandOf);
}
return islandOf;
}
Paso a paso, en palabras
  1. Los dos puentes están cerrados. islands recorre las paradas en orden: Almacén aún no tiene isla, así que count = 1 y visit arranca desde ahí.
  2. visit(Almacén) le da la isla 1 y baja enseguida al primer vecino sin isla: Estación. Almacén espera en gris en la pila de llamadas.
  3. Cada vez más hondo: Estación → Plaza → Mercado. Hay cuatro llamadas abiertas a la vez y cada una espera a la de arriba.
  4. Alrededor de Mercado todo tiene ya una isla. visit vuelve y Mercado pasa a negro. De vuelta por la pila: en Plaza y en Estación tampoco hay nada nuevo.
  5. Almacén vuelve a estar arriba. Su último vecino Mercado ya está visto y el primer recorrido termina: la isla 1 es Almacén, Estación, Mercado, Plaza. Los puentes cerrados lo han detenido en el río.
  6. El bucle de islands continúa: Estación, Mercado y Plaza ya tienen isla y se saltan. Parque no tiene: count = 2.
  7. El mismo recorrido en la otra orilla: Parque → Estadio → Hospital → Cine, luego atrás hasta Parque.
  8. Dos islas: 4 paradas en cada orilla. Cada parada recibió un número una vez y cada calle se miró desde sus dos extremos: O(V + E).

El programa cierra los puentes, lanza islands e imprime las islas:

const closed = [
["Mercado", "Parque"], ["Plaza", "Cine"],
];
for (const [a, b] of closed) {
graph.set(a, graph.get(a).filter((s) => s !== b));
graph.set(b, graph.get(b).filter((s) => s !== a));
}
const islandOf = islands(graph, stops);
const count = Math.max(...islandOf.values());
console.log(`Islas: ${count}`);
for (let i = 1; i <= count; i++) {
const members = stops.filter((s) => islandOf.get(s) === i);
console.log(`Isla ${i}: ${members.join(", ")}`);
}
Salida
Islas: 2
Isla 1: Almacén, Estación, Mercado, Plaza
Isla 2: Parque, Cine, Hospital, Estadio

El número de la isla se guarda en un Map, y su cantidad es el mayor número que hay en él.

Pruébalo

Predice la salida

El puente Mercado – Parque está abierto, pero hay obras alrededor del Cine: Plaza – Cine y Cine – Hospital están cerradas. ¿Cuántas islas imprime el programa?

const closed = [
["Plaza", "Cine"], ["Cine", "Hospital"],
];
for (const [a, b] of closed) {
graph.set(a, graph.get(a).filter((s) => s !== b));
graph.set(b, graph.get(b).filter((s) => s !== a));
}
const islandOf = islands(graph, stops);
const count = Math.max(...islandOf.values());
console.log(`Islas: ${count}`);
Respuesta

La idea

Una pila propia en vez de recursión

Aquí las islas tienen 4 paradas cada una y la recursión baja 4 llamadas. En una ciudad en cuadrícula de 100 000 cruces, un recorrido en profundidad puede bajar decenas de miles de niveles, y cada nivel es un frame en la pila de llamadas. La pila es pequeña y se desborda. Cómo la recursión se convierte en un bucle está en la lección «De la recursión al bucle». Aquí es lo mismo: la pila pasa a ser una colección corriente.

function walk(graph, start) {
const order = [];
const seen = new Set();
const stack = [start];
while (stack.length > 0) {
const stop = stack.pop();
if (seen.has(stop)) continue;
seen.add(stop);
order.push(stop);
const next = graph.get(stop);
for (let i = next.length - 1; i >= 0; i--) {
if (!seen.has(next[i])) stack.push(next[i]);
}
}
return order;
}

Hay dos diferencias con la recursión:

  • Una parada puede entrar dos veces en la pila: desde dos vecinos, antes de visitarse. Por eso la comprobación «¿ya se ha visto?» se hace también al sacarla: la segunda vez se salta.
  • Los vecinos entran en orden inverso. Lo último que entra sale primero, así que el primer vecino tiene que quedar arriba. De ese modo el orden es el mismo que con recursión:
const islandOf = new Map();
visit(graph, "Almacén", 1, islandOf);
console.log(`Recursión: ${[...islandOf.keys()].join(", ")}`);
console.log(`Pila propia: ${walk(graph, "Almacén").join(", ")}`);
Salida
Recursión: Almacén, Estación, Plaza, Mercado, Parque, Estadio, Hospital, Cine
Pila propia: Almacén, Estación, Plaza, Mercado, Parque, Estadio, Hospital, Cine
Más precisamente, para curiosos

Si metes los vecinos en el orden de la lista, el recorrido sigue siendo en profundidad y encuentra las mismas islas, solo cambia el orden de las visitas. Para las islas el orden no importa. Para los ciclos de un grafo dirigido, más abajo, sí: ahí necesitas saber cuándo termina cada parada, y con una pila de paradas eso es más difícil. Por eso findCycle es recursivo, y en grafos profundos la pila guarda un par (parada, hasta dónde ha llegado en su lista).

La idea

Un ciclo en el plan de reparto

Las reglas del plan son un grafo dirigido: la flecha Mercado → Parque significa «Mercado antes que Parque». Si las flechas forman un ciclo, no hay ningún orden que las cumpla: cada parada del ciclo tendría que ir antes que ella misma.

«Visto» por sí solo no basta para atrapar un ciclo. Mercado → Plaza lleva a una parada vista, pero no hay ciclo: de Plaza no puedes volver a Mercado. Por eso las paradas tienen tres colores:

  • blanco: aún no visto;
  • gris: en el camino actual, su llamada sigue abierta;
  • negro: todo lo que viene después está comprobado y no hay ciclo.

Una flecha hacia una parada gris vuelve a tu propio camino: ese es el ciclo.

function findCycle(plan, stop, color, path) {
color.set(stop, "grey");
path.push(stop);
for (const next of plan.get(stop)) {
const c = color.get(next) ?? "white";
if (c === "grey") {
return [...path.slice(path.indexOf(next)), next];
}
if (c === "white") {
const found = findCycle(plan, next, color, path);
if (found !== null) return found;
}
}
color.set(stop, "black");
path.pop();
return null;
}

function findCycle(plan, stop, color, path) {
color.set(stop, "grey");
path.push(stop);
for (const next of plan.get(stop)) {
const c = color.get(next) ?? "white";
if (c === "grey") {
return [...path.slice(path.indexOf(next)), next];
}
if (c === "white") {
const found = findCycle(plan, next, color, path);
if (found !== null) return found;
}
}
color.set(stop, "black");
path.pop();
return null;
}
Paso a paso, en palabras
  1. Cada flecha es una regla: la parada de la que sale se entrega primero. Todas las paradas están en blanco, ninguna se ha visto. El recorrido empieza en Almacén.
  2. Gris significa en el camino actual: Almacén → Estación → Plaza. Estas llamadas siguen abiertas.
  3. De Plaza no sale ninguna regla: pasa a negro, todo lo que viene después está comprobado y sale del camino. Estación también está lista.
  4. Mercado también apunta a Plaza, pero Plaza está en negro: desde ahí no hay camino de vuelta a Mercado. Dos reglas hacia la misma parada no son un ciclo.
  5. Al otro lado del río el camino crece: Parque → Cine → Hospital → Estadio, todas en gris.
  6. Estadio → Parque: Parque está en gris, todavía en el camino. La flecha vuelve al propio camino, y eso es un ciclo.
  7. El ciclo es el camino desde Parque hasta el final: Parque → Cine → Hospital → Estadio → Parque. Ningún orden de estas cuatro paradas cumple todas las reglas, así que la regla Estadio → Parque tiene que desaparecer.

El bucle lanza findCycle desde cada parada blanca, como islands lanza visit:

function anyCycle(plan, stops) {
const color = new Map();
for (const stop of stops) {
if (color.has(stop)) continue;
const found = findCycle(plan, stop, color, []);
if (found !== null) return found;
}
return null;
}
const rules = [
["Almacén", "Estación"], ["Estación", "Plaza"],
["Almacén", "Mercado"], ["Mercado", "Plaza"],
["Mercado", "Parque"], ["Parque", "Cine"],
["Cine", "Hospital"], ["Hospital", "Estadio"],
["Estadio", "Parque"],
];
const cycle = anyCycle(plan, stops);
console.log(cycle === null ? "Sin ciclo" : `Ciclo: ${cycle.join(" → ")}`);
Salida
Ciclo: Parque → Cine → Hospital → Estadio → Parque

Quita la última regla y el programa imprime Sin ciclo. Entonces el orden en que las paradas se ponen negras es el orden de reparto invertido. El ordenamiento topológico, la lección siguiente, se apoya en eso.

function findCycle(plan, stop, color, path) {
  color.set(stop, "grey");
  path.push(stop);
  for (const next of plan.get(stop)) {
    const c = color.get(next) ?? "white";
    if (c === "grey") {
      return [...path.slice(path.indexOf(next)), next];
    }
    if (c === "white") {
      const found = findCycle(plan, next, color, path);
      if (found !== null) return found;
    }
  }
  color.set(stop, );
  path.pop();
  return null;
}
Más precisamente, para curiosos

En un grafo no dirigido cada calle es una flecha en los dos sentidos, y el vecino del que vienes siempre está «visto». Ahí hay un ciclo si llegas a una parada vista que no es aquella de la que acabas de venir. Los tres colores hacen falta para un grafo dirigido.

La idea

Cuánto cuesta

Cada parada recibe un número una vez y se visita una vez. Al visitarla mira sus calles, y una calle de doble sentido se ve desde los dos extremos. En total: V visitas y 2E miradas, O(V + E), igual que BFS.

Es lineal solo si el número de la isla es compartido por todo el recorrido. Si para cada parada preguntas «¿en qué isla estoy?» con un recorrido nuevo y un conjunto vacío de paradas vistas, haces n recorridos. Ejecuta los dos en una ciudad en cuadrícula de n cruces que el río corta en dos:

  • Un solo islandOf compartido para toda la ciudad O(n)
  • Un recorrido nuevo para cada cruce O(n²)
Algoritmon = 10n = 1000n = 2000n = 100.000
Un solo islandOf compartido para toda la ciudad2848089730498.100
Un recorrido nuevo para cada cruce1482.405.4409.730.980≈ 24.327.450.000
El código se ejecuta en tu navegador, en JavaScript. La cantidad de operaciones es la misma en todos los lenguajes, porque el algoritmo es el mismo. El tiempo depende de la máquina, así que solo sirve para comparar. «≈» es una estimación a partir de la curva: un n tan grande no lo ejecutamos, para que la página no se congele.
function islands(graph, stops) {
const islandOf = new Map();
let count = 0;
for (const stop of stops) {
if (islandOf.has(stop)) continue;
count++;
visit(graph, stop, count, islandOf);
}
return islandOf;
}

Se cuenta cada parada sacada de la pila y cada mirada a un vecino. Los dos usan su propia pila: con 100 000 cruces la recursión en el navegador se rompe con un desbordamiento de pila. Con 1000 cruces el número compartido hace 4808 operaciones, y un recorrido nuevo para cada cruce, 2 405 440. Con 2000 el recorrido nuevo ya está en 9 730 980, mientras que el compartido con 100 000 hace 498 100.

Pruébalo

Encuentra el error

Un compañero decidió que en visit la isla debe crecer con cada paso. El programa imprime:

function visit(graph, stop, island, islandOf) {
islandOf.set(stop, island);
for (const next of graph.get(stop)) {
if (!islandOf.has(next)) {
visit(graph, next, island + 1, islandOf);
}
}
}
Salida
Islas: 5
Isla 1: Almacén
Isla 2: Estación, Parque
Isla 3: Plaza, Estadio
Isla 4: Mercado, Hospital
Isla 5: Cine

Hay cinco islas, y Estación y Parque están «en una isla», aunque los puentes están cerrados. ¿En qué línea está el error?

Elige la línea con el error.

Más precisamente

Hasta qué profundidad puede llegar la recursión

Los motores de JavaScript cortan la recursión a unos 10 000 niveles (según el navegador y el tamaño del frame) con RangeError: Maximum call stack size exceeded. Un array con push y pop es una pila con O(1) al final y no tiene ese límite.

Ejercicio

Las calles críticas

Resuélvelo en tu editor.

Calles críticas. El puente Mercado – Parque está cerrado por un mes, y Plaza – Cine sigue funcionando. Una calle es crítica si cerrarla parte la ciudad en más islas. Cierra el puente, imprime cuántas islas hay, luego para cada calle abierta quítala, cuenta las islas con islands y vuelve a ponerla. Imprime las calles críticas en el orden de streets.

const closed = [
["Mercado", "Parque"],
];
Salida
Islas: 1
Críticas: Plaza - Cine, Cine - Hospital, Parque - Estadio, Hospital - Estadio

Más tareas

  • La isla más grande. Imprime la isla con más paradas y cuántas horas de furgoneta a la semana necesita si cada parada lleva 3 horas.
  • El ciclo en el plan. Escribe findCycle con tu propia pila: pon en la pila un par (parada, índice del siguiente vecino), y la parada se pone negra cuando el índice llega al final de su lista.
  • Islas en el mapa. El mapa del almacén es una cuadrícula de # (estante) y . (libre). Cuenta las zonas libres, donde los vecinos de una celda son arriba, abajo, izquierda y derecha.

Test

Ponte a prueba

1. ¿Qué hace DFS cuando todo alrededor de la parada actual ya se ha visto?
2. ¿Por qué islands recorre todas las paradas en vez de lanzar visit solo desde el almacén?
3. En el plan dirigido findCycle ve una flecha hacia una parada negra. ¿Es un ciclo?
4. La ciudad tiene 100 000 cruces en una cadena larga. ¿Qué le pasa al visit recursivo?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·