Files
eduplan-cpp-eltex/lessons/D4_algo.md
T

36 KiB
Raw Blame History

D4, часть 1. Графы: обход, топологическая сортировка, кратчайшие пути (урок)

Это не проверка, а урок: сначала разбираем механизм, потом решаешь tasks/12_dijkstra. Граф — это не абстракция «для собеседований», а модель любой сети связей: маршрутизаторы и линки, процессы и их зависимости, файлы и их include-цепочки.

1. Представления графа: матрица смежности vs список смежности

Граф — это набор вершин V и рёбер E. Два способа хранить связи в памяти:

// матрица смежности: adj[u][v] = вес ребра (или 0/inf, если ребра нет)
std::vector<std::vector<int>> adj(n, std::vector<int>(n, 0));

// список смежности: adj[u] = список (сосед, вес)
std::vector<std::vector<std::pair<int,int>>> adj(n);

Матрица смежности: память O(V²) независимо от числа рёбер, проверка «есть ли ребро u→v» — O(1) прямым обращением по индексу, но перебор всех соседей вершины — всегда O(V), даже если соседей всего два. Подходит для плотных графов (E близко к V²) и когда нужна частая проверка «есть ли ребро».

Список смежности: память O(V+E) — платишь только за реально существующие рёбра, перебор соседей вершины — O(deg(v)), то есть ровно столько операций, сколько соседей. Подходит для разреженных графов (типичный случай: сети, графы зависимостей), где E ≈ V, а не V². tasks/12_dijkstra — 100 000 вершин и 200 000 рёбер: матрица заняла бы 10¹⁰ ячеек и не влезла бы в память, поэтому единственный вариант — список смежности.

Факты для карточек

  • base | Память матрицы смежности для V вершин? — O(V²)
  • base | Память списка смежности? — O(V+E)
  • core | Сложность перебора всех соседей вершины в списке смежности? — O(deg(v)), в матрице — всегда O(V)
  • core | Почему для графа 100 000 вершин нужен список смежности, а не матрица? — матрица заняла бы 10¹⁰ ячеек, не влезает в память

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

2. BFS: обход в ширину и кратчайший путь по числу рёбер

std::vector<int> bfs(int src, const std::vector<std::vector<int>>& adj) {
    std::vector<int> dist(adj.size(), -1);
    std::queue<int> q;
    dist[src] = 0;
    q.push(src);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u])
            if (dist[v] == -1) {           // ещё не посещена
                dist[v] = dist[u] + 1;
                q.push(v);
            }
    }
    return dist;
}

BFS раскрывает граф слоями: сначала все вершины на расстоянии 1 ребро от источника, потом на расстоянии 2, и так далее — это следствие того, что структура данных — очередь (FIFO): вершина, добавленная раньше, обрабатывается раньше, поэтому к моменту, когда очередь дошла до вершин расстояния k, все вершины расстояния <k уже обработаны. Отсюда ключевое свойство: в невзвешенном графе BFS даёт кратчайший путь по числу рёбер — первый раз, когда вершина помечена посещённой, это гарантированно по кратчайшему пути.

Каждая вершина кладётся в очередь ровно один раз (проверка dist[v] == -1 это гарантирует), и для каждой вершины перебираются все её соседи один раз — суммарно O(V+E): O(V) на инициализацию и постановку/снятие каждой вершины из очереди, O(E) на суммарный перебор всех рёбер по всем вершинам (каждое ребро просматривается один или два раза, в зависимости от ориентированности).

Факты для карточек

  • base | Сложность BFS на списке смежности? — O(V+E)
  • base | Какую структуру данных использует BFS? — очередь (FIFO)
  • core | Что гарантированно находит BFS в невзвешенном графе? — кратчайший путь по числу рёбер от источника
  • core | Почему BFS даёт кратчайший путь именно из-за очереди, а не из-за чего-то ещё? — FIFO-порядок раскрывает граф строго по слоям расстояния, вершина расстояния k не может обработаться раньше всех вершин расстояния <k

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

3. DFS: обход в глубину

void dfs(int u, const std::vector<std::vector<int>>& adj, std::vector<bool>& visited) {
    visited[u] = true;
    for (int v : adj[u])
        if (!visited[v]) dfs(v, adj, visited);   // рекурсия = неявный стек вызовов
}

DFS идёт максимально глубоко по одной ветке, прежде чем откатиться и попробовать соседнюю — это следствие того, что структура данных — стек (LIFO), явный или неявный (стек вызовов рекурсии). Сложность та же, что у BFS — O(V+E): те же рассуждения про однократное посещение каждой вершины и однократный перебор рёбер, разница только в порядке обхода, не в асимптотике.

