lab06 — графы и топологическая сортировка¶
Граф «с нуля»: ориентированный граф на списке смежности, два обхода — в ширину (BFS) и в глубину (DFS), обнаружение цикла и топологическая сортировка (алгоритм Кана). Лаборатория закрывает самый большой пробел курса: до неё структуры были «линейными» (массив, список, дерево), а граф описывает то, с чем инженер сталкивается каждый день, — зависимости: какой сервис от какого зависит и в каком порядке всё это поднимать.
Хранилище списка смежности — обычный dict (в отличие от lab05, где хеш-таблица писалась руками): здесь предмет лабораторной — алгоритмы на графе, а не контейнер под него. Длины списков по-прежнему считаются собственной array_length из common.
Содержание¶
- Что такое граф и список смежности
- DirectedGraph — граф на списке смежности
- BFS — обход в ширину
- DFS — обход в глубину
- Обнаружение цикла
- Топологическая сортировка (алгоритм Кана)
- Сложность — итог
- Где это в проде
- Меню
Что такое граф и список смежности¶
Граф — это вершины (vertices) и рёбра (edges) между ними. В ориентированном графе ребро имеет направление: A -> B не то же самое, что B -> A. Для зависимостей это принципиально: «app зависит от db» — направленное утверждение.
Хранить граф можно двумя способами:
| Представление | Что это | Память | Проверить ребро A→B | Перебрать соседей A |
|---|---|---|---|---|
| Список смежности (adjacency list) | для каждой вершины — список её соседей | O(V + E) | O(degree) | O(degree) |
| Матрица смежности (adjacency matrix) | таблица V×V из 0/1 | O(V²) | O(1) | O(V) |
Для разреженных графов (рёбер немного — а зависимости сервисов именно такие) список смежности экономнее: реальные графы обычно имеют V вершин и порядка V, а не V² рёбер. Поэтому в лабораторной — список смежности.
DirectedGraph — граф на списке смежности¶
Внутри — dict {вершина: [соседи...]}. Порядок вставки вершин и рёбер сохраняется (в Python dict упорядочен с 3.7), и это важно: благодаря ему обходы детерминированы, а топологический порядок воспроизводим.
class DirectedGraph:
def __init__(self) -> None:
self._adjacency: dict = {} # {вершина: список исходящих соседей}
def add_vertex(self, vertex) -> None:
if vertex not in self._adjacency:
self._adjacency[vertex] = [] # идемпотентно
def add_edge(self, src, dst) -> None:
self.add_vertex(src) # концы создаются автоматически
self.add_vertex(dst)
neighbors = self._adjacency[src]
for existing in neighbors:
if existing == dst:
return # параллельное ребро не дублируем
neighbors.append(dst)
add_edgeсам создаёт отсутствующие вершины — граф можно строить одними рёбрами.- Дублирующее ребро игнорируется: на корректность обходов это не влияет, но искажало бы степени вершин и засоряло бы вывод.
neighbors()возвращает копию списка соседей — снаружи внутреннюю структуру не испортить.
BFS — обход в ширину¶
BFS (breadth-first search) идёт «волнами»: стартовая вершина, потом все её соседи, потом соседи соседей. Реализован на очереди (FIFO): берём из головы, кладём непосещённых соседей в хвост.
def bfs(graph, start) -> list:
order, visited = [], set()
queue = [start]
visited.add(start)
head = 0 # «указатель головы» вместо pop(0)
while head < array_length(queue):
current = queue[head]
head += 1
order.append(current)
for neighbor in graph.neighbors(current):
if neighbor not in visited:
visited.add(neighbor) # помечаем при ПОСТАНОВКЕ в очередь
queue.append(neighbor)
return order
Два нюанса:
- Вершина помечается посещённой при добавлении в очередь, а не при извлечении. Иначе один и тот же сосед мог бы попасть в очередь несколько раз (через разные пути) до того, как до него дойдёт обработка.
- Вместо
queue.pop(0)(это O(n) — сдвигает весь список) держим индекс головыhead. Так каждое извлечение — O(1).
Для графа-ромба 1->2, 1->3, 2->4, 3->4 обход от 1 даёт [1, 2, 3, 4]: сначала уровень {1}, потом {2, 3}, потом {4}. BFS находит кратчайший путь (в рёбрах) от старта до каждой достижимой вершины.
DFS — обход в глубину¶
DFS (depth-first search) уходит как можно глубже по первому непосещённому соседу и откатывается, только упёршись в тупик. Реализован на явном стеке (LIFO), а не рекурсией — чтобы не упереться в лимит рекурсии Python на больших графах.
def dfs(graph, start) -> list:
order, visited = [], set()
stack = [start]
while array_length(stack) > 0:
current = stack.pop() # снимаем с вершины стека
if current in visited:
continue # уже обработана через другой путь
visited.add(current)
order.append(current)
neighbors = graph.neighbors(current)
i = array_length(neighbors) - 1 # кладём соседей в ОБРАТНОМ порядке,
while i >= 0: # чтобы первый сосед оказался сверху
if neighbors[i] not in visited:
stack.append(neighbors[i])
i -= 1
return order
Нюансы:
- Соседи кладутся в стек в обратном порядке — тогда первый сосед окажется на вершине стека и обработается первым, как в естественной рекурсии (слева направо).
- На стеке вершина может оказаться несколько раз (через разные пути), поэтому проверка
if current in visited: continueстоит при извлечении, а не при добавлении.
Для того же ромба DFS от 1 даёт [1, 2, 4, 3]: из 1 идём в 2, из 2 вглубь в 4 (тупик), откат к 3 (а 4 уже посещена). Сравните с BFS [1, 2, 3, 4] — обходят те же вершины, но в разном порядке.
| BFS (в ширину) | DFS (в глубину) | |
|---|---|---|
| Структура | очередь (FIFO) | стек (LIFO) / рекурсия |
| Идёт | по уровням | вглубь до тупика |
| Находит | кратчайший путь (в рёбрах) | любой путь; основа топосорта и поиска цикла |
| Аналогия | круги на воде | блуждание по лабиринту с откатами |
Обнаружение цикла¶
В ориентированном графе цикл ищем трёхцветным DFS (раскраска вершин):
- белая — ещё не посещалась;
- серая — сейчас в стеке обработки (мы «внутри» неё);
- чёрная — она и всё её поддерево полностью разобраны.
Ключевая идея: если из текущей вершины есть ребро в серую вершину — это обратное ребро (back edge), и оно замыкает цикл. Ребро в чёрную вершину цикла не даёт (то поддерево уже закрыто, назад оно не ведёт).
_WHITE, _GRAY, _BLACK = 0, 1, 2
def has_cycle(graph) -> bool:
color = {v: _WHITE for v in graph.vertices()}
for root in graph.vertices():
if color[root] != _WHITE:
continue
stack = [[root, 0]] # пары (вершина, индекс следующего соседа)
color[root] = _GRAY
while array_length(stack) > 0:
frame = stack[-1]
vertex, idx = frame[0], frame[1]
neighbors = graph.neighbors(vertex)
if idx < array_length(neighbors):
frame[1] = idx + 1
neighbor = neighbors[idx]
if color[neighbor] == _GRAY:
return True # ребро в серую вершину — цикл
if color[neighbor] == _WHITE:
color[neighbor] = _GRAY
stack.append([neighbor, 0])
else:
color[vertex] = _BLACK # все соседи разобраны — закрываем
stack.pop()
return False
- DFS запускается из каждой ещё белой вершины — граф может быть несвязным, и цикл может прятаться в любой компоненте.
- На стеке вместе с вершиной хранится индекс следующего соседа — так итеративный DFS «помнит», где остановился, имитируя кадры рекурсии.
- Петля
A -> A— простейший цикл: при разбореA(она серая) встречаем ребро в серуюA.
Топологическая сортировка (алгоритм Кана)¶
Топологическая сортировка упорядочивает вершины так, что для каждого ребра src -> dst вершина src стоит раньше dst. Существует только для DAG (directed acyclic graph — ориентированного графа без циклов): при цикле «раньше» определить нельзя (a раньше b, b раньше c, c раньше a — противоречие).
Алгоритм Кана работает через степени входа (in-degree — число входящих рёбер):
- Посчитать in-degree каждой вершины.
- В очередь — все вершины с
in-degree == 0(ни от кого не зависят). - Снять вершину из очереди → в результат; у всех её соседей уменьшить in-degree; если у соседа стал
0— в очередь. - Если в результат попали не все вершины — остался цикл (его вершины так и не получили нулевую степень).
def topological_sort(graph) -> list:
in_degree = {v: 0 for v in graph.vertices()}
for v in graph.vertices():
for neighbor in graph.neighbors(v):
in_degree[neighbor] += 1
queue = [v for v in graph.vertices() if in_degree[v] == 0]
order, head = [], 0
while head < array_length(queue):
current = queue[head]; head += 1
order.append(current)
for neighbor in graph.neighbors(current):
in_degree[neighbor] -= 1 # «убрали» вершину — ослабили зависимость
if in_degree[neighbor] == 0:
queue.append(neighbor)
if array_length(order) != array_length(graph.vertices()):
raise CycleError("в графе есть цикл (циклическая зависимость)")
return order
Цикл сигнализируется исключением CycleError — это честнее, чем вернуть частичный или пустой список: вызывающий код обязан обработать невозможность построить порядок.
От топосортировки к порядку запуска¶
В нашей модели ребро app -> db значит «app зависит от db». Топосортировка даёт «зависимый раньше зависимости» (app перед db), а запускать надо наоборот — сперва то, от чего зависят. Поэтому startup_order просто разворачивает результат:
def startup_order(graph) -> list:
order = topological_sort(graph) # app, web, ... перед db, cache
return order[::-1] # разворот: сначала зависимости
Для графа сервисов это даёт корректный порядок: db, cache, queue поднимаются раньше api и worker, а api — раньше web.
Сложность — итог¶
V — число вершин, E — число рёбер.
| Операция | Сложность | Память |
|---|---|---|
add_vertex |
O(1) | — |
add_edge |
O(degree) — проверка дубля | — |
bfs / dfs |
O(V + E) | O(V) |
has_cycle |
O(V + E) | O(V) |
topological_sort / startup_order |
O(V + E) | O(V) |
Все обходы — линейные по размеру графа O(V + E): каждую вершину обрабатываем один раз и каждое ребро проходим один раз. Это и есть «нормальная» сложность для графовых алгоритмов; для разреженного графа (E ≈ V) — фактически O(V).
Где это в проде¶
Граф зависимостей и топологическая сортировка — это буквально то, как системы решают «что от чего зависит и в каком порядке запускать»:
- Порядок запуска сервисов.
systemdстроит граф юнитов изAfter=/Requires=/Wants=и топологически вычисляет порядок старта (и обратный — для остановки). Вdocker-composeдирективаdepends_onзадаёт рёбра графа, по которому Compose поднимает контейнеры в нужном порядке. Это ровноstartup_orderиз лабораторной. - Граф ресурсов Terraform.
terraformстроит DAG ресурсов: ссылкаaws_instance.webнаaws_security_group.sg— это ребро зависимости. По топологическому порядку Terraform создаёт ресурсы (а приdestroy— в обратном).terraform graphвыводит этот DAG. Цикл зависимостей — ошибкаCycle: ..., которую Terraform ловит ровно так же, какhas_cycle. - DAG в Airflow / CI-пайплайнах. Apache Airflow — это Directed Acyclic Graph в самом названии: задачи (tasks) с зависимостями
t1 >> t2, исполняемые в топологическом порядке. Те же DAG-зависимости — в стадиях GitLab CI / GitHub Actions (needs:), в системах сборки (make, Bazel): что собрать раньше, чтобы артефакт был готов к моменту использования. - Обнаружение циклических зависимостей. Циклы в зависимостях — это баг, который надо находить: циклический
importв Python/Go, циклы между микросервисами (A ждёт B, B ждёт A — дедлок при старте), циклы в зависимостях пакетов (npm,go mod). Алгоритмhas_cycle(трёхцветный DFS) — основа линтеров и инструментов вродеmadge(JS),import-linter(Python), которые отказываются собирать систему с циклом.
Меню¶
menu создаёт пример-граф сервисов и в цикле читает выбор:
| Пункт | Действие |
|---|---|
| 1 | Показать граф (список смежности) |
| 2 | Добавить вершину |
| 3 | Добавить ребро (зависимость A → B) |
| 4 | BFS от вершины |
| 5 | DFS от вершины |
| 6 | Проверить граф на цикл |
| 7 | Топологическая сортировка |
| 8 | Порядок запуска сервисов (startup order) |
| 9 | Демонстрация на графе сервисов (обходы + порядок старта) |
| 10 | Демонстрация графа с циклом (a → b → c → a) |
| 0 | Выход |
Пункт 9 (demo_services) показывает весь конвейер на ацикличном графе сервисов, пункт 10 (demo_cycle) — что происходит при циклической зависимости: has_cycle находит цикл, а topological_sort отказывается строить порядок и бросает CycleError.