Деревья и графы: BFS, DFS, кратчайшие пути
Задачи на деревья и графы — один из самых частых «порогов» на алгоритмическом собеседовании в Яндекс: они проверяют умение построить модель, выбрать обход (BFS/DFS), аккуратно хранить состояние и писать код без ошибок в индексации и структурах данных.
Эта тема напрямую опирается на предыдущие статьи курса:
из статьи про хеш-структуры вам пригодятся dict и set для хранения графа и visited
из статьи про очереди и deque — реализация BFS
из статьи про стек — итеративный DFS и понимание стека вызовов при рекурсии!Иллюстрация идеи BFS как обхода по слоям и почему расстояния в не взвешенном графе считаются корректно
Что такое граф и как он связан с деревом
Граф состоит из:
вершин (узлов)
рёбер (связей)Часто в задачах встречаются варианты:
неориентированный граф: ребро соединяет две вершины в обе стороны
ориентированный граф: ребро идёт из u в v
взвешенный граф: у ребра есть стоимость (вес)Дерево — частный случай графа:
связный
без цикловПолезная мысль для интервью: почти любой алгоритм обхода дерева — это BFS/DFS по графу, просто у дерева есть дополнительные свойства, которые иногда упрощают решение.
Как хранить граф в Python
На интервью чаще всего ожидают список смежности.
Список смежности (самый частый вариант)
Если у нас n вершин с номерами 0..n-1 (или 1..n), то обычно делают:
g[u] — список соседей uДля неориентированного графа каждое ребро добавляется в обе стороны.
Сложность памяти для списка смежности обычно оценивают как , где:
— число вершин
— число рёберСмысл записи : память растёт пропорционально количеству вершин (храним контейнер для каждой) и количеству рёбер (храним всех соседей).
Пример построения для неориентированного графа:
Взвешенный граф
Тогда g[u] обычно хранит пары (v, w):
Почему обычно не используют матрицу смежности
Матрица смежности — это n x n таблица. По памяти это , что почти всегда слишком дорого при около .
BFS: обход в ширину и кратчайшие пути без весов
BFS (Breadth-First Search) обходит граф «по слоям» от стартовой вершины.
Главная ценность BFS на интервью:
в не взвешенном графе (или когда все веса равны) BFS находит кратчайшее расстояние по числу рёберИнвариант BFS
Удобная формулировка:
когда вершина извлекается из очереди, её расстояние уже минимально возможноеЭто верно, потому что очередь FIFO гарантирует обработку вершин в порядке увеличения расстояния.
Реализация BFS на deque
Мы будем хранить:
dist[v] — расстояние от старта до v (или -1, если не достижима)
parent[v] — откуда пришли (полезно для восстановления пути)Почему важно отметить deque:
popleft() у deque работает эффективно
если пытаться делать очередь через list.pop(0), то удаление из начала потребует сдвигать элементы, и это часто «убивает» времяВосстановление пути по parent
Если нужно вывести сам путь start -> ... -> target:
Сложность BFS
BFS обычно оценивают как по времени, где:
— число вершин
— число рёберСмысл: каждая вершина ставится в очередь максимум один раз, и каждое ребро просматривается ограниченное число раз.
DFS: обход в глубину, компоненты связности и циклы
DFS (Depth-First Search) «ныряет» в глубину, пока может, а затем откатывается назад.
Где DFS очень типичен:
посчитать компоненты связности в неориентированном графе
проверить достижимость
найти цикл (особенно в ориентированном графе)
топологическая сортировка (ориентированный ациклический граф)!Иллюстрация связи рекурсивного DFS со стеком и того, что итеративная версия делает то же самое
Рекурсивный DFS: просто, но есть риск глубины
Рекурсивная версия часто самая читаемая, но на больших графах можно упереться в ограничение глубины рекурсии.
На интервью полезно проговорить:
Рекурсия использует стек вызовов, глубина может быть до , поэтому иногда безопаснее итеративный DFS.Итеративный DFS через стек
Это прямое применение структуры из прошлой темы про стеки.
Нюанс: мы делаем проверку visited при извлечении из стека. Это упрощает код, потому что одна и та же вершина может быть добавлена несколько раз разными путями.
Компоненты связности (частая задача)
Если граф неориентированный, то компоненты связности считаются так:
идём по всем вершинам
если вершина ещё не посещена, запускаем DFS/BFS из неё и увеличиваем счётчикДеревья на практике: корень, родитель, глубина
На собеседовании дерево часто дают как:
массив parent для вершин 1..n, где parent[v] — родитель
или как список рёберТиповые подзадачи:
найти глубины вершин от корня
посчитать размеры поддеревьев
проверить свойства (например, что это дерево)Если дан список рёбер дерева, то вы строите список смежности как для графа, выбираете корень root и запускаете DFS/BFS, но добавляете параметр p (родитель), чтобы не «ходить назад».
Пример DFS по дереву с вычислением глубин:
Кратчайшие пути: какой алгоритм выбрать
Самый частый вопрос на интервью: по графу нужно найти минимальную стоимость пути. Дальше решает тип рёбер.
Невзвешенный граф
используем BFS
расстояние — число рёберВзвешенный граф с неотрицательными весами
чаще всего нужен алгоритм ДейкстрыЕсть отрицательные веса
Дейкстра не подходит
в интервью обычно достаточно сказать это вслух и предложить другой класс алгоритмов (например, Беллмана–Форда), но код чаще всего не требуют, если задача не про этоДейкстра на Python: heapq и релаксации
Алгоритм Дейкстры поддерживает текущие лучшие расстояния dist[v] и всегда выбирает следующую вершину с минимальным неизвестным расстоянием.
В Python это обычно делается кучей:
модуль heapq: heapq — Heap queue algorithmИнвариант Дейкстры (важно проговорить)
когда вершина извлечена из кучи с расстоянием d, и d совпадает с текущим dist[v], это расстояние уже минимально и больше не улучшится (при неотрицательных весах)Реализация
Что важно в этой реализации:
проверка if d != dist[v]: continue отсекает устаревшие записи в куче
веса w должны быть неотрицательными, иначе инвариант «достали минимум и зафиксировали» ломаетсяСложность Дейкстры (как обычно говорят на интервью)
Часто достаточно формулировки:
по времени примерно Здесь:
— вершины
— рёбра
появляется из-за операций с кучей (добавление и извлечение минимума)Частный, но полезный случай: веса 0 и 1 (0-1 BFS)
Если веса рёбер только 0 или 1, можно сделать быстрее и проще, используя deque:
если вес 0, добавляем вершину влево (appendleft)
если вес 1, добавляем вправо (append)Это хороший мостик к теме deque из предыдущих статей: структура данных помогает сохранить правильный порядок обработки.
На собеседовании обычно достаточно:
узнать этот приём
понимать, почему deque здесь работаетТиповые форматы ввода и каркас solve()
В графовых задачах чаще всего дают:
n m
дальше m рёберВзвешенный вариант:
u v wКаркас для чтения и запуска алгоритма:
Важно: под конкретную задачу меняется только чтение (ориентированный/неориентированный, веса) и то, какой алгоритм вы вызываете.
Как это объяснять на собеседовании
Универсальная структура ответа (из первой статьи курса) здесь особенно хорошо работает:
Переформулировать задачу как графовую модель
1. что является вершинами
2. что является рёбрами
3. ориентированность, веса
Выбрать алгоритм
1. BFS, если рёбра равны по стоимости
2. Дейкстра, если веса неотрицательные
3. DFS, если нужна структура достижимости, компоненты, циклы
Назвать инвариант
1. BFS обрабатывает вершины по слоям
2. Дейкстра фиксирует вершину с минимальным расстоянием (при неотрицательных весах)
Оценить сложность
1. BFS/DFS:
2. Дейкстра: примерно
Частые ошибки
перепутали индексацию 1..n и 0..n-1
забыли добавить ребро в обе стороны для неориентированного графа
сделали очередь через list.pop(0) вместо deque.popleft()
в BFS отметили visited слишком поздно и получили много повторных добавлений (обычно ставят метку при добавлении в очередь)
применили Дейкстру при отрицательных весах
рекурсивный DFS на большой глубине без понимания риска переполнения стекаДальше по курсу графы будут встречаться как основа для более продвинутых тем: топологическая сортировка, поиск циклов, задачи на решётки, а также техники «поиск по ответу + проверка достижимости».