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

# теория · шаг 3 из 8

Классификация рёбер и поиск цикла

Коротко

Во время обхода каждое ребро u → v попадает ровно в один из четырёх видов, и определяет его цвет вершины v в момент просмотра. Белая - древесное ребро, по нему обход и спускается. СЕРАЯ - обратное ребро: v лежит на текущем пути, значит найден ЦИКЛ. Чёрная - либо прямое (v - уже законченный потомок), либо перекрёстное (v в другой ветви); различает их сравнение tin. Главный вывод: ориентированный граф содержит цикл тогда и только тогда, когда обход находит обратное ребро - то есть ребро в СЕРУЮ вершину. Отсюда самая частая ошибка темы: искать цикл по массиву visited вместо цветов. Ребро в уже посещённую вершину циклом не является, если эта вершина чёрная, - обход просто пришёл к ней вторым путём. В неориентированном графе видов всего два: древесные и обратные, а ребро в родителя циклом не считается.

Если ты различаешь четыре вида рёбер по цвету и временам, знаешь критерий цикла через обратное ребро и помнишь, почему visited вместо серого цвета даёт ложные срабатывания, листай до «Теперь сам».

Как думаешь, всякое ли повторное ребро - цикл

Ориентированный граф: a→b, a→c, b→c. Запусти обход из a: идём в b, из b в c, возвращаемся, потом из a смотрим ребро a→c - а вершина c уже посещена. Прежде чем читать дальше, ответь: цикл ли это? И чем этот случай отличается от треугольника a→b, b→c, c→a, где цикл есть?

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

Осмотр здания с журналом из прошлого урока. Ты идёшь по коридору и видишь дверь в комнату, где уже горит свет. Два разных случая. Первый: это комната, из которой ты СЕЙЧАС пришёл, она открыта и стоит на твоём пути - значит коридоры замыкаются в кольцо. Второй: это комната, которую ты осмотрел час назад и закрыл за собой, - никакого кольца, просто к ней ведут две дороги. Отличить одно от другого можно, только помня, какие двери у тебя ЗА СПИНОЙ ещё открыты.

Механизм: четыре вида рёбер

смотрим ребро u → v, вершина u сейчас СЕРАЯ

v белая   → ДРЕВЕСНОЕ ребро: по нему спускаемся, оно входит в дерево обхода
v серая   → ОБРАТНОЕ ребро: v на текущем пути, значит есть ЦИКЛ u → ... → v → ... → u
v чёрная  → уже закончена, спускаться некуда; два подслучая:
            tin[u] < tin[v]  → ПРЯМОЕ: v - потомок u, до которого дошли раньше другим путём
            tin[u] > tin[v]  → ПЕРЕКРЁСТНОЕ: v в ранее законченной ветви

Поиск цикла в ориентированном графе:

color = [0] * V          # 0 белая, 1 серая, 2 чёрная

def dfs(v):
    color[v] = 1
    for u in adj[v]:
        if color[u] == 1:
            return True          # обратное ребро - цикл найден
        if color[u] == 0 and dfs(u):
            return True
    color[v] = 2
    return False

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

def dfs(v, parent):
    color[v] = 1
    for u in adj[v]:
        if u == parent:          # осторожно: при КРАТНЫХ рёбрах так нельзя,
            continue             # надо пропускать конкретное ребро, а не вершину
        if color[u] == 1:
            return True
        if color[u] == 0 and dfs(u, v):
            return True
    color[v] = 2
    return False

Правило

Ребро u → v классифицируется по цвету v: белая - древесное, СЕРАЯ - обратное (цикл), чёрная - прямое (tin[u] < tin[v]) или перекрёстное (tin[u] > tin[v]). Ориентированный граф ацикличен тогда и только тогда, когда обход не нашёл ни одного обратного ребра. Проверять цикл по visited НЕЛЬЗЯ: чёрная вершина - это не цикл. В неориентированном графе бывают только древесные и обратные рёбра, а ребро в родителя пропускают (при кратных рёбрах - по номеру ребра, а не по номеру вершины).