DFS не гарантирует кратчайший путь (может найти путь в 10 рёбер, когда есть путь в 2), зато даёт естественный механизм для задач, где важен порядок «сначала потомки, потом сам узел» — топологическая сортировка через постфиксный обход, обнаружение циклов, поиск компонент связности.

Ловушки

  • Рекурсивный DFS на графе с длинной цепочкой (например, список из 100 000 вершин, выстроенных в цепь) → глубина рекурсии 100 000 → переполнение стека вызовов (stack overflow), не ловится компилятором, падает в рантайме. Решение — итеративный DFS с явным std::stack.
  • Забыть пометить вершину посещённой до рекурсивного спуска, а не после → на графе с циклом уйдёт в бесконечную рекурсию.

Факты для карточек

  • base | Сложность DFS на списке смежности? — O(V+E)
  • base | Какую структуру данных использует DFS? — стек (явный или стек вызовов рекурсии)
  • core | Гарантирует ли DFS кратчайший путь? — нет, в отличие от BFS
  • deep | Почему рекурсивный DFS опасен на графе-цепочке из 100 000 вершин? — глубина рекурсии равна длине цепочки, стек вызовов переполняется

Почему дальше: раз BFS/DFS помечают вершины посещёнными за один проход, естественно использовать это для подсчёта изолированных друг от друга частей графа — компонент связности.

4. Связные компоненты

Механизм: перебираем все вершины 0..n-1; если вершина ещё не посещена — запускаем от неё BFS или DFS, это помечает посещёнными всю компоненту, к которой она принадлежит, и увеличиваем счётчик компонент на 1. Суммарная сложность по всем запускам всё равно O(V+E) — каждая вершина и каждое ребро обрабатываются ровно один раз за весь перебор, несмотря на то что BFS/DFS запускается несколько раз (по числу компонент), а не один.

Задача Number of Islands — это ровно связные компоненты на сетке: клетка '1' — вершина, соседние по 4 направлениям (вверх/вниз/влево/вправо) клетки '1' — рёбра. BFS/DFS (flood fill) от каждой ещё не посещённой клетки '1' закрашивает весь остров, число запусков = число островов. Граф здесь не хранится явно списком смежности — соседи вычисляются на лету по координатам, но асимптотика та же: O(rows·cols).

Факты для карточек

  • base | Сложность подсчёта связных компонент через BFS/DFS от каждой непосещённой вершины? — O(V+E), суммарно по всем запускам
  • core | Что в задаче Number of Islands является «вершиной» и «ребром»? — вершина — клетка '1', ребро — соседство по 4 направлениям

Почему дальше: связные компоненты не различают направление рёбер. В ориентированном графе зависимостей («B зависит от A») важен порядок — какую вершину обработать раньше другой. Это топологическая сортировка.

5. Топологическая сортировка: алгоритм Кана

Топологический порядок существует только для направленного ациклического графа (DAG) — если есть цикл (A зависит от B, B зависит от A), непротиворечивого порядка «раньше/позже» построить нельзя в принципе.

std::vector<int> kahn_toposort(int n, const std::vector<std::vector<int>>& adj) {
    std::vector<int> indeg(n, 0);
    for (int u = 0; u < n; ++u)
        for (int v : adj[u]) indeg[v]++;      // степень входа: сколько рёбер входит в v

    std::queue<int> q;
    for (int u = 0; u < n; ++u)
        if (indeg[u] == 0) q.push(u);          // вершины без зависимостей — стартовые

    std::vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : adj[u])
            if (--indeg[v] == 0) q.push(v);    // все зависимости v уже обработаны
    }
    return order;   // order.size() < n  ->  в графе есть цикл
}

Механизм: степень входа вершины — число рёбер, входящих в неё, то есть число нерассмотренных зависимостей. Вершина с indeg == 0 не зависит ни от кого необработанного — её можно поставить в порядок прямо сейчас. Когда вершина обработана, она «снимает» зависимость со всех своих соседей (--indeg[v]); как только у соседа не осталось необработанных зависимостей, он тоже готов — кладём его в очередь. Сложность O(V+E): та же логика, что у BFS — каждая вершина обрабатывается один раз, каждое ребро уменьшает indeg один раз.

