Графы: BFS, DFS и топологическая сортировка
Граф
Граф — вершины и рёбра между ними. Дороги между городами, друзья в соцсети, зависимости пакетов, ссылки между страницами, переходы состояний.
| Вид | Пример |
|---|---|
| неориентированный | дружба: 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" (суша) и "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, "Большой остров: рекурсия может упереться в лимит глубины — используйте стек или очередь"
def num_islands(grid):
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
def sink(r, c):
stack = [(r, c)]
grid[r][c] = "0"
while stack:
r, c = stack.pop()
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] = "0"
stack.append((nr, nc))
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1":
count += 1
sink(r, c)
return count
Кратчайший путь в лабиринте. Сетка: "." — проход, "#" — стена. Верните минимальное число
шагов от 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
from collections import deque
def shortest_path(grid, start, end):
rows, cols = len(grid), len(grid[0])
queue = deque([(start, 0)])
seen = {start}
while queue:
(r, c), dist = queue.popleft()
if (r, c) == end:
return dist
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] == "." and (nr, nc) not in seen:
seen.add((nr, nc))
queue.append(((nr, nc), dist + 1))
return -1
Порядок прохождения курсов. Есть 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]]) == []
from collections import deque
def course_order(n, prerequisites):
graph = [[] for _ in range(n)]
indegree = [0] * n
for a, b in prerequisites:
graph[b].append(a)
indegree[a] += 1
queue = deque(i for i in range(n) if indegree[i] == 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)
return order if len(order) == n else []
Дейкстра. Дан взвешенный ориентированный граф: 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}
import heapq
from collections import defaultdict
def dijkstra(edges, source):
graph = defaultdict(list)
for u, v, w in edges:
graph[u].append((v, w))
dist = {source: 0}
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue
for v, w in graph[u]:
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
Почему BFS находит кратчайший путь в невзвешенном графе, а DFS — нет?
Войдите, чтобы отмечать пройденные уроки.