← Параллель BВсе параллели⬇ Скачать конспект

Занятие 1. Параллель B — Графы 1

Обход в глубину: времена входа-выхода, топологическая сортировка, компоненты сильной связности и конденсация. Динамика на деревьях и на DAG. Во второй части — семинар с разбором задач по этой теме.
Лекция + семинар ≈ 4 ч Язык кода: C++17 Русский

1. Как хранить граф: представления и степени 0:09

Тема занятия — графы, и расшифровывается она как DFS (обход в глубину). Все графы на этом занятии удобно считать ориентированными: именно для ориентированных графов вводятся времена входа-выхода, топсорт и компоненты сильной связности.

Формально граф — это пара $G = (V, E)$, где $V$ — множество вершин, а $E \subseteq V \times V$ — множество рёбер. Для ориентированного графа ребро — упорядоченная пара $(u, v)$ («из $u$ в $v$»); для неориентированного — неупорядоченная $\{u, v\}$.

Важное дополнение
В записи представление графа считается уже известным, но для полноты конспекта напомним три стандартных способа хранения и их сравнение. Это добавка конспектёра, а не фрагмент записи.
СпособКак устроенПамятьПроверка ребра $u\to v$Перебор соседей $u$
Список смежностиvector<vector<int>> g; g[u] хранит всех соседей $u$$O(V+E)$$O(\deg u)$$O(\deg u)$
Матрица смежностиbool a[V][V]$O(V^2)$$O(1)$$O(V)$
Список рёберvector<pair<int,int>> edges$O(E)$$O(E)$$O(E)$

Почти все алгоритмы занятия работают со списком смежности: он даёт суммарный обход всех соседей за $O(V+E)$, что и определяет время работы DFS.

C++
vector<vector<int>> g;          // g[v] — список исходящих рёбер
void add_edge(int u, int v) {   // ориентированное ребро u -> v
    g[u].push_back(v);
}
// для неориентированного графа добавляем оба направления:
void add_undirected(int u, int v) {
    g[u].push_back(v);
    g[v].push_back(u);
}
// взвешенные рёбра: храним пары (сосед, вес)
vector<vector<pair<int,int>>> gw;

Индексация. Если вершины нумеруются с $0$ (как в массивах C++), размер — $n$. Если входные данные нумеруют вершины с $1$, удобно держать размер $n+1$ и не трогать индекс $0$; либо сразу вычитать единицу при чтении. Главное — не смешивать соглашения.

Степени. Для ориентированного графа различают исходящую степень $\deg^+ v = |g[v]|$ и входящую $\deg^- v$. Сумма всех исходящих степеней равна числу рёбер: $\sum_v \deg^+ v = E$. В неориентированном графе сумма степеней чётна и равна $2E$ (лемма о рукопожатиях).

Граничный случай: петли и кратные рёбра
Ребро вида $(v,v)$ — петля — в списке смежности просто хранится как сосед $v$ у вершины $v$. Кратные рёбра (два одинаковых $(u,v)$) допустимы, но иногда мешают (например, при подсчёте степеней или при построении конденсации без дублей). Дерево, по определению, не содержит ни петель, ни кратных рёбер.

2. Обход в глубину и времена входа-выхода 1:32

Классический DFS принимает вершину, помечает её использованной и рекурсивно запускается от всех ещё не посещённых соседей. В main мы перебираем все вершины и запускаем DFS от каждой непосещённой — так обходятся все компоненты слабой связности.

C++
vector<vector<int>> g;
vector<char> used;

void dfs(int v) {
    used[v] = true;
    for (int u : g[v])
        if (!used[u]) dfs(u);
}

int main() {
    // ... ввод графа ...
    int n = g.size();
    used.assign(n, false);
    for (int v = 0; v < n; ++v)
        if (!used[v]) dfs(v);
}

Теперь введём времена входа-выхода. Заведём глобальный счётчик времени timer, изначально равный нулю, и два вектора tin, tout. Каждый раз, когда мы входим в DFS от вершины, записываем текущее время в tin[v] и увеличиваем счётчик; каждый раз, когда выходим из DFS, записываем время в tout[v] и снова увеличиваем счётчик.

C++
vector<int> tin, tout;
int timer = 0;

void dfs(int v) {
    used[v] = true;
    tin[v] = timer++;            // вошли в v
    for (int u : g[v])
        if (!used[u]) dfs(u);
    tout[v] = timer++;           // вышли из v
}
Тонкость с timer++
Запись tin[v] = timer++ сначала записывает в tin[v] старое значение, а лишь потом увеличивает счётчик. Поэтому событие «вход в $v$» и событие «выход из $v$» получают разные номера, а максимальное значение счётчика — около $2n-1$ (каждая вершина входит и выходит ровно один раз).

Каждой вершине соответствует отрезок $[\mathrm{tin}[v], \mathrm{tout}[v]]$: точка входа и точка выхода. Именно эти числа мы дальше используем для топсорта, поиска предков и компонент сильной связности.

3. Вложенность интервалов $[\mathrm{tin}, \mathrm{tout}]$ 7:17

Для любых двух вершин $u$ и $v$ отрезки $[\mathrm{tin}[u], \mathrm{tout}[u]]$ и $[\mathrm{tin}[v], \mathrm{tout}[v]]$ либо не пересекаются, либо один целиком вложен в другой.

Почему так. Исполнение рекурсии структурно: вызов функции не может «прыгать» внутрь уже начатого вызова чужой функции — вложенные вызовы образуют стек. Вход в $v$ и выход из $v$ — это моменты старта и завершения одного вызова. Поэтому, если $v$ начался внутри вызова $u$ (то есть $\mathrm{tin}[u] < \mathrm{tin}[v] < \mathrm{tout}[u]$), то вызов $u$ не мог завершиться раньше $v$, значит $\mathrm{tout}[v] < \mathrm{tout}[u]$ — отрезок $v$ вложен в отрезок $u$. Иначе вызов $v$ произошёл до или после вызова $u$, и отрезки не пересекаются. Это частный случай «структурности» вызовов; из-за такой предсказуемости, кстати, в современном программировании не используют goto для «прыжков в середину» чужого кода.

