36 KiB
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, ленивое
удаление, условие неотрицательных весов).
Проверь себя
-
Граф на 100 000 вершин и 200 000 рёбер. Почему для него используют список смежности, а не матрицу, и во сколько раз (порядок величины) список экономнее по памяти?
Ответ
Матрица заняла бы V²=10¹⁰ ячеек, список — V+E≈300 000 ячеек: разница на порядки (≈33 000 раз), матрица физически не влезает в разумную память. -
Почему BFS, а не DFS, используют для поиска кратчайшего пути в невзвешенном графе?
Ответ
BFS раскрывает граф слоями через очередь (FIFO) — все вершины расстояния k обрабатываются раньше вершин расстояния k+1, поэтому первое посещение вершины гарантированно происходит по кратчайшему пути. DFS идёт вглубь одной ветки и такой гарантии не даёт. -
В алгоритме Кана после обработки все вершины кроме трёх остались с
indeg > 0. Что это означает и как это связано с количеством обработанных вершин?Ответ
Эти три (и, возможно, другие зависимые от них) вершины образуют цикл — они никогда не наберут `indeg == 0`, поэтому `order.size() < n`, что и есть признак цикла в графе. -
Граф с одним ребром веса -5 в остальном с неотрицательными весами. Почему нельзя просто «запустить Дейкстру и она сработает, если это ребро не на пути к целевой вершине»?
Ответ
Нельзя гарантировать заранее, какие вершины уже «зафиксированы» жадным выбором к моменту, когда алгоритм дойдёт до отрицательного ребра — если оно ведёт в уже решённую вершину, её расстояние должно было бы уменьшиться, но Дейкстра его не пересмотрит; корректность в общем случае не гарантирована, поэтому условие w ≥ 0 обязательно для всех рёбер, а не только на пути к конкретной вершине. -
Почему сложность 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