К этапу 13

Этап 13 · урок 2

DFS, компоненты и циклы

Новый запуск DFS из непосещённой вершины открывает ровно одну новую компоненту связности.

Язык кода

После урока вы сможете

  • отделять внешний цикл по компонентам от внутреннего DFS
  • помечать вершину до рекурсивных переходов
  • обнаруживать цикл в простом неориентированном графе с учётом родителя

В графе с рёбрами 0–1 и 2–3 первый DFS помечает {0,1}, а внешний цикл запускает второй DFS из 2 и помечает {2,3}: компонент ровно две.

Как посчитать компоненты связности?

Посчитать компоненты связности неориентированного графа.

Отдельный поиск пути для каждой пары вершин

Для каждой пары вершин отдельно запускать поиск пути и затем пытаться группировать ответы.

Почему повторные обходы исследуют одну область снова?

Одни и те же области графа обходятся много раз.

Один DFS покрывает ровно компоненту старта

Один DFS посещает все и только вершины компоненты стартовой вершины.

Когда вершина считается назначенной компоненте?

visited содержит вершины уже полностью назначенных найденным компонентам; во время DFS текущая вершина помечается до обхода соседей.

Внешний цикл запускает DFS только из unseen

Идти по всем вершинам. Если v не посещена, увеличить счётчик и запустить DFS(v), который отмечает v и рекурсивно идёт в непосещённых соседей.

Почему посещённый сосед не всегда означает цикл

В неориентированном графе каждое ребро записано в обе стороны. Когда DFS приходит parent → vertex, список vertex снова содержит parent; это обратная запись того же ребра, а не цикл. Поэтому рекурсивный вызов передаёт родителя:

  • непосещённого соседа продолжаем обходить;
  • посещённого соседа, равного parent, пропускаем;
  • другой посещённый сосед означает, что существует путь назад без родительского ребра, то есть цикл.

Для цепочки 0–1–2 вершина 1 видит посещённую 0, но пропускает её как родителя. В треугольнике 0–1–2–0 вершина 2 видит посещённую 0, которая не является её родителем, и цикл найден.

Этот критерий относится к простому неориентированному графу без параллельных рёбер. В ориентированном графе нужен другой инвариант: обычно три цвета, где ребро в серую вершину текущего стека означает цикл. Для мультиграфа родительское ребро различают по идентификатору, иначе второе параллельное ребро будет ошибочно пропущено.

Подсчёт компонент на C++17 и Python 3

C++17

#include <cassert>
#include <stdexcept>
#include <vector>
using namespace std;

void validateGraph(const vector<vector<int>>& graph) {
    for (const auto& neighbors : graph) {
        for (int neighbor : neighbors) {
            if (neighbor < 0 || neighbor >= static_cast<int>(graph.size())) {
                throw out_of_range("neighbor");
            }
        }
    }
}

void visit(int vertex, const vector<vector<int>>& graph, vector<bool>& seen) {
    seen[vertex] = true;
    for (int neighbor : graph[vertex]) {
        if (!seen[neighbor]) visit(neighbor, graph, seen);
    }
}

int componentCount(const vector<vector<int>>& graph) {
    validateGraph(graph);
    vector<bool> seen(graph.size(), false);
    int components = 0;
    for (int vertex = 0; vertex < static_cast<int>(graph.size()); ++vertex) {
        if (seen[vertex]) continue;
        ++components;
        visit(vertex, graph, seen);
    }
    return components;
}

bool findUndirectedCycle(
    int vertex,
    int parent,
    const vector<vector<int>>& graph,
    vector<bool>& seen
) {
    seen[vertex] = true;
    for (int neighbor : graph[vertex]) {
        if (!seen[neighbor]) {
            if (findUndirectedCycle(neighbor, vertex, graph, seen)) return true;
        } else if (neighbor != parent) {
            return true;
        }
    }
    return false;
}

bool hasUndirectedCycle(const vector<vector<int>>& graph) {
    validateGraph(graph);
    vector<bool> seen(graph.size(), false);
    for (int vertex = 0; vertex < static_cast<int>(graph.size()); ++vertex) {
        if (!seen[vertex] && findUndirectedCycle(vertex, -1, graph, seen)) {
            return true;
        }
    }
    return false;
}

int main() {
    vector<vector<int>> graph{{1}, {0}, {3}, {2}};
    assert(componentCount(graph) == 2);
    assert(componentCount({}) == 0);
    assert(!hasUndirectedCycle({{1}, {0, 2}, {1}}));
    assert(hasUndirectedCycle({{1, 2}, {0, 2}, {0, 1}}));
    bool rejected = false;
    try {
        hasUndirectedCycle({{0}, {2}});
    } catch (const out_of_range&) {
        rejected = true;
    }
    assert(rejected);
}

Python 3

def validate_graph(graph: list[list[int]]) -> None:
    for neighbors in graph:
        for neighbor in neighbors:
            if not 0 <= neighbor < len(graph):
                raise IndexError("neighbor")


def component_count(graph: list[list[int]]) -> int:
    validate_graph(graph)
    seen = [False] * len(graph)

    def visit(vertex: int) -> None:
        seen[vertex] = True
        for neighbor in graph[vertex]:
            if not seen[neighbor]:
                visit(neighbor)

    components = 0
    for vertex in range(len(graph)):
        if seen[vertex]:
            continue
        components += 1
        visit(vertex)
    return components