Как проявляется цикл: если в графе есть цикл, все вершины цикла имеют indeg > 0 до конца работы алгоритма (каждая ждёт кого-то из цикла, а тот ждёт её) — они никогда не попадут в очередь. Проверка: order.size() < n после завершения — значит, часть вершин не обработана, в графе цикл. Это и есть способ решить задачу Course Schedule: курсы — вершины, пререквизит «A перед B» — ребро A→B; если топологический порядок покрывает все курсы, все курсы можно пройти, иначе где-то циклическая зависимость.

Факты для карточек

  • base | Для какого типа графа определена топологическая сортировка? — направленный ациклический граф (DAG)
  • base | Сложность алгоритма Кана? — O(V+E)
  • core | Что такое степень входа вершины в алгоритме Кана? — число входящих в неё рёбер (нерассмотренных зависимостей)
  • core | Как алгоритм Кана обнаруживает цикл? — счётчик обработанных вершин order.size() меньше n после завершения
  • core | Как задача Course Schedule сводится к топологической сортировке? — курсы — вершины, пререквизит — направленное ребро, цикл = невозможно пройти все курсы

Ловушки

  • Забыть, что order.size() < n — единственный надёжный признак цикла (пустая очередь сама по себе не значит успех) → код считает граф с циклом корректно отсортированным.
  • Спутать степень входа со степенью выхода при инициализации очереди → в очередь попадут не те вершины, порядок будет неверным с первого шага.

Почему дальше: топологическая сортировка отвечает на вопрос «в каком порядке», но не «на каком расстоянии». Если рёбрам приписать веса, следующий естественный вопрос — кратчайший путь по сумме весов, а не по числу рёбер.

6. Дейкстра: кратчайшие пути с неотрицательными весами

Условие применимости — все веса рёбер ≥ 0 (tasks/12_dijkstra явно требует w >= 0, при этом вес 0 допустим). Причина ограничения — алгоритм жадный: как только вершина извлечена с минимальным текущим расстоянием, она считается окончательно решённой и больше не пересматривается. Это верно только если из невыбранных вершин путь до неё не может стать короче — а если есть отрицательное ребро, путь через ещё не рассмотренную вершину теоретически мог бы уменьшить уже «зафиксированное» расстояние, и жадность ломается: алгоритм даст неверный ответ, а не просто отработает медленнее.

Наивная реализация (без очереди с приоритетом): на каждом из V шагов ищем среди непосещённых вершину с минимальным dist линейным перебором — O(V) на шаг, всего O(V²). Годится для плотных графов, где E ≈ V².

С priority_queue (мин-куча по dist): каждое расслабление ребра — это push в кучу O(log V), таких расслаблений всего O(E), плюс O(V) извлечений минимума — итого O((V+E) log V). Для tasks/12_dijkstra (100 000 вершин, 200 000 рёбер) это единственный вариант, укладывающийся в 2 секунды: V² здесь — 10¹⁰ операций, а (V+E)·log V — около 300 000 · 17 ≈ 5·10⁶.

long long shortest_path(int n,
                         const std::vector<std::tuple<int,int,int>>& edges,  // (u, v, w)
                         int src, int dst) {
    if (src == dst) return 0;
    std::vector<std::vector<std::pair<int,long long>>> adj(n);
    for (auto& [u, v, w] : edges) adj[u].push_back({v, w});   // ориентированное ребро

    std::vector<long long> dist(n, -1);
    using P = std::pair<long long,int>;                        // (расстояние, вершина)
    std::priority_queue<P, std::vector<P>, std::greater<P>> pq; // мин-куча
    dist[src] = 0;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (dist[u] != -1 && d != dist[u]) continue;  // устаревшая запись — "ленивое" удаление
        if (u == dst) return d;
        for (auto& [v, w] : adj[u]) {
            long long nd = d + w;
            if (dist[v] == -1 || nd < dist[v]) {
                dist[v] = nd;
                pq.push({nd, v});
            }
        }
    }
    return -1;   // недостижима
}

«Ленивое» удаление устаревших записей — std::priority_queue не умеет напрямую уменьшать ключ уже лежащего элемента (нет операции decrease-key), поэтому вместо обновления кладут в кучу новую пару (nd, v) при каждом улучшении расстояния — старая запись остаётся в куче. Когда до неё доходит очередь на извлечение, проверка if (d != dist[v]) continue; находит, что для v уже известно расстояние лучше, чем в этой записи, и просто пропускает её. Куча может временно раздуться до O(E) записей вместо O(V), но асимптотика O((V+E) log V) не меняется, потому что каждая запись — это одна операция push в ответ на одно расслабление ребра.

