Saltar a contenido

A2.2 Grafos, ciclos y series

Un grafo es un conjunto de puntos (nodos) unidos por líneas (aristas): una red de vuelos, de carreteras o de amigos. A diferencia de un árbol, puede tener ciclos: se puede volver al punto de partida. Eso obliga a tener cuidado, porque una recursión con ciclos no termina.

Una red de vuelos

Una tabla con los vuelos directos. Para reproducir los ejemplos, ejecuta antes:

CREATE TABLE vuelos (
  origen  TEXT NOT NULL,
  destino TEXT NOT NULL
);
INSERT INTO vuelos VALUES
  ('Cádiz', 'Sevilla'), ('Sevilla', 'Madrid'), ('Sevilla', 'Málaga'),
  ('Málaga', 'Madrid'), ('Madrid', 'Barcelona'), ('Barcelona', 'Cádiz');

Hay un ciclo: Cádiz → Sevilla → Madrid → Barcelona → Cádiz.

Rutas sin repetir ciudades

Para listar las rutas desde Cádiz hasta Madrid hay que evitar pasar dos veces por la misma ciudad. Se guarda en la columna camino todo lo recorrido y, en el paso recursivo, se comprueba que el destino no esté ya en el camino:

WITH RECURSIVE rutas(ciudad, camino, tramos) AS (
  SELECT 'Cádiz', 'Cádiz', 0
  UNION ALL
  SELECT v.destino, r.camino || ' > ' || v.destino, r.tramos + 1
  FROM vuelos v
  JOIN rutas r ON v.origen = r.ciudad
  WHERE instr(r.camino, v.destino) = 0                -- no repetir ciudades
)
SELECT camino, tramos FROM rutas
WHERE ciudad = 'Madrid'
ORDER BY tramos, camino;

Resultado:

camino tramos
Cádiz > Sevilla > Madrid 2
Cádiz > Sevilla > Málaga > Madrid 3

2 filas

Hay dos rutas: una de dos tramos (Cádiz-Sevilla y Sevilla-Madrid) y otra de tres (pasando también por Málaga). tramos cuenta los vuelos de cada ruta. La comprobación instr(...) = 0 es lo que corta el ciclo: sin ella, la recursión seguiría dando vueltas para siempre.

Sin protección, el ciclo no acaba

Si quitas la condición de «no repetir» y la red tiene un ciclo, la CTE genera filas sin parar. SQLite no sabe que se repite. Hay dos salvaguardas: limitar la profundidad (WHERE tramos < 4) o limitar el resultado con LIMIT.

Un ciclo sin protección, visto de cerca

Para ver qué pasa, esta misma búsqueda sin la protección, con un LIMIT para poder pararla:

WITH RECURSIVE rutas(ciudad, camino) AS (
  SELECT 'Cádiz', 'Cádiz'
  UNION ALL
  SELECT v.destino, r.camino || ' > ' || v.destino
  FROM vuelos v
  JOIN rutas r ON v.origen = r.ciudad
)
SELECT camino FROM rutas LIMIT 9;

Resultado:

camino
Cádiz
Cádiz > Sevilla
Cádiz > Sevilla > Madrid
Cádiz > Sevilla > Málaga
Cádiz > Sevilla > Madrid > Barcelona
Cádiz > Sevilla > Málaga > Madrid
Cádiz > Sevilla > Madrid > Barcelona > Cádiz
Cádiz > Sevilla > Málaga > Madrid > Barcelona
Cádiz > Sevilla > Madrid > Barcelona > Cádiz > Sevilla

9 filas

Las rutas siguen creciendo (Cádiz > Sevilla > Madrid > Barcelona > Cádiz > Sevilla...): es el ciclo. Sin el LIMIT, la consulta no terminaría.

UNION frente a UNION ALL: parar por repetición

Si no hace falta el camino y solo quieres saber a qué ciudades se puede llegar, hay una salida más sencilla: UNION (sin ALL) elimina las filas repetidas, y la recursión se detiene cuando una vuelta no aporta ninguna fila nueva:

WITH RECURSIVE alcanzables(ciudad) AS (
  SELECT 'Cádiz'
  UNION
  SELECT v.destino
  FROM vuelos v
  JOIN alcanzables a ON v.origen = a.ciudad
)
SELECT ciudad FROM alcanzables ORDER BY ciudad;

Resultado:

ciudad
Barcelona
Cádiz
Madrid
Málaga
Sevilla

5 filas

Aunque hay un ciclo, la consulta termina: cuando una vuelta solo produce ciudades que ya estaban, no hay filas nuevas y la recursión acaba. Fíjate en que Cádiz está en el resultado: se puede volver a ella por el ciclo.

Generar series: un calendario

Las CTE recursivas no solo recorren datos: también los inventan. Una serie de fechas, un día tras otro, con date(d, '+1 day'):

WITH RECURSIVE dias(d) AS (
  SELECT '2025-03-03'
  UNION ALL
  SELECT date(d, '+1 day') FROM dias WHERE d < '2025-03-09'
)
SELECT d,
       CASE strftime('%w', d)
         WHEN '0' THEN 'domingo' WHEN '1' THEN 'lunes'  WHEN '2' THEN 'martes'
         WHEN '3' THEN 'miércoles' WHEN '4' THEN 'jueves' WHEN '5' THEN 'viernes'
         ELSE 'sábado'
       END AS dia_semana
FROM dias;

Resultado:

d dia_semana
2025-03-03 lunes
2025-03-04 martes
2025-03-05 miércoles
2025-03-06 jueves
2025-03-07 viernes
2025-03-08 sábado
2025-03-09 domingo

7 filas

El WHERE d < '2025-03-09' es la condición de parada: cuando la fecha llega a esa, no se genera la siguiente.

Rellenar los días sin datos

Una aplicación muy útil: una tabla de ventas por día solo tiene los días con ventas. Un informe necesita todos los días, con 0 en los vacíos. Se genera el calendario y se une con LEFT JOIN:

CREATE TABLE ventas_dia (
  dia     TEXT    PRIMARY KEY,   -- 'AAAA-MM-DD'
  importe INTEGER NOT NULL
);
INSERT INTO ventas_dia VALUES
  ('2025-03-03', 120), ('2025-03-04', 95), ('2025-03-06', 140), ('2025-03-09', 200);
WITH RECURSIVE dias(d) AS (
  SELECT '2025-03-03'
  UNION ALL
  SELECT date(d, '+1 day') FROM dias WHERE d < '2025-03-09'
)
SELECT dias.d AS dia, COALESCE(v.importe, 0) AS importe
FROM dias
LEFT JOIN ventas_dia v ON v.dia = dias.d;

Resultado:

dia importe
2025-03-03 120
2025-03-04 95
2025-03-05 0
2025-03-06 140
2025-03-07 0
2025-03-08 0
2025-03-09 200

7 filas

Errores frecuentes

Error Cómo evitarlo
Una recursión con ciclos y sin protección Guarda el camino y comprueba que no se repita, o usa UNION, o limita la profundidad
Una serie sin condición de parada Siempre un WHERE en el paso recursivo que acabe la serie
Comprobar la repetición con LIKE sobre nombres que se parecen instr puede dar falsos positivos si un nombre contiene a otro: guarda los ids separados por comas, o un delimitador a cada lado
Esperar que UNION conserve filas repetidas UNION las elimina; usa UNION ALL si las quieres
Rutas «más cortas» sin ordenarlas Ordena por el número de tramos o por una longitud acumulada

Para practicar

Los ejercicios SA2.5 y SA2.6 de SA2 · Ejercicios generan un calendario y buscan las ciudades alcanzables.