Динамическое программирование и DSU

Два фундаментальных инструмента олимпиадника


IOI Prep Camp — Abbas Aliyev — 6 мая 2026

DP (11:00–13:00)     DSU (14:00–15:45)

Задача: кузнечик

Условие: Кузнечик стоит на позиции 0 числовой прямой. За один прыжок он может переместиться на +1, +2 или +3. Сколько существует различных способов добраться до позиции n?

n ≤ 105. Вывести ответ по модулю 109 + 7.

Пример: n = 4

Способы: 1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2, 1+3, 3+1 → 7 способов

Как бы вы решили эту задачу?

Первая попытка: рекурсия

Наблюдение: чтобы попасть в позицию n, нужно прийти из n-1, n-2 или n-3

f(n) = f(n-1) + f(n-2) + f(n-3)

База: f(0) = 1, f(1) = 1, f(2) = 2


int f(int n) {
    if (n == 0) return 1;
    if (n == 1) return 1;
    if (n == 2) return 2;
    return f(n-1) + f(n-2) + f(n-3);
}
  

Дерево рекурсии для f(5):

f(5) → f(4) + f(3) + f(2)
         ↓       ↓
       f(3)+f(2)+f(1)  f(2)+f(1)+f(0)
         ↓
       f(2)+f(1)+f(0) ...

Одни и те же подзадачи решаются многократно! Сложность: O(3n)

Решение: запомнить результаты

Мемоизация (top-down):


int memo[100001];
bool computed[100001];

int f(int n) {
    if (n <= 0) return (n == 0) ? 1 : 0;
    if (computed[n]) return memo[n];
    computed[n] = true;
    memo[n] = f(n-1) + f(n-2) + f(n-3);
    return memo[n];
}
      

DP-таблица (bottom-up):


vector<long long> dp(n + 1);
dp[0] = 1; dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; i++)
    dp[i] = (dp[i-1] + dp[i-2]
           + dp[i-3]) % MOD;
cout << dp[n];
      

Сложность: O(n) — каждое значение считается ровно один раз

DP = рекурсия без повторных вычислений. Мы считаем каждую подзадачу один раз и сохраняем ответ.

Рецепт DP: 4 шага

  1. Определить состояние: что описывает подзадачу? (dp[i] = ?)
  2. Написать рекуррентность: как dp[i] выражается через меньшие подзадачи?
  3. Определить базу: при каких i ответ известен без рекурсии?
  4. Определить ответ: какое dp-значение содержит финальный ответ?

Пример (кузнечик):

  1. Состояние: dp[i] = количество способов добраться до позиции i
  2. Рекуррентность: dp[i] = dp[i-1] + dp[i-2] + dp[i-3]
  3. База: dp[0] = 1
  4. Ответ: dp[n]
На олимпиаде: если видите задачу на подсчёт/оптимизацию — первая мысль: «Можно ли определить DP-состояние?»

Задача: LIS (Longest Increasing Subsequence)

Дан массив a из n чисел. Найти длину наибольшей строго возрастающей подпоследовательности.

Подпоследовательность — набор элементов массива (не обязательно подряд), взятых в порядке их появления.

n ≤ 5000 (для O(n²)), n ≤ 105 (для O(n log n)).

Пример: a = [3, 1, 4, 1, 5, 9, 2, 6]

LIS: [1, 4, 5, 9] или [1, 4, 5, 6] → длина 4

Полный перебор — 2n подмножеств. При n = 5000 нереально. Нужен DP!

Состояние и рекуррентность

Рецепт DP:

  1. Состояние: dp[i] = длина LIS, заканчивающейся в элементе a[i]
  2. Рекуррентность:
    dp[i] = max(dp[j] + 1) для всех j < i, где a[j] < a[i]
    Если таких j нет: dp[i] = 1
  3. База: dp[i] = 1 для всех i
  4. Ответ: max(dp[0], dp[1], ..., dp[n-1])
Почему «заканчивающейся в a[i]»? Потому что для расширения подпоследовательности нам важно знать, какой элемент стоит последним — чтобы проверить, можно ли добавить следующий.

Заполнение таблицы: пример

Массив: a = [3, 1, 4, 1, 5, 9, 2, 6]

i=0: a[0]=3, нет j<0 → dp[0] = 1

