Обнаружение циклов в ориентированных и неориентированных графах
Обнаруживайте циклы в неориентированных графах с отслеживанием родителей, а в ориентированных — с помощью раскраски вершин в DFS и трёх состояний посещения: белого, серого и чёрного
«Обнаружение циклов в ориентированных и неориентированных графах» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.
Почему важно обнаруживать циклы
Цикл в графе — это путь, который начинается и заканчивается в одном и том же узле. Обнаружение циклов критически важно для многих алгоритмов: топологическая сортировка не работает на графах с циклами, при разрешении зависимостей необходимо обнаруживать циклические зависимости, а обнаружение взаимоблокировок при планировании в ОС требует поиска циклов в графах распределения ресурсов. Подход различается для неориентированных и ориентированных графов — для них нужны принципиально разные алгоритмы.
from collections import defaultdict
# Undirected cycle: A-B-C-A (triangle)
undirected = defaultdict(list)
for u, v in [('A','B'),('B','C'),('C','A')]:
undirected[u].append(v)
undirected[v].append(u)
# Directed cycle: A->B->C->A
directed = defaultdict(list)
for u, v in [('A','B'),('B','C'),('C','A')]:
directed[u].append(v) # one direction only
# Key difference:
# Undirected: edge A-B appears as both A->B and B->A
# Must track parent to distinguish cycle from back-edge to parent
print('Undirected and directed cycles need different detection')Обнаружение циклов в неориентированном графе с помощью DFS
В неориентированном графе цикл существует, если DFS посещает узел, который уже находится в текущем пути, а не просто был посещён ранее. Сложность в том, что каждое ребро представлено в обоих направлениях, поэтому список соседей дочернего узла содержит текущий узел — его родителя. Необходимо отслеживать родителя каждого узла, чтобы ошибочно не принять ребро обратно к родителю за цикл. Если Вы встретили посещённый узел, который не является родителем, значит, цикл найден.
def has_cycle_undirected(n, edges):
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = set()
def dfs(node, parent):
visited.add(node)
for nb in graph[node]:
if nb not in visited:
if dfs(nb, node): # recurse with current as parent
return True
elif nb != parent: # visited and not parent = CYCLE
return True
return False
for node in range(n):
if node not in visited:
if dfs(node, -1): # -1 = no parent for root
return True
return False
print(has_cycle_undirected(4, [(0,1),(1,2),(2,3),(3,1)])) # True
print(has_cycle_undirected(3, [(0,1),(1,2)])) # FalseЦикл в неориентированном графе с помощью BFS
Обнаружение циклов с помощью BFS в неориентированном графе также отслеживает родителя каждого посещённого узла. При обработке соседей узла, если сосед уже посещён и не является родителем текущего узла, значит, существует цикл. Используйте словарь для хранения родителей. Этот подход со сложностью O(V + E) не связан с ограничением глубины рекурсии и является предпочтительной итеративной альтернативой для больших графов.
from collections import deque, defaultdict
def has_cycle_bfs_undirected(n, edges):
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = set()
for start in range(n):
if start in visited:
continue
visited.add(start)
parent = {start: -1}
queue = deque([start])
while queue:
node = queue.popleft()
for nb in graph[node]:
if nb not in visited:
visited.add(nb)
parent[nb] = node
queue.append(nb)
elif parent[node] != nb: # visited and not parent = CYCLE
return True
return False
print(has_cycle_bfs_undirected(4, [(0,1),(1,2),(2,0)])) # TrueЦикл в ориентированном графе: почему отслеживание родителей не работает
В ориентированном графе отслеживания родителей недостаточно. Рассмотрим A→C и B→C: у узла C два «родителя», но цикла нет. Правильный подход использует раскраску с тремя состояниями: белый (не посещён), серый (находится в текущем пути или стеке DFS), чёрный (полностью обработан). Цикл существует, если во время DFS мы встречаем серый узел — это означает, что найдено обратное ребро к предку в текущем пути.
# Three-state DFS coloring:
# WHITE (0): not yet visited
# GRAY (1): currently being visited (in DFS stack)
# BLACK (2): fully visited (all descendants processed)
# Why parent fails for directed graphs:
# A -> C (no cycle)
# B -> C (no cycle)
# If we DFS from A, mark C gray
# Then DFS from B finds C is gray -- but this is NOT a cycle!
# C is gray from A's path, not B's path.
# Parent tracking only works when the back-edge goes to the IMMEDIATE parent.
print('Directed graph: use 3-state coloring (white/gray/black)')Обнаружение циклов в ориентированном графе с помощью DFS с тремя состояниями
Используйте массив state[] со значениями 0 (белый, не посещён), 1 (серый, в стеке), 2 (чёрный, обработан). Запустите DFS, помечая узел серым при входе и чёрным при выходе. Если DFS когда-либо достигает серого узла, найдено обратное ребро — значит, существует цикл. Если он достигает чёрного узла, этот путь уже полностью исследован и не содержит циклов, поэтому его можно пропустить.
def has_cycle_directed(n, edges):
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
state = [0] * n # 0=white, 1=gray, 2=black
def dfs(node):
state[node] = 1 # mark gray (in stack)
for nb in graph[node]:
if state[nb] == 1: # gray = back edge = CYCLE
return True
if state[nb] == 0: # white = unvisited
if dfs(nb):
return True
state[node] = 2 # mark black (fully processed)
return False
for node in range(n):
if state[node] == 0:
if dfs(node):
return True
return False
print(has_cycle_directed(4, [(0,1),(1,2),(2,0),(2,3)])) # True (0->1->2->0)
print(has_cycle_directed(3, [(0,1),(1,2)])) # FalseРасписание курсов: цикл в DAG
Расписание курсов (LeetCode №207) — задача определить, можно ли завершить все курсы при заданных предварительных требованиях. Представьте курсы как узлы, а предварительные требования — как ориентированные рёбра. Все курсы можно завершить тогда и только тогда, когда граф является DAG (не содержит циклов). Используйте обнаружение циклов с помощью DFS с тремя состояниями: если цикл найден, верните ложное значение, иначе — истинное.
from collections import defaultdict
def can_finish(num_courses, prerequisites):
graph = defaultdict(list)
for a, b in prerequisites:
graph[b].append(a) # b is prerequisite for a: b -> a
state = [0] * num_courses
def dfs(course):
if state[course] == 1: return False # cycle!
if state[course] == 2: return True # already verified
state[course] = 1 # mark as in-progress
for next_course in graph[course]:
if not dfs(next_course):
return False
state[course] = 2 # mark as done
return True
return all(dfs(i) for i in range(num_courses) if state[i] == 0)
print(can_finish(2, [[1,0]])) # True: take 0 then 1
print(can_finish(2, [[1,0],[0,1]])) # False: circular dependencyОбнаружение циклов с помощью алгоритма Кана (BFS)
Другой способ обнаружения циклов в ориентированных графах — топологическая сортировка BFS по алгоритму Кана. Подсчитайте полустепени захода всех узлов. Поместите в очередь узлы с нулевой полустепенью захода. Обрабатывайте их по одному: уменьшайте полустепени захода соседей и добавляйте в очередь те, у которых значение стало равно нулю. Если количество обработанных узлов равно V, цикла нет; в противном случае цикл существует, поскольку необработанные узлы образуют циклы. Этот подход со сложностью O(V + E) интуитивен и его легче запомнить, чем DFS с тремя состояниями.
from collections import defaultdict, deque
def has_cycle_kahn(n, edges):
graph = defaultdict(list)
in_degree = [0] * n
for u, v in edges:
graph[u].append(v)
in_degree[v] += 1
# Start with all zero in-degree nodes
queue = deque(i for i in range(n) if in_degree[i] == 0)
processed = 0
while queue:
node = queue.popleft()
processed += 1
for nb in graph[node]:
in_degree[nb] -= 1
if in_degree[nb] == 0:
queue.append(nb)
return processed != n # if not all processed, cycle exists
print(has_cycle_kahn(4, [(0,1),(1,2),(2,0),(2,3)])) # True
print(has_cycle_kahn(3, [(0,1),(1,2)])) # FalseНайти цикл: сбор узлов цикла
Иногда необходимо определить, какие узлы входят в цикл, а не просто обнаружить его наличие. Во время DFS с тремя состояниями, когда найдено обратное ребро, проследите назад по стеку вызовов (или стеку пути), чтобы собрать все узлы между предком и текущим узлом. Стек пути, поддерживаемый вместе с массивом состояний, хранит текущий путь DFS и позволяет восстановить цикл за O(длины цикла).
def find_cycle_nodes(n, edges):
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
state = [0] * n
path = [] # current DFS path
cycle = []
def dfs(node):
state[node] = 1
path.append(node)
for nb in graph[node]:
if state[nb] == 1: # back edge -> found cycle
start = path.index(nb)
cycle.extend(path[start:])
return True
if state[nb] == 0 and dfs(nb):
return True
path.pop()
state[node] = 2
return False
for i in range(n):
if state[i] == 0 and dfs(i):
break
return cycle
print(find_cycle_nodes(4, [(0,1),(1,2),(2,0),(2,3)])) # [0, 1, 2]Поиск состояний, безопасных в конечном итоге
Поиск состояний, безопасных в конечном итоге (LeetCode №802) — задача определить, какие узлы в конечном счёте ведут к конечному узлу (без исходящих рёбер), не попадая в цикл. Узел является «безопасным», если все пути из него ведут к конечным узлам. Используйте DFS с тремя состояниями: чёрные узлы (полностью обработанные без обнаружения цикла) безопасны. Узлы, входящие в цикл или ведущие к нему, небезопасны.
def eventual_safe_nodes(graph):
n = len(graph)
state = [0] * n # 0=unvisited, 1=visiting, 2=safe
def dfs(node):
if state[node] == 1: # currently visiting = cycle
return False
if state[node] == 2: # already verified safe
return True
state[node] = 1 # mark as visiting
for nb in graph[node]:
if not dfs(nb):
return False # leads to cycle, not safe
state[node] = 2 # mark as safe
return True
return [i for i in range(n) if dfs(i)]
# [[1,2],[2,3],[5],[0],[5],[],[]] means:
# 0->[1,2], 1->[2,3], 2->[5], 3->[0] (cycle!), 4->[5], 5->[], 6->[]
print(eventual_safe_nodes([[1,2],[2,3],[5],[0],[5],[],[]]))
# [2, 4, 5, 6]Избыточное ребро в неориентированном графе
Избыточное ребро (LeetCode №684) находит ребро, которое создаёт цикл при добавлении к исходно ацикличному неориентированному графу. Хотя эту задачу можно решить с помощью обнаружения циклов через DFS, наиболее ясное решение использует структуру объединения множеств (DSU): обрабатывайте рёбра по одному; если обе конечные вершины уже соединены и принадлежат одной компоненте, текущее ребро создаёт цикл и является ответом. DSU обеспечивает сложность O(alpha(n)) для каждой операции — фактически O(1).
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1))
rank = [0] * (n + 1)
def find(x):
if parent[x] != x:
parent[x] = find(parent[x]) # path compression
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False # already connected = cycle!
if rank[px] < rank[py]: px, py = py, px
parent[py] = px
if rank[px] == rank[py]: rank[px] += 1
return True
for u, v in edges:
if not union(u, v):
return [u, v] # this edge creates the cycle
return []
print(find_redundant_connection([[1,2],[1,3],[2,3]])) # [2,3]
print(find_redundant_connection([[1,2],[2,3],[3,4],[1,4],[1,5]])) # [1,4]Резюме: стратегии обнаружения циклов
Итак, набор методов обнаружения циклов выглядит так: для неориентированных графов используйте DFS с отслеживанием родителей или DSU. Для ориентированных графов используйте DFS с тремя состояниями (белый/серый/чёрный) или топологическую сортировку BFS по алгоритму Кана. Выбирайте DSU, когда добавляете рёбра по одному, по мере поступления. Выбирайте алгоритм Кана, когда Вам также нужен топологический порядок. Выбирайте DFS с тремя состояниями, когда необходимо определить конкретные узлы цикла. При обсуждении обнаружения циклов на собеседованиях всегда указывайте различие между ориентированными и неориентированными графами.
# Cycle detection summary:
# Graph type | Algorithm | Complexity
# ------------|----------------------|-----------
# Undirected | DFS + parent track | O(V + E)
# Undirected | Union-Find (DSU) | O(E * alpha(V))
# Directed | DFS 3-state (W/G/B) | O(V + E)
# Directed | Kahn's BFS topo sort | O(V + E)
# When to choose:
# Online (edges added one at a time): Union-Find
# Need topological order too: Kahn's BFS
# Need cycle nodes identified: 3-state DFS with path stack
# Simple existence check: any of the above
print('Always clarify directed vs undirected before coding')Быстрая проверка
Проверьте своё понимание понятий «Структуры данных & алгоритмы — подготовка к техническому собеседованию» из этого урока.
Повторение урока
В этом уроке Вы изучили: обнаружение циклов в неориентированных графах с помощью DFS и отслеживания родителей, обнаружение циклов в ориентированных графах с помощью раскраски с тремя состояниями — белым, серым и чёрным, альтернативный подход BFS по алгоритму Кана для ориентированных графов, а также такие применения, как расписание курсов, избыточное ребро и безопасные в конечном итоге состояния. Далее мы перейдём к основам динамического программирования.
Часто задаваемые вопросы
Урок «Обнаружение циклов в ориентированных и неориентированных графах» бесплатный?
Да — полный текст урока «Обнаружение циклов в ориентированных и неориентированных графах» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Обнаружение циклов в ориентированных и неориентированных графах»?
Обнаруживайте циклы в неориентированных графах с отслеживанием родителей, а в ориентированных — с помощью раскраски вершин в DFS и трёх состояний посещения: белого, серого и чёрного Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать Coding Interview Prep?
Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.
Сколько времени занимает урок «Обнаружение циклов в ориентированных и неориентированных графах»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке Coding Interview Prep?
Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Представление графов и настройка обхода
- BFS: кратчайший путь и обход по уровням
- DFS: компоненты связности и заливка
- Обнаружение циклов в ориентированных и неориентированных графах