0Pricing
Coding Interview Prep · レッスン

有向・無向グラフの循環検出

無向グラフでは親を追跡して、有向グラフではDFSの色付け(白・灰・黒の3状態の訪問管理)を使って循環を検出します。

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

サイクル検出が重要な理由

グラフにおけるサイクルとは、同じノードから始まり、そのノードに戻ってくる経路です。サイクル検出は多くのアルゴリズムで重要です。サイクルを含むグラフではトポロジカルソートが失敗し、依存関係の解決では循環依存を検出する必要があり、OSのスケジューリングにおけるデッドロック検出ではリソース割り当てグラフのサイクルを見つける必要があります。無向グラフと有向グラフではアプローチが異なり、それぞれ根本的に異なるアルゴリズムが必要です。

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

DFSによる無向グラフのサイクル検出

無向グラフでは、DFSが現在の経路上にある(単に訪問済みであるだけではない)ノードを訪問すると、サイクルが存在します。難しい点は、すべての辺が両方向に現れることです。そのため、子ノードを訪問すると、その隣接ノードの一覧には現在のノード(親)も含まれます。親への辺をサイクルと誤判定しないように、各ノードの親を追跡する必要があります。訪問済みで、かつ自分の親ではないノードに遭遇した場合、サイクルが見つかったことになります。

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

BFSによる無向グラフのサイクル検出

無向グラフでBFSによってサイクルを検出する場合も、訪問済みの各ノードの親を追跡します。あるノードの隣接ノードを処理するとき、隣接ノードがすでに訪問済みで、現在のノードの親でなければ、サイクルが存在します。親を格納するには辞書を使用します。このO(V + E)の方法は再帰上限の問題を避けられるため、大規模なグラフに対する反復処理の方法として推奨されます。

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

有向グラフのサイクル:親の追跡では不十分な理由

有向グラフでは、親を追跡するだけでは不十分です。A→CとB→Cを考えてみましょう。ノードCには2つの「親」がありますが、サイクルはありません。正しいアプローチでは3状態の色分けを使います。白(未訪問)、灰(現在のDFS経路/スタック内)、黒(完全に処理済み)です。DFS中に灰のノードに遭遇した場合、サイクルが存在します。これは、現在の経路にある祖先への後退辺が見つかったことを意味します。

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

3状態DFSによる有向グラフのサイクル検出

値として0(白/未訪問)、1(灰/スタック内)、2(黒/完了)を持つ配列state[]を使用します。DFSを開始し、ノードに入るときに灰、終了するときに黒としてマークします。DFSが灰のノードに到達した場合、後退辺が見つかったことになり、サイクルが存在します。黒のノードに到達した場合、その経路はすでに完全に探索されておりサイクルがないため、スキップします。

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

コーススケジュール:DAG内のサイクル

コーススケジュール(LeetCode #207)は、前提条件が与えられたときにすべてのコースを修了できるかを問う問題です。コースをノード、前提条件を有向辺としてモデル化します。すべてのコースを修了できるのは、グラフがDAG(サイクルのない有向非巡回グラフ)である場合、かつその場合に限ります。3状態DFSによるサイクル検出を使い、サイクルが見つかったらFalse、見つからなければ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

カーンのアルゴリズム(BFS)によるサイクル検出

有向グラフのサイクル検出には、カーンのBFSトポロジカルソートを使う別の方法もあります。すべてのノードの入次数を数えます。入次数が0のノードをキューに入れます。各ノードを処理し、その隣接ノードの入次数を減らして、0になったノードをキューに追加します。処理したノード数がVと等しければサイクルはなく、そうでなければサイクルが存在します(未処理のノードがサイクルを形成しています)。このO(V + E)の方法は直感的で、3状態DFSよりも覚えやすい方法です。

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

サイクルの特定:サイクルノードの収集

サイクルの存在を検出するだけでなく、どのノードがサイクルの一部であるかを特定する必要がある場合もあります。3状態DFSで後退辺が見つかったら、コールスタック(またはパススタック)をたどって、祖先から現在のノードまでのすべてのノードを収集します。state配列と並行してパススタックを管理すると、現在のDFS経路を取得でき、O(cycle_length)でサイクルを再構成できます。

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]

最終的に安全なノードを見つける

最終的に安全なノードを見つける(LeetCode #802)は、サイクルに陥ることなく最終ノード(出ていく辺を持たないノード)へ最終的に到達するノードを求める問題です。あるノードから始まるすべての経路が最終ノードへ到達する場合、そのノードは「安全」です。3状態DFSを使います。黒(サイクルを検出せずに完全に処理済み)のノードは安全です。サイクルの一部であるノードやサイクルへつながるノードは安全ではありません。

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]

無向グラフの余分な辺

余分な辺(LeetCode #684)は、非巡回の無向グラフに辺を追加したときにサイクルを作る辺を見つける問題です。DFSによるサイクル検出でも解けますが、最も簡潔な解法はUnion-Find(DSU)を使う方法です。辺を1本ずつ処理し、両端点がすでに接続されている(同じ連結成分に属している)場合、現在の辺がサイクルを作るため、それが答えです。DSUでは1回の操作をO(alpha(n))で実行でき、実質的に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]

まとめ:サイクル検出の戦略

サイクル検出のツールキットをまとめます。無向グラフでは、親を追跡するDFSまたはUnion-Findを使います。有向グラフでは、3状態DFS(白/灰/黒)またはカーンのBFSトポロジカルソートを使います。辺を1本ずつ追加するオンライン処理ではUnion-Findを選びます。トポロジカル順序も必要な場合はカーンのアルゴリズムを選びます。特定のサイクルノードを特定する必要がある場合は、3状態DFSを選びます。面接でサイクル検出について説明するときは、必ず有向グラフと無向グラフの違いを明示してください。

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

理解度チェック

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

レッスンのまとめ

このレッスンでは、親の追跡を伴うDFSによる無向グラフのサイクル検出、白/灰/黒の3状態の色分けによる有向グラフのサイクル検出、有向グラフに対するカーンのBFSによる代替手法、さらにコーススケジュール、余分な辺、最終的に安全なノードといった応用を学びました。次は、動的計画法の基礎に進みます。

よくある質問

「有向・無向グラフの循環検出」レッスンは無料ですか?

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

「有向・無向グラフの循環検出」で何を学びますか?

無向グラフでは親を追跡して、有向グラフではDFSの色付け(白・灰・黒の3状態の訪問管理)を使って循環を検出します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

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

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

「有向・無向グラフの循環検出」レッスンにはどのくらい時間がかかりますか?

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

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

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

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

  1. グラフ表現と走査の準備
  2. BFS:最短経路とレベル走査
  3. DFS:連結成分とflood fill
  4. 有向・無向グラフの循環検出
← Coding Interview Prepに戻る