i=1: a[1]=1, нет j: a[j]<1 → dp[1] = 1

i=2: a[2]=4, j=0: 3<4 (2), j=1: 1<4 (2) → dp[2] = 2

i=3: a[3]=1, нет j: a[j]<1 → dp[3] = 1

i=4: a[4]=5, best: dp[2]+1=3 → dp[4] = 3

i=5: a[5]=9, best: dp[4]+1=4 → dp[5] = 4

i=6: a[6]=2, j=1: 1<2 (2) → dp[6] = 2

i=7: a[7]=6, best: dp[4]+1=4 → dp[7] = 4

dp = [1, 1, 2, 1, 3, 4, 2, 4] → Ответ: max = 4

Восстановление: dp[5]=4 ← dp[4]=3 ← dp[2]=2 ← dp[1]=1 → [1, 4, 5, 9]

LIS за O(n²): полный код


#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;

    vector<int> dp(n, 1);
    for (int i = 1; i < n; i++)
        for (int j = 0; j < i; j++)
            if (a[j] < a[i])
                dp[i] = max(dp[i], dp[j] + 1);

    cout << *max_element(dp.begin(), dp.end()) << "\n";
    return 0;
}
  

Сложность: O(n²) — два вложенных цикла

O(n²) проходит при n ≤ 5000. Для n ≤ 105 нужен O(n log n) — разберём далее.

Ускорение: O(n log n)

Идея: поддерживаем массив tail[], где tail[k] = наименьший последний элемент возрастающей подпоследовательности длины k+1

Свойство: массив tail[] всегда отсортирован!

Алгоритм: для каждого a[i]:

  • Если a[i] > tail.back() → добавляем в конец (LIS удлиняется)
  • Иначе → находим первый tail[j] ≥ a[i] (бинарный поиск) и заменяем tail[j] = a[i]

a = [3, 1, 4, 1, 5, 9, 2, 6]
tail: [3] → [1] → [1,4] → [1,4] → [1,4,5] → [1,4,5,9] → [1,2,5,9] → [1,2,5,6]

Длина tail = длина LIS = 4

Массив tail — это НЕ сама LIS! Он только помогает найти длину. Для восстановления последовательности нужна дополнительная информация.

LIS за O(n log n): код


#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;

    vector<int> tail;
    for (int i = 0; i < n; i++) {
        auto it = lower_bound(tail.begin(), tail.end(), a[i]);
        if (it == tail.end())
            tail.push_back(a[i]);
        else
            *it = a[i];
    }
    cout << tail.size() << "\n";
    return 0;
}
  
  • lower_bound — находит первый элемент ≥ a[i]
  • Если такого нет — a[i] больше всех, удлиняем LIS
  • Сложность: O(n log n)
Для нестрого возрастающей подпоследовательности: замените lower_bound на upper_bound

Задача: пути в сетке с препятствиями

Дана сетка n×n. Некоторые клетки заблокированы. Из (1,1) нужно попасть в (n,n). Разрешены ходы вправо и вниз. Посчитать количество путей (mod 109 + 7).

CSES 1638 «Grid Paths». n ≤ 1000.

.  .  .  .
.  *  .  .
.  .  .  .
.  .  *  .

'*' — заблокирована, '.' — свободна

DP на сетке: формулировка

  1. Состояние: dp[i][j] = количество путей из (1,1) в клетку (i,j)
  2. Рекуррентность:
    Если клетка свободна: dp[i][j] = dp[i-1][j] + dp[i][j-1]
    Если заблокирована: dp[i][j] = 0
  3. База: dp[1][1] = 1 (если свободна)
  4. Ответ: dp[n][n]
dp[i-1][j]
     ↓
dp[i][j-1] → dp[i][j]
В клетку (i,j) можно попасть только сверху или слева → суммируем два значения.

Заполнение таблицы

Карта:

.  .  .  .
.  *  .  .
.  .  .  .
.  .  *  .

DP:

1111
1012
1124
1204

dp[2][2]=0 (заблокирована) • dp[2][3]=dp[1][3]+dp[2][2]=1+0=1

dp[4][3]=0 (заблокирована) • dp[4][4]=dp[3][4]+dp[4][3]=4+0=4

Код: CSES 1638


