Euler Tour и LCA (Binary Lifting)

Быстрые запросы на деревьях


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

Дерево — это особый граф

Дерево — связный граф без циклов на n вершинах и n−1 рёбрах.
  1. Между любыми двумя вершинами ровно один путь
  2. Удаление любого ребра разбивает граф на две компоненты
  3. Можно выбрать корень → появляется иерархия (родитель, потомки, поддерево)
Граф с циклом:
1 — 2 — 3
|       |
4 — 5 — 6
Дерево:
    1
   / \
  2   3
 / \   \
4   5   6
Деревья проще графов: нет циклов, есть единственные пути. Это позволяет использовать специальные структуры данных.

Задача: e-olymp 11429 — Подчинённые

В компании n сотрудников. Структура подчинения — дерево: у каждого (кроме директора) ровно один начальник. Для каждого сотрудника определить, сколько у него подчинённых (прямых и непрямых).

n ≤ 2·105. Лимит: 1 сек.
     1
    / \
   2   3
  / \
 4   5

Ответ: сотрудник 1 → 4, сотрудник 2 → 2, сотрудники 3, 4, 5 → 0

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

DFS на дереве — без visited[]

  1. В дереве нет циклов → DFS не может вернуться в уже посещённую вершину (кроме родителя)
  2. Вместо visited[] достаточно передавать параметр parent
  3. DFS из корня обходит каждую вершину ровно раз и задаёт естественный порядок обхода

void dfs(int v, int parent) {
    for (int u : adj[v]) {
        if (u != parent) {
            dfs(u, v);
        }
    }
}
  
DFS на дереве задаёт линейный порядок обхода вершин. Этот порядок — основа всего, что мы будем делать сегодня.

Укоренение дерева: parent[] и depth[]

Задача: дано дерево, корень = 1. Для каждой вершины найти parent[v] и depth[v].


vector<int> adj[MAXN];
int parent[MAXN], depth[MAXN];

void dfs(int v, int p, int d) {
    parent[v] = p;
    depth[v] = d;
    for (int u : adj[v]) {
        if (u != p) {
            dfs(u, v, d + 1);
        }
    }
}

// Вызов: dfs(1, 0, 0);
  
depth 0:       1
depth 1:      / \
             2   3
depth 2:    / \   \
           4   5   6

Размер поддерева через DFS

  • sz[v] = количество вершин в поддереве v (включая саму v)
  • sz[v] = 1 + Σ sz[u] для всех детей u вершины v
  • Ответ на задачу: подчинённых у v = sz[v] − 1

int sz[MAXN];

void dfs(int v, int p) {
    sz[v] = 1;
    for (int u : adj[v]) {
        if (u != p) {
            dfs(u, v);
            sz[v] += sz[u];
        }
    }
}
  
     1 (sz=5)
    / \
(sz=3)2   3(sz=1)
  / \
(1)4   5(1)

Ответ: подчинённых[1]=4, [2]=2, [3]=0, [4]=0, [5]=0

Порядок обхода DFS = линейный порядок вершин

     1
    / \
   2   5
  / \
 3   4

Порядок: 1, 2, 3, 4, 5
(нумерация по времени входа)

Ключевое свойство:

  • Поддерево вершины 2: {2, 3, 4} — позиции 1, 2, 3 (подряд!)
  • Поддерево вершины 1: {1, 2, 3, 4, 5} — позиции 0–4 (подряд!)
Поддерево = непрерывный отрезок в DFS-порядке. Это позволяет свести задачи на поддеревьях к задачам на отрезках массива!

Задача: проверка предка за O(1)

Дано корневое дерево из n вершин. Отвечайте на q запросов: «является ли вершина u предком вершины v?»

n, q ≤ 2·105. Нужен O(1) на запрос.
  • Наивно: подняться от v к корню, проверить, встретим ли u → O(n) на запрос → TLE
  • Идея: если поддерево u — отрезок, то v ∈ поддерево(u) ⇔ v попадает в этот отрезок

Нам нужны два числа для каждой вершины

  • Чтобы задать отрезок [начало, конец] для поддерева v, нужно знать:
  • Когда DFS вошёл в вершину v → tin[v]
  • Когда DFS вышел из вершины v → tout[v]

Тогда: поддерево v = все вершины u, где tin[v] ≤ tin[u] ≤ tout[v]

Euler tour — не новый алгоритм. Это просто DFS, в котором мы записываем время входа и выхода.

