Nadmiarowa krawędź i wykrywanie cykli
Wykrywać krawędź tworzącą cykl w grafie nieskierowanym, wykonując union dla każdej krawędzi i sprawdzając, czy dwa wierzchołki są już połączone
Nadmiarowa krawędź i wykrywanie cykli to bezpłatna lekcja Coding Interview Prep na CoddyKit. To lekcja 3 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 Coding Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs Coding Interview Prep zawiera 4 lekcji w sumie.
Czym jest Redundant Connection?
Problem Redundant Connection (LeetCode 684) daje Państwu drzewo złożone z n węzłów oraz jedną dodatkową krawędź, która tworzy dokładnie jeden cykl. Zadanie polega na znalezieniu krawędzi, której usunięcie przywróci drzewo. Jeśli istnieje więcej niż jedna poprawna odpowiedź, należy zwrócić ostatnią z nich na liście wejściowej.
Drzewo z n węzłami ma dokładnie n-1 krawędzi, jest spójne i nie zawiera cykli. Dodanie jednej kolejnej krawędzi tworzy dokładnie jeden cykl. Dodana (nadmiarowa) krawędź łączy dwa węzły, które już należały do tej samej składowej — jest to klasyczny przypadek wykrywania cyklu za pomocą DSU.
# Example
# n=5, edges = [[1,2],[1,3],[2,3],[2,4],[3,5]]
# Adding edge [2,3] creates cycle 1-2-3-1
# So [2,3] is the redundant connection
# Key insight: process edges one by one with DSU
# The FIRST edge where both endpoints are already connected is the redundant one
print('Tree property: n nodes, n-1 edges, no cycles')
print('Adding 1 edge: n nodes, n edges, exactly 1 cycle')
print('DSU approach: find the edge that connects already-connected nodes')Wykrywanie cykli za pomocą DSU
DSU w naturalny sposób wykrywa cykle: przed dodaniem krawędzi (u, v) należy sprawdzić, czy find(u) == find(v). Jeśli oba węzły mają ten sam korzeń, są już połączone — dodanie tej krawędzi tworzy cykl. Jest to nadmiarowa krawędź.
To podejście działa dla grafów nieskierowanych. Dla każdej krawędzi albo pomyślnie łączymy dwie składowe (cykl jeszcze nie powstał), albo wykrywamy, że oba końce należą już do tej samej składowej (cykl został znaleziony). Złożoność czasowa wynosi O(n × alpha(n)), czyli niemal O(n).
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1)) # 1-indexed
rank = [0] * (n + 1)
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False # same component => cycle found
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
edges = [[1,2],[1,3],[2,3],[2,4],[3,5]]
print(find_redundant_connection(edges)) # [2, 3]Prześledzenie działania algorytmu
Prześledźmy krok po kroku przykład [[1,2],[1,3],[2,3]]. Początkowo każdy węzeł jest osobną składową: {1}, {2}, {3}.
- Krawędź [1,2]: find(1)=1, find(2)=2, różne — wykonujemy union. Składowe: {1,2}, {3}
- Krawędź [1,3]: find(1)=root, find(3)=3, różne — wykonujemy union. Składowe: {1,2,3}
- Krawędź [2,3]: find(2)=root, find(3)=root — ten sam korzeń! Wykryto cykl. Zwracamy [2,3].
Algorytm przetwarza krawędzie w kolejności i zwraca pierwszą krawędź, która domyka cykl. Ponieważ zadanie gwarantuje tylko jedną dodatkową krawędź, jest to zawsze właściwa nadmiarowa krawędź.
def find_redundant_trace(edges):
parent = list(range(len(edges) + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for u, v in edges:
pu, pv = find(u), find(v)
print(f'Edge ({u},{v}): find({u})={pu}, find({v})={pv}', end=' => ')
if pu == pv:
print('CYCLE DETECTED!')
return [u, v]
parent[pv] = pu
print('merged')
return []
result = find_redundant_trace([[1,2],[1,3],[2,3]])
print('Redundant edge:', result)Wykrywanie cykli w grafach nieskierowanych za pomocą DFS
Alternatywą dla DSU w wykrywaniu cykli w grafach nieskierowanych jest DFS ze śledzeniem rodzica. Podczas DFS, jeśli dotrzemy do węzła, który został już odwiedzony i nie jest bezpośrednim rodzicem bieżącego węzła, znaleźliśmy krawędź wsteczną — oznacza to obecność cyklu.
Podejście oparte na DFS wymaga jednak czasu O(V + E) i informuje, czy cykl istnieje, ale nie wskazuje łatwo, która konkretnie krawędź jest nadmiarowa. DSU jest preferowane w zadaniach wymagających wskazania konkretnej nadmiarowej krawędzi, ponieważ znajdujemy ją naturalnie w momencie nieudanego union.
from collections import defaultdict
def has_cycle_dfs(n, edges):
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 == parent:
continue # skip the edge we came from
if nb in visited:
return True # back edge => cycle
if dfs(nb, node):
return True
return False
for node in range(1, n + 1):
if node not in visited:
if dfs(node, -1):
return True
return False
print(has_cycle_dfs(3, [[1,2],[1,3],[2,3]])) # True
print(has_cycle_dfs(3, [[1,2],[1,3]])) # FalseWykrywanie cykli w grafach skierowanych
W przypadku grafów skierowanych wykrywanie cykli za pomocą DSU nie działa bezpośrednio, ponieważ krawędzie mają kierunek. Zamiast tego należy użyć DFS z oznaczaniem trzema kolorami: biały oznacza węzeł nieodwiedzony, szary — węzeł na bieżącej ścieżce DFS, a czarny — węzeł w pełni przetworzony. Krawędź wsteczna prowadząca do szarego węzła wskazuje cykl.
W grafie nieskierowanym każda krawędź wsteczna oznacza cykl. W grafie skierowanym krawędź poprzeczna prowadząca do czarnego węzła nie oznacza cyklu — cykle wskazują wyłącznie krawędzie wsteczne prowadzące do szarych węzłów. To rozróżnienie ma kluczowe znaczenie i jest sprawdzane w zadaniach typu course-schedule.
def has_cycle_directed(n, edges):
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
# 0=white(unvisited), 1=grey(in stack), 2=black(done)
color = [0] * (n + 1)
def dfs(node):
color[node] = 1 # grey: currently visiting
for nb in graph[node]:
if color[nb] == 1:
return True # back edge to grey node => cycle
if color[nb] == 0:
if dfs(nb):
return True
color[node] = 2 # black: fully processed
return False
for node in range(1, n + 1):
if color[node] == 0:
if dfs(node):
return True
return False
from collections import defaultdict
print(has_cycle_directed(3, [[1,2],[2,3],[3,1]])) # True: 1->2->3->1
print(has_cycle_directed(3, [[1,2],[1,3],[2,3]])) # FalseRedundant Connection II: wariant dla grafów skierowanych
LeetCode 685 rozszerza problem na grafy skierowane, w których każdy węzeł ma dokładnie jednego rodzica (tworzące ukorzenione drzewo z jedną dodatkową krawędzią). Występują dwa przypadki: węzeł ma dwóch rodziców (stopień wejściowy równy 2) albo istnieje cykl, ale żaden węzeł nie ma dwóch rodziców.
Rozwiązanie najpierw wyszukuje węzły o stopniu wejściowym równym 2. Jeśli taki węzeł zostanie znaleziony, jedna z dwóch wchodzących do niego krawędzi musi być odpowiedzią. Następnie wykrywanie cykli za pomocą DSU ustala, którą z dwóch kandydujących krawędzi należy usunąć. To dwuetapowe podejście poprawnie obsługuje wszystkie przypadki.
def find_redundant_directed(edges):
n = len(edges)
parent_map = {} # node -> its parent in the input
candidate1 = candidate2 = None
for u, v in edges:
if v in parent_map: # v already has a parent
candidate1 = [parent_map[v], v] # earlier edge
candidate2 = [u, v] # later edge
else:
parent_map[v] = u
# DSU cycle detection, skipping candidate2 if it exists
dsu = list(range(n + 1))
def find(x):
while dsu[x] != x: dsu[x] = dsu[dsu[x]]; x = dsu[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py: return False
dsu[px] = py; return True
for u, v in edges:
if candidate2 and [u, v] == candidate2: continue # skip candidate2
if not union(u, v): # cycle found without candidate2
return candidate1 if candidate1 else [u, v]
return candidate2 # no cycle when excluding candidate2 => candidate2 is redundant
print(find_redundant_directed([[1,2],[1,3],[2,3]])) # [2,3]
print(find_redundant_directed([[1,2],[2,3],[3,4],[4,1],[1,5]])) # [4,1]Poprawność grafu po usunięciu krawędzi
Po wskazaniu nadmiarowej krawędzi możemy zweryfikować wynik, sprawdzając, czy po jej usunięciu pozostaje prawidłowe drzewo: dokładnie n-1 krawędzi, wszystkie węzły połączone i brak cykli. W kontekście zadania rekrutacyjnego DSU w naturalny sposób gwarantuje ten warunek — jeśli zwrócimy krawędź, dla której union zakończył się niepowodzeniem, po jej usunięciu pozostanie dokładnie n-1 krawędzi, które zostały pomyślnie połączone i tworzą drzewo rozpinające.
To właśnie dlatego DSU jest tak przejrzystym rozwiązaniem tego problemu: udane operacje union stopniowo budują drzewo, a nieudana operacja wskazuje jedyną krawędź, która do niego nie należy.
def verify_tree(n, edges, removed_edge):
parent = list(range(n + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
components = n
for u, v in edges:
if [u, v] == removed_edge:
continue # skip the removed edge
pu, pv = find(u), find(v)
if pu == pv:
print('CYCLE DETECTED after removal! Wrong answer.')
return False
parent[pv] = pu
components -= 1
if components != 1:
print(f'Graph not connected ({components} components). Wrong answer.')
return False
print('Valid tree after removing edge:', removed_edge)
return True
edges = [[1,2],[1,3],[2,3]]
verify_tree(3, edges, [2,3])
verify_tree(3, edges, [1,2]) # wrong removalAnaliza złożoności czasowej i pamięciowej
Rozwiązanie problemu Redundant Connection oparte na DSU przetwarza każdą z n krawędzi dokładnie raz, a każda operacja union/find ma zamortyzowany koszt O(alpha(n)). Całkowita złożoność czasowa wynosi: O(n × alpha(n)), czyli w praktyce O(n).
Złożoność pamięciowa wynosi O(n) ze względu na tablice parent i rank. Jest to rozwiązanie optymalne — trzeba co najmniej odczytać wszystkie n krawędzi i przechowywać pewien stan dla każdego węzła. Dla porównania naiwne podejście, uruchamiające DFS po każdym dodaniu krawędzi, ma złożoność czasową O(n²) i pamięciową O(n + E).
# Summary of complexities
complexity = {
'Naive (DFS after each edge)': {'time': 'O(n^2)', 'space': 'O(n)'},
'DSU (path compression + rank)': {'time': 'O(n * alpha(n))', 'space': 'O(n)'},
'Sorting + DSU (Kruskal style)': {'time': 'O(n log n)', 'space': 'O(n)'},
}
for approach, costs in complexity.items():
print(f'{approach}:')
print(f' Time: {costs["time"]}')
print(f' Space: {costs["space"]}')
print()
print('alpha(n) <= 4 for all practical n, so DSU is effectively O(n).')Przypadek brzegowy: pętla własna
Krawędź będąca pętlą własną [u, u] natychmiast tworzy cykl, ponieważ oba jej końce są tym samym węzłem. W DSU find(u) == find(u) jest zawsze prawdziwe, więc union kończy się niepowodzeniem, a [u, u] zostaje zwrócona jako nadmiarowa krawędź.
Większość ograniczeń w zadaniach gwarantuje brak pętli własnych, ale solidny kod powinien je obsługiwać. Implementacja DSU radzi sobie z nimi naturalnie, bez żadnego przypadku specjalnego — sprawdzenie cyklu if find(u) == find(v) wykrywa go, zanim zostanie podjęta próba wykonania union. Zawsze należy weryfikować rozwiązanie na danych obejmujących przypadki brzegowe, takich jak pętle w grafie z jednym węzłem oraz dane wejściowe o minimalnym rozmiarze.
def find_redundant_robust(edges):
n = len(edges)
parent = list(range(n + 1))
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
for u, v in edges:
pu, pv = find(u), find(v)
if pu == pv:
return [u, v] # handles self-loops too: u==v => pu==pv always
parent[pv] = pu
return []
# Self-loop test
print(find_redundant_robust([[1,2],[2,2]])) # [2,2] self-loop
# Minimum tree test
print(find_redundant_robust([[1,2],[2,3],[1,3]])) # [1,3]
# Standard test
print(find_redundant_robust([[1,2],[1,3],[2,3],[2,4],[3,5]])) # [2,3]Uogólnienie wykrywania cykli na różne algorytmy
Wiele algorytmów wykrywa cykle, a każdy z nich pasuje do innego scenariusza:
- DSU: grafy nieskierowane, napływ krawędzi online, O(alpha(n)) na krawędź — najlepszy do zliczania cykli lub znajdowania nadmiarowej krawędzi
- DFS ze śledzeniem rodzica: grafy nieskierowane, wszystkie krawędzie znane od początku, O(V+E) — najlepszy, gdy potrzebują Państwo znaleźć ścieżkę cyklu
- DFS z trzema kolorami: grafy skierowane, wykrywanie krawędzi wstecznych, O(V+E) — najlepszy w zadaniach typu course-schedule i przy sortowaniu topologicznym
- Sortowanie topologiczne (algorytm Kahna): grafy skierowane, wykrywa cykl za pomocą pozostałych węzłów o niezerowym stopniu wejściowym — najlepsze, gdy potrzebne jest również uporządkowanie
# When to use which cycle-detection method:
# Problem type => preferred algorithm
problems = [
('Redundant Connection (undirected)', 'DSU'),
('Course Schedule (directed)', 'DFS three-color or Kahn topological sort'),
('Detect cycle in undirected graph', 'DFS with parent tracking or DSU'),
('Find cycle members in directed graph', 'DFS three-color + backtrack'),
('Online graph edges with cycle check', 'DSU'),
('Minimum spanning tree validity', 'DSU (Kruskal)'),
]
for problem, solution in problems:
print(f'{problem}\n => {solution}\n')Pełne rozwiązanie z uwzględnieniem przypadków brzegowych
Oto gotowe do użycia produkcyjnego rozwiązanie problemu Redundant Connection, które obsługuje wszystkie przypadki brzegowe: węzły indeksowane od 1, dokładnie jedną nadmiarową krawędź oraz gwarancję, że jej usunięcie pozostawia prawidłowe drzewo. Wykorzystuje ono optymalny DSU ze skracaniem ścieżki o połowę i łączeniem według rangi.
Po wysłaniu rozwiązania proszę spróbować odpowiedzieć na pytanie uzupełniające: co się stanie, jeśli graf będzie mógł zawierać wiele nadmiarowych krawędzi? Trzeba będzie śledzić wszystkie krawędzie domykające cykl i zwrócić ostatnią z nich na liście wejściowej — ta sama strategia zachłanna nadal zadziała, ponieważ DSU przetwarza krawędzie w kolejności.
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1))
rank = [0] * (n + 1)
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False
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]
return [] # should never reach here given valid input
test_cases = [
[[1,2],[1,3],[2,3]],
[[1,2],[2,3],[3,4],[1,4],[1,5]],
[[1,2],[1,3],[2,3],[2,4],[3,5]],
]
for tc in test_cases:
print(find_redundant_connection(tc))Szybki sprawdzian
Sprawdź swoją znajomość pojęć z kursu Data Structures & Algorithms — Coding Interview Prep omawianych w tej lekcji.
Podsumowanie lekcji
W tej lekcji nauczyli się Państwo, że: nadmiarowa krawędź łączy dwa węzły, które są już połączone w grafie nieskierowanym, DSU wykrywa ją, sprawdzając find(u) == find(v) przed wykonaniem union i zwracając tę krawędź, a w grafach skierowanych do wykrywania cykli zamiast DSU trzeba użyć DFS z trzema kolorami albo algorytmu Kahna. Następnie zastosujemy DSU do problemu Accounts Merge, w którym węzłami są adresy e-mail, a wspólne adresy między kontami powodują operacje union.
Często zadawane pytania
Czy lekcja „Nadmiarowa krawędź i wykrywanie cykli” jest bezpłatna?
Tak — pełny tekst „Nadmiarowa krawędź i wykrywanie cykli” 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 Coding Interview Prep, przejdź na CoddyKit PRO. Kurs Coding Interview Prep zawiera 4 lekcji w sumie.
Co nauczysz się w „Nadmiarowa krawędź i wykrywanie cykli”?
Wykrywać krawędź tworzącą cykl w grafie nieskierowanym, wykonując union dla każdej krawędzi i sprawdzając, czy dwa wierzchołki są już połączone Ćwiczysz Coding 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ąć Coding Interview Prep?
Nie wymagamy żadnego doświadczenia. Coding 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 3 z 4.
Ile czasu zajmuje lekcja „Nadmiarowa krawędź i wykrywanie cykli”?
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 Coding Interview Prep?
Tak. Każda lekcja Coding 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
- DSU z kompresją ścieżek
- Łączenie według rangi i ograniczenie odwrotną funkcją Ackermanna
- Nadmiarowa krawędź i wykrywanie cykli
- Scalanie kont i spójne składowe