Перейти к содержанию

14. Алгоритмы на деревьях

14.1 Обходы деревьев

Введение

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

Обходы лежат в основе большинства алгоритмов на деревьях: поиска, подсчётов, динамики, обработки запросов.


Основные виды обходов

1. Обход в глубину (DFS)

Идея: мы идём как можно глубже по дереву, прежде чем возвращаться назад.

Интуиция: представьте лабиринт — вы идёте вперёд, пока не упрётесь, затем возвращаетесь назад и пробуете другой путь.

Порядки обхода:

  • Вход в вершину (preorder)
  • Обход поддерева
  • Выход из вершины (postorder)

Пример дерева:

graph TD
1 --> 2
1 --> 3
2 --> 4
2 --> 5
Hold "Ctrl" to enable pan & zoom
DFS (preorder): 1 → 2 → 4 → 5 → 3

DFS (postorder): 4 → 5 → 2 → 3 → 1


2. Обход в ширину (BFS)

Идея: мы обходим дерево по уровням.

Интуиция: сначала все вершины на расстоянии 1, затем на расстоянии 2 и т.д.

Пример:

BFS: 1 → 2 → 3 → 4 → 5


Зачем нужны обходы

Обходы используются для:

  • подсчёта размеров поддеревьев
  • поиска расстояний
  • проверки свойств
  • динамики на деревьях
  • построения путей

Реализация DFS (C++)

#include <iostream>
#include <vector>
using namespace std;

int n;
vector<vector<int>> g;
vector<bool> used;

void dfs(int v) {
    used[v] = true;
    cout << v << " ";
    for (int to : g[v]) {
        if (!used[to]) dfs(to);
    }
}

int main() {
    cin >> n;
    g.resize(n + 1);
    used.assign(n + 1, false);

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

    dfs(1);
}

Реализация BFS (C++)

#include <iostream>
#include <vector>
#include <queue>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;

    vector<vector<int>> g(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    vector<bool> used(n + 1, false);
    queue<int> q;

    q.push(1);
    used[1] = true;

    while (!q.empty()) {
        int v = q.front();
        q.pop();

        cout << v << " ";

        for (int to : g[v]) {
            if (!used[to]) {
                used[to] = true;
                q.push(to);
            }
        }
    }
}

Пример с ручным разбором

Дано дерево:

1 соединена с 2 и 3 2 соединена с 4 и 5

DFS: 1 → 2 → 4 → назад → 5 → назад → 3

BFS: 1 → 2 → 3 → 4 → 5


Связь с алгоритмом Дейкстры

Хотя Дейкстра применяется к графам с весами, идея выбора вершины похожа на BFS.

Идея

Мы всегда выбираем вершину с минимальным расстоянием от старта.

Как происходит выбор

  • храним текущие расстояния
  • используем очередь с приоритетом
  • каждый раз берём вершину с минимальным расстоянием

Почему алгоритм корректен

Если все веса неотрицательные:

  • как только вершина выбрана, её расстояние уже оптимально
  • потому что нельзя найти более короткий путь через более длинные пути

Ограничения

Алгоритм не работает с отрицательными рёбрами

Сложность

  • с кучей: O((n + m) log n)

Типичные ошибки

  • забывают отмечать вершины посещёнными
  • делают DFS без проверки used
  • путают порядок обхода
  • используют рекурсию без увеличения стека
  • забывают, что дерево — неориентированный граф

Когда использовать

Используйте DFS, когда:

  • нужно обработать поддеревья
  • нужна рекурсия
  • нужна постобработка

Используйте BFS, когда:

  • нужны кратчайшие расстояния в невзвешенном графе
  • нужен обход по уровням

Итог

Обходы деревьев — базовый инструмент. Почти любая задача на деревья сводится к правильному обходу и аккуратному хранению информации в процессе.