1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 4 из 7

Алгоритм Косарайю: два обхода и все компоненты

Коротко

Наивный поиск компонент сильной связности стоит O(V·(V+E)). Алгоритм Косарайю находит их за O(V + E) и состоит из трёх шагов, каждый из которых ты уже умеешь. Первый: обычный обход в глубину по графу, вершины складываются в список в порядке ВЫХОДА. Второй: граф разворачивается - все рёбра меняют направление. Третий: обход по развёрнутому графу, но вершины берутся в порядке УБЫВАНИЯ времени выхода из первого шага; каждое дерево этого обхода - ровно одна компонента. Работает это из-за одного факта: вершина с наибольшим временем выхода лежит в «истоковой» компоненте конденсации, а после разворота исток становится стоком, из которого обход не может выбраться наружу.

Если ты помнишь все три шага Косарайю, понимаешь роль порядка выхода и умеешь объяснить, почему обход по развёрнутому графу не вылезает за пределы компоненты, листай до «Теперь сам».

Как думаешь, чем плох наивный способ

Простейший способ найти компоненты: для каждой вершины запустить обход по графу (кто достижим из неё) и обход по развёрнутому графу (кто достигает её), а потом пересечь множества. Прежде чем читать дальше, посчитай цену такого способа для V = 10⁵ и E = 10⁶ - и сравни с O(V + E).

На что это похоже

Разбор запутанного клубка ниток. Если дёргать за случайную нитку, вытянешь кусок вместе с соседними петлями - границы клубка не видно. Но если начать с той нитки, которая заведомо лежит СВЕРХУ, она снимается целиком и одна. Весь фокус Косарайю - найти такую «верхнюю» нитку, и время выхода из обхода как раз её показывает.

Механизм: три шага

# 1) первый обход: копим порядок ВЫХОДА
order = []
visited = [False] * V
def dfs1(v):
    visited[v] = True
    for u in adj[v]:
        if not visited[u]:
            dfs1(u)
    order.append(v)                 # записали при выходе

for v in range(V):
    if not visited[v]:
        dfs1(v)

# 2) разворачиваем граф: каждое ребро u → v становится v → u
radj = [[] for _ in range(V)]
for u in range(V):
    for v in adj[u]:
        radj[v].append(u)

# 3) второй обход по РАЗВЁРНУТОМУ графу в порядке УБЫВАНИЯ времени выхода
comp = [-1] * V
def dfs2(v, c):
    comp[v] = c
    for u in radj[v]:
        if comp[u] == -1:
            dfs2(u, c)

c = 0
for v in reversed(order):           # от последнего вышедшего к первому
    if comp[v] == -1:
        dfs2(v, c)
        c += 1                      # каждое дерево второго обхода - компонента

Цена: два обхода по O(V + E) плюс построение развёрнутого графа O(V + E). Памяти нужно вдвое больше - хранится и граф, и его разворот.

Есть и одноходовый алгоритм - Тарьяна: он находит компоненты за один обход, без разворота графа, с помощью стека и величины «минимальное время входа, достижимое из поддерева». Асимптотика та же O(V + E), констант меньше, память меньше - но рассуждение тоньше. Косарайю берут, когда важна понятность; Тарьяна - когда важна экономия.

Правило

Косарайю: (1) обход по графу, вершины копятся в порядке ВЫХОДА; (2) разворот графа; (3) обход по развёрнутому графу в порядке УБЫВАНИЯ времени выхода - каждое дерево второго обхода есть компонента сильной связности. Цена O(V + E) и двойная память. Порядок во втором проходе обязателен: он гарантирует, что обход стартует в компоненте, из которой в развёрнутом графе нельзя выйти.

Разбор: прогоняем на графе

Граф: a→b, b→c, c→a, c→d, d→e, e→d, d→f. Компоненты мы уже знаем из прошлого урока: {a,b,c}, {d,e}, {f}.

