0Pricing
Coding Interview Prep · Урок

BFS: кратчайший путь и обход по уровням

Используйте BFS для поиска кратчайшего пути в невзвешенном графе, решайте задачу word-ladder по уровням и клонируйте граф с помощью хеш-таблицы

«BFS: кратчайший путь и обход по уровням» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 2 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.

BFS и кратчайший путь в невзвешенных графах

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

from collections import deque, defaultdict

def shortest_path(graph, start, end):
    if start == end:
        return 0
    visited = {start}
    queue = deque([(start, 0)])  # (node, distance)
    while queue:
        node, dist = queue.popleft()
        for neighbour in graph[node]:
            if neighbour == end:
                return dist + 1
            if neighbour not in visited:
                visited.add(neighbour)
                queue.append((neighbour, dist + 1))
    return -1  # no path found

graph = defaultdict(list)
for u, v in [(0,1),(1,2),(2,3),(0,3),(1,4)]:
    graph[u].append(v); graph[v].append(u)
print(shortest_path(graph, 0, 3))  # 1 (direct edge)
print(shortest_path(graph, 0, 4))  # 2 (0->1->4)

Отслеживание фактического кратчайшего пути

Чтобы восстановить сам путь, а не только его длину, поддерживайте словарь родителей, в котором фиксируется, как была достигнута каждая вершина. Достигнув конечной вершины, пройдите по карте родителей от конца к началу и разверните полученный результат. Это добавляет O(V) памяти для карты родителей, но после завершения BFS позволяет получить полный путь за время, пропорциональное его длине.

from collections import deque, defaultdict

def shortest_path_with_route(graph, start, end):
    parent = {start: None}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        if node == end:
            break
        for nb in graph[node]:
            if nb not in parent:
                parent[nb] = node
                queue.append(nb)
    if end not in parent:
        return []  # no path
    # Reconstruct path by tracing back
    path = []
    node = end
    while node is not None:
        path.append(node)
        node = parent[node]
    return path[::-1]  # reverse

graph = defaultdict(list)
for u, v in [(0,1),(1,2),(2,3),(0,4),(4,3)]:
    graph[u].append(v); graph[v].append(u)
print(shortest_path_with_route(graph, 0, 3))  # [0, 4, 3] or [0, 1, 2, 3]

Лестница слов: BFS на неявном графе

