0Pricing
DSA Interview Prep · レッスン

Redundant Connectionと閉路検出

各辺に対してunionを適用し、2つの頂点がすでに連結されているか確認することで、無向グラフに閉路を作る辺を検出します。

「Redundant Connectionと閉路検出」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン3/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

冗長な接続とは

Redundant Connection 問題(LeetCode 684)では、n 個のノードからなる木と、ちょうど 1 本の余分な辺が与えられます。この余分な辺によって、ちょうど 1 つの閉路が形成されます。辺を 1 本削除して木に戻すとき、その削除すべき辺を見つけてください。複数の答えがある場合は、入力リストの最後に現れる辺を返します。

n 個のノードからなる木には、ちょうど n-1 本の辺があり、連結していて閉路はありません。そこにもう 1 本辺を追加すると、ちょうど 1 つの閉路ができます。追加された(冗長な)辺は、すでに同じ連結成分に属していた 2 つのノードを結びます。これは 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')

DSU による閉路検出

DSU は自然に閉路を検出できます。辺 (u, v) を追加する前に、find(u) == find(v) を確認します。同じルートを持つ場合、2 つのノードはすでに連結されているため、この辺を追加すると閉路ができます。これが冗長な辺です。

この方法は無向グラフで機能します。各辺について、2 つの連結成分の union に成功する(まだ閉路はない)か、両端点がすでに同じ連結成分に属していることを検出する(閉路が見つかる)かのどちらかになります。時間計算量は O(n × alpha(n)) で、ほぼ 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]

アルゴリズムを順に追跡する

[[1,2],[1,3],[2,3]] を例に、手順を追って確認しましょう。最初は各ノードがそれぞれ独立した連結成分です: {1}, {2}, {3}。

  • 辺 [1,2]: find(1)=1、find(2)=2 で異なるため、union します。連結成分: {1,2}, {3}
  • 辺 [1,3]: find(1)=root、find(3)=3 で異なるため、union します。連結成分: {1,2,3}
  • 辺 [2,3]: find(2)=root、find(3)=root で同じルートです。閉路を検出しました。[2,3] を返します。

アルゴリズムは辺を順番に処理し、閉路を完成させる最初の辺を返します。余分な辺は 1 本だけと保証されているため、これが常に正しい冗長な辺になります。

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)

DFS による無向グラフの閉路検出

無向グラフの閉路検出では、DSU の代わりに親を追跡する DFSを使う方法もあります。DFS 中に、すでに訪問済みで、かつ現在のノードの直接の親ではないノードに到達した場合、後退辺が見つかったことになり、閉路を示します。

ただし、DFS の方法は O(V + E) の時間が必要で、閉路が存在するかどうかは返せますが、どの辺が冗長なのかを簡単に特定することはできません。特定の冗長な辺を見つける問題では、union に失敗した時点で自然にその辺を特定できるため、DSU が適しています。

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

有向グラフの閉路検出

有向グラフでは辺に向きがあるため、DSU による閉路検出をそのまま使うことはできません。代わりに、3 色による DFSを使います。白は未訪問、灰色は現在の DFS パス上、黒は処理完了を表します。灰色のノードへ戻る後退辺がある場合、閉路が存在します。

無向グラフでは、どの後退辺も閉路を意味します。一方、有向グラフでは、黒いノードへの横断辺は閉路ではありません。閉路を示すのは灰色のノードへの後退辺だけです。この違いは重要で、コーススケジュールの問題で問われます。

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: 有向グラフ版

LeetCode 685 では、各ノードがちょうど 1 つの親を持つ有向グラフ(余分な辺が 1 本ある根付き木)へと問題を拡張しています。考えられるケースは 2 つあります。あるノードに 2 つの親がある場合(入次数が 2 の場合)と、どのノードにも 2 つの親がない状態で閉路がある場合です。

解法ではまず、入次数が 2 のノードを確認します。見つかった場合、そのノードへ入る 2 本の辺のどちらか一方が答えです。次に DSU による閉路検出を使い、候補となる 2 本の辺のうち、どちらを削除すべきかを判定します。この 2 段階の方法ですべてのケースを正しく処理できます。

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]

辺を削除した後のグラフの妥当性

