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)])) # FalseWykrywanie 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)])) # TrueCykl 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)])) # FalsePlan 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 dependencyWykrywanie 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)])) # FalseZnajdowanie 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
- Reprezentacje grafów i przygotowanie przejść
- BFS: najkrótsza ścieżka i przejście poziomami
- DFS: spójne składowe i flood fill
- Wykrywanie cykli w grafach skierowanych i nieskierowanych