Графы: BFS, DFS и топологическая сортировка

⏱ 50 мин · 🧩 заданий: 5

Граф

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

Вид Пример
неориентированный дружба: A—B
ориентированный подписка, зависимость: A → B
взвешенный дороги с расстояниями
дерево связный граф без циклов
DAG ориентированный без циклов: зависимости, задачи

Представление

from collections import defaultdict

edges = [("A", "B"), ("A", "C"), ("B", "D"), ("C", "D"), ("D", "E")]

graph = defaultdict(list)                  # список смежности — основной вариант
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)                     # для неориентированного — в обе стороны
print(dict(graph))

names = ["A", "B", "C", "D", "E"]          # матрица смежности — для плотных графов
idx = {n: i for i, n in enumerate(names)}
matrix = [[0] * len(names) for _ in names]
for u, v in edges:
    matrix[idx[u]][idx[v]] = matrix[idx[v]][idx[u]] = 1
for name, row in zip(names, matrix):
    print(name, row)
Список смежности Матрица
память O(V + E) O(V²)
перебрать соседей O(степени) O(V)
есть ли ребро u–v O(степени) (или O(1) с множествами) O(1)

Часто граф неявный: сетка (соседи клетки — 4 направления), слова (соседи отличаются одной буквой), состояния головоломки.

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

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

graph = {"A": ["B", "C"], "B": ["A", "D"], "C": ["A", "D"], "D": ["B", "C", "E"], "E": ["D"], "X": ["Y"], "Y": ["X"]}

def dfs(start):
    visited, order, stack = set(), [], [start]
    while stack:
        node = stack.pop()
        if node in visited:
            continue
        visited.add(node)
        order.append(node)
        for nxt in reversed(graph[node]):
            if nxt not in visited:
                stack.append(nxt)
    return order

print(dfs("A"))

def components(graph):
    seen, result = set(), []
    for node in graph:
        if node not in seen:
            comp = dfs(node)
            seen.update(comp)
            result.append(comp)
    return result

print("компоненты связности:", components(graph))

Пройдите пример 👣 Пошагово: видно, как меняются стек и множество посещённых.

DFS применяют для: компонент связности, поиска цикла, топологической сортировки, перебора путей, «заливки» областей.

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

Очередь: сначала все соседи, потом соседи соседей. Вершины посещаются по возрастанию расстояния → кратчайший путь в невзвешенном графе.

from collections import deque

graph = {"A": ["B", "C"], "B": ["A", "D"], "C": ["A", "D"], "D": ["B", "C", "E"], "E": ["D"]}

def bfs_path(start, goal):
    queue = deque([start])
    parent = {start: None}                     # заодно и visited
    while queue:
        node = queue.popleft()
        if node == goal:
            path = []
            while node is not None:            # восстановить путь по родителям
                path.append(node)
                node = parent[node]
            return path[::-1]
        for nxt in graph[node]:
            if nxt not in parent:
                parent[nxt] = node
                queue.append(nxt)
    return None

print(bfs_path("A", "E"))

Отмечайте при добавлении в очередь

В BFS помечайте вершину посещённой когда кладёте в очередь, а не когда достаёте — иначе одна вершина попадёт в очередь много раз.

Сетка как граф

from collections import deque

def rotting_oranges(grid):
    """Сколько минут, пока сгниют все апельсины (2 — гнилой, 1 — свежий). BFS от нескольких источников."""
    rows, cols = len(grid), len(grid[0])
    queue = deque((r, c) for r in range(rows) for c in range(cols) if grid[r][c] == 2)
    fresh = sum(row.count(1) for row in grid)
    minutes = 0
    while queue and fresh:
        for _ in range(len(queue)):
            r, c = queue.popleft()
            for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                    grid[nr][nc] = 2
                    fresh -= 1
                    queue.append((nr, nc))
        minutes += 1
    return minutes if fresh == 0 else -1

print(rotting_oranges([[2, 1, 1], [1, 1, 0], [0, 1, 1]]))

Топологическая сортировка

Порядок вершин DAG, в котором каждое ребро идёт «вперёд»: сначала зависимости, потом зависящие. Так устроены сборка проектов, миграции, планирование задач, порядок импорта.

Алгоритм Кана: считаем входящие степени, кладём в очередь вершины без зависимостей, «удаляем» их и уменьшаем степени соседей.

from collections import deque, defaultdict

deps = {"app": ["db", "cache"], "db": ["config"], "cache": ["config"], "config": [], "tests": ["app"]}

def topo_sort(deps):
    graph = defaultdict(list)
    indegree = {node: 0 for node in deps}
    for node, needs in deps.items():
        for need in needs:
            graph[need].append(node)
            indegree[node] += 1
    queue = deque(n for n, d in indegree.items() if d == 0)
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for nxt in graph[node]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:
                queue.append(nxt)
    if len(order) != len(deps):
        raise ValueError("циклическая зависимость")
    return order

print(topo_sort(deps))
try:
    topo_sort({"a": ["b"], "b": ["a"]})
except ValueError as e:
    print("ValueError:", e)

from graphlib import TopologicalSorter            # в стандартной библиотеке с Python 3.9
print(list(TopologicalSorter(deps).static_order()))

Дейкстра — кратчайшие пути с весами

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

import heapq