#include <bits/stdc++.h>
using namespace std;

const int MOD = 1e9 + 7;

int main() {
    int n;
    cin >> n;
    vector<string> grid(n);
    for (auto& s : grid) cin >> s;

    vector<vector<long long>> dp(n, vector<long long>(n, 0));
    dp[0][0] = (grid[0][0] == '.') ? 1 : 0;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            if (grid[i][j] == '*') { dp[i][j] = 0; continue; }
            if (i > 0) dp[i][j] += dp[i-1][j];
            if (j > 0) dp[i][j] += dp[i][j-1];
            dp[i][j] %= MOD;
        }
    }
    cout << dp[n-1][n-1] << "\n";
    return 0;
}
  

Сложность: O(n²) по времени и памяти

Не забудьте: если (1,1) заблокирована, ответ 0. Также: % MOD после каждого сложения!

Как выглядит DP на сетке

(1,1) → → → →
  ↓       ↓
  ↓   *   ↓
  ↓       ↓
  ↓ → → (n,n)
Вариации задачи:
  • Максимальная сумма на пути: dp[i][j] = max(...) + grid[i][j]
  • Ходы вправо, вниз, по диагонали: добавить dp[i-1][j-1]
  • Несколько стартовых/финишных точек

Общий паттерн: dp[i][j] зависит от соседей «выше» и «левее» по порядку заполнения.

Задача: подсчёт путей в графе

Дан ориентированный ациклический граф (DAG) с n вершинами и m рёбрами. Посчитать количество различных путей из вершины 1 в вершину n.

CSES 1681 «Counting Paths». n ≤ 105, m ≤ 2·105.

1 → 2 → 4
1 → 3 → 4
2 → 3

Пути из 1 в 4: 1→2→4, 1→3→4, 1→2→3→4 → ответ 3

Наблюдение: это обобщение задачи на сетке! Сетка — частный случай DAG.

DP на DAG = топсорт + рекуррентность

  1. Состояние: dp[v] = количество путей из вершины 1 в вершину v
  2. Рекуррентность: dp[v] = ∑dp[u] для всех рёбер u → v
  3. База: dp[1] = 1
  4. Ответ: dp[n]

Порядок вычисления: топологическая сортировка!

  • Обрабатываем вершины в топологическом порядке
  • Когда обрабатываем v, все u (предшественники) уже посчитаны
Топологическая сортировка даёт правильный порядок для заполнения DP на DAG — как «строка за строкой» для сетки.

Пример: подсчёт путей

Граф: 1 → 2, 1 → 3, 2 → 3, 2 → 4, 3 → 4

Топологический порядок: [1, 2, 3, 4]

dp[1] = 1    (база)

dp[2] = dp[1] = 1    (ребро 1→2)

dp[3] = dp[1] + dp[2] = 2    (рёбра 1→3, 2→3)

dp[4] = dp[2] + dp[3] = 3    (рёбра 2→4, 3→4)

Проверка: пути 1→2→4, 1→3→4, 1→2→3→4 — действительно 3

Код: CSES 1681


#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 7;

int main() {
    int n, m;
    cin >> n >> m;
    vector<vector<int>> adj(n + 1);
    vector<int> indeg(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        indeg[v]++;
    }

    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);

    vector<long long> dp(n + 1, 0);
    dp[1] = 1;

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            dp[v] = (dp[v] + dp[u]) % MOD;
            if (--indeg[v] == 0) q.push(v);
        }
    }
    cout << dp[n] << "\n";
    return 0;
}
  

Сложность: O(n + m)

Алгоритм Кана одновременно даёт и топологический порядок, и вычисляет DP — два в одном!

Вариации DP на DAG

  • Длиннейший путь в DAG: dp[v] = max(dp[u] + 1) по входящим рёбрам
  • Кратчайший путь в DAG (с весами): dp[v] = min(dp[u] + w(u,v))
  • Количество путей с максимальной длиной: комбинация двух массивов
Любая задача оптимизации/подсчёта на DAG решается DP в топологическом порядке. Это один из самых мощных паттернов.

Связи:

  • LIS → длиннейший путь в DAG (ребро i→j если j>i и a[j]>a[i])
  • Сетка → частный случай DAG

Паттерны DP: сводка

