Алгоритм Тарьяна: поиск компонент связности орграфа

В 1972 году Роберт Тарьян опубликовал короткую статью «Depth-first search and linear graph algorithms», в которой описал, как за один проход поиска в глубину найти все сильно связные компоненты ориентированного графа. Алгоритм Тарьяна работает за линейное время , использует один стек и два целочисленных массива на вершину. С тех пор он стал стандартным способом раскладывать орграф на максимальные «куски замкнутой циркуляции» - для компиляторов, систем зависимостей, 2-SAT и любых задач, где важно понимать структуру циклов.
Что такое сильно связная компонента
Сильно связная компонента (strongly connected component, SCC) орграфа - это максимальное по включению подмножество вершин , такое что для любой пары существует ориентированный путь и обратный путь . Иначе говоря, внутри SCC из любой вершины можно дойти до любой другой, двигаясь только по стрелкам.
Любая отдельная вершина сама по себе образует SCC. Если стянуть каждую SCC в одну метавершину, получится конденсация графа - ориентированный ациклический граф (DAG), в котором ребро между метавершинами означает наличие хотя бы одного ребра между соответствующими SCC. Конденсация позволяет рассуждать о зависимостях, игнорируя внутренние циклы.
Важно не путать с неориентированным случаем: там понятия «связная компонента» и «сильно связная компонента» совпадают - нет направления, достижимость симметрична автоматически. SCC становятся содержательным понятием только на орграфах.
Идея алгоритма
Алгоритм Тарьяна делает один обход DFS и поддерживает для каждой вершины два числа:
- - discovery time, момент первого посещения (номер вызова DFS).
- - минимальное значение , достижимое из поддерева DFS с корнем , если разрешить максимум одно «обратное» ребро в текущую активную ветку.
Параллельно ведётся стек активных вершин: каждая новая вершина кладётся на стек при первом посещении и снимается, только когда обнаруживается, что она - корень SCC. Флаг onStack[v] за отличает «вершину в текущем поддереве» от «вершины уже закрытой SCC».
Ключ: если для после обработки всех детей , то из поддерева нельзя «убежать» в более ранний узел текущей цепочки - значит, является корнем своей SCC. Выгружаем со стека всё до включительно - это и есть очередная компонента.
Псевдокод
time = 0
stack = []
SCCs = []
def strongconnect(v):
disc[v] = low[v] = time
time += 1
stack.append(v)
onStack[v] = True
for w in adj[v]:
if disc[w] is undefined:
strongconnect(w) # дерево DFS
low[v] = min(low[v], low[w])
elif onStack[w]:
low[v] = min(low[v], disc[w]) # обратное / поперечное в текущую ветку
# иначе ребро в уже закрытую SCC - игнорируем
if low[v] == disc[v]: # v - корень SCC
comp = []
while True:
w = stack.pop()
onStack[w] = False
comp.append(w)
if w == v:
break
SCCs.append(comp)
for v in V:
if disc[v] is undefined:
strongconnect(v)
Все три ветки существенны: первая объединяет поддеревья детей, вторая ловит back/cross-edges внутри текущей ветки, третья игнорирует рёбра в уже свёрнутые SCC - они справа в конденсации и не вернут нас назад.
Подробный пример
Возьмём орграф из 7 вершин и 8 рёбер: . Запустим DFS из вершины 1.
- Заходим в 1: , стек .
- Идём по . Заходим в 2: , стек .
- Идём по . Заходим в 3: , стек .
- Ребро : 1 в стеке, значит .
- Ребро . Заходим в 4: , стек .
- Идём . Заходим в 5: , стек .
- Идём . Заходим в 6: , стек .
- Ребро : 4 в стеке, .
- Ребро . Заходим в 7: , стек . Соседей у 7 нет, проверяем - снимаем со стека SCC .
- Возврат в 6: , - не корень.
- Возврат в 5: - не корень.
- Возврат в 4: - корень, снимаем со стека .
- Возврат в 3: - не корень.
- Возврат в 2: - не корень.
- Возврат в 1: - корень, снимаем .
Итого три SCC: , , . Стек пуст, обход закончен.
Сложность и сравнение
Каждое ребро рассматривается ровно один раз (как древесное или как back/cross), каждая вершина один раз попадает на стек и один раз снимается с него. Память - под массивы , , onStack и сам стек. Итог: времени, памяти.
Альтернатива - алгоритм Косарайю (1978): сначала DFS на исходном графе с сохранением порядка завершения, затем DFS на транспонированном графе в обратном порядке. Та же асимптотика , но Косарайю требует построения и делает две DFS вместо одной - лишний проход и удвоенная память на списки смежности. Константа Тарьяна меньше; на больших разреженных графах разница заметна, особенно при кэш-локальности обходов. Алгоритм Габова (1999) - ещё одна альтернатива с двумя стеками вместо , асимптотика та же, на практике используется реже.
Применения
- Системы пакетных зависимостей. Cargo, npm, Gradle ищут SCC в графе «пакет → зависит от», чтобы детектировать циклы - нормальный граф зависимостей обязан быть DAG.
- 2-SAT за линейное время. Формулу 2-SAT сводят к импликационному графу с вершинами; формула выполнима тогда и только тогда, когда никакая переменная и её отрицание не лежат в одной SCC.
- Dataflow-анализ компилятора. На графе вызовов или CFG SCC соответствуют взаимно-рекурсивным группам функций - их обрабатывают единым блоком.
- Достижимость и редукция. Стягивание SCC превращает орграф в DAG, на котором быстро считаются транзитивное замыкание и топологический порядок.
Связь с другими алгоритмами Тарьяна
Роберт Тарьян - лауреат Тьюринговской премии 1986 года - придумал целое семейство алгоритмов на DFS-временах с похожей low-link идеей:
- Поиск мостов в неориентированном графе - ребро является мостом, если , где - потомок в дереве DFS. Алгоритм почти идентичен; разница в учёте ребра родителя.
- Поиск точек сочленения (cut vertices) - вершина является точкой сочленения, если у неё в дереве DFS есть ребёнок с (с особым случаем корня).
- Двусвязные компоненты (biconnected components) - на схожем стеке, но из рёбер.
- Union-Find с path compression и union by rank - структура с почти-константной амортизированной сложностью за операцию, фундамент для Краскала.
Всё семейство объединяет одна идея: запоминать момент входа, поддерживать минимум достижимого «времени возврата» и принимать решение в момент выхода из вершины. Освоив один алгоритм, остальные читаются как варианты.
Частые ошибки
- Перепутать и в обратном ребре. При обработке back-edge берётся именно - ещё не окончательно посчитан. Современные изложения допускают : даёт тот же ответ, но скрывает инвариант.
- Забыть флаг
onStack. Без него обратное ребро в уже закрытую SCC ошибочно уменьшит и сольёт две разные компоненты. - Использовать рекурсию на больших графах. Стек вызовов Python / JVM ограничен; на графах в миллионы вершин нужна итеративная версия с явным стеком фреймов.
- Применять алгоритм к неориентированному графу как есть. Для неориентированного графа задача SCC бессмысленна (= обычная связность через BFS/Union-Find); Тарьян здесь решает другие задачи - мосты и точки сочленения.
- Считать, что порядок вершин внутри SCC что-то значит. Алгоритм возвращает SCC как множества; порядок в стеке зависит от обхода и не несёт смысловой нагрузки.
FAQ
Чем алгоритм Тарьяна лучше Косарайю? Одной DFS вместо двух и без построения транспонированного графа. На больших разреженных графах работает заметно быстрее на практике, хотя асимптотически оба . Косарайю проще объяснить «на пальцах», но Тарьян выигрывает в коде и в памяти.
Можно ли искать SCC через BFS? В общем случае нет: BFS не даёт естественного понятия «время выхода» и «поддерева», на котором построен low-link. Существуют BFS-варианты для частных случаев (плоские графы), но универсальный линейный алгоритм SCC требует DFS.
Что если граф не связен?
Внешний цикл for v in V запускает из каждой непосещённой вершины - алгоритм корректно обработает любое число слабо связных частей. Отдельная проверка связности не нужна.
Коротко
Алгоритм Тарьяна находит все сильно связные компоненты орграфа за один проход DFS и время , поддерживая для каждой вершины время первого посещения , минимальное достижимое из поддерева и стек активных вершин. Корнем SCC оказывается вершина, у которой ; на этом моменте стек выгружается до неё. Та же low-link идея используется Тарьяном для мостов, точек сочленения и двусвязных компонент - освоить один из этих алгоритмов означает понять всё семейство.
Читайте также

Алгоритм Прима - как построить остовное дерево по шагам
Разбираем, как алгоритм Прима шаг за шагом строит минимальное остовное дерево графа: идея жадного выбора, лемма о разрезе и трассировка на конкретном примере.

Хроматический полином графа: как считать и применять
Что такое хроматический полином графа, как его вычислить через теорему об удалении и стягивании ребра, свойства коэффициентов и связь с хроматическим числом. Формулы и пошаговый пример.

Максимальное независимое множество в графе: разбор
Что такое независимое множество вершин графа, чем различаются максимальное и наибольшее множество, как связаны число независимости, кликовое число и вершинное покрытие, как искать ответ вручную и алгоритмами.