Euler Tour: tin[] и tout[]


int timer = 0;
int tin[MAXN], tout[MAXN];

void dfs(int v, int p) {
    tin[v] = timer++;
    for (int u : adj[v]) {
        if (u != p) {
            dfs(u, v);
        }
    }
    tout[v] = timer - 1;
}
  

Мы используем: tout[v] = timer - 1 (закрытый отрезок)

Поддерево v = отрезок [tin[v], tout[v]]

Будьте внимательны с соглашением! В разных источниках tout определяется по-разному. Мы используем: tout[v] = номер последней вершины, посещённой в поддереве v.

Пример Euler Tour

       1
      /|\
     2  3  4
    / \    |
   5   6   7

DFS: 1, 2, 5, 6, 3, 4, 7
vtin[v]tout[v]
106
213
522
633
344
456
766
Позиция:  0    1    2    3    4    5    6
Вершина:  1    2    5    6    3    4    7
          [--------1-----------------------]
               [----2--------]
                    [5]  [6]
                              [3]
                                   [----4----]
                                        [7]

Проверка: u предок v? → O(1)

u является предком v тогда и только тогда, когда:
tin[u] ≤ tin[v] И tout[v] ≤ tout[u]
(Вершина v «внутри» временного отрезка вершины u)

Примеры (из предыдущего слайда):

  • Является ли 2 предком 5? tin[2]=1 ≤ tin[5]=2 ✓ и tout[5]=2 ≤ tout[2]=3 ✓ → ДА
  • Является ли 2 предком 7? tin[2]=1 ≤ tin[7]=6 ✓ но tout[7]=6 ≤ tout[2]=3? НЕТ

bool is_ancestor(int u, int v) {
    return tin[u] <= tin[v] && tout[v] <= tout[u];
}
  

Главная идея: поддерево → отрезок

Пусть order[] — массив вершин в порядке tin. Тогда:

  • Поддерево вершины v = order[tin[v] .. tout[v]]
  • Это непрерывный подмассив длины sz[v]

Следствие — любая задача на поддереве сводится к задаче на отрезке массива:

  • Сумма значений в поддереве → сумма на отрезке
  • Минимум в поддереве → минимум на отрезке
  • Обновление значений в поддереве → обновление на отрезке
Euler tour превращает дерево в массив. Поддерево = отрезок. Все инструменты для массивов (prefix sum, segment tree, BIT) теперь работают на деревьях!

Задача: сумма значений в поддереве

Дано дерево из n вершин, каждая вершина v имеет вес w[v]. Для каждой вершины v найти сумму весов всех вершин в её поддереве.

Решение:

  1. Запускаем DFS, вычисляем tin[v]
  2. Создаём массив a[] в Euler-порядке: a[tin[v]] = w[v]
  3. Считаем префиксные суммы
  4. Сумма в поддереве v = prefix[tout[v]+1] - prefix[tin[v]]

// После DFS (tin[], tout[] заполнены)
vector<long long> a(n), prefix(n + 1, 0);
for (int v = 1; v <= n; v++)
    a[tin[v]] = w[v];

for (int i = 0; i < n; i++)
    prefix[i + 1] = prefix[i] + a[i];

// Сумма в поддереве v:
long long subtree_sum = prefix[tout[v] + 1] - prefix[tin[v]];
  

Сложность: O(n) предвычисление, O(1) на запрос

Euler Tour: полная реализация


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

const int MAXN = 200005;
vector<int> adj[MAXN];
int tin[MAXN], tout[MAXN], sz[MAXN], depth[MAXN], parent[MAXN];
int timer = 0;
int order[MAXN];  // order[i] = вершина с tin = i

void dfs(int v, int p, int d) {
    parent[v] = p;
    depth[v] = d;
    tin[v] = timer;
    order[timer] = v;
    timer++;
    sz[v] = 1;
    for (int u : adj[v]) {
        if (u != p) {
            dfs(u, v, d + 1);
            sz[v] += sz[u];
        }
    }
    tout[v] = timer - 1;
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    dfs(1, 0, 0);
    // Поддерево v = отрезок [tin[v], tout[v]]
    // sz[v] == tout[v] - tin[v] + 1
    return 0;
}
  

Проверка: sz[v] == tout[v] - tin[v] + 1

Осторожно: глубина рекурсии

Проблема: при n = 200000 и дереве-«бамбуке» глубина рекурсии = n
Стандартный стек = 1–8 МБ → segmentation fault

