DSA Interview Prep · Lekcja

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

Lekcja 3 z 413 kroki

Nadmiarowa krawędź i wykrywanie cykli to bezpłatna lekcja DSA 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 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.

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]]))        # False

Wykrywanie 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]]))  # False

Redundant 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 removal

Analiza 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.

Bezpłatny start

Ucz się Python dzięki korepetycjom AI — za darmo

Pisz i uruchamiaj kod w przeglądarce, otrzymuj natychmiastową pomoc od korepetytora AI dostępnego 24/7 i kontynuuj naukę w sieci lub w aplikacji.

Kursy
30
Lekcje
120

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 DSA Interview Prep, przejdź na CoddyKit PRO. Kurs DSA 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 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 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 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. DSU z kompresją ścieżek
  2. Łączenie według rangi i ograniczenie odwrotną funkcją Ackermanna
  3. Nadmiarowa krawędź i wykrywanie cykli
  4. Scalanie kont i spójne składowe
← Powrót do DSA Interview Prep