0Pricing
DSA Interview Prep · Lekcja

Wykrywanie cykli w grafach skierowanych i nieskierowanych

Wykryją Państwo cykle w grafach nieskierowanych, śledząc rodziców, oraz w grafach skierowanych, używając kolorowania DFS stanami biały/szary/czarny.

Wykrywanie cykli w grafach skierowanych i nieskierowanych to bezpłatna lekcja DSA Interview Prep na CoddyKit. To lekcja 4 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej DSA Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.

Dlaczego wykrywanie cykli ma znaczenie

Cykl w grafie to ścieżka, która zaczyna się i kończy w tym samym węźle. Wykrywanie cykli ma kluczowe znaczenie w wielu algorytmach: sortowanie topologiczne nie działa dla grafów zawierających cykle, rozwiązywanie zależności musi wykrywać zależności cykliczne, a wykrywanie zakleszczeń w planowaniu zadań systemu operacyjnego wymaga znajdowania cykli w grafach alokacji zasobów. Podejście różni się w przypadku grafów nieskierowanych i skierowanych — wymagają one zasadniczo różnych algorytmów.

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')

Wykrywanie cykli w grafie nieskierowanym za pomocą DFS

W grafie nieskierowanym cykl istnieje, jeśli DFS odwiedzi węzeł, który znajduje się już na bieżącej ścieżce (a nie tylko został odwiedzony). Trudność polega na tym, że każda krawędź występuje w obu kierunkach, więc podczas odwiedzania węzła potomnego na jego liście sąsiadów znajduje się również bieżący węzeł (rodzic). Należy śledzić rodzica każdego węzła, aby nie uznać omyłkowo krawędzi prowadzącej z powrotem do rodzica za cykl. Jeśli napotkamy odwiedzony węzeł, który nie jest rodzicem, znaleźliśmy cykl.

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

Wykrywanie cyklu w grafie nieskierowanym za pomocą BFS

Wykrywanie cykli w grafie nieskierowanym za pomocą BFS również wymaga śledzenia rodzica każdego odwiedzonego węzła. Podczas przetwarzania sąsiadów węzła, jeśli sąsiad został już odwiedzony i nie jest rodzicem bieżącego węzła, istnieje cykl. Do przechowywania rodziców należy użyć słownika. To podejście o złożoności O(V + E) eliminuje problem limitu rekurencji i jest preferowaną iteracyjną alternatywą dla dużych grafów.

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

Cykl w grafie skierowanym: dlaczego śledzenie rodziców nie działa

W grafie skierowanym śledzenie rodziców jest niewystarczające. Rozważmy A→C i B→C: węzeł C ma dwóch „rodziców”, ale nie ma cyklu. Właściwe podejście wykorzystuje trzy stany oznaczania: biały (nieodwiedzony), szary (na bieżącej ścieżce/stosie DFS), czarny (w pełni przetworzony). Cykl istnieje, jeśli podczas DFS napotkamy szary węzeł — oznacza to znalezienie krawędzi wstecznej prowadzącej do przodka na bieżącej ścieżce.

# 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)')

Wykrywanie cykli w grafie skierowanym za pomocą DFS z trzema stanami

Należy użyć tablicy state[] z wartościami 0 (biały/nieodwiedzony), 1 (szary/na stosie) i 2 (czarny/zakończony). Rozpocznij DFS, oznaczając węzeł jako szary przy wejściu i czarny przy wyjściu. Jeśli DFS kiedykolwiek dotrze do szarego węzła, znaleziono krawędź wsteczną — istnieje cykl. Jeśli dotrze do czarnego węzła, dana ścieżka została już w pełni zbadana i nie zawiera cyklu, więc należy ją pominąć.

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

Plan zajęć: cykl w DAG-u

Plan zajęć (LeetCode #207) wymaga ustalenia, czy można ukończyć wszystkie kursy przy danych wymaganiach wstępnych. Należy zamodelować kursy jako węzły, a wymagania wstępne jako skierowane krawędzie. Wszystkie kursy można ukończyć wtedy i tylko wtedy, gdy graf jest DAG-iem (nie zawiera cykli). Należy użyć wykrywania cykli metodą DFS z trzema stanami — jeśli zostanie znaleziony cykl, zwróć False; w przeciwnym razie zwróć True.

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

Wykrywanie cykli za pomocą algorytmu Kahna (BFS)

Alternatywna metoda wykrywania cykli w grafach skierowanych wykorzystuje sortowanie topologiczne BFS algorytmu Kahna. Należy zliczyć stopnie wejściowe wszystkich węzłów. Włóż do kolejki węzły o stopniu wejściowym równym 0. Przetwarzaj je po kolei: zmniejszaj stopnie wejściowe sąsiadów i dodawaj do kolejki te, dla których stopień osiągnął 0. Jeśli liczba przetworzonych węzłów jest równa V, graf nie zawiera cyklu; w przeciwnym razie cykl istnieje (nieprzetworzone węzły tworzą cykle). To podejście o złożoności O(V + E) jest intuicyjne i łatwiejsze do zapamiętania niż DFS z trzema stanami.

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

Znajdowanie cyklu: zbieranie węzłów cyklu

Czasami trzeba ustalić, które węzły należą do cyklu, a nie tylko wykryć jego istnienie. Podczas DFS z trzema stanami, po znalezieniu krawędzi wstecznej należy prześledzić stos wywołań (lub stos ścieżki) i zebrać wszystkie węzły znajdujące się między przodkiem a bieżącym węzłem. Stos ścieżki utrzymywany wraz z tablicą stanów przechowuje bieżącą ścieżkę DFS, umożliwiając odtworzenie cyklu w czasie O(długość_cyklu).

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]