Решения:

  1. Увеличить стек (Linux: ulimit -s unlimited)
  2. Итеративный DFS (сложнее, но возможно)
  3. На IOI: обычно стека хватает (256MB stack)
На Codeforces добавляйте в начало main():
#pragma comment(linker, "/STACK:1000000000") (Windows)
или используйте итеративный DFS. На IOI — стека обычно хватает.

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

e-olymp 11429 «Подчинённые» — посчитать количество подчинённых (sz[])

★★ CSES 1137 «Subtree Queries» — сумма в поддереве с обновлениями (Euler tour + BIT)

★★★ CSES 1138 «Path Queries» — сумма на пути до корня (Euler tour + BIT, разностный трюк)

Подсказка к CSES 1137: Обновление вершины v = обновление позиции tin[v]. Запрос суммы в поддереве v = сумма на отрезке [tin[v], tout[v]].

Перерыв после практики: 15:45–16:00

LCA — Lowest Common Ancestor

LCA(u, v) — наименьший (самый глубокий) общий предок вершин u и v в корневом дереве.
Другими словами: вершина w, которая является предком и u, и v, и имеет максимальную глубину.
       1
      / \
     2   3
    / \   \
   4   5   6
  /
 7
  • LCA(7, 5) = 2
  • LCA(7, 6) = 1
  • LCA(4, 5) = 2
  • LCA(4, 7) = 4 (сама вершина!)
LCA(u, v) может быть равен u или v! (Если один из них — предок другого)

Зачем нужен LCA?

  1. Расстояние между вершинами:
    dist(u, v) = depth[u] + depth[v] − 2·depth[LCA(u, v)]
  2. Путь между вершинами: путь u→v проходит через LCA(u, v)
  3. Запросы на путях: сумма/минимум на пути = объединение двух вертикальных путей через LCA
  4. Продвинутые структуры: HLD, virtual tree, auxiliary tree
LCA сводит задачи на путях в дереве к задачам на вертикальных путях (от вершины к предку). А вертикальные пути — это просто отрезки на пути к корню.

Задача: e-olymp 3298 — Общий предок

Дано корневое дерево из n вершин. Ответьте на q запросов вида «найти LCA(u, v)».

n ≤ 2·105, q ≤ 2·105. Лимит: 2 сек.
  • Предобработка за O(n log n) или лучше
  • Каждый запрос за O(log n) или лучше

Начнём с наивного алгоритма, потом ускорим.

Наивный LCA: выравнивание + подъём

  1. Выравнивание глубин: если depth[u] > depth[v], поднимаем u вверх
  2. Одновременный подъём: поднимаем u и v по одному ребру вверх, пока u ≠ v
  3. Когда u == v — это LCA

Пример: LCA(7, 6)

Шаг 1: depth[7]=3, depth[6]=2 → поднимаем 7→4 (depth=2)
Шаг 2: u=4, v=6. Поднимаем оба: 4→2, 6→3
Шаг 3: u=2, v=3. Поднимаем оба: 2→1, 3→1. u==v!
Ответ: LCA(7, 6) = 1

Наивный LCA: код


int lca_naive(int u, int v) {
    // Шаг 1: выравниваем глубины
    while (depth[u] > depth[v]) u = parent[u];
    while (depth[v] > depth[u]) v = parent[v];
    // Шаг 2: поднимаемся вместе
    while (u != v) {
        u = parent[u];
        v = parent[v];
    }
    return u;
}
  
  • Сложность: O(n) в худшем случае (дерево-бамбук)
  • При q = 200000 запросов → O(n·q) = O(4·1010) — TLE

Идея: прыгать не по 1, а по 2k

Любое число можно представить как сумму степеней двойки.
Пример: 13 = 8 + 4 + 1 = 23 + 22 + 20
Значит: подняться на 13 шагов = подняться на 8 + 4 + 1 шагов
  • Если мы заранее знаем «предок на расстоянии 2k» для каждой вершины:
  • Подняться на h шагов = O(log h) прыжков
  • Каждый прыжок — O(1) (таблица предвычислена)
Вместо O(n) шагов по 1 ребру — O(log n) прыжков по 2k рёбер.
Нужно лишь предвычислить таблицу up[v][k] = предок v на расстоянии 2k.

Binary Lifting: таблица up[v][k]

up[v][k] = предок вершины v на расстоянии 2k
  • База: up[v][0] = parent[v] (предок на расстоянии 1)
  • Рекуррентность: up[v][k] = up[ up[v][k-1] ][k-1]
  • Подняться на 2k = подняться на 2k-1, потом ещё на 2k-1
