# D4, часть 1. Графы: обход, топологическая сортировка, кратчайшие пути (урок) Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/12_dijkstra`. Граф — это не абстракция «для собеседований», а модель любой сети связей: маршрутизаторы и линки, процессы и их зависимости, файлы и их include-цепочки. ## 1. Представления графа: матрица смежности vs список смежности Граф — это набор вершин `V` и рёбер `E`. Два способа хранить связи в памяти: ```c++ // матрица смежности: adj[u][v] = вес ребра (или 0/inf, если ребра нет) std::vector> adj(n, std::vector(n, 0)); // список смежности: adj[u] = список (сосед, вес) std::vector>> 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: обход в ширину и кратчайший путь по числу рёбер ```c++ std::vector bfs(int src, const std::vector>& adj) { std::vector dist(adj.size(), -1); std::queue 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, все вершины расстояния >& adj, std::vector& 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), непротиворечивого порядка «раньше/позже» построить нельзя в принципе. ```c++ std::vector kahn_toposort(int n, const std::vector>& adj) { std::vector indeg(n, 0); for (int u = 0; u < n; ++u) for (int v : adj[u]) indeg[v]++; // степень входа: сколько рёбер входит в v std::queue q; for (int u = 0; u < n; ++u) if (indeg[u] == 0) q.push(u); // вершины без зависимостей — стартовые std::vector 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⁶. ```c++ long long shortest_path(int n, const std::vector>& edges, // (u, v, w) int src, int dst) { if (src == dst) return 0; std::vector>> adj(n); for (auto& [u, v, w] : edges) adj[u].push_back({v, w}); // ориентированное ребро std::vector dist(n, -1); using P = std::pair; // (расстояние, вершина) std::priority_queue, std::greater

> 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)` — объединить два множества. ```c++ struct DSU { std::vector 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)).
## Материалы - Документация `std::priority_queue` (мин-куча в реализации Дейкстры) — https://en.cppreference.com/w/cpp/container/priority_queue