EduBrick

Дерево обхода и поиск циклов

Рёбра графа распадаются на четыре вида, и по виду ребра сразу видно, есть ли цикл. Разные критерии для ориентированного и неориентированного случая.

4 мин

Запустим обход в глубину и оставим только те рёбра, по которым он реально прошёл. Получится дерево — дерево обхода. Остальные рёбра никуда не делись, и их полезно классифицировать.

Четыре вида рёбер

вид куда ведёт
древесное в непосещённого потомка, по нему прошёл обход
обратное в предка, уже посещённого
прямое в потомка, но обход прошёл не по нему
перекрёстное в вершину из уже завершённого поддерева сбоку

В неориентированном графе бывают только древесные и обратные.

Прямых нет: если ребро ведёт в потомка, то к моменту, когда обход дошёл до этого потомка, ребро уже рассматривалось — и потомок был бы посещён по нему.

Перекрёстных тоже нет: пусть ребро соединяет uu и vv из разных поддеревьев. Тогда, обходя то из них, куда обход попал раньше, мы бы прошли по этому ребру и второе поддерево оказалось бы внутри первого.

В ориентированном графе бывают все четыре.

Циклы в неориентированном графе

Цикл — это спуск по древесным рёбрам плюс одно обратное. Значит, цикл есть тогда и только тогда, когда найдётся обратное ребро.

Единственная тонкость: ребро в родителя обратным считать нельзя — мы только что по нему пришли.

vector<int> parent;

bool dfs(int v, int p) {
    used[v] = 1;
    parent[v] = p;
    for (int to : g[v]) {
        if (to == p) continue;              // ребро назад к родителю
        if (used[to]) { /* нашли цикл v -> to */ return true; }
        if (dfs(to, v)) return true;
    }
    return false;
}

Проверка to == p пропускает одно ребро в родителя. Если между vv и pp есть кратное ребро, это уже настоящий цикл длины 2, и пропускать нужно ровно один экземпляр, а не все. Аккуратнее всего хранить номер ребра, по которому пришли, а не номер вершины.

Восстановление цикла. Нашли обратное ребро из vv в uu — поднимаемся по родителям от vv, пока не дойдём до uu:

vector<int> cycle;
for (int x = v; x != u; x = parent[x]) cycle.push_back(x);
cycle.push_back(u);
reverse(cycle.begin(), cycle.end());

Циклы в ориентированном графе

Здесь двух состояний мало. Посещённая вершина может быть либо предком (мы внутри её вызова), либо вершиной из завершённого поддерева — и это разные случаи: первое даёт цикл, второе нет.

Поэтому красим в три цвета:

  • 0 — не посещена;
  • 1 — вошли, но ещё не вышли (вершина на текущем пути от корня);
  • 2 — вышли, поддерево полностью обработано.
vector<int> color;

bool dfs(int v) {
    color[v] = 1;
    for (int to : g[v]) {
        if (color[to] == 1) return true;    // обратное ребро — цикл
        if (color[to] == 0 && dfs(to)) return true;
    }
    color[v] = 2;
    return false;
}

Ребро в вершину цвета 2 — прямое или перекрёстное, цикла не даёт. Ребро в вершину цвета 1 — обратное, и цикл найден: он проходит по текущему пути от to до v.

Проверять на родителя здесь не нужно: ребро vuv \to u и ребро uvu \to v — разные рёбра, и вместе они образуют настоящий цикл длины 2.

Восстановление цикла — то же самое, подъём по родителям. Только результат нужно развернуть: в ориентированном графе порядок вершин в цикле существенен.

Проверено вместе с топологической сортировкой: на двадцати тысячах случайных ориентированных графов наличие цикла, найденное трёхцветным обходом, совпало с проверкой через транзитивное замыкание.

Сводка критериев

задача критерий
цикл в неориентированном есть обратное ребро (кроме ребра в родителя)
цикл в ориентированном есть ребро в вершину цвета 1
нечётный цикл граф не двудолен
граф — дерево связен и m=n1m = n - 1
граф — лес нет циклов

Для неориентированного графа есть и совсем дешёвая проверка: если mnm \ge n, цикл точно есть. Обходить нужно только когда m<nm < n — и тогда достаточно сравнить число компонент с nmn - m.