Шаг 1. Стартуем с a. Вход a → вход b → вход c → ребро c→a: серая, не идём → ребро c→d → вход d → вход e → ребро e→d: серая, не идём → выход e → ребро d→f → вход f (рёбер нет) → выход f → выход d → выход c → выход b → выход a. Порядок выхода: order = [e, f, d, c, b, a].

Шаг 2. Разворачиваем: b→a, c→b, a→c, d→c, e→d, d→e, f→d.

Шаг 3. Идём с конца order, то есть a, b, c, d, f, e. - a не раскрашена → обход по развёрнутому графу: a → c → b (из b ведёт b→a, но a уже в компоненте; из c ведёт c→b). Собрали компоненту 0: {a, b, c}. Наружу выйти не удалось - рёбра c→d в развёрнутом графе нет, есть d→c, а оно ведёт внутрь. - b, c уже раскрашены - пропускаем. - d не раскрашена → обход: d → e (ребро d→e в развёрнутом графе есть), из e ведёт e→d - уже наша. Ребро d→c ведёт в компоненту 0, но c уже раскрашена. Компонента 1: {d, e}. - f не раскрашена → обход: из f ведёт f→d, но d раскрашена. Компонента 2: {f}.

Три компоненты - совпало с ручным разбором. Заметь, где сработал порядок: мы начали с a, вершины с максимальным временем выхода, и именно её компонента в развёрнутом графе оказалась «глухой».

Разбор: почему это работает

Ключевая лемма: вершина с наибольшим временем выхода лежит в компоненте, которая в конденсации является ИСТОКОМ (в неё не ведёт ни одно ребро извне).

Почему: пусть компонента C не исток, то есть есть ребро из компоненты D в C. Покажем, что у какой-нибудь вершины D время выхода больше, чем у любой вершины C. Возможны два случая.

Если обход вошёл в D раньше, чем в любую вершину C, то пока D серая, он доберётся по этому ребру до C и обойдёт её целиком (компонента сильно связна, из любой её вершины достижимы все остальные), а вложенные вызовы завершатся раньше объемлющего - значит вся C выйдет до выхода из D. Если же обход вошёл в какую-то вершину C раньше, чем в D, то он снова обойдёт всю C и закончит её ДО того, как войдёт в D: вернуться из C в D нельзя (иначе они были бы одной компонентой), поэтому никакая вершина C не может оставаться серой, когда обход входит в D. В обоих случаях максимум времени выхода находится не в C, а выше по конденсации. Значит вершина с глобальным максимумом лежит в компоненте-истоке.

Теперь разворот. После него все рёбра конденсации меняют направление, и исток становится СТОКОМ: из него нет рёбер наружу. Запуская обход по развёрнутому графу из вершины с максимальным временем выхода, мы стартуем внутри компоненты-стока - и обход физически не может выйти за её пределы, а внутри достигает всех её вершин (компонента сильно связна, и разворот сильную связность сохраняет). Значит первое дерево - ровно одна компонента.

Дальше по индукции: раскрашенные вершины выбрасываются, и следующая нераскрашенная вершина с наибольшим временем выхода снова оказывается в истоке ОСТАВШЕЙСЯ конденсации. Уточнение, без которого индукция не держится: у второй и следующих компонент рёбра наружу в развёрнутом графе уже есть - ведут они в компоненты, найденные раньше. Обход туда не уходит не потому, что рёбер нет, а потому, что те вершины уже раскрашены и пропускаются. Каждое дерево второго обхода - ровно одна компонента, и все они найдены за два прохода.

Что ломается без этого

Первое: запустить второй обход по ИСХОДНОМУ графу, а не по развёрнутому - тогда обход из истока уйдёт вниз по конденсации и склеит в одну компоненту всё, что из истока достижимо. Второе: взять во втором проходе порядок возрастания времени выхода (или порядок номеров) - обход начнётся не в истоке, выйдет за пределы компоненты и снова всё склеит. Третье: пересчитать порядок выхода на развёрнутом графе - нужен порядок из ПЕРВОГО прохода. Четвёртое: забыть внешний цикл в первом проходе - вершины, недостижимые из стартовой, не попадут в order, и их компоненты потеряются. Пятое, практическое: строить разворот графа заново на каждом запросе - если компонент ищут часто, разворот стоит построить один раз и хранить, либо взять алгоритм Тарьяна, которому разворот не нужен вовсе.