Факты для карточек

  • base | Обязательное условие применимости Дейкстры? — все веса рёбер неотрицательны (w ≥ 0)
  • base | Сложность наивной Дейкстры (без кучи)? — O(V²)
  • core | Сложность Дейкстры с priority_queue? — O((V+E) log V)
  • core | Почему отрицательное ребро ломает Дейкстру? — вершина считается решённой сразу после извлечения минимума, а отрицательное ребро из ещё не рассмотренной вершины теоретически могло бы уменьшить это «зафиксированное» расстояние
  • core | Что проверяет if (d != dist[v]) continue; в реализации на куче? — что извлечённая запись не устарела (для v уже нашли путь короче)
  • deep | Почему куча может держать до O(E) записей вместо O(V)? — при каждом улучшении расстояния в кучу кладётся новая запись вместо обновления старой (нет decrease-key)

Ловушки

  • Забыть проверку устаревшей записи (if (d != dist[v]) continue;) → для одной вершины расслабление соседей выполняется несколько раз лишний раз, результат остаётся верным, но сложность деградирует к худшему случаю.
  • Применить Дейкстру к графу с отрицательным ребром без проверки условия → неверный (заниженный или завышенный, в зависимости от структуры) ответ без явной ошибки — тестами не всегда ловится, если отрицательное ребро не лежит на кратчайшем пути.

Почему дальше: если в графе всё же есть отрицательные веса, нужен алгоритм без жадного «фиксирования» результата — Беллман-Форд.

7. Беллман-Форд: отрицательные веса

Механизм: вместо жадного выбора минимума расслабляем все E рёбер V-1 раз подряд. После k-й полной итерации гарантированно найдены все кратчайшие пути, использующие не более k рёбер; кратчайший путь без отрицательных циклов не может состоять больше чем из V-1 ребра (иначе он повторял бы вершину), поэтому V-1 итераций достаточно, чтобы расстояния стабилизировались. Сложность — O(V·E): V-1 проходов по всем E рёбрам.

Дополнительная V-я итерация по всем рёбрам служит детектором отрицательного цикла: если после V-1 итераций расстояние для какого-то ребра всё ещё можно уменьшить, значит в графе есть цикл с отрицательной суммой весов — кратчайший путь в принципе не определён (можно крутиться по циклу бесконечно, уменьшая сумму). Дейкстра такой цикл не заметит и просто даст неверный ответ; Беллман-Форд явно сигнализирует о его существовании.

Факты для карточек

  • base | Сложность Беллман-Форда? — O(V·E)
  • base | Сколько раз алгоритм проходит по всем рёбрам в основном цикле? — V-1 раз
  • core | Как Беллман-Форд обнаруживает отрицательный цикл? — если расстояние ещё можно уменьшить на дополнительной V-й итерации, в графе есть отрицательный цикл
  • core | Почему V-1 итераций достаточно? — кратчайший путь без отрицательных циклов не может содержать больше V-1 ребра (иначе повторяет вершину)

Почему дальше: и Дейкстра, и Беллман-Форд работают на ориентированных рёбрах с весами. Для неориентированных рёбер есть отдельная задача — построить минимальный связывающий набор рёбер (остовное дерево) без циклов; для неё нужна структура, которая быстро отвечает «эти две вершины уже соединены?» — union-find.

8. Union-Find (Disjoint Set Union): ранг, сжатие пути, Kruskal

Структура хранит разбиение вершин на непересекающиеся множества (компоненты) и поддерживает две операции: find(x) — найти представителя множества, union(x, y) — объединить два множества.

struct DSU {
    std::vector<int> parent, rank_;
    DSU(int n) : parent(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);   // сжатие пути (path compression)
        return parent[x];
    }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;                           // уже в одном множестве — цикл
        if (rank_[x] < rank_[y]) std::swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;                // объединение по рангу
        return true;
    }
};

Сжатие пути: при каждом find все вершины на пути до корня перепривязываются напрямую к корню — следующий find для любой из них займёт один шаг вместо повторного прохода всей цепочки. Объединение по рангу: при слиянии корень с меньшим рангом (примерной оценкой высоты дерева) подвешивается под корень с большим — это не даёт деревьям расти в глубину без необходимости. По отдельности каждый приём даёт логарифмическое улучшение, вместе — амортизи- рованная сложность операции становится O(α(n)), где α — обратная функция Аккермана: она растёт настолько медленно, что для любого практически представимого n (меньше числа атомов во вселенной) α(n) ≤ 4. На практике это и называют «почти O(1)».