Времена входа и выхода DFS: отрезки вложены или не пересекаютсяТаймер +1 при каждом входе и выходе. Отрезок [tin, tout] — пока вершина в стеке вызовов.A[0, 9]B[1, 6]C[7, 8]D[2, 3]E[4, 5]0123456789времяABCDED и E внутри отрезка B — они потомки B. Отрезки B и C не пересекаются — ни один не предок другого.
Вход и выход задают отрезок времени. Из-за стека вызовов любые два отрезка либо вложены (предок и потомок), либо не пересекаются.
Развёрнутое объяснение
Полезное следствие вложенности: $u$ является предком $v$ в дереве обхода тогда и только тогда, когда $\mathrm{tin}[u] \le \mathrm{tin}[v]$ и $\mathrm{tout}[v] \le \mathrm{tout}[u]$. Это удобно для запросов «предок?» (см. §18). Пока вершина находится в стеке вызовов (серая), её интервал ещё не закрыт; после выхода интервал «закрыт».

4. Топологическая сортировка: определение 12:29

Топологическая сортировка ориентированного графа — это такое упорядочивание всех вершин $v_1, v_2, \dots, v_n$, что вдоль него все рёбра идут строго слева направо.

Если $i < j$, то ребра $v_j \to v_i$ не существует. Иначе говоря, для каждого ребра $(u, v)$ вершина $u$ стоит в порядке раньше $v$.

Топсорт, вообще говоря, не единственный: например, у графа из двух вершин без рёбер корректны оба порядка $1,2$ и $2,1$. Пока нас интересует «хоть какая-нибудь» корректная сортировка.

5. Когда топсорт существует 15:52

Топологическая сортировка существует $\iff$ в графе нет ориентированного цикла (граф — DAG).

Если цикл есть, топсорта нет. Предположим противное: пусть корректный топсорт есть. Посмотрим на вершины цикла в этом порядке. Идя по циклу, мы на одном из его рёбер обязаны вернуться «назад» — то есть пройти по ребру справа налево. Но в топсорте таких рёбер быть не должно. Противоречие. Значит, при наличии цикла построить топсорт невозможно.

Если цикла нет, топсорт есть. Это мы сейчас и научимся делать конструктивно. Классическая идея — запустить DFS и выписывать вершины по ходу обхода, однако мы «изобретём» алгоритм с нуля, опираясь на времена выхода.

6. Топсорт через времена выхода 17:25

Рассмотрим ребро $u \to v$, про которое известно, что из $v$ нельзя добраться обратно в $u$ (в частности, в ациклическом графе это выполнено всегда). Что можно сказать про времена входа и выхода?

Ключевая лемма
Если в графе есть ребро $u \to v$ и нет пути из $v$ обратно в $u$, то $\mathrm{tout}[v] < \mathrm{tout}[u]$.

Доказательство. Возможны два случая. Если DFS запустился сначала от $v$, то он целиком завершится (выйдет из $v$) до того, как мы вообще начнём вызов от $u$, — тогда $\mathrm{tout}[v] < \mathrm{tout}[u]$. Иначе DFS запустился раньше от $u$; тогда в ходе вызова $u$ мы обязаны рано или поздно дойти до $v$ (по ребру $u\to v$) и запустить рекурсивный вызов от $v$ (если только не посетили $v$ ещё раньше — тогда тем более вышли из него). В любом случае вызов $v$ завершится внутри вызова $u$, значит $\mathrm{tout}[v] < \mathrm{tout}[u]$. Лемма доказана.

На самом деле верно и более сильное утверждение: если есть путь $u \leadsto v$ и нет пути $v \leadsto u$, то $\mathrm{tout}[v] < \mathrm{tout}[u]$ — всё по той же логике вложенных вызовов.

Обобщим локальное условие на глобальный порядок: выпишем вершины в порядке убывания $\mathrm{tout}$. Покажем, что это корректный топсорт.

Корректность. Пусть порядок — $v_1, \dots, v_n$ с $\mathrm{tout}[v_1] > \mathrm{tout}[v_2] > \dots > \mathrm{tout}[v_n]$. Предположим противное: есть ребро, идущее «справа налево», то есть $v_i \to v_j$ при $i > j$. По лемме (обратного пути нет, иначе был бы цикл) имеем $\mathrm{tout}[v_j] < \mathrm{tout}[v_i]$. Но из $i > j$ следует $\mathrm{tout}[v_i] < \mathrm{tout}[v_j]$. Противоречие. Значит, рёбер справа налево нет — порядок корректный.

Почему только за tout, а не за tin
Порядок входа зависит от произвольного порядка запуска DFS, поэтому по $\mathrm{tin}$ топсорт не строится. Порядок выхода же в ациклическом графе всегда согласован с рёбрами — это и есть содержание леммы.

7. Реализация топсорта за $O(V+E)$ 29:59

Наивно можно запустить DFS, собрать пары $(\mathrm{tout}, v)$ и отсортировать их — это $O((V+E) + V\log V)$. Есть два улучшения.

Сортировка подсчётом. Значения $\mathrm{tout}$ лежат в диапазоне $[0, 2n-1]$ (каждая вершина входит и выходит один раз). Значит, подходит сортировка подсчётом за $O(V)$; суммарно — $O(V+E)$.

Ещё проще — писать вершину при выходе. Заметим: когда timer увеличивается в конце DFS, мы сразу же записываем эту вершину в массив. Поэтому, если в конце каждого DFS делать push_back(v), массив окажется записанным в порядке возрастания $\mathrm{tout}$. Остаётся его перевернуть.

C++
vector<int> order;

void dfs(int v) {
    used[v] = true;
    for (int u : g[v])
        if (!used[u]) dfs(u);
    order.push_back(v);            // порядок возрастания tout
}

int main() {
    // ... ввод ...
    for (int v = 0; v < n; ++v)
        if (!used[v]) dfs(v);
    reverse(order.begin(), order.end());   // порядок убывания tout = топсорт
}
Итог
Одна дополнительная строчка в DFS даёт корректный топсорт за $O(V+E)$.
Зачем вообще топсорт
1) Динамика на DAG (§17): в порядке топсорта все рёбра идут слева направо, и ДП можно считать одним проходом. 2) Кэш-дружественная перенумерация: если один и тот же граф обходить много раз, топсорт-перенумерация делает обращения к массивам по индексу-вершине почти последовательными, что ускоряет работу на больших графах (доступы в память перестают быть хаотичными).

