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

411 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D4, часть 1. Графы: обход, топологическая сортировка, кратчайшие пути (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/12_dijkstra`. Граф —
это не абстракция «для собеседований», а модель любой сети связей: маршрутизаторы и линки,
процессы и их зависимости, файлы и их include-цепочки.
## 1. Представления графа: матрица смежности vs список смежности
Граф — это набор вершин `V` и рёбер `E`. Два способа хранить связи в памяти:
```c++
// матрица смежности: 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: обход в ширину и кратчайший путь по числу рёбер
```c++
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: обход в глубину
```c++
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), непротиворечивого порядка «раньше/позже»
построить нельзя в принципе.
```c++
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⁶.
```c++
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)` — объединить два
множества.
```c++
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`, ленивое
удаление, условие неотрицательных весов).
<details>
<summary>Проверь себя</summary>
1. Граф на 100 000 вершин и 200 000 рёбер. Почему для него используют список смежности, а не
матрицу, и во сколько раз (порядок величины) список экономнее по памяти?
<details><summary>Ответ</summary>Матрица заняла бы V²=10¹⁰ ячеек, список — V+E≈300 000
ячеек: разница на порядки (≈33 000 раз), матрица физически не влезает в разумную память.</details>
2. Почему BFS, а не DFS, используют для поиска кратчайшего пути в невзвешенном графе?
<details><summary>Ответ</summary>BFS раскрывает граф слоями через очередь (FIFO) — все
вершины расстояния k обрабатываются раньше вершин расстояния k+1, поэтому первое посещение
вершины гарантированно происходит по кратчайшему пути. DFS идёт вглубь одной ветки и такой
гарантии не даёт.</details>
3. В алгоритме Кана после обработки все вершины кроме трёх остались с `indeg > 0`. Что это
означает и как это связано с количеством обработанных вершин?
<details><summary>Ответ</summary>Эти три (и, возможно, другие зависимые от них) вершины
образуют цикл — они никогда не наберут `indeg == 0`, поэтому `order.size() < n`, что и есть
признак цикла в графе.</details>
4. Граф с одним ребром веса -5 в остальном с неотрицательными весами. Почему нельзя просто
«запустить Дейкстру и она сработает, если это ребро не на пути к целевой вершине»?
<details><summary>Ответ</summary>Нельзя гарантировать заранее, какие вершины уже
«зафиксированы» жадным выбором к моменту, когда алгоритм дойдёт до отрицательного ребра —
если оно ведёт в уже решённую вершину, её расстояние должно было бы уменьшиться, но
Дейкстра его не пересмотрит; корректность в общем случае не гарантирована, поэтому условие
w ≥ 0 обязательно для всех рёбер, а не только на пути к конкретной вершине.</details>
5. Почему сложность Kruskal — O(E log E), если union-find работает почти за O(1)?
<details><summary>Ответ</summary>Потому что перед объединением рёбра нужно отсортировать по
весу — это O(E log E) и доминирует над суммарной почти константной стоимостью всех
union-find операций O(E·α(V)).</details>
</details>
## Материалы
- Документация `std::priority_queue` (мин-куча в реализации Дейкстры) — https://en.cppreference.com/w/cpp/container/priority_queue