Разбор: классифицируем рёбра

Граф: a→b, b→c, c→a, a→c, d→b. Обход стартует из a, потом внешний цикл дойдёт до d.

вход a (серая, tin=0)
  ребро a→b: b белая  → ДРЕВЕСНОЕ, идём
    вход b (серая, tin=1)
      ребро b→c: c белая → ДРЕВЕСНОЕ, идём
        вход c (серая, tin=2)
          ребро c→a: a СЕРАЯ → ОБРАТНОЕ ← цикл a→b→c→a
        выход c (чёрная, tout=3)
    выход b (чёрная, tout=4)
  ребро a→c: c ЧЁРНАЯ, tin[a]=0 < tin[c]=2 → ПРЯМОЕ
выход a (чёрная, tout=5)

внешний цикл: вход d (серая, tin=6)
  ребро d→b: b ЧЁРНАЯ, tin[d]=6 > tin[b]=1 → ПЕРЕКРЁСТНОЕ
выход d (tout=7)

Все четыре вида в одном примере. Обрати внимание на два «повторных» ребра: a→c и d→b ведут в уже посещённые вершины, но ни одно из них не даёт цикла - в обоих случаях вершина ЧЁРНАЯ, её обход давно закончен, и вернуться из неё в текущий путь нельзя. Цикл дало только c→a: единица в момент просмотра была серой.

Разбор: почему в неориентированном графе нет перекрёстных рёбер

Пусть в неориентированном графе есть ребро {u, v}, и пусть обход вошёл в u раньше, чем в v (tin[u] < tin[v]). Докажем, что v обязательно окажется потомком u в дереве обхода - а значит ребро будет либо древесным, либо обратным, и на перекрёстное места не остаётся.

Когда мы стоим в u, ребро {u, v} лежит в списке соседей u, и мы его обязательно просмотрим до выхода из u. В этот момент v либо ещё белая, либо уже серая, либо уже чёрная - разберём все три случая.

Белая. Мы прямо по этому ребру и спустимся: оно древесное, v - потомок u.

Серая. Невозможно: серые вершины - это те, что лежат на пути от корня к u, то есть вошли РАНЬШЕ u. А мы предположили tin[u] < tin[v].

Чёрная. Значит v уже вошла и вышла. Вошла она позже u (по предположению) и притом до того, как мы дошли до этого ребра, - то есть внутри вызова dfs(u), ведь u всё ещё серая. По теореме о скобках отрезки u и v либо вложены, либо не пересекаются; не пересекаться они не могут - вход в v случился внутри отрезка u. Остаётся вложенность, а при tin[u] < tin[v] вложен отрезок v, то есть v - потомок u, и ребро обратное (смотрим мы его со стороны предка).

Практический смысл: в неориентированном графе достаточно ДВУХ видов рёбер, и любое ребро в посещённую вершину (кроме ребра в родителя) - обратное, то есть цикл. Именно поэтому проверка «есть ли цикл в неориентированном графе» такая короткая, а вот в ориентированном она требует цветов.

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

Первое, самое частое: искать цикл в ориентированном графе по visited вместо трёх цветов. На графе a→b, a→c, b→c такой код объявит цикл (при просмотре a→c вершина c уже посещена), хотя циклов нет вовсе. Ошибка живучая: на многих тестах она даёт верный ответ, потому что цикл там действительно есть. Второе: забыть вернуть вершину в чёрный цвет - точнее, красить её чёрной ДО перебора соседей; тогда настоящие циклы перестанут находиться. Третье: в неориентированном графе пропускать родителя по номеру ВЕРШИНЫ при кратных рёбрах: два ребра между u и v - это настоящий цикл длины два, но код его не заметит, потому что примет второе ребро за ребро в родителя; правильный способ - помнить номер ребра, по которому пришли. Четвёртое: считать, что петля (v→v) обнаружится сама - она даёт обратное ребро и обнаружится, но при пропуске родителя её легко случайно отсечь. Пятое: путать «есть цикл» и «граф не дерево»: у неориентированного графа без циклов может быть несколько компонент (это лес, а не дерево).

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