Лестница слов (LeetCode #127) — задача о поиске минимального количества изменений одного символа, необходимых для преобразования начального слова в конечное, при условии, что каждое промежуточное слово есть в словаре. Это BFS на неявном графе, где вершины — слова, а рёбра соединяют слова, различающиеся одной буквой. Порождайте все варианты с изменением одной буквы и проверяйте, есть ли они в множестве слов. BFS гарантирует минимальную последовательность преобразований.

from collections import deque

def word_ladder(begin_word, end_word, word_list):
    word_set = set(word_list)
    if end_word not in word_set:
        return 0
    queue = deque([(begin_word, 1)])
    visited = {begin_word}
    while queue:
        word, steps = queue.popleft()
        for i in range(len(word)):
            for c in 'abcdefghijklmnopqrstuvwxyz':
                new_word = word[:i] + c + word[i+1:]
                if new_word == end_word:
                    return steps + 1
                if new_word in word_set and new_word not in visited:
                    visited.add(new_word)
                    queue.append((new_word, steps + 1))
    return 0

print(word_ladder('hit', 'cog', ['hot','dot','dog','lot','log','cog']))  # 5

Обход по уровням: отслеживание расстояния

Обход по уровням группирует вершины по их расстоянию от исходной вершины, что непосредственно полезно в задачах, требующих обработки каждого уровня. Расстояние можно хранить в элементе очереди как кортеж (node, dist) или использовать приём с размером очереди: перед каждым уровнем запоминать размер очереди, обрабатывать ровно столько вершин, а затем увеличивать счётчик уровня. Оба подхода дают одинаковый результат.

from collections import deque, defaultdict

def bfs_levels(graph, start):
    levels = {}
    visited = {start}
    queue = deque([start])
    dist = 0
    while queue:
        # Process all nodes at current distance
        for _ in range(len(queue)):
            node = queue.popleft()
            levels[node] = dist
            for nb in graph[node]:
                if nb not in visited:
                    visited.add(nb)
                    queue.append(nb)
        dist += 1
    return levels

graph = defaultdict(list)
for u, v in [(0,1),(0,2),(1,3),(2,3),(3,4)]:
    graph[u].append(v); graph[v].append(u)
print(bfs_levels(graph, 0))  # {0:0, 1:1, 2:1, 3:2, 4:3}

Клонирование графа

Клонирование графа (LeetCode #133) создаёт глубокую копию связного неориентированного графа. Используйте BFS и хеш-таблицу, сопоставляющую исходные вершины их клонам. При первом посещении вершины создайте её клон и добавьте его в таблицу. Обрабатывая соседей, находите или создавайте их клоны и соединяйте рёбра. Хеш-таблица выполняет две задачи: отслеживает посещённые вершины и сопоставляет исходные вершины с копиями.

from collections import deque

class Node:
    def __init__(self, val=0, neighbors=None):
        self.val = val
        self.neighbors = neighbors if neighbors is not None else []

def clone_graph(node):
    if not node:
        return None
    old_to_new = {node: Node(node.val)}
    queue = deque([node])
    while queue:
        curr = queue.popleft()
        for nb in curr.neighbors:
            if nb not in old_to_new:
                old_to_new[nb] = Node(nb.val)
                queue.append(nb)
            old_to_new[curr].neighbors.append(old_to_new[nb])
    return old_to_new[node]

# Build a simple graph: 1 -- 2 -- 3 -- 4 -- 1
n1 = Node(1); n2 = Node(2); n3 = Node(3); n4 = Node(4)
n1.neighbors = [n2, n4]; n2.neighbors = [n1, n3]
n3.neighbors = [n2, n4]; n4.neighbors = [n3, n1]
cloned = clone_graph(n1)
print(cloned.val, [n.val for n in cloned.neighbors])  # 1 [2, 4]

Двунаправленный BFS

Двунаправленный BFS запускается одновременно от исходной и конечной вершин, расширяя по одному уровню с каждого конца. Когда две границы поиска встречаются, кратчайший путь найден. Для больших графов это уменьшает пространство поиска с O(b^d) до O(2 * b^(d/2)), где b — коэффициент ветвления, а d — длина пути. Это даёт значительное ускорение для сильно связанных графов, например в задаче о лестнице слов с большими словарями.

from collections import defaultdict

def word_ladder_bidir(begin, end, word_list):
    word_set = set(word_list)
    if end not in word_set:
        return 0
    front, back = {begin}, {end}
    visited = {begin, end}
    steps = 1
    while front and back:
        # Always expand the smaller frontier
        if len(front) > len(back):
            front, back = back, front
        next_front = set()
        for word in front:
            for i in range(len(word)):
                for c in 'abcdefghijklmnopqrstuvwxyz':
                    nw = word[:i] + c + word[i+1:]
                    if nw in back:  # frontiers met!
                        return steps + 1
                    if nw in word_set and nw not in visited:
                        visited.add(nw)
                        next_front.add(nw)
        front = next_front
        steps += 1
    return 0

print(word_ladder_bidir('hit','cog',['hot','dot','dog','lot','log','cog']))  # 5

BFS 0–1 для взвешенных графов

BFS 0–1 работает с графами, в которых веса рёбер равны только 0 или 1. Вместо обычной очереди используйте двустороннюю очередь: добавляйте в конец рёбра с весом 1 (следующий уровень), а в начало — рёбра с весом 0 (тот же уровень). Это даёт поиск кратчайшего пути за O(V + E) — быстрее, чем O((V+E) log V) у алгоритма Дейкстры, когда веса принимают только два значения. Такой подход часто используется в задачах с сетками, где одни перемещения бесплатны, а другие стоят 1.

from collections import deque

def zero_one_bfs(graph, start, n):
    # graph: list of (neighbour, weight) where weight is 0 or 1
    dist = [float('inf')] * n
    dist[start] = 0
    dq = deque([start])
    while dq:
        node = dq.popleft()
        for nb, w in graph[node]:
            if dist[node] + w < dist[nb]:
                dist[nb] = dist[node] + w
                if w == 0:
                    dq.appendleft(nb)   # same level
                else:
                    dq.append(nb)       # next level
    return dist

# Simple test:
graph = [[(1, 0), (2, 1)],   # node 0: free to 1, cost 1 to 2
         [(3, 1)],            # node 1: cost 1 to 3
         [(3, 0)],            # node 2: free to 3
         []]
print(zero_one_bfs(graph, 0, 4))  # [0, 0, 1, 1]

Стены и ворота (BFS из нескольких источников)

Стены и ворота заполняет каждую пустую комнату расстоянием до ближайших ворот. Используйте BFS из нескольких источников: одновременно инициализируйте очередь всеми воротами (со значением 0) и расширяйте поиск наружу. Значение каждой ячейки устанавливается равным уровню, на котором она была достигнута впервые. Это решение за O(mn) эффективнее, чем отдельный запуск BFS из каждой пустой комнаты, который занял бы O(m²n²).

from collections import deque

def walls_and_gates(rooms):
    if not rooms:
        return
    rows, cols = len(rooms), len(rooms[0])
    INF = float('inf')
    queue = deque()
    # Multi-source: all gates at distance 0
    for r in range(rows):
        for c in range(cols):
            if rooms[r][c] == 0:  # gate
                queue.append((r, c))
    dirs = [(0,1),(0,-1),(1,0),(-1,0)]
    while queue:
        r, c = queue.popleft()
        for dr, dc in dirs:
            nr, nc = r+dr, c+dc
            if 0<=nr<rows and 0<=nc<cols and rooms[nr][nc]==INF:
                rooms[nr][nc] = rooms[r][c] + 1
                queue.append((nr, nc))

rooms = [[float('inf'),-1,0,float('inf')],
         [float('inf'),float('inf'),float('inf'),-1],
         [float('inf'),-1,float('inf'),-1],
         [0,-1,float('inf'),float('inf')]]
walls_and_gates(rooms)
print(rooms[0][0], rooms[1][1])  # 3, 2

BFS для игры «Змеи и лестницы»

Змеи и лестницы (LeetCode #909) — задача на поиск кратчайшего пути с помощью BFS на числовой доске. Представьте доску как невзвешенный граф, где с любой клетки можно переместиться на 1–6 клеток и попасть на змею или лестницу, которые телепортируют игрока. BFS находит минимальное количество бросков кубика. Главная сложность — преобразование между одномерной позицией и двумерными координатами доски с учётом зигзагообразной раскладки, в которой направление строк чередуется.

from collections import deque

def snakes_and_ladders(board):
    n = len(board)
    def get_board(pos):
        r, c = divmod(pos - 1, n)
        if r % 2 == 1: c = n - 1 - c  # alternating direction
        return board[n - 1 - r][c]

    visited = {1}
    queue = deque([(1, 0)])
    while queue:
        pos, moves = queue.popleft()
        for dice in range(1, 7):
            next_pos = pos + dice
            if next_pos > n * n:
                break
            val = get_board(next_pos)
            if val != -1:
                next_pos = val  # snake or ladder
            if next_pos == n * n:
                return moves + 1
            if next_pos not in visited:
                visited.add(next_pos)
                queue.append((next_pos, moves + 1))
    return -1

print('BFS models game as an unweighted shortest-path problem')

Сложность и оптимизация BFS

Временная сложность BFS равна O(V + E), поскольку каждая вершина добавляется в очередь один раз, а каждое ребро проверяется постоянное число раз. Пространственная сложность равна O(V) из-за множества посещённых вершин и очереди. Для графов-сеток V = m*n и E = 4*m*n (у каждой ячейки 4 соседа), поэтому BFS на сетке выполняется за O(mn). Важная оптимизация: используйте множество посещённых вершин (проверка за O(1)), а не список (проверка за O(n)). Отмечайте вершину как посещённую при добавлении в очередь, а не при извлечении.

# BFS on a graph with V vertices and E edges:
# Time:  O(V + E) -- each vertex and edge visited once
# Space: O(V)     -- visited set + queue

# BFS on an m x n grid:
# V = m*n cells
# E <= 4*m*n edges (4 directions, max)
# Time:  O(m*n)
# Space: O(m*n)

# Common pitfalls:
# 1. Marking visited on dequeue (not enqueue) -> same node queued multiple times
# 2. Using a list for visited -> O(n) membership check -> O(V*E) total
# 3. Not handling disconnected graph -> BFS from single source misses components
print('O(V+E) time, O(V) space -- mark visited on enqueue')

Ближайший 0 в двоичной матрице

Матрица 01 (LeetCode #542) находит расстояние от каждой ячейки до ближайшего 0. BFS из нескольких источников, запущенный одновременно из всех нулей, даёт оптимальное решение за O(mn). Инициализируйте очередь всеми ячейками со значением 0 и расстоянием 0, а для всех ячеек со значением 1 установите бесконечное расстояние. BFS распространяет расстояния от нулей наружу, устанавливая расстояние каждой ячейки со значением 1 при первом достижении — гарантированно кратчайшее.

from collections import deque

def update_matrix(mat):
    rows, cols = len(mat), len(mat[0])
    dist = [[float('inf')] * cols for _ in range(rows)]
    queue = deque()
    for r in range(rows):
        for c in range(cols):
            if mat[r][c] == 0:
                dist[r][c] = 0
                queue.append((r, c))
    dirs = [(0,1),(0,-1),(1,0),(-1,0)]
    while queue:
        r, c = queue.popleft()
        for dr, dc in dirs:
            nr, nc = r+dr, c+dc
            if 0<=nr<rows and 0<=nc<cols:
                if dist[r][c] + 1 < dist[nr][nc]:
                    dist[nr][nc] = dist[r][c] + 1
                    queue.append((nr, nc))
    return dist

mat = [[0,0,0],[0,1,0],[1,1,1]]
result = update_matrix(mat)
for row in result: print(row)  # [[0,0,0],[0,1,0],[1,2,1]]

Быстрая проверка

Проверьте понимание концепций «Структуры данных и алгоритмы — подготовка к собеседованию по программированию» из этого урока.

Итоги урока

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

Часто задаваемые вопросы

Урок «BFS: кратчайший путь и обход по уровням» бесплатный?

Да — полный текст урока «BFS: кратчайший путь и обход по уровням» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «BFS: кратчайший путь и обход по уровням»?

Используйте BFS для поиска кратчайшего пути в невзвешенном графе, решайте задачу word-ladder по уровням и клонируйте граф с помощью хеш-таблицы Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать Coding Interview Prep?

Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 2 из 4.

Сколько времени занимает урок «BFS: кратчайший путь и обход по уровням»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке Coding Interview Prep?

Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Представление графов и настройка обхода
  2. BFS: кратчайший путь и обход по уровням
  3. DFS: компоненты связности и заливка
  4. Обнаружение циклов в ориентированных и неориентированных графах
← Назад к Coding Interview Prep