8. Компоненты связности 41:33

В неориентированном графе компонента связности — это максимальное множество вершин, между любой парой которых есть путь. Отношение «есть путь между $u$ и $v$» — это отношение эквивалентности, а компоненты — его классы эквивалентности.

Найти их просто: запускаем DFS и красим вершины одного запуска в один цвет; каждый новый запуск из main — новый цвет.

C++
int col = 0;
vector<int> color;
void dfs_undirected(int v, int c) {
    color[v] = c;
    for (int u : g[v])
        if (color[u] == -1) dfs_undirected(u, c);
}
// for (int v = 0; v < n; ++v) if (color[v] == -1) dfs_undirected(v, col++);

9. Компоненты сильной связности 44:45

В ориентированном графе пути несимметричны: из $u$ может быть путь в $v$, а обратного пути нет. Поэтому понятие связности разветвляется.

КСС — это отношение эквивалентности
Рефлексивность: из $u$ есть пустой путь в $u$. Симметричность: определение симметрично по $u$ и $v$. Транзитивность: если $u \leftrightarrow v$ и $v \leftrightarrow w$, склеиваем пути $u\leadsto v\leadsto w$ и $w\leadsto v\leadsto u$ — получаем $u \leftrightarrow w$. Значит, КСС разбивают вершины на классы.

В DAG нетривиальных КСС нет: у любой пары вершин одной КСС из сильной связности следует цикл, что невозможно. Значит, каждая КСС состоит из одной вершины.

10. Конденсация графа 49:12

Конденсация — это процесс: каждую КСС «сжимаем» в одну вершину, а затем возвращаем рёбра между разными компонентами (между сжатыми вершинами). Полученный граф называют метаграфом, а в разговорной речи — тоже конденсацией.

Конденсация: каждую КСС сжимаем в одну вершинуСлева исходный граф, компоненты обведены. Справа — что остаётся после сжатия.123456⟹{1,2,3}{4,5}{6}Оранжевые рёбра идут между компонентами и сохраняются. Циклов в сжатом графе нет — это DAG.
Внутри КСС все вершины взаимно достижимы, поэтому её можно заменить одной вершиной. Рёбра между компонентами остаются, и получается DAG.
Конденсация всегда DAG
Если бы в сжатом графе был цикл, то в исходном графе вершины всех компонент этого цикла были бы взаимно достижимы, то есть лежали бы в одной КСС — противоречие с тем, что мы их сжали в разные вершины.

Кратные рёбра в конденсации неудобны, поэтому обычно для каждой пары компонент оставляют одно ребро (списки смежности сортируют и удаляют дубликаты или хранят как set).

11. Алгоритм поиска КСС (Косарайю) 56:00

Нам неизвестно, как выделить КСС напрямую, но мы знаем: конденсация — DAG, а на DAG можно строить топсорт. Однако исходный граф циклический, и корректного топсорта у него нет. Зато есть времена выхода.

Лемма (о времени выхода на компонентах)
Пусть в конденсации есть ребро из компоненты $U$ в компоненту $V$. Тогда среди вершин $U$ найдётся вершина с $\mathrm{tout}$, большим, чем $\mathrm{tout}$ любой вершины из $V$; в частности, $\max_{x\in U}\mathrm{tout}[x] > \max_{y\in V}\mathrm{tout}[y]$.

Почему. Рассмотрим ребро $x_0 \to y_0$ между компонентами ($x_0 \in U$, $y_0 \in V$). Если DFS дошёл до всей компоненты $V$ раньше, чем закрылась некоторая вершина $U$, то сначала обходится вся $V$ (все её вершины выходят), и лишь потом выходит та вершина $U$. Значит, у этой вершины $U$ выход позже, чем у всех вершин $V$. Взяв вершину $U$ с максимальным $\mathrm{tout}$, получаем утверждение.

Отсюда — ключевой вывод: если для каждой компоненты взять максимум $\mathrm{tout}$ по её вершинам, эти максимумы ведут себя как времена выхода в конденсации. Значит, вершина с глобальным максимумом $\mathrm{tout}$ лежит в первой компоненте топсорта конденсации.

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

Обращение всех рёбер сохраняет КСС
Поменяв направление всех рёбер, мы не меняем компоненты сильной связности: каждый путь $u \leadsto v$ превращается в путь $v \leadsto u$ в обратном графе и наоборот.

В обратном графе из вершины первой компоненты нет исходящих рёбер в другие компоненты (все такие рёбра смотрели в неё). Поэтому DFS по обратному графу из вершины с максимальным $\mathrm{tout}$ закрасит ровно первую компоненту.

Алгоритм (Косарайю).

  1. Запустить DFS по исходному графу $G$, выписать вершины в порядке выхода; перевернуть массив — получим порядок убывания $\mathrm{tout}$ (псевдотопсорт).
  2. Построить обратный граф $G^R$ (все рёбра развёрнуты).
  3. Идти по вершинам в порядке убывания $\mathrm{tout}$. Если вершина ещё не покрашена — запустить DFS по $G^R$, закрашивая все достижимые непокрашенные вершины в новый цвет. Множество вершин одного цвета — это одна КСС.
  4. Продолжать, пока не покрашены все вершины.

Почему корректно. На каждом шаге берём непокрашенную вершину с максимальным $\mathrm{tout}$ среди оставшихся — она принадлежит первой (в топсорте конденсации) из ещё не выделенных компонент. DFS по обратному графу не выходит за её пределы и обходит ровно её (по определению КСС все вершины компоненты взаимно достижимы). Значит, каждый запуск выделяет ровно одну новую компоненту.

Сложность. Два обхода + обращение рёбер: $O(V+E)$.

Частая ошибка
Нельзя вместо обратного графа обходить исходный: тогда из вершины с максимальным $\mathrm{tout}$ можно «утечь» в достижимые компоненты и закрасить несколько КСС одним цветом. Соответствующий контрпример приводится в записи при обсуждении алгоритма.