Поиск цикла - едва ли не самая частая графовая задача в прикладном коде. Зависимости пакетов, модулей, сборочных целей: цикл означает, что собрать нельзя, и хорошая система обязана назвать участников цикла, а не упасть по глубине рекурсии. Взаимные блокировки в базе данных - это цикл в графе «транзакция ждёт транзакцию», и детектор дедлоков ищет его именно обратным ребром. Циклы в схемах данных (внешние ключи), в конфигурации (включения файлов), в электронных таблицах (формула ссылается сама на себя), в наследовании классов и миксинов - всюду один и тот же алгоритм. Классификация рёбер нужна и сама по себе: компиляторы по обратным рёбрам графа потока управления находят ЦИКЛЫ в программе - те самые for и while, которые надо оптимизировать; это называется анализом естественных циклов.

Теперь сам

Ориентированный граф: 1→2, 2→3, 3→1, 4→1, 4→3. Обход стартует из вершины 1, затем внешний цикл доходит до остальных; соседи по возрастанию. Классифицируй каждое ребро и скажи, сколько в графе циклов.

Ответ. Вход 1 (серая, tin=0). Ребро 1→2: двойка белая - древесное, идём. Вход 2 (серая, tin=1). Ребро 2→3: тройка белая - древесное, идём. Вход 3 (серая, tin=2). Ребро 3→1: единица СЕРАЯ, она на текущем пути - обратное, найден цикл 1→2→3→1. Выход 3 (чёрная, tout=3), выход 2 (tout=4), выход 1 (tout=5). Внешний цикл: вершина 4 не посещена, вход 4 (серая, tin=6). Ребро 4→1: единица ЧЁРНАЯ, сравниваем времена входа: tin[4]=6 > tin[1]=0 - перекрёстное. Ребро 4→3: тройка тоже чёрная, tin[4]=6 > tin[3]=2 - перекрёстное. Выход 4 (tout=7). Итог: два древесных, одно обратное, два перекрёстных, и цикл в графе ровно один.

Теперь главное. Оба перекрёстных ребра ведут в уже посещённые вершины, и код на visited объявил бы на каждом из них цикл - но циклов там нет: из вершин 1 и 3 в четвёрку не ведёт ни одного ребра, вернуться в неё нельзя. Проверь это по списку рёбер: входящих рёбер у четвёрки нет вовсе, значит она не лежит ни на одном цикле. Именно так ошибка и живёт в реальном коде - граф с циклом «подтверждает» неверную проверку, а ломается она на вершинах, до которых из цикла не добраться.

Главное

Каждое ребро u → v обход относит к одному из четырёх видов по цвету v: белая - ДРЕВЕСНОЕ (по нему спускаемся), СЕРАЯ - ОБРАТНОЕ (вершина на текущем пути, найден цикл), чёрная - ПРЯМОЕ, если tin[u] < tin[v] (законченный потомок), или ПЕРЕКРЁСТНОЕ, если tin[u] > tin[v] (другая ветвь). Критерий: ориентированный граф ацикличен тогда и только тогда, когда обход не встретил обратного ребра. Отсюда главная ловушка темы - искать цикл по visited: ребро в ЧЁРНУЮ вершину циклом не является, обход просто пришёл туда вторым путём, и на графе a→b, a→c, b→c такой код найдёт несуществующий цикл. В неориентированном графе прямых и перекрёстных рёбер не бывает (любое ребро ведёт к предку или потомку - следствие теоремы о скобках), зато ребро в родителя надо пропускать, и при кратных рёбрах - по номеру ребра, а не вершины. На этом стоят детекторы дедлоков, проверки зависимостей и анализ циклов в компиляторах.

Комментарии

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

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

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