ПаттернСостояниеРекуррентностьПорядокПример
1Ddp[i]f(dp[i-1], dp[i-2], ...)i = 0..nКузнечик
LISdp[i] = LIS до a[i]max(dp[j]+1)i = 0..n-1CSES 1145
Сеткаdp[i][j]dp[i-1][j] + dp[i][j-1]строка за строкойCSES 1638
DAGdp[v]sum/max(dp[u])топ. порядокCSES 1681
На олимпиаде: увидели задачу → определите паттерн → примените рецепт.

DP рецепт — запомните навсегда

  1. Состояние — что описывает подзадачу?
  2. Рекуррентность — как связаны подзадачи?
  3. База — где остановиться?
  4. Ответ — какое dp-значение вернуть?

Подсказки при затруднении:

  • «Не знаю состояние» → Что нужно знать, чтобы продолжить решение?
  • «Не знаю рекуррентность» → Какое последнее действие перед текущим состоянием?
  • «Не знаю порядок» → Какие подзадачи нужны? Они должны быть «меньше».

Обед! Возвращаемся в 14:00 к DSU.

Задача: динамическая связность

Есть n городов (изначально изолированных). Происходят два типа событий:

  1. «Построена дорога между городами u и v»
  2. «Связаны ли города u и v?» (существует ли путь)

n ≤ 105, событий ≤ 2·105. Лимит: 1 сек.

5 городов. Дороги добавляются: 1-2, 3-4, 1-3
Запрос: связаны 2 и 4? → ДА (2-1-3-4)
Запрос: связаны 2 и 5? → НЕТ

Как решать?

Наивный подход: BFS/DFS каждый раз

Идея 1: запускать BFS/DFS на каждый запрос

  • Сложность: O(n + m) на запрос, O(q · (n + m)) суммарно
  • При n = m = q = 105: ~1010 операций → TLE

Идея 2: массив comp[v] = номер компоненты

  • Запрос: O(1) — сравнить comp[u] и comp[v]
  • Объединение: перекрасить одну компоненту → O(n) на одно ребро
  • Суммарно: O(n · m) — всё ещё слишком медленно
Нужна структура, которая делает и объединение, и запрос быстро.

DSU: Disjoint Set Union

Другие названия: Union-Find, система непересекающихся множеств

Три операции:

  1. make(x) — создать множество из одного элемента x
  2. find(x) — найти «лидера» (представителя) множества, содержащего x
  3. unite(x, y) — объединить множества, содержащие x и y

Ключевое свойство: x и y в одном множестве ⇔ find(x) == find(y)

DSU поддерживает разбиение элементов на непересекающиеся группы и позволяет:
• Проверять, в одной ли группе два элемента — почти O(1)
• Объединять две группы — почти O(1)

Представление: лес деревьев

  • Каждое множество — дерево
  • Корень дерева — лидер (представитель)
  • Каждый элемент хранит ссылку на родителя: parent[x]
  • Корень: parent[x] == x

Множество {1,2,3,5}:

      1
     / \
    2   3
    |
    5
Лидер: 1

Множество {4,6}:

    4
    |
    6

Лидер: 4

find(5): 5 → 2 → 1 (поднимаемся до корня)

find(6): 6 → 4

Базовая реализация


vector<int> parent;

void make(int n) {
    parent.resize(n);
    for (int i = 0; i < n; i++)
        parent[i] = i;  // каждый — сам себе лидер
}

int find(int x) {
    while (parent[x] != x)
        x = parent[x];
    return x;
}

void unite(int x, int y) {
    x = find(x);
    y = find(y);
    if (x != y)
        parent[x] = y;  // подвесить x под y
}

bool same(int x, int y) {
    return find(x) == find(y);
}
  
  • make: O(n) — инициализация
  • find: O(глубина дерева) — идём до корня
  • unite: O(find) — находим лидеров и соединяем

Проблема: вырождение в цепочку

unite(1, 2): parent[1] = 2
unite(2, 3): parent[2] = 3
unite(3, 4): parent[3] = 4
unite(4, 5): parent[4] = 5

Результат: цепочка 1 → 2 → 3 → 4 → 5

find(1) проходит 4 шага. В общем случае: O(n) на один find!

Плохо (цепочка):

1 → 2 → 3 → 4 → 5
find(1) = O(n)