12. Разбор примера на девяти вершинах 1:19:55

На занятии разобран граф из девяти вершин со следующими рёбрами:

Внутри компонент: $1\to2\to3\to1$; $8\to7\to9\to6\to8$; $4\to5\to4$. Между компонентами: $4\to3$ и $2\to8$.

Пример на 9 вершинах: три КСС и две перемычкиРёбра 1→2→3→1, 4→5→4, 8→7→9→6→8, перемычки 4→3 и 2→8.КСС №0КСС №1КСС №2451238796Конденсация: №0 → №1 → №2 — номера из алгоритма Косарайю идут в порядке топсорта.
Граф из разбора: три КСС (зелёная, синяя, фиолетовая) и две перемычки. Конденсация — путь №0 → №1 → №2.

Прогон первого DFS (стартуем от вершины 1): обходим $1\to2\to3$ (выходим из 3), из 2 идём в 8, затем $8\to7\to9\to6$, выходим 6, 9, 7, 8, затем 2 и 1. Далее из вершины 4: $4\to5$, выходим 5, 4.

Массив выхода (возрастание $\mathrm{tout}$): $3, 6, 9, 7, 8, 2, 1, 5, 4$. Переворачиваем — порядок убывания: $4, 5, 1, 2, 8, 7, 9, 6, 3$.

Обратный граф и покраска.

  1. Вершина 4: в $G^R$ идём в 5, из 5 — в 4 (уже цвет). Компонента №0 = $\{4,5\}$.
  2. Следующая непокрашенная — 1: в $G^R$ есть $1\to3$ (обратное к $3\to1$), $3\to2$, значит обходим $\{1,3,2\}$. Компонента №1 = $\{1,2,3\}$. Обратное ребро $8\to2$ ведёт в уже покрашенную 2 — не заходим.
  3. Следующая непокрашенная — 8: обходим $8\to6\to9\to7$. Компонента №2 = $\{6,7,8,9\}$.
Бонус
Номера компонент получились в порядке топсорта конденсации: рёбра идут из компоненты с меньшим номером в компоненту с большим: №0 $\to$ №1 $\to$ №2.
Почему нельзя обходить исходный граф
Если вместо порядка убывания $\mathrm{tout}$ идти в порядке возрастания и работать в исходном графе, можно «провалиться» из одной компоненты в следующую и закрасить сразу несколько КСС. На лекции показан соответствующий контрпример.

13. Код КСС и построение конденсации 1:31:20

Первый этап — обычный DFS, пишущий вершину при выходе. Второй этап — DFS по обратному графу, красящий всё достижимое в один цвет.

C++
int n;
vector<vector<int>> g, gr;      // gr — обратный граф
vector<char> used;
vector<int> order, comp;
int col = 0;

void dfs1(int v) {
    used[v] = true;
    for (int u : g[v])
        if (!used[u]) dfs1(u);
    order.push_back(v);            // возрастание tout
}

void dfs2(int v, int c) {
    comp[v] = c;
    for (int u : gr[v])
        if (comp[u] == -1) dfs2(u, c);
}

void build_scc() {
    used.assign(n, false);
    order.clear();
    for (int v = 0; v < n; ++v)
        if (!used[v]) dfs1(v);
    reverse(order.begin(), order.end());     // убывание tout
    comp.assign(n, -1);
    for (int v : order)
        if (comp[v] == -1) dfs2(v, col++);
}
C++
// построение конденсации
vector<pair<int,int>> edges;
vector<vector<int>> cg(col);            // граф компонент
for (auto [u, v] : edges) {
    if (comp[u] != comp[v])
        cg[comp[u]].push_back(comp[v]);
}
// при необходимости убрать дубликаты в каждом cg[i]
Как кодировать пары компонент
Если нужно быстро удалять дубликаты и определять уникальность пары $(c_u, c_v)$, её можно закодировать одним числом: long long key = (long long)c_u * n + c_v, сложить все ключи в set/unordered_set, а потом раскодировать: c_u = key / n, c_v = key % n. Это стандартный приём упаковки пар в одно число.
Типичные ошибки
1) Забыть перевернуть order. 2) Обходить не обратный, а исходный граф. 3) Заходить в уже покрашенные вершины — нельзя, иначе перекрасим соседние компоненты. 4) Переполнение при кодировании пар, если n большое.

14. Динамика на деревьях: высота поддерева 1:39:19

Вторую часть лекции ведёт второй преподаватель. Идея: динамики на поддеревьях удобно считать DFS-ом, подвесив дерево за произвольный корень.

Задача. Для каждой вершины $v$ найти длину самого длинного пути из $v$ вниз по поддереву («высоту»).

$dp[v] = \max_{u \text{ — ребёнок } v}(dp[u] + 1)$, для листа $dp[v] = 0$.

Идея обхода. В дереве нет циклов, поэтому вместо массива used можно передавать в DFS родителя p и не ходить в него. Порядок пересчёта обеспечивает рекурсия: когда мы выходим из вызова ребёнка, его $dp$ уже посчитана.

C++
vector<vector<int>> g;
vector<int> h;

void dfs(int v, int p) {
    h[v] = 0;                       // база: путь из одной вершины имеет длину 0
    for (int u : g[v]) {
        if (u == p) continue;       // не идём к родителю
        dfs(u, v);
        h[v] = max(h[v], h[u] + 1);
    }
}
// ответ: запустить dfs(root, -1)
Сложность и базовый случай
Суммарный размер списков смежности — $O(n)$, значит $O(n)$. Базу можно поставить нулями: при первом ребёнке $h[u]+1 \ge 1 > 0$, так что максимум пересчитается корректно.

Ответ для корня лежит в его $h$; для любой другой вершины — в её собственной $h[v]$.

15. Диаметр дерева 1:49:58

Диаметр дерева — самый длинный путь в нём (по количеству рёбер или вершин — несущественно, с точностью до единицы). Диаметров может быть несколько.

Введём две динамики:

$d[v] = \max\Big(\underbrace{\max_u d[u]}_{\text{не через } v},\ \underbrace{\max_u down[u] + 1}_{\text{через } v,\ \text{в одну сторону}},\ \underbrace{x + y + 2}_{\text{через } v,\ \text{в две стороны}}\Big)$

