Learning Platform
Урок 10.04 · 22 мин
Средний
Graph traversalCycle detectionPath findingBFS in SQLVisited set

Дерево — это граф без циклов. С деревьями recursive CTE работает изящно: working table сама исчерпывается на листьях, и рекурсия останавливается. Но как только в данных появляется цикл — A ссылается на B, B на C, C обратно на A — рекурсия превращается в бесконечный цикл и съедает всю память.

В этом уроке: как выглядит граф в SQL-таблицах, что ломается без cycle detection и как его правильно прикрутить.

Граф в одной таблице

Граф представляется таблицей рёбер: каждая запись — это связь «из A в B». Возьмём маленький пример: воображаемая сеть городов с прямыми авиарейсами.

Создаём граф рейсов: 5 городов, несколько маршрутов (некоторые из них замыкаются в цикл):

PostgreSQL

В графе циклы — это норма. Транспортная сеть, friend-of-friend в социалке, граф зависимостей в монорепо — везде встречаются обратные рёбра.

Что произойдёт без cycle detection

Допустим, мы хотим найти все города, достижимые из Москвы. Напишем наивный recursive CTE:

Внимание: этот запрос завершится с ошибкой или зависнет из-за бесконечной рекурсии. Раскомментируй WHERE depth, чтобы ограничить.

PostgreSQL

Без WHERE r.depth < 6 recursive term будет крутиться вечно: Москва → Дубай → Москва → Дубай → … Working table никогда не пустеет.

Терминатор по глубине помогает, но он не различает уникальные города и повторы. В результате ты видишь «Дубай» на глубинах 1, 3, 5… — это один город, но запрос возвращает его много раз с разными depth. Для задачи «какие города достижимы» это неудобно: нужен DISTINCT сверху, или — лучше — встроенная защита от циклов.

Цикл в графе ломает наивную рекурсию

Working table получает 'Дубай', порождает 'Москва'. Следующий шаг — 'Москва' порождает 'Дубай'. И так до бесконечности.

Шаг 0{Москва}
Шаг 1{Стамбул, Дубай}
Шаг 2{Дубай, Бангкок, Москва}Москва возвращается через ребро Дубай → Москва. Working table уже была проинициирована Москвой, и мы её снова получаем как 'новую'.
Шаг 3{Бангкок, Стамбул, Москва, Дубай}
...вечно
Без терминатораOOM или таймаут

Решение: тащить путь и проверять на вхождение

Классическая техника cycle detection в recursive CTE — тащить путь в виде массива, и в recursive term проверять, что новый узел в этот путь ещё не входит.

Безопасный обход графа с защитой от циклов:

PostgreSQL

Условие f.dst <> ALL(r.path) — это «новый город не равен ни одному из уже посещённых в этом пути». PostgreSQL вычисляет это эффективно через линейный скан массива.

Каждая строка результата recursive CTE — это один путь, ведущий в свою конечную точку. Один город может попасть в результат много раз — через разные пути. Финальный GROUP BY city свёртывает это в «уникальные достижимые города».

Поиск всех путей от A до B

Часто нужно не «куда вообще можно добраться», а «все пути из A в B, до какой-то глубины». Это та же конструкция, но фильтр на финальном шаге:

Все пути из Москвы в Токио, без повторов городов в пути:

PostgreSQL

Здесь видим все маршруты из Москвы в Токио, отсортированные по общему времени. Один маршрут идёт через Стамбул и Бангкок, другой — через Дубай и Бангкок, и так далее. Каждый — без повторных посещений городов.

Задача достижимости
и поиск всех путей — это разные задачи. Достижимость можно решить с агрессивной дедупликацией (см. урок 5 про UNION vs UNION ALL), все пути — без дедупликации, но с защитой от циклов.

Кратчайший путь — не задача рекурсивного CTE

Технически, в нашем последнем запросе мы получили все пути, отсортировали по total_hours и можем взять LIMIT 1 — вот тебе и «кратчайший». Но это работает только потому, что граф маленький.

На реальных графах (тысячи рёбер) перебор всех путей с защитой от циклов взрывается комбинаторно. Это NP-сложный поиск в общем случае.

Алгоритм Дейкстры или A*, которые решают задачу за O((V+E)·log V), нельзя записать прямой recursive CTE: они требуют приоритетной очереди и подбора минимального следующего шага. SQL такого не умеет — нужны процедурные надстройки (PL/pgSQL функция) или специализированная графовая БД (Neo4j, Memgraph).

Для recursive CTE подходят: достижимость, обход всего связного компонента, поиск всех путей до ограниченной глубины. Кратчайший путь — только на маленьких графах через «перебрать всё и взять минимум».

Bidirectional поиск: «друзья друзей»

Ещё один типичный сценарий — обход в неориентированном графе, где ребро (A,B) можно пройти и из A в B, и из B в A. В SQL это записывается как UNION ALL двух направлений или хитрый JOIN.

«Друзья друзей» в неориентированном графе:

PostgreSQL

Здесь рёбра «симметричны»: если есть (1, 2), можно из 1 попасть в 2 и из 2 в 1. Поэтому в JOIN мы пишем f.a = fr.person OR f.b = fr.person, а нового соседа выбираем как «противоположный конец ребра».

Detection vs prevention

Есть два способа защититься от циклов:

  1. Prevention — то, что мы делали выше: тащим путь, в recursive term проверяем dst <> ALL(path). Цикл просто не возникает: новый узел не добавляется, если он уже в пути.
  2. Detection — тащим флаг is_cycle, ставим его в TRUE, если узел уже видели. Цикл возникает, но мы его регистрируем и обрабатываем.

PostgreSQL 14+ для второго подхода добавил специальный синтаксис CYCLE ... SET ... USING ... — это сахар над теми же массивами. О нём в уроке 6.

Проверка знанийKnowledge check
Ты пишешь recursive CTE для обхода графа знакомств. В пути 7 человек: [1, 2, 3, 4, 5, 6, 7]. На текущем шаге рассматриваем ребро (7, 3). Должен ли recursive term добавить узел 3 в результат? Какой именно предикат это решит?
ОтветAnswer
Не должен — узел 3 уже в пути ([..., 3, ..., 7]), его повторное посещение означало бы цикл. Предикат: f.b <> ALL(p.path) если ребро направлено и мы идём из f.a в f.b. Для неориентированного графа — «другой конец ребра <> ALL(p.path)». Конкретно для (7, 3) при текущем узле 7: «другой конец» это 3, и 3 = ANY([1,2,3,4,5,6,7]) → предикат ложен → recursive term не добавляет эту строку. Цикл предотвращён.
Graph: property graph и RDF-тройки

Чек-лист урока

  • Граф в SQL — это таблица рёбер (src, dst, [weight]).
  • Наивная рекурсия по графу с циклами не сходится — нужен либо терминатор по глубине, либо cycle detection.
  • Классическая техника — тащить путь как ARRAY и в recursive term проверять next_node <> ALL(path).
  • Поиск всех путей и достижимость — задачи recursive CTE. Кратчайший путь на больших графах — нет (используй процедурный код или графовую БД).
  • В неориентированном графе ребро (a, b) проходимо в обе стороны — JOIN пишется через OR и CASE.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём ключевое отличие между обходом дерева и обходом графа через WITH RECURSIVE?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6