冗長な辺を特定した後、その辺を削除しても有効な木が残ることを確認できます。つまり、辺がちょうど n-1 本あり、すべてのノードが連結していて、閉路がないことを確認します。面接問題の条件では、DSU によってこれが自然に保証されます。union に失敗した辺を返すと、それを削除した後に残るのは、union に成功したちょうど n-1 本の辺であり、それらが全域木を形成するためです。

この保証があるため、この問題では DSU をすっきり使えます。成功した union が木を段階的に構築し、失敗した union によって木に属さない 1 本の辺を特定できるのです。

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

時間計算量と空間計算量の分析

DSU を使った冗長な接続の解法では、n 本の辺をそれぞれ 1 回だけ処理し、各 union/find 操作の償却計算量は O(alpha(n)) です。全体の時間計算量は O(n × alpha(n)) で、実質的に O(n) です。

空間計算量は、parent 配列と rank 配列のための O(n) です。これは最適です。少なくとも n 本の辺をすべて読み込み、各ノードについて何らかの状態を保持する必要があるためです。各辺の追加後に DFS を実行する単純な方法と比較すると、そちらは時間計算量が O(n²)、空間計算量が 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).')

エッジケース: 自己ループ

自己ループの辺 [u, u] は、両端点が同じノードであるため、直ちに閉路を作ります。DSU では find(u) == find(u) が常に真になるため、union はすぐに失敗し、[u, u] が冗長な辺として返されます。

ほとんどの問題では自己ループがないと制約で保証されていますが、堅牢なコードではこれにも対応するべきです。DSU の実装なら特別な場合分けをしなくても自然に処理できます。閉路判定の if find(u) == find(v) が、union を試みる前に自己ループを検出するためです。1 ノードのループや最小サイズの入力など、エッジケースの入力でも必ず確認してください。

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]

アルゴリズムごとの閉路検出の一般化

閉路を検出するアルゴリズムはいくつもあり、それぞれ適した状況が異なります。

  • DSU: 無向グラフ、辺がオンラインで到着、辺ごとに O(alpha(n)) — 個数のカウントや冗長な辺の検出に最適
  • 親を追跡する DFS: 無向グラフ、すべての辺が最初から判明、O(V+E) — 閉路の経路が必要な場合に最適
  • 3 色による DFS: 有向グラフ、後退辺の検出、O(V+E) — コーススケジュールやトポロジカルソートに最適
  • トポロジカルソート(Kahn's): 有向グラフ、残った入次数が 0 でないノードによって閉路を検出 — 順序も必要な場合に最適
# 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')

エッジケースに対応した完全な解法

ここでは、1 始まりのノード番号、冗長な辺がちょうど 1 本であること、そしてその辺を削除すると有効な木が残るという保証など、すべてのエッジケースに対応した Redundant Connection の実用的な解法を示します。パスの半圧縮とランクによる union を使った最適な DSU を採用しています。

提出した後は、次の発展問題にも挑戦してみてください。グラフに複数の冗長な辺が存在する場合はどうなるでしょうか。その場合は、閉路を完成させるすべての辺を記録し、入力の最後に現れる辺を返す必要があります。DSU は辺を順番に処理するため、同じ貪欲戦略がそのまま機能します。

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

理解度チェック

このレッスンで扱った Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、冗長な接続とは、無向グラフですでに連結している 2 つのノードを結ぶ辺であること、DSU は union の前に find(u) == find(v) を確認し、その辺を返すことで冗長な接続を検出すること、そして有向グラフの閉路検出には DSU ではなく、3 色による DFS または Kahn's algorithm が必要であることを学びました。次は DSU を accounts-merge 問題に適用します。この問題ではメールアドレスがノードとなり、アカウント間で共有されるメールアドレスが union を発生させます。

よくある質問

「Redundant Connectionと閉路検出」レッスンは無料ですか?

はい。「Redundant Connectionと閉路検出」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「Redundant Connectionと閉路検出」で何を学びますか?

各辺に対してunionを適用し、2つの頂点がすでに連結されているか確認することで、無向グラフに閉路を作る辺を検出します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン3/4です。

「Redundant Connectionと閉路検出」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 経路圧縮付きDSU
  2. ランクによるUnionと逆アッカーマン境界
  3. Redundant Connectionと閉路検出
  4. Accounts Mergeと連結成分
← DSA Interview Prepに戻る