Обход графа в Python: как реализовать BFS и DFS на списках смежности

На чтение
6 мин
Дата обновления
05.11.2026

Обход графа отвечает на простой вопрос: какие вершины достижимы из выбранной точки и в каком порядке их рассматривать? Два основных подхода — поиск в ширину, BFS, и поиск в глубину, DFS. Их удобнее понимать на одном небольшом графе, прослеживая очередь, стек и отметки посещения.

Ниже — авторские учебные реализации на Python 3 без сторонних библиотек. Вершины обозначены строками, граф конечный, невзвешенный и записан списками смежности. Каждая вершина, включая изолированную, присутствует ключом словаря. Эти условия позволяют сосредоточиться на алгоритме, а не на проверке произвольного входного формата.

Как читать список смежности

graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D'],
    'C': ['A', 'D'],
    'D': ['B', 'C'],
    'X': []
}

Из A можно перейти в B и C, из B — в A и D. В примере рёбра неориентированные, поэтому каждое направление записано у обоих концов. X существует отдельно и ни с кем не соединена. Если вы описываете ориентированный граф, обратное ребро автоматически не возникает: в список нужно включать только разрешённые переходы.

Здесь есть цикл A → B → D → C → A. Поэтому обычная рекурсивная прогулка «идти ко всем соседям» без отметок будет снова возвращаться в уже пройденные места. Дерево — частный вид структуры, а произвольный граф требует учитывать повторные связи.

BFS: очередь хранит следующий слой

Для очереди используем collections.deque: добавляем справа через append(), извлекаем слева через popleft(). Документация Python специально рекомендует deque для таких операций очереди. Список с удалением первого элемента заставляет сдвигать остальные элементы, поэтому для этой роли он менее подходящая базовая конструкция.

from collections import deque

def bfs(graph, start):
    if start not in graph:
        raise ValueError('Неизвестная начальная вершина')
    queue = deque([start])
    visited = {start}
    parent = {start: None}
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                parent[neighbor] = node
                queue.append(neighbor)
    return order, parent

order, parent = bfs(graph, 'A')
print(order)  # ['A', 'B', 'C', 'D']

Сначала извлекается A, после неё очередь содержит B и C. При обработке B обнаруживается D и добавляется после C. Когда дойдём до C, D уже отмечена, поэтому второй раз в очередь не попадёт. Обратите внимание: отметка ставится при добавлении в очередь, а не после извлечения. Это исключает дублирующее ожидание одной вершины через разных родителей.

Порядок соседей влияет на конкретный порядок обхода. Если записать C перед B, некоторые результаты будут другими. Это не ошибка, если соблюдается смысл обхода. Для воспроизводимого примера здесь используются упорядоченные списки, а не наборы соседей без заданной последовательности.

Восстановление пути по родителям

Словарь parent хранит, откуда вершина впервые обнаружена. Для невзвешенного графа BFS подходит для поиска пути с минимальным числом рёбер. Это не то же самое, что минимальная стоимость: если переходы имеют разные веса, нужен подходящий алгоритм для взвешенной задачи.

def restore_path(parent, target):
    if target not in parent:
        return None
    path = []
    node = target
    while node is not None:
        path.append(node)
        node = parent[node]
    return path[::-1]

print(restore_path(parent, 'D'))  # ['A', 'B', 'D']
print(restore_path(parent, 'A'))  # ['A']
print(restore_path(parent, 'X'))  # None

У D родитель B, у B — A, у A стоит маркер None. Собранный обратный путь разворачиваем. Результат None означает, что в полученном дереве обхода нет цели: например, X недостижима из A. Это отличается от пути из A в саму A, который содержит одну вершину и ноль рёбер.

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

DFS с явным стеком

Стек работает по принципу «последним добавлен — первым извлечён». Для него подходит обычный список с append() и pop() на конце. Следующий учебный вариант помещает соседей в обратном порядке, чтобы первым рассматривать левого соседа из исходного списка:

def dfs_stack(graph, start):
    if start not in graph:
        raise ValueError('Неизвестная начальная вершина')
    stack = [start]
    visited = set()
    order = []
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        order.append(node)
        stack.extend(reversed(graph[node]))
    return order

print(dfs_stack(graph, 'A'))  # ['A', 'B', 'D', 'C']

Здесь одна вершина может оказаться в стеке несколько раз, но проверка перед обработкой пропускает повтор. Это свойство именно показанного простого варианта; не переносите автоматически на него утверждение «в стеке всегда каждая вершина ровно один раз». Если важна память на большом графе, анализируйте представление состояния и количество отложенных элементов.

DFS через рекурсию

Рекурсивная запись делает переход к соседу непосредственным вызовом функции. Отмечаем вершину до спуска, чтобы цикл не возвращал нас к незавершённой работе:

def dfs_recursive(graph, start, visited=None):
    if start not in graph:
        raise ValueError('Неизвестная начальная вершина')
    if visited is None:
        visited = set()
    order = []

    def visit(node):
        if node in visited:
            return
        visited.add(node)
        order.append(node)
        for neighbor in graph[node]:
            visit(neighbor)

    visit(start)
    return order

print(dfs_recursive(graph, 'A'))  # ['A', 'B', 'D', 'C']

Обратите внимание на visited=None в параметрах. Новое множество создаётся при обычном отдельном вызове; при необходимости можно явно передать общее состояние. Это позволяет не смешивать независимые обходы и в то же время повторно использовать функцию для всего несвязного графа.

В Python глубина рекурсии ограничена. Длинная цепочка вершин может привести к RecursionError, даже если циклы обработаны правильно. Повышение лимита не является универсальным исправлением. Для глубокой структуры рассмотрите явный стек: он убирает зависимость обхода от глубины вложенных вызовов Python, хотя память для состояния всё равно нужна.

Как пройти несвязный граф целиком

Один запуск из A не посещает X. Для нашего неориентированного примера переберём все вершины и начнём новый DFS там, где общей отметки ещё нет:

seen = set()
components = []
for node in graph:
    if node not in seen:
        components.append(dfs_recursive(graph, node, seen))
print(components)  # [['A', 'B', 'D', 'C'], ['X']]

Получились две компоненты связности. Общее множество seen не даёт заново обходить уже найденную компоненту. Для ориентированного графа понятия сильной и слабой связности требуют отдельного определения и алгоритма; такой простой разбор достижимости нельзя без оговорки назвать поиском всех сильно связных компонент.

Что выбрать для задачи

ЗадачаНачальный выборЧто проверить
Минимум переходов в невзвешенной сетиBFS с родителямиВсе рёбра считаются одинаковыми по стоимости
Обойти достижимую область с углублениемDFSОтметки посещения и порядок соседей
Очень глубокая структураDFS с явным стекомПамять стека и повторы отложенных вершин
Полный обход несвязной сетиВнешний цикл с общими отметкамиОриентированность и нужное понятие связности

У BFS очередь может стать широкой, если на очередном уровне много вершин. Рекурсивный DFS хранит цепочку вызовов, а приведённый итеративный вариант — отложенных соседей. Поэтому нельзя обещать, что один обход всегда потребляет меньше памяти другого: это зависит от графа и конкретной реализации.

Проверьте функции на цикле, изолированной вершине, пути в начальную вершину и недостижимой цели. Затем поменяйте порядок соседей и сравните результаты. Так становится видно, что является обязательным свойством алгоритма, а что — особенностью выбранного порядка и учебной записи графа.