up[v][2] = предок на 4:

v --------→ up[v][1] --------→ up[v][2] = up[up[v][1]][1]
     2^1=2               2^1=2

Размер таблицы: n × LOG, где LOG = ⌈log₂n⌉ ≈ 18 для n = 200000

Предвычисление up[][]


const int LOG = 18;  // ceil(log2(200000)) = 18
int up[MAXN][LOG];

void preprocess(int root) {
    // Сначала DFS для parent[] и depth[]
    dfs(root, 0, 0);

    // Заполняем таблицу
    for (int v = 1; v <= n; v++)
        up[v][0] = parent[v];

    for (int k = 1; k < LOG; k++)
        for (int v = 1; v <= n; v++)
            up[v][k] = up[ up[v][k-1] ][k-1];
}
  
  • Внешний цикл по k — по уровню (от маленьких к большим)
  • Порядок важен: для k нужны значения k−1, которые уже вычислены
  • Соглашение: up[root][k] = 0 для всех k (вышли за корень)

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

Пример таблицы up[][]

       1
      / \
     2   3
    / \
   4   5
  /
 6
vdepthup[v][0]up[v][1]up[v][2]
10000
21100
31100
42210
52210
63420

Разбор up[6][1]: up[up[6][0]][0] = up[4][0] = 2. Из 6 на 2 шага: 6→4→2 ✓

Разбор up[6][2]: up[up[6][1]][1] = up[2][1] = 0. Из 6 на 4 шага: выход за корень → 0

LCA, Шаг 1: выравнивание глубин

Привести u и v к одной глубине: разложить diff в двоичную систему


// Шаг 1: выравнивание
if (depth[u] < depth[v]) swap(u, v);
int diff = depth[u] - depth[v];
for (int k = 0; k < LOG; k++) {
    if ((diff >> k) & 1) {
        u = up[u][k];
    }
}
// Теперь depth[u] == depth[v]
  

Пример 1: diff = 4 = 100₂ → прыжок k=2: u = up[u][2] (на 4 уровня) ✓

Пример 2: diff = 5 = 101₂ → k=0: +1, k=2: +4. Итого +5 ✓

LCA, Шаг 2: поднимаемся вместе

После шага 1: depth[u] == depth[v]. Если u == v — готово. Иначе:

Перебираем k от большого к малому.
Если up[u][k] ≠ up[v][k] — прыгаем (остаёмся ниже LCA).
Если up[u][k] == up[v][k] — не прыгаем (иначе перепрыгнем!).
После всех прыжков: u и v — дети LCA. Ответ: parent[u].

k=2: up[u][2]≠up[v][2] → прыгаем (поднялись на 4)

k=1: up[u][1]==up[v][1] → НЕ прыгаем (перепрыгнем!)

k=0: up[u][0]≠up[v][0] → прыгаем (поднялись на 1)

Итого: u и v — дети LCA. Ответ = parent[u].

Пример: LCA(7, 5)

          1
         / \
        2   3
       /|\   \
      4  5  8  6
     /       \
    7         9

Шаг 1: depth[7]=3, depth[5]=2. diff=1. Прыжок k=0: u = up[7][0] = 4. Теперь u=4, v=5.

Проверка: u=4 ≠ v=5 → шаг 2

Шаг 2:
k=3: up[4][3]=0, up[5][3]=0 → равны, не прыгаем
k=2: up[4][2]=0, up[5][2]=0 → равны, не прыгаем
k=1: up[4][1]=1, up[5][1]=1 → равны, не прыгаем
k=0: up[4][0]=2, up[5][0]=2 → равны, не прыгаем

Ответ: parent[4] = parent[5] = 2

Пример: LCA(9, 5) — с реальными прыжками

Дерево со слайда 7.6. parent: 9→8, 8→3, 3→1, 5→2, 2→1

Шаг 1: depth[9]=3, depth[5]=2. diff=1. u = up[9][0] = 8. Теперь u=8, v=5, depth=2.

Проверка: u=8 ≠ v=5 → шаг 2

Шаг 2:
k=3: up[8][3]=0, up[5][3]=0 → равны, не прыгаем
k=2: up[8][2]=0, up[5][2]=0 → равны, не прыгаем
k=1: up[8][1]=1, up[5][1]=1 → равны, не прыгаем
k=0: up[8][0]=3, up[5][0]=2 → не равны! Прыгаем!

