Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+410
View File
@@ -0,0 +1,410 @@
# 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