где $x$ и $y$ — два наибольших значения $down[u]$ среди детей $u$ (максимум и второй максимум).

Почему три случая. Диаметр поддерева $v$ либо не проходит через $v$ (тогда он целиком лежит в поддереве какого-то ребёнка и это максимум $d[u]$), либо проходит через $v$; во втором случае он либо уходит вниз в одну сторону (тогда это $\max_u down[u]+1$), либо в две разные стороны через двух детей (тогда это $x+y+2$ — два спуска плюс два ребра до детей). Мы не знаем заранее, какой случай реализуется, поэтому берём максимум по всем трём.

C++
vector<vector<int>> g;
vector<int> down, diam;

void dfs(int v, int p) {
    down[v] = 0;
    diam[v] = 0;
    int best = 0, second = 0;       // два максимума (down[u] + 1)
    for (int u : g[v]) {
        if (u == p) continue;
        dfs(u, v);
        down[v] = max(down[v], down[u] + 1);
        diam[v] = max(diam[v], diam[u]);
        int d = down[u] + 1;
        if (d > best) { second = best; best = d; }
        else if (d > second) { second = d; }
    }
    diam[v] = max(diam[v], best + second);   // = x + y + 2
}
// ответ: diam[root]
Найти максимум и второй максимум за один проход
Когда приходит очередной элемент $t$: если $t > best$, то $second \leftarrow best$, $best \leftarrow t$; иначе если $t > second$, то $second \leftarrow t$. Так «детская задача» про два максимума решается за линейное время без сортировки.
Сложность и взвешенный случай
$O(n)$: сумма всех списков смежности равна $2(n-1) = O(n)$, несмотря на то что для каждого ребёнка делается константа работы. Если рёбра взвешены, меняется только арифметика: вместо $+1$ добавляем вес ребра, вместо $+2$ — сумму весов двух рёбер. Структура решения сохраняется.
Диаметр через вершину v: склеиваем два самых длинных спускаdown[u] — длина самого длинного пути из u вниз. Берём два наибольших: x и y.u₁down = 2u₂down = 1u₃down = 3vx = 3 (через u₃), y = 2 (через u₁) ⟹ путь через v: x + y + 2 = 7 рёбер (оранжевый).
Диаметр через $v$ — два самых длинных спуска из разных детей плюс два ребра: $x + y + 2$.

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

16. Максимальное независимое множество 2:09:20

Задача. В дереве выбрать как можно больше вершин так, чтобы никакие две выбранные не были соединены ребром. На произвольных графах это NP-трудно, но на дереве решается ДП за линейное время.

Подвесим дерево. Введём два состояния для каждой вершины:

$dp_0[v] = \sum_{u} \max(dp_0[u],\ dp_1[u]), \qquad dp_1[v] = 1 + \sum_{u} dp_0[u]$

Ответ — $\max(dp_0[root], dp_1[root])$.

Почему так. Если $v$ не взята, то на каждого ребёнка ограничений нет — ребёнок можно брать или не брать, поэтому для каждого берём максимум. Если $v$ взята, то ни один ребёнок взят быть не может (иначе появилось бы ребро между двумя выбранными), поэтому каждый ребёнок обязан быть «не взят» (состояние $dp_0$), а саму $v$ добавляем к сумме как $+1$.

C++
vector<vector<int>> g;
vector<array<int,2>> dp;      // dp[v][0] — v не взята, dp[v][1] — взята

void dfs(int v, int p) {
    dp[v][0] = 0;
    dp[v][1] = 1;
    for (int u : g[v]) {
        if (u == p) continue;
        dfs(u, v);
        dp[v][0] += max(dp[u][0], dp[u][1]);
        dp[v][1] += dp[u][0];
    }
}
// ответ: max(dp[root][0], dp[root][1])
Приём: дополнительный параметр состояния
Главная техника здесь — завести для вершины несколько состояний (взяли / не взяли) и пересчитывать их по-разному. Этот приём применим и в более сложных задачах: иногда состояние описывает больше подробностей (например, цвет, чётность, номер ближайшей выбранной вершины). Сложность — $O(n)$.
Типичные ошибки
1) В пересчёте $dp_0$ сложить $dp_0[u] + dp_1[u]$ вместо максимума. 2) Забыть про базу $dp_1[v] = 1$. 3) Считать ответ не как максимум двух состояний корня. 4) Пытаться решить жадностью или «двумя цветами» — для дерева это не работает без ДП.

17. Динамика на DAG: длиннейший путь 2:18:55

Задача. В ориентированном ациклическом графе найти самый длинный (по числу рёбер) путь. В произвольном графе это NP-трудно (гамильтонов путь), но на DAG решается просто.

Идея. Построим топсорт. В нём все рёбра идут слева направо. Значит, если считать ДП справа налево, то к моменту обработки $v$ все его «правые» соседи уже посчитаны.

$dp[v] = \max_{v \to u}\big(dp[u] + 1\big)$
C++
vector<vector<int>> g;
vector<int> order = topsort(g);   // порядок топсорта, 0..n-1
vector<int> dp(n, 0);

for (int i = n - 1; i >= 0; --i) {
    int v = order[i];
    for (int u : g[v])
        dp[v] = max(dp[v], dp[u] + 1);
}
// ответ: max по всем v (или dp[стартовой вершины])
Инвариант и сложность
Инвариант: когда мы обрабатываем $v$, для всех $u$ с ребром $v\to u$ значение $dp[u]$ уже посчитано — это гарантирует топсорт. Каждое ребро просматривается один раз, поэтому суммарно $O(V+E)$.
Почему топсорт, а не «просто DFS»
Технически ДП можно считать и прямо в DFS, но там легко запутаться в порядке пересчёта. Топсорт превращает граф в линейный массив, и ДП становится тривиальным циклом. Это и есть второе (после конденсации) применение топсорта.

18. Семинар: предок через $\mathrm{tin}/\mathrm{tout}$ 2:27:14

Дальше идёт семинар (ведущий — Артём). Задача 1. Дан корневой дерево на $n$ вершинах. Приходят запросы: «вершина $u$ — предок вершины $v$?». Нужно отвечать за $O(1)$ после $O(n)$ препроцессинга.

