Два фундаментальных инструмента олимпиадника
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(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) — каждое значение считается ровно один раз
Пример (кузнечик):
Дан массив 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:
Массив: 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]
#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²) — два вложенных цикла
Идея: поддерживаем массив tail[], где tail[k] = наименьший последний элемент возрастающей подпоследовательности длины k+1
Свойство: массив tail[] всегда отсортирован!
Алгоритм: для каждого 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
#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]lower_bound на upper_bound
Дана сетка n×n. Некоторые клетки заблокированы. Из (1,1) нужно попасть в (n,n). Разрешены ходы вправо и вниз. Посчитать количество путей (mod 109 + 7).
CSES 1638 «Grid Paths». n ≤ 1000.
. . . . . * . . . . . . . . * .
'*' — заблокирована, '.' — свободна
dp[i-1][j]
↓
dp[i][j-1] → dp[i][j]
Карта:
. . . . . * . . . . . . . . * .
DP:
| 1 | 1 | 1 | 1 |
| 1 | 0 | 1 | 2 |
| 1 | 1 | 2 | 4 |
| 1 | 2 | 0 | 4 |
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
#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²) по времени и памяти
% MOD после каждого сложения!
(1,1) → → → → ↓ ↓ ↓ * ↓ ↓ ↓ ↓ → → (n,n)
Общий паттерн: 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.
Порядок вычисления: топологическая сортировка!
Граф: 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
#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)
Связи:
| Паттерн | Состояние | Рекуррентность | Порядок | Пример |
|---|---|---|---|---|
| 1D | dp[i] | f(dp[i-1], dp[i-2], ...) | i = 0..n | Кузнечик |
| LIS | dp[i] = LIS до a[i] | max(dp[j]+1) | i = 0..n-1 | CSES 1145 |
| Сетка | dp[i][j] | dp[i-1][j] + dp[i][j-1] | строка за строкой | CSES 1638 |
| DAG | dp[v] | sum/max(dp[u]) | топ. порядок | CSES 1681 |
Подсказки при затруднении:
Обед! Возвращаемся в 14:00 к DSU.
Есть n городов (изначально изолированных). Происходят два типа событий:
n ≤ 105, событий ≤ 2·105. Лимит: 1 сек.
5 городов. Дороги добавляются: 1-2, 3-4, 1-3 Запрос: связаны 2 и 4? → ДА (2-1-3-4) Запрос: связаны 2 и 5? → НЕТ
Как решать?
Идея 1: запускать BFS/DFS на каждый запрос
Идея 2: массив comp[v] = номер компоненты
Другие названия: Union-Find, система непересекающихся множеств
Три операции:
Ключевое свойство: x и y в одном множестве ⇔ find(x) == find(y)
Множество {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);
}
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)
Как сделать деревья плоскими?
Идея: при вызове 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)!
Идея: при объединении подвешивать меньшее дерево под большее
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)
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)
Начальное состояние:
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)
Множество A (size=5):
a
/ | \
. . .
|
.
Множество B (size=2):
b | .
sz[A]=5 > sz[B]=2 → подвешиваем B под A
Результат:
a
/ | \ \
. . . b
| |
. .
Теорема: при объединении по размеру глубина любой вершины ≤ log₂(n)
Доказательство: глубина растёт только когда множество «поглощается» большим → размер удваивается → максимум log₂(n) удвоений
Функция Аккермана растёт НЕВЕРОЯТНО быстро:
Обратная функция α(n) = min{k : A(k) ≥ n}:
Проблема: рекурсивный 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;
}
Два прохода: первый — находим корень, второй — перенаправляем все вершины
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[])!
#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) — подмножество рёбер, соединяющее все вершины с минимальной суммой весов.
Алгоритм Крускала:
!same(u, v) из DSU!unite(u, v)!
Сложность: O(m log m + m · α(n)) = O(m log m) (сортировка доминирует)
#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)
Расширения (для самостоятельного изучения):
Дополнительно (для дорешивания):
Поднимайте руку, если нужна помощь!
Задачи для самостоятельного решения:
DP: CSES 1633, 1634, 1637, 1638, 1145, 1681 | DSU: CSES 1676, 1675, CF 1559D2, CSES 1668
Спасибо за неделю! Удачи на IOI!