u = up[8][0] = 3, v = up[5][0] = 2. Теперь u=3, v=2 (глубина 1)

Ответ: parent[3] = parent[2] = 1

Проверяйте: после шага 2, parent[u] == parent[v] == LCA.

Полный код: LCA за O(log n)


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

const int MAXN = 200005;
const int LOG = 18;

vector<int> adj[MAXN];
int depth[MAXN], up[MAXN][LOG];
int n;

void dfs(int v, int p, int d) {
    depth[v] = d;
    up[v][0] = p;
    for (int k = 1; k < LOG; k++)
        up[v][k] = up[ up[v][k-1] ][k-1];
    for (int u : adj[v]) {
        if (u != p)
            dfs(u, v, d + 1);
    }
}

int lca(int u, int v) {
    if (depth[u] < depth[v]) swap(u, v);
    int diff = depth[u] - depth[v];

    for (int k = 0; k < LOG; k++)
        if ((diff >> k) & 1)
            u = up[u][k];

    if (u == v) return u;

    for (int k = LOG - 1; k >= 0; k--) {
        if (up[u][k] != up[v][k]) {
            u = up[u][k];
            v = up[v][k];
        }
    }
    return up[u][0];
}

int main() {
    int q;
    cin >> n >> q;
    for (int i = 0; i < n - 1; i++) {
        int a, b;
        cin >> a >> b;
        adj[a].push_back(b);
        adj[b].push_back(a);
    }
    dfs(1, 0, 0);

    while (q--) {
        int u, v;
        cin >> u >> v;
        cout << lca(u, v) << "\n";
    }
    return 0;
}
  

Сложность: O(n log n) предобработка, O(log n) на запрос

Сложность Binary Lifting

ЭтапВремяПамять
Предобработка (DFS + таблица)O(n log n)O(n log n)
Один запрос LCAO(log n)
Всего для q запросовO(n log n + q log n)O(n log n)

Для n = q = 200000, LOG = 18: предобработка ~3.6M операций, память ~14 МБ

МетодПредобработкаЗапросПамять
НаивныйO(n)O(n)O(n)
Binary LiftingO(n log n)O(log n)O(n log n)
Euler tour + RMQO(n log n)O(1)O(n log n)
Farach-Colton & BenderO(n)O(1)O(n)
Binary Lifting — лучший баланс простоты и эффективности для олимпиад. Запомните этот шаблон!

Применение 1: расстояние в дереве за O(log n)

dist(u, v) = depth[u] + depth[v] − 2 · depth[LCA(u, v)]
  • Путь u → v проходит через LCA(u, v)
  • Длина u → LCA = depth[u] − depth[LCA]
  • Длина LCA → v = depth[v] − depth[LCA]

int dist(int u, int v) {
    return depth[u] + depth[v] - 2 * depth[lca(u, v)];
}
  

Пример: dist(7, 6) = depth[7] + depth[6] − 2·depth[LCA(7,6)]
= 3 + 2 − 2·0 = 5
Проверка: 7→4→2→1→3→6, длина 5 ✓

Применение 2: k-й предок за O(log n)

Для вершины v и числа k найти предка v на расстоянии ровно k.

int kth_ancestor(int v, int k) {
    for (int i = 0; i < LOG; i++) {
        if ((k >> i) & 1) {
            v = up[v][i];
        }
    }
    return v;  // 0 если вышли за корень
}
  

Пример: 3-й предок вершины 7:

k=3 = 011₂. Бит 0: v = up[7][0] = 4. Бит 1: v = up[4][1] = 1.

Ответ: 1 (7→4→2→1) ✓

Задача: CSES 1687 «Company Queries I» — прямое применение

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

CSES 1687 «Company Queries I» — k-й предок (прямое применение binary lifting)
CSES 1688 «Company Queries II» — LCA двух вершин (прямое применение)
★★ CSES 1135 «Distance Queries» — расстояние через LCA
★★ CSES 1137 «Subtree Queries» — Euler tour + BIT
★★★ CF 191C «Fools and Roads» — LCA + разностный трюк
Подсказки:
• CSES 1135: dist(u,v) = depth[u] + depth[v] − 2·depth[LCA(u,v)]
• CF 191C: +1 к u и v, −2 из LCA(u,v). Ответ для ребра = сумма в поддереве нижнего конца.

Последний час — решаем и обсуждаем. Задавайте вопросы!