graph = {
    "Дом": [("Метро", 5), ("Автобус", 2)],
    "Автобус": [("Метро", 1), ("Работа", 9)],
    "Метро": [("Работа", 3)],
    "Работа": [],
}

def dijkstra(graph, source):
    dist = {source: 0}
    prev = {}
    heap = [(0, source)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue                              # устаревшая запись в куче
        for v, w in graph[u]:
            if d + w < dist.get(v, float("inf")):
                dist[v] = d + w
                prev[v] = u
                heapq.heappush(heap, (d + w, v))
    return dist, prev

dist, prev = dijkstra(graph, "Дом")
path, node = [], "Работа"
while node:
    path.append(node)
    node = prev.get(node)
print(dist, " → ".join(reversed(path)))

Сложность O((V + E) log V). С отрицательными весами — Беллман–Форд; все пары — Флойд–Уоршелл.

Union-Find (система непересекающихся множеств)

Быстро отвечает «в одной ли компоненте вершины» и объединяет компоненты — почти O(1) на операцию:

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]     # сжатие пути
            x = self.parent[x]
        return x

    def union(self, a, b):
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False                                   # уже вместе → ребро создаёт цикл
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra                               # меньшее дерево под большее
        self.size[ra] += self.size[rb]
        return True

uf = UnionFind(6)
for a, b in [(0, 1), (1, 2), (3, 4)]:
    uf.union(a, b)
print(uf.find(0) == uf.find(2), uf.find(0) == uf.find(3))
print("компонент:", len({uf.find(i) for i in range(6)}))
print("ребро 2–0 создаёт цикл:", not uf.union(2, 0))

Применение: компоненты связности в динамике, поиск лишнего ребра, алгоритм Краскала (минимальное остовное дерево), объединение аккаунтов.

Шпаргалка

Задача Алгоритм
обойти всё, компоненты, заливка DFS или BFS
кратчайший путь без весов BFS
кратчайший путь с весами ≥ 0 Дейкстра
порядок с зависимостями, есть ли цикл в DAG топологическая сортировка
динамически объединять и проверять связность Union-Find
минимальное остовное дерево Краскал (Union-Find) или Прим (куча)

Сложность обходов — O(V + E).

Итог

  • Граф — список смежности (defaultdict(list)); сетка — неявный граф с 4 соседями.
  • DFS — стек/рекурсия, BFS — очередь; всегда visited.
  • BFS даёт кратчайший путь без весов; Дейкстра — с неотрицательными весами (heapq).
  • Топологическая сортировка (Кан) — зависимости и поиск циклов; graphlib в stdlib.
  • Union-Find — связность и циклы почти за O(1).

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Число островов. Дана сетка из "1" (суша) и "0" (вода). Остров — группа соседних по горизонтали и вертикали клеток суши. Верните число островов. Сетку можно изменять.

Какие тесты проверяют код (3)
grid = [list("11000"), list("11000"), list("00100"), list("00011")]
assert num_islands(grid) == 3

assert num_islands([list("111"), list("010"), list("111")]) == 1 and num_islands([]) == 0 and num_islands([list("000")]) == 0
big = [["1"] * 120 for _ in range(120)]; assert num_islands(big) == 1, "Большой остров: рекурсия может упереться в лимит глубины — используйте стек или очередь"
Задание 2 Программа на Python

Кратчайший путь в лабиринте. Сетка: "." — проход, "#" — стена. Верните минимальное число шагов от start до end (клетки — кортежи (строка, столбец)), двигаясь по 4 направлениям, или -1, если пути нет. BFS.

Какие тесты проверяют код (2)
maze = ["..#.",
        ".##.",
        "....",
        "#.#."]
assert shortest_path(maze, (0, 0), (0, 3)) == 7 and shortest_path(maze, (0, 0), (0, 0)) == 0

assert shortest_path([".#.", "###", "..."], (0, 0), (2, 2)) == -1
Задание 3 Программа на Python

Порядок прохождения курсов. Есть n курсов (0…n−1) и список пар [a, b]: чтобы пройти a, нужно сначала пройти b. Верните любой допустимый порядок прохождения всех курсов или пустой список, если это невозможно (есть цикл). Алгоритм Кана.

Какие тесты проверяют код (3)
pre = [[1, 0], [2, 0], [3, 1], [3, 2]]
order = course_order(4, pre)
pos = {c: i for i, c in enumerate(order)}
assert sorted(order) == [0, 1, 2, 3] and all(pos[b] < pos[a] for a, b in pre)

assert course_order(2, [[1, 0], [0, 1]]) == [] and course_order(1, []) == [0]
assert course_order(3, [[1, 0], [2, 1], [0, 2]]) == []
Задание 4 Программа на Python

Дейкстра. Дан взвешенный ориентированный граф: edges — список (откуда, куда, вес), веса неотрицательные. Верните словарь кратчайших расстояний от source до всех достижимых вершин. Используйте heapq.

Какие тесты проверяют код (2)
edges = [("A", "B", 4), ("A", "C", 1), ("C", "B", 2), ("B", "D", 1), ("C", "D", 5), ("E", "A", 1)]
assert dijkstra(edges, "A") == {"A": 0, "C": 1, "B": 3, "D": 4}

assert dijkstra([], "X") == {"X": 0}
Задание 5 Один вариант ответа

Почему BFS находит кратчайший путь в невзвешенном графе, а DFS — нет?

Войдите, чтобы отмечать пройденные уроки.