Решение. Считаем времена входа-выхода обычным DFS. Тогда:

$u$ — предок $v \iff \mathrm{tin}[u] \le \mathrm{tin}[v] \ \text{ и } \ \mathrm{tout}[v] \le \mathrm{tout}[u]$.
C++
bool is_ancestor(int u, int v) {
    return tin[u] <= tin[v] && tout[v] <= tout[u];
}
Нельзя обойтись только tin
Если проверять лишь $\mathrm{tin}[u] < \mathrm{tin}[v]$, можно получить ложное «да» для вершин из разных ветвей: например, у одной $u$ время входа меньше, но она не предок. Условие «вошли раньше» нужно дополнить условием «вышли позже» (или, что то же, интервал $v$ вложен в интервал $u$). Именно вложенность гарантирует предка (см. §3).

19. Семинар: суммы по уровням в поддереве 2:32:11

Задача 2. В каждой вершине записано число $a_v$. Приходят запросы $(v, d)$: нужно просуммировать $a_u$ по всем потомкам $u$ вершины $v$, находящимся ровно на расстоянии $d$ от неё (то есть на глубине $\mathrm{depth}[v] + d$). Ответить на все запросы за $O(n + q\log n)$.

Ключевые наблюдения.

  1. Для вершины-потомка расстояние от $v$ — это разность глубин: $\mathrm{depth}[u] - \mathrm{depth}[v] = d$. Значит, нас интересуют вершины фиксированной глубины $D = \mathrm{depth}[v] + d$.
  2. Если сгруппировать все вершины по глубине и внутри каждой глубины упорядочить по времени входа $\mathrm{tin}$, то вершины любого поддерева образуют в этом массиве непрерывный отрезок (потому что интервал входов поддерева — тоже непрерывный отрезок, см. §3).
  3. Тогда сумма по нужным потомкам — это обычная сумма на отрезке массива глубин, то есть префиксными суммами.
Запрос (v = 2, d = 2): сумма потомков на глубине 3Вершины одной глубины, выписанные по tin, — поддерево v занимает в них сплошной отрезок.12345678a=59a=210a=411a=112a=313a=6глубина 0глубина 1глубина 2глубина 3глубина 3:префиксы:891011121305711121521по tin →11 − 0 = 11
Внутри одной глубины вершины поддерева идут подряд по $\mathrm{tin}$, поэтому ответ — разность двух префиксных сумм.
C++
// byDepth[D] — вершины глубины D в порядке возрастания tin
// pref[D][i] — префиксные суммы a по byDepth[D]
// массив key[D][i] = tin[byDepth[D][i]] — для бинпоиска
// запрос (v, d):
int D = depth[v] + d;
auto &arr = key[D];
int L = lower_bound(arr.begin(), arr.end(), tin[v])      - arr.begin();
int R = upper_bound(arr.begin(), arr.end(), tout[v])     - arr.begin();
long long ans = pref[D][R] - pref[D][L];
Почему порядок выхода тоже подойдёт
Можно класть вершины одной глубины по $\mathrm{tin}$ или по $\mathrm{tout}$ — порядок получится один и тот же: обход одной ветви завершается полностью, прежде чем начинается следующая. Поэтому границы отрезка можно искать и по $\mathrm{tin}$, и по $\mathrm{tout}$.
Сложность
Препроцессинг $O(n)$; каждый запрос — два бинпоиска и разность префиксов: $O(\log n)$. Итого $O(n + q\log n)$.

20. Семинар: восстановление массива по суммам 2:43:06

Задача 3. Дан неизвестный массив $a_0, \dots, a_{n-1}$ и тройки $(l, r, x)$, означающие, что сумма $a_l + a_{l+1} + \dots + a_{r-1} = x$. Нужно восстановить любой подходящий массив (или определить, что его нет) за $O(n + q)$.

Переход к префиксным суммам. Заведём $P_0 = 0$, $P_{i+1} = a_0 + \dots + a_i$. Тогда условие на отрезке переписывается amazingly просто:

$a_l + \dots + a_{r-1} = P_r - P_l = x$.

Теперь неизвестны уже не $a_i$, а $P_i$, и каждое условие — это соотношение между двумя числами: $P_r = P_l + x$.

Как это стало графом. Сделаем вершинами индексы префиксных сумм $0, 1, \dots, n$. Условие $P_r - P_l = x$ — это ребро между $l$ и $r$ с весом $x$ в одну сторону и $-x$ в другую:

Условия на суммы → граф на префиксных суммахВершины — индексы P₀…P₄. Условие «a[l] + … + a[r−1] = x» — ребро l → r с весом x.+5+3+6+8P₀P₁P₂P₃P₄= 0= 2= 5= 0= 8Pне связана — любоеОбход из P₀ = 0: P₂ = 5, P₄ = 5 + 3 = 8, P₁ = 8 − 6 = 2.Зелёное ребро 0 → 4 (+8) ведёт в уже посещённую P₄: 0 + 8 = 8 — сходится, противоречия нет.
Каждое условие $P_r - P_l = x$ — ребро с весом. Обходим граф из $P_0 = 0$ и раздаём значения; ребро, ведущее в уже посещённую вершину, проверяет согласованность.

Алгоритм. Запускаем DFS по каждой ещё не посещённой компоненте, назначая стартовое значение (например, $0$) и распространяя его по рёбрам с их весами. Если встречаем ребро в уже посещённую вершину, проверяем согласованность: должно быть $P_u + w = P_v$. Если нет — условие противоречиво, ответа нет.

C++
vector<vector<pair<int,long long>>> g;   // (сосед, вес)
vector<long long> P;
vector<char> vis;
bool ok = true;

void dfs(int v, long long val) {
    vis[v] = true;
    P[v] = val;
    for (auto [u, w] : g[v]) {
        if (!vis[u]) dfs(u, val + w);
        else if (P[u] != P[v] + w) ok = false;   // противоречие
    }
}
// стартуем каждую компоненту со значения 0
// после этого a[i] = P[i+1] - P[i]
Почему это корректно
Внутри компоненты связности все разности $P$ между вершинами определены однозначно (это сумма весов вдоль пути). Если по двум разным путям получаются разные разности, при обходе цикла накопится ненулевая сумма — это и есть противоречие, которое мы ловим. Для компоненты из одной вершины значение $P$ произвольно; можно взять $0$. В итоге любой подходящий массив получается из найденных $P$ как разности.
Сложность
Каждое ребро просматривается один раз, вершин $n+1$: $O(n + q)$.