Хорошо (звезда):

      5
   / | \ \
  1  2  3  4
find(1) = O(1)

Как сделать деревья плоскими?

Path Compression (сжатие путей)

Идея: при вызове find(x) — перенаправить все пройденные вершины напрямую к корню


int find(int x) {
    if (parent[x] != x)
        parent[x] = find(parent[x]);
    return parent[x];
}
  

До find(1):

1 → 2 → 3 → 4

После find(1):

    4
  / | \
 1  2  3

Все вершины на пути теперь ссылаются напрямую на корень. Следующие вызовы find — O(1)!

Union by Rank/Size

Идея: при объединении подвешивать меньшее дерево под большее


vector<int> sz;  // sz[x] = размер множества (только для корней)

void make(int n) {
    parent.resize(n);
    sz.assign(n, 1);
    for (int i = 0; i < n; i++)
        parent[i] = i;
}

void unite(int x, int y) {
    x = find(x);
    y = find(y);
    if (x == y) return;
    if (sz[x] < sz[y]) swap(x, y);
    parent[y] = x;     // подвешиваем меньшее (y) под большее (x)
    sz[x] += sz[y];
}
  

Гарантия: глубина дерева ≤ log₂(n)

DSU: финальный код


struct DSU {
    vector<int> parent, sz;

    DSU(int n) : parent(n), sz(n, 1) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);
        return parent[x];
    }

    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        parent[y] = x;
        sz[x] += sz[y];
    }

    bool same(int x, int y) { return find(x) == find(y); }
    int size(int x) { return sz[find(x)]; }
};
  

Сложность: O(α(n)) на операцию (α — обратная функция Аккермана, ≤ 4 для всех практических n)

Сжатие путей + объединение по размеру → амортизированно O(1) на операцию. DSU — почти бесплатная структура.

Сжатие путей: визуализация

Начальное состояние:

7 → 5 → 3 → 1 (корень)

Вызов find(7):

Шаг 1: find(7) вызывает find(5)

Шаг 2: find(5) вызывает find(3)

Шаг 3: find(3) вызывает find(1) — корень!

Возврат: parent[3]=1, parent[5]=1, parent[7]=1

После find(7):
      1
    / | \
   3  5  7

Последующие find(3), find(5), find(7) — O(1)

Union by Size: пример

Множество A (size=5):

     a
   / | \
  .  .  .
  |
  .

Множество B (size=2):

  b
  |
  .

sz[A]=5 > sz[B]=2 → подвешиваем B под A

Результат:
       a
     / | \ \
    .  .  .  b
    |        |
    .        .

Теорема: при объединении по размеру глубина любой вершины ≤ log₂(n)

Доказательство: глубина растёт только когда множество «поглощается» большим → размер удваивается → максимум log₂(n) удвоений

Почему O(α(n)) — это «почти O(1)»

Функция Аккермана растёт НЕВЕРОЯТНО быстро:

  • A(1) = 2
  • A(2) = 4
  • A(3) = 16
  • A(4) = 65536
  • A(5) = 265536 (число с ~20000 цифрами)

Обратная функция α(n) = min{k : A(k) ≥ n}:

  • α(n) ≤ 4 для n ≤ 265536
  • Во вселенной ~1080 атомов — α(1080) = 4
На олимпиаде: считайте DSU операции за O(1). Это не совсем точно, но для оценки сложности — достаточно.
O(α(n)) получается только при комбинации обеих оптимизаций. Одно сжатие путей даёт O(log n), одно объединение по рангу — тоже O(log n).

Итеративная версия find

Проблема: рекурсивный find может переполнить стек при очень длинных цепочках


int find(int x) {
    int root = x;
    while (parent[root] != root)
        root = parent[root];
    while (parent[x] != root) {
        int next = parent[x];
        parent[x] = root;
        x = next;
    }
    return root;
}
  

Два прохода: первый — находим корень, второй — перенаправляем все вершины

На олимпиаде рекурсивная версия обычно проходит (глубина ≤ log n после первых операций). Итеративную используйте, если n > 106 и переживаете за стек.

Задача: строительство дорог

n городов, m дорог добавляются по одной. После каждого добавления вывести:

  • Количество компонент связности
  • Размер наибольшей компоненты