Зачем это в работе

Компоненты сильной связности - это «группы взаимозависимого», и находить их нужно всюду, где есть направленные связи. Сборка и пакеты: модули одной компоненты придётся собирать вместе, и система скажет это пользователю вместо абстрактного «цикл». Компиляторы: взаимно рекурсивные функции образуют компоненту в графе вызовов - их анализируют и оптимизируют совместно. Статический анализ и рефакторинг: компоненты в графе зависимостей классов показывают клубки, которые надо развязывать. 2-SAT: ответ читается прямо из компонент графа импликаций - если переменная и её отрицание попали в одну компоненту, решения нет. Анализ графа ссылок, поиск сообществ, обнаружение взаимных блокировок, проверка достижимости в огромных графах (сначала конденсация, потом ДАГ, на котором всё считается быстро) - везде один и тот же алгоритм из двух обходов.

Теперь сам

Граф: 1→2, 2→1, 2→3, 3→4, 4→3. Первый обход стартует с вершины 1, соседи по возрастанию. Выпиши порядок выхода, разверни граф и проведи второй обход. Сколько получится компонент?

Ответ. Первый обход: вход 1 → ребро 1→2, двойка белая → вход 2 → ребро 2→1: единица СЕРАЯ, не идём → ребро 2→3 → вход 3 → ребро 3→4 → вход 4 → ребро 4→3: тройка серая, не идём → выход 4 → выход 3 → выход 2 → выход 1. Порядок выхода: order = [4, 3, 2, 1]. Разворот: 2→1, 1→2, 3→2, 4→3, 3→4. Второй обход идём с конца order: сначала вершина 1. Она не раскрашена → обход по развёрнутому графу: из 1 ведёт 1→2, двойка не раскрашена → берём; из 2 ведёт 2→1 - уже наша. Всё, компонента 0: {1, 2}. Обрати внимание: ребро 2→3 исходного графа в развёрнутом стало 3→2, то есть из нашей компоненты наружу хода нет - ровно то, ради чего делался разворот. Дальше по списку: вершина 2 раскрашена, пропускаем. Вершина 3 не раскрашена → обход: из 3 ведут 3→2 (раскрашена) и 3→4 (нет) → берём 4; из 4 ведёт 4→3 - наша. Компонента 1: {3, 4}. Вершина 4 раскрашена. Итого две компоненты: {1,2} и {3,4}, а конденсация - две точки и ребро из {1,2} в {3,4} (из-за 2→3).

Главное

Алгоритм Косарайю находит все компоненты сильной связности за O(V + E) тремя шагами: обход по графу с записью вершин в порядке ВЫХОДА; разворот всех рёбер; обход по развёрнутому графу в порядке УБЫВАНИЯ времени выхода, где каждое дерево - ровно одна компонента. Работает из-за леммы: вершина с наибольшим временем выхода лежит в компоненте-ИСТОКЕ конденсации, а после разворота исток становится стоком, из которого обход не может выйти наружу; дальше то же рассуждение повторяется по индукции для оставшихся вершин. Порядок во втором проходе не украшение, а суть: возьми порядок номеров или забудь развернуть граф - и обход склеит в одну компоненту всё, до чего дотянется. Памяти нужно вдвое больше (граф и его разворот); алгоритм Тарьяна делает то же за ОДИН обход без разворота, ценой более тонкого рассуждения. Компоненты нужны всюду, где есть взаимные зависимости: группы модулей в сборке, взаимно рекурсивные функции, 2-SAT, дедлоки, конденсация большого графа перед быстрым анализом.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.