21. Семинар: цикл чётной длины 2:57:34

Задача 4. Дан неориентированный граф на $n$ вершинах и $m$ рёбрах (без петель и, как договорились на семинаре, без кратных рёбер). Проверить, есть ли в нём цикл чётной длины, за $O(n+m)$.

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

Факт о недревесных рёбрах
В неориентированном DFS-обходе любое ребро, не попавшее в дерево обхода, соединяет вершину с её предком (обратное ребро). Если бы оно вело в другую ветвь, то при обходе первой ветви мы бы уже зашли в эту вершину.

Обратное ребро $v \to u$, где $u$ — предок $v$, вместе с древесным путём $u \leadsto v$ образует цикл длины

$\big(\mathrm{depth}[v] - \mathrm{depth}[u]\big) + 1$.

Значит:

Склейка двух нечётных циклов. Если два нечётных цикла $C_1$ и $C_2$ имеют хотя бы одно общее ребро, их симметрическая разность (объединение без общих рёбер) имеет длину

$|C_1| + |C_2| - 2\cdot|C_1 \cap C_2| = \text{нечёт} + \text{нечёт} - \text{чёт} = \text{чёт}.$

Она распадается на циклы, и хотя бы один из них чётный. Поэтому достаточно найти два обратных ребра, чьи древесные пути пересекаются по ребру. Делается это так: обрабатываем обратные рёбра по одному и красим рёбра древесного пути от более глубокого конца вверх до предка; если натыкаемся на уже покрашенное ребро — нашли пересечение двух нечётных циклов, а значит, и чётный цикл.

Почему распадается ровно на один цикл, а не на несколько нечётных
В общем случае симметрическая разность двух нечётных циклов могла бы распасться на несколько кусков, и из одной лишь чётности суммарной длины «хотя бы один чётный» не следует (например, два нечётных куска в сумме дают чётное число, но оба нечётны). Здесь это не происходит благодаря структуре: оба цикла — это обратное ребро плюс путь в дереве DFS от вершины до её предка, а такие пути по построению вложены друг в друга (общая часть — это всегда непрерывный отрезок пути от точки первого совпадения и выше, а не разбросанные по разным местам рёбра). Поэтому пересечение двух путей — один сплошной кусок, и, вычтя его дважды, получаем ровно одну простую цикл-склейку, а не несколько кусков.
C++
// DFS-дерево: parent[v], depth[v]
// ребро visited[e] для рёбер дерева
// для каждого обратного ребра (v -> anc):
//   if ((depth[v] - depth[anc]) % 2 == 1) -> ответ "ЕСТЬ чётный цикл"
//   else идём от v вверх до anc:
//           если ребро уже покрашено -> ответ "ЕСТЬ чётный цикл"
//           иначе красим его
// если обработали все рёбра без конфликта -> чётного цикла нет
Почему O(n+m)
Если чётного цикла нет, каждый рёберный путь красится не более одного раза: как только два пути пересекаются, мы немедленно находим ответ. Значит, суммарная работа по покраске линейна, плюс сам DFS — $O(n+m)$.
Типичная ошибка
Путать чётный и нечётный циклы: двухцветная раскраска находит не-двудольность (нечётный цикл). Здесь же задача про чётный цикл, и наивный DFS с «пришли в посещённую — значит цикл» тоже не различает длины. Нужен именно анализ разности глубин и склейка обратных рёбер.

22. Семинар: пути через выделенные вершины 3:34:26

Задача 8 (ведущий — Тимофей). Дан DAG на $n$ вершинах и $k$ выделенных вершин $v_1, \dots, v_k$. Нужно посчитать число путей из $v_1$ в $v_k$, которые проходят через все выделенные вершины (каждую хотя бы раз, порядок внутри — в каком угодно порядке). Две асимптотики: $O\big(k\,(n+m)\big)$ и $O(n+m)$.

Ключевое наблюдение: порядок фиксирован
Если искомый путь существует, то относительный порядок выделенных вершин на нём определён однозначно. Иначе нашлись бы два пути с разным относительным порядком двух выделенных вершин $a, b$ (в одном $a$ раньше $b$, в другом $b$ раньше $a$). Но тогда в графе есть цикл: путь $a \leadsto b$ и путь $b \leadsto a$. Противоречие с тем, что граф — DAG.

Итак, порядок выделенных вершин задаётся их порядком в любом топсорте. Обозначим его $p_1, \dots, p_k$ (должно быть $p_1 = v_1$, $p_k = v_k$).

Первое решение, $O(k(n+m))$. Число путей через все выделенные вершины равно произведению числа путей между соседними выделенными вершинами:

$\text{ответ} = \prod_{i=1}^{k-1} \big(\#\text{путей } p_i \leadsto p_{i+1}\big) \pmod{}.$

В самом деле, путь распадается на независимые части «от $p_i$ до $p_{i+1}$», и любые комбинации частей склеиваются в корректный путь (промежуточные части не пересекаются, так как между соседними выделенными вершин других выделенных нет, а порядок фиксирован). Каждую часть считаем ДП по топсорту: $dp[v] = \sum_{v\to u} dp[u]$, база $dp[p_{i+1}] = 1$. Итого $O(k(n+m))$.

Второе решение, $O(n+m)$. Построим топсорт один раз. В нём выделенные вершины идут в порядке $p_1, \dots, p_k$. Они делят топсорт на отрезки. Ключевой факт: любой путь из $p_i$ в $p_{i+1}$ не может «выпрыгнуть» за пределы отрезка между ними — все рёбра идут слева направо, и вернуться к $p_{i+1}$ из более правой части невозможно. Поэтому ДП можно считать по всему графу, но для каждой пары «обрезать» рёбра, ведущие правее следующей выделенной вершины. Каждое ребро при этом обрабатывается один раз, так как отрезки не пересекаются: суммарно $O(n+m)$.

C++
// order — топсорт; pos[v] — позиция v в order
// находим p[0..k-1] = выделенные вершины в порядке топсорта
// проверяем, что p[0]==v1, p[k-1]==vk, иначе ответ 0
long long ans = 1;
vector<long long> dp(n, 0);               // один раз, а не на каждый отрезок
for (int i = 0; i + 1 < k; ++i) {
    // границы в массиве order: от pos[p[i]] до pos[p[i+1]]
    for (int j = pos[p[i]]; j <= pos[p[i + 1]]; ++j)
        dp[order[j]] = 0;                 // сбрасываем только свой отрезок: O(длина отрезка)
    dp[p[i + 1]] = 1;                     // база
    for (int j = pos[p[i + 1]] - 1; j >= pos[p[i]]; --j) {
        int v = order[j];
        for (int u : g[v])
            if (pos[u] <= pos[p[i + 1]])
                dp[v] = (dp[v] + dp[u]) % MOD;
    }
    ans = ans * dp[p[i]] % MOD;
    if (ans == 0) break;
}
Про модуль
Число путей может быть огромным, поэтому ответ считается по модулю (в условии задачи модуль задан). Обратите внимание: если между какими-то соседними выделенными вершинами путей нет, произведение обнуляется — ответ 0.

23. Семинар: сумма длин всех путей в DAG 3:50:37

Задача 10. Дан взвешенный DAG на $n$ вершинах и $m$ рёбрах. Найти сумму длин всех простых путей по модулю (длина пути — сумма весов его рёбер).

Идея учёта по рёбрам. Вместо суммирования по путям, просуммируем по рёбрам их вклад:

$\sum_{\text{пути } P} \mathrm{len}(P) = \sum_{\text{ребро } e} w_e \cdot \#\{P : e \in P\}.$

Путь проходит через ребро $u \to v$ тогда и только тогда, когда некоторый путь заканчивается в $u$ и некоторый путь начинается в $v$; эти части независимы. Значит,

$\#\{P : u\to v \in P\} = \mathrm{cnt\_in}[u] \cdot \mathrm{cnt\_out}[v],$

где $\mathrm{cnt\_out}[v]$ — число путей, начинающихся в $v$ (включая тривиальный путь из одной вершины), а $\mathrm{cnt\_in}[u]$ — число путей, заканчивающихся в $u$ (тоже включая тривиальный).

Пересчёт. Считаем по топсорту:

$\mathrm{cnt\_out}[v] = 1 + \sum_{v\to u} \mathrm{cnt\_out}[u], \qquad \mathrm{cnt\_in}[v] = 1 + \sum_{u\to v} \mathrm{cnt\_in}[u].$

Первое — идём справа налево (или в обратном топсорте), второе — по обратному графу. «Единица» — это тривиальный путь из самой вершины (длина 0). Тогда ответ

$\text{ответ} = \sum_{\text{ребро } u\to v} w_{uv}\cdot \mathrm{cnt\_in}[u] \cdot \mathrm{cnt\_out}[v] \pmod{}.$
C++
vector<long long> cntOut(n, 0), cntIn(n, 0);
// cntOut по обратному топсорту
for (int i = n - 1; i >= 0; --i) {
    int v = order[i];
    cntOut[v] = 1;
    for (int u : g[v])
        cntOut[v] = (cntOut[v] + cntOut[u]) % MOD;
}
// cntIn по графу gr (обратному) и прямому топсорту
for (int i = 0; i < n; ++i) {
    int v = order[i];
    cntIn[v] = 1;
    for (int u : gr[v])
        cntIn[v] = (cntIn[v] + cntIn[u]) % MOD;
}
long long ans = 0;
for (auto [u, v, w] : edges)
    ans = (ans + (long long)w % MOD * cntIn[u] % MOD * cntOut[v]) % MOD;
Проверка на простом примере
Пусть путь из одной вершины. Тогда рёбер нет, оба счётчика равны 1, сумма пустого множества рёбер — 0. Пусть путь $a \to b$ с весом $w$. Тогда для ребра: $\mathrm{cnt\_in}[a]=1$ (только путь $[a]$), $\mathrm{cnt\_out}[b]=1$ (только $[b]$), вклад $w$. Ответ $w$ — длина единственного пути. Верно.
Типичная ошибка
Забыть, что $\mathrm{cnt\_in}$ и $\mathrm{cnt\_out}$ включают тривиальные пути. Без них путь, начинающийся прямо в $u$ или заканчивающийся в $v$, не был бы учтён, и вклад ребра оказался бы занижен.

24. Шпаргалка

Понятие / алгоритмИдеяСложность
Времена входа-выходасчётчик при входе и выходе DFS$O(V+E)$
Вложенность $[\mathrm{tin},\mathrm{tout}]$интервалы вложены или не пересекаются; $u$ предок $v \iff \mathrm{tin}[u]\le\mathrm{tin}[v],\ \mathrm{tout}[v]\le\mathrm{tout}[u]$$O(1)$ проверка
Топсортпорядок убывания $\mathrm{tout}$; на практике — push_back при выходе и reverse$O(V+E)$
Существование топсортаесть $\iff$ граф ациклический—
Компоненты связностиDFS + покраска$O(V+E)$
КСС (Косарайю)DFS → порядок по $\mathrm{tout}$; в обратном графе красим по убыванию $\mathrm{tout}$$O(V+E)$
Конденсациясжать КСС, рёбра между компонентами; всегда DAG$O(V+E)$
Высота поддерева$dp[v]=\max(dp[u]+1)$$O(n)$
Диаметр деревадва максимума спусков: $\max(d[u],\ down[u]{+}1,\ x{+}y{+}2)$$O(n)$
Максимальное независимое множество$dp_0, dp_1$ по состояниям «взяли / не взяли»$O(n)$
Длиннейший путь в DAGДП справа налево по топсорту$O(V+E)$
Что осталось за рамками занятия
В записи этого занятия не разбирались BFS, кратчайшие пути (Дейкстра, 0-1 BFS), система непересекающихся множеств (DSU) и проверка двудольности как отдельная задача — они, вероятно, в других лекциях курса. Здесь же двудольность возникает лишь как инструмент при обсуждении чётных циклов.