CSES 1676. n ≤ 105, m ≤ 2·105.

n=5, дороги: (1,2), (3,5), (1,3)
После (1,2): 4 компоненты, макс = 2
После (3,5): 3 компоненты, макс = 2
После (1,3): 2 компоненты, макс = 4

Наблюдение: DSU уже хранит размеры множеств (sz[])!

Решение: DSU + отслеживание


#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, sz;
    int components, max_size;

    DSU(int n) : parent(n), sz(n, 1), components(n), max_size(1) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }

    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        parent[y] = x;
        sz[x] += sz[y];
        components--;
        max_size = max(max_size, sz[x]);
    }
};

int main() {
    int n, m; cin >> n >> m;
    DSU dsu(n);
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v; u--; v--;
        dsu.unite(u, v);
        cout << dsu.components << " " << dsu.max_size << "\n";
    }
}
  

Сложность: O(m · α(n)) ≈ O(m)

MST: алгоритм Крускала

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

Алгоритм Крускала:

  1. Отсортировать все рёбра по весу (по возрастанию)
  2. Для каждого ребра (u, v, w) в порядке увеличения веса:
    • Если u и v в разных компонентах → добавить ребро в MST
    • Иначе → пропустить (создало бы цикл)
  3. Повторять, пока не набрали n-1 ребро
«В разных компонентах?» — это !same(u, v) из DSU!
«Добавить ребро» — это unite(u, v)!

Сложность: O(m log m + m · α(n)) = O(m log m) (сортировка доминирует)

MST: код (CSES 1675)


#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, sz;
    DSU(int n) : parent(n), sz(n, 1) {
        iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        parent[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    int n, m; cin >> n >> m;
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) { cin >> u >> v >> w; u--; v--; }
    sort(edges.begin(), edges.end());

    DSU dsu(n);
    long long total = 0;
    int count = 0;

    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) { total += w; count++; }
    }
    cout << (count == n - 1 ? to_string(total) : "IMPOSSIBLE") << "\n";
}
  

Замечание: unite возвращает bool — true если объединение произошло (ребро добавлено в MST)

Где ещё нужен DSU

  1. Динамическая связность — проверка «в одной ли компоненте»
  2. Алгоритм Крускала — MST за O(m log m)
  3. Обработка запросов оффлайн — если запросы можно переупорядочить
  4. Подсчёт компонент связности — добавление рёбер по одному
  5. Задачи на объединение множеств — с отслеживанием размера, минимума, максимума
DSU — одна из самых частых структур на олимпиадах. Выучите шаблон наизусть: ~20 строк кода, которые решают десятки задач.

Расширения (для самостоятельного изучения):

  • DSU с откатом (rollback) — для задач оффлайн на дереве запросов
  • Взвешенный DSU — дополнительная информация на рёбрах

Практика: 15 минут

⭐ Базовая: CSES 1676 «Road Construction» — реализуйте DSU, выводите компоненты и макс. размер
⭐⭐ Средняя: CSES 1675 «Road Reparation» (MST, алгоритм Крускала)
⭐⭐⭐ Сложная: CF 1559D2 «Mocha and Diana (Hard)» — два DSU одновременно

Дополнительно (для дорешивания):

  • CSES 1668 «Building Teams» (можно через DSU с доп. информацией)
  • CSES 1671 «Shortest Routes I» (Дейкстра, не DSU, но полезно)

Поднимайте руку, если нужна помощь!

Итоги последнего дня

Динамическое программирование

  • Рецепт: состояние → рекуррентность → база → ответ
  • Паттерны: 1D, LIS, сетка, DAG
  • Ключевая идея: не пересчитывать, запоминать

DSU (Disjoint Set Union)

  • Операции: find, unite, same
  • Оптимизации: сжатие путей + union by size
  • Сложность: O(α(n)) ≈ O(1)
  • Применения: связность, Крускал, offline
DP и DSU вместе покрывают огромную долю олимпиадных задач. DP — для оптимизации и подсчёта. DSU — для объединения и связности.

Задачи для самостоятельного решения:

DP: CSES 1633, 1634, 1637, 1638, 1145, 1681  |  DSU: CSES 1676, 1675, CF 1559D2, CSES 1668

Спасибо за неделю! Удачи на IOI!