Znajdowanie stanów ostatecznie bezpiecznych

Znajdowanie stanów ostatecznie bezpiecznych (LeetCode #802) wymaga wskazania węzłów, z których ostatecznie można dotrzeć do węzła końcowego (bez krawędzi wychodzących), nie zatrzymując się w cyklu. Węzeł jest „bezpieczny”, jeśli każda ścieżka z niego prowadzi do węzła końcowego. Należy użyć DFS z trzema stanami: czarne węzły (w pełni przetworzone bez wykrycia cyklu) są bezpieczne. Węzły należące do cyklu lub prowadzące do niego nie są bezpieczne.

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]

Nadmiarowa krawędź w grafie nieskierowanym

Nadmiarowa krawędź (LeetCode #684) wyszukuje krawędź, która tworzy cykl po dodaniu jej do grafu nieskierowanego, który wcześniej nie zawierał cykli. Można rozwiązać to zadanie za pomocą wykrywania cykli metodą DFS, jednak najprostsze rozwiązanie wykorzystuje Union-Find (DSU): przetwarzaj krawędzie po kolei; jeśli oba końce są już połączone (należą do tej samej składowej), bieżąca krawędź tworzy cykl i jest odpowiedzią. DSU zapewnia złożoność O(alpha(n)) na operację — w praktyce 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]

Podsumowanie: strategie wykrywania cykli

Podsumowując zestaw metod wykrywania cykli: dla grafów nieskierowanych należy użyć DFS ze śledzeniem rodziców lub Union-Find. Dla grafów skierowanych należy użyć DFS z trzema stanami (biały/szary/czarny) albo sortowania topologicznego BFS algorytmu Kahna. Union-Find należy wybrać, gdy krawędzie są dodawane pojedynczo (przetwarzanie online). Algorytm Kahna jest właściwy, gdy potrzebne jest również uporządkowanie topologiczne. DFS z trzema stanami należy wybrać, gdy trzeba zidentyfikować konkretne węzły cyklu. Podczas rozmów rekrutacyjnych dotyczących wykrywania cykli należy zawsze wyraźnie rozróżniać grafy skierowane i nieskierowane.

# 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')

Szybki test

Sprawdź swoje zrozumienie zagadnień Data Structures & Algorithms — Coding Interview Prep z tej lekcji.

Podsumowanie lekcji

W tej lekcji nauczyli się Państwo: wykrywania cykli w grafach nieskierowanych za pomocą DFS ze śledzeniem rodziców, wykrywania cykli w grafach skierowanych za pomocą oznaczania trzema stanami: białym, szarym i czarnym, alternatywy BFS algorytmu Kahna dla grafów skierowanych, a także zastosowań obejmujących plan zajęć, nadmiarową krawędź i ostatecznie bezpieczne stany. Następnie przejdziemy do podstaw programowania dynamicznego.

Często zadawane pytania

Czy lekcja „Wykrywanie cykli w grafach skierowanych i nieskierowanych” jest bezpłatna?

Tak — pełny tekst „Wykrywanie cykli w grafach skierowanych i nieskierowanych” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu DSA Interview Prep, przejdź na CoddyKit PRO. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.

Co nauczysz się w „Wykrywanie cykli w grafach skierowanych i nieskierowanych”?

Wykryją Państwo cykle w grafach nieskierowanych, śledząc rodziców, oraz w grafach skierowanych, używając kolorowania DFS stanami biały/szary/czarny. Ćwiczysz DSA Interview Prep z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć DSA Interview Prep?

Nie wymagamy żadnego doświadczenia. DSA Interview Prep w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 4 z 4.

Ile czasu zajmuje lekcja „Wykrywanie cykli w grafach skierowanych i nieskierowanych”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji DSA Interview Prep?

Tak. Każda lekcja DSA Interview Prep zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. Reprezentacje grafów i przygotowanie przejść
  2. BFS: najkrótsza ścieżka i przejście poziomami
  3. DFS: spójne składowe i flood fill
  4. Wykrywanie cykli w grafach skierowanych i nieskierowanych
← Powrót do DSA Interview Prep