0Pricing
DSA Interview Prep · Урок

Обнаружение циклов в ориентированных и неориентированных графах

Обнаруживайте циклы в неориентированных графах с отслеживанием родителей, а в ориентированных — с помощью раскраски вершин в DFS и трёх состояний посещения: белого, серого и чёрного

«Обнаружение циклов в ориентированных и неориентированных графах» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA 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) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «Обнаружение циклов в ориентированных и неориентированных графах»?

Обнаруживайте циклы в неориентированных графах с отслеживанием родителей, а в ориентированных — с помощью раскраски вершин в DFS и трёх состояний посещения: белого, серого и чёрного Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

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

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

Сколько времени занимает урок «Обнаружение циклов в ориентированных и неориентированных графах»?

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

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

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

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

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