Kruskal (минимальное остовное дерево): сортируем все рёбра по весу — O(E log E); идём по рёбрам от меньшего веса к большему, для каждого ребра (u,v) проверяем find(u) != find(v) — если вершины ещё не в одной компоненте, добавляем ребро в остов и делаем unite(u,v) (иначе ребро создало бы цикл — пропускаем). Итоговая сложность — O(E log E) (сортировка доминирует над почти O(1) операциями union-find).

Задача Redundant Connection: дан граф-дерево из n вершин и n рёбер (то есть ровно одно лишнее ребро, замыкающее единственный цикл) — нужно найти это лишнее ребро. Решение — union-find: идём по рёбрам по порядку, для каждого делаем unite; первое ребро, для которого unite вернул false (обе вершины уже в одной компоненте до объединения), и есть искомое — оно замыкает цикл.

Задача Network Delay Time: рёбра с весами (время передачи сигнала), нужно время, за которое сигнал от вершины k дойдёт до всех остальных. Это ровно Дейкстра от источника k; ответ — максимум из всех найденных кратчайших расстояний (если какая-то вершина осталась недостижима — ответ -1).

Факты для карточек

  • base | Какие два приёма дают почти-константную сложность union-find? — сжатие пути и объединение по рангу
  • base | Амортизированная сложность операции union-find с обоими приёмами? — O(α(n)), обратная функция Аккермана, практически O(1)
  • core | Сложность алгоритма Kruskal и что в ней доминирует? — O(E log E), доминирует сортировка рёбер
  • core | Как union-find решает Redundant Connection? — первое ребро, для которого unite вернул false (вершины уже в одной компоненте), и есть лишнее
  • core | Как Network Delay Time сводится к Дейкстре? — вершина k — источник, ответ — максимум из всех кратчайших расстояний, -1 если что-то недостижимо
  • deep | Каково верхнее ограничение обратной функции Аккермана для практических n? — α(n) ≤ 4 для любого n, меньшего числа атомов во вселенной

Ловушки

  • Забыть сжатие пути в find → дерево может выродиться в цепочку, find деградирует к O(n) на несбалансированных входных данных.
  • В Kruskal забыть проверку find(u) != find(v) перед добавлением ребра → в остов попадёт ребро, замыкающее цикл, результат перестанет быть деревом.

Ссылки на задачи этого дня: tasks/12_dijkstra (Дейкстра на priority_queue, ленивое удаление, условие неотрицательных весов).

Проверь себя
  1. Граф на 100 000 вершин и 200 000 рёбер. Почему для него используют список смежности, а не матрицу, и во сколько раз (порядок величины) список экономнее по памяти?

    ОтветМатрица заняла бы V²=10¹⁰ ячеек, список — V+E≈300 000 ячеек: разница на порядки (≈33 000 раз), матрица физически не влезает в разумную память.
  2. Почему BFS, а не DFS, используют для поиска кратчайшего пути в невзвешенном графе?

    ОтветBFS раскрывает граф слоями через очередь (FIFO) — все вершины расстояния k обрабатываются раньше вершин расстояния k+1, поэтому первое посещение вершины гарантированно происходит по кратчайшему пути. DFS идёт вглубь одной ветки и такой гарантии не даёт.
  3. В алгоритме Кана после обработки все вершины кроме трёх остались с indeg > 0. Что это означает и как это связано с количеством обработанных вершин?

    ОтветЭти три (и, возможно, другие зависимые от них) вершины образуют цикл — они никогда не наберут `indeg == 0`, поэтому `order.size() < n`, что и есть признак цикла в графе.
  4. Граф с одним ребром веса -5 в остальном с неотрицательными весами. Почему нельзя просто «запустить Дейкстру и она сработает, если это ребро не на пути к целевой вершине»?

    ОтветНельзя гарантировать заранее, какие вершины уже «зафиксированы» жадным выбором к моменту, когда алгоритм дойдёт до отрицательного ребра — если оно ведёт в уже решённую вершину, её расстояние должно было бы уменьшиться, но Дейкстра его не пересмотрит; корректность в общем случае не гарантирована, поэтому условие w ≥ 0 обязательно для всех рёбер, а не только на пути к конкретной вершине.
  5. Почему сложность Kruskal — O(E log E), если union-find работает почти за O(1)?

    ОтветПотому что перед объединением рёбра нужно отсортировать по весу — это O(E log E) и доминирует над суммарной почти константной стоимостью всех union-find операций O(E·α(V)).

Материалы