def has_undirected_cycle(graph: list[list[int]]) -> bool:
    validate_graph(graph)
    seen = [False] * len(graph)

    def find_cycle(vertex: int, parent: int) -> bool:
        seen[vertex] = True
        for neighbor in graph[vertex]:
            if not seen[neighbor]:
                if find_cycle(neighbor, vertex):
                    return True
            elif neighbor != parent:
                return True
        return False

    for vertex in range(len(graph)):
        if not seen[vertex] and find_cycle(vertex, -1):
            return True
    return False


assert component_count([[1], [0], [3], [2]]) == 2
assert component_count([]) == 0
assert not has_undirected_cycle([[1], [0, 2], [1]])
assert has_undirected_cycle([[1, 2], [0, 2], [0, 1]])
try:
    has_undirected_cycle([[0], [2]])
    raise AssertionError("invalid neighbor was accepted")
except IndexError:
    pass

Каждая вершина и каждое ребро читаются один раз

Подсчёт компонент и проверка цикла работают за O(n+m) времени при списках смежности: каждая вершина помечается один раз, каждая запись ребра читается один раз. Память O(n) на seen и стек рекурсии; глубокая цепочка может потребовать явный stack, особенно в Python.

Пустой граф, изолированные вершины, self-loop и формат рёбер

Пустой граф; все вершины изолированы; одна компонента; self-loop сразу образует цикл; обычный цикл не должен вызвать бесконечную рекурсию. Каждый сосед обязан быть индексом 0..n-1; обе реализации явно отклоняют нарушение. Проверка цикла предполагает симметричный список простого неориентированного графа.

Тесты на одну, две и n компонент

n=0 -> 0; три изолированные -> 3; цепочка -> 1 и без цикла; две отдельные пары -> 2; треугольник содержит цикл; self-loop; цикл в компоненте, которая не содержит вершину 0; неверный сосед отклоняется.

Сигналы островов, областей и групп достижимости

Нужно найти острова, области, группы достижимости, проверить связность или понять, содержит ли неориентированная сеть замкнутый путь. Сначала уточните направленность и допускаются ли параллельные рёбра: от этого зависит инвариант цикла.

Почему простой DFS не находит сильные компоненты?

В ориентированном графе «компонента» может означать слабую или сильную связность; простой DFS даёт множество достижимых, не SCC. Родительский критерий цикла тоже нельзя переносить на ориентированный граф — там отслеживают вершины текущего пути.

Мини-проверка: когда ставить visited?

Вопрос: почему вершину помечают до рекурсивных вызовов? Ответ: сосед по циклу иначе успеет снова войти в неё.

Мини-проверка: зачем нужен внешний цикл?

Вопрос: зачем внешний цикл, если есть DFS? Ответ: один DFS не достигает вершин других компонент.

Проследите два запуска DFS

Для графа 0–1–2 и 3–4 покажите внешний цикл, два запуска DFS и состояние visited.

Посчитайте области в сетке без подсказки

Задача 1

Посчитайте острова в прямоугольной сетке из 0/1 при связности только по четырём сторонам.

Состояние DFS отвечает и за охват, и за запрещённый возврат

Компоненты считаются числом стартов из ещё не охваченных вершин. Для цикла в простом неориентированном графе DFS дополнительно помнит родителя и отличает обратную запись того же ребра от пути к другой уже посещённой вершине.

Не начат

Закрепление

Попробовать самостоятельно

Небольшой маршрут от разбора к самостоятельному решению. Статусы синхронизируются с общей практикой.

  1. С разборомCodeRun · внешняя задачаСложность CodeRun: СредняяУровень AlgoDS: Основной

    Поиск в глубину

    Отработайте полный DFS с явной отметкой посещения и аккуратным вводом графа.

    Сначала завершите уроки-зависимости
  2. С разборомLeetCode · внешняя задачаСложность LeetCode: MediumУровень AlgoDS: Основной

    Keys and Rooms

    Рассматривай комнаты как вершины, ключи как рёбра и отмечай вершину до обхода её соседей.

    Сначала завершите уроки-зависимости
  3. С разборомLeetCode · внешняя задачаСложность LeetCode: MediumУровень AlgoDS: Основной

    Number of Provinces

    Каждый запуск обхода из непосещённой вершины соответствует ровно одной новой компоненте.

    Сначала завершите уроки-зависимости
  4. Перенос паттернаCodeRun · внешняя задачаСложность CodeRun: СредняяУровень AlgoDS: Основной

    Компоненты связности

    Запускайте новый обход только из непосещённой вершины и связывайте каждый запуск с новой компонентой.

    Сначала завершите уроки-зависимости
  5. Перенос паттернаLeetCode · внешняя задачаСложность LeetCode: MediumУровень AlgoDS: Основной

    Reorder Routes to Make All Paths Lead to the City Zero

    Сделай граф двусторонним для обхода, сохранив в ребре признак исходного направления.

    Сначала завершите уроки-зависимости
  6. СамостоятельноCodeRun · внешняя задачаСложность CodeRun: ЛёгкаяУровень AlgoDS: Основной

    Откуда достижима первая вершина

    Поменяйте направление рёбер, чтобы один обход отвечал на вопрос о достижимости исходной вершины.

    Сначала завершите уроки-зависимости
Все задачи по теме