1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DFSとBFSの違いはスタックとキューの違いだけ

1
Posted at

DFS(深さ優先探索)とBFS(幅優先探索)を、別々のコードとして覚える必要はない。

探索の本体を共通にして、次に調べる頂点を入れておくデータ構造だけを差し替えると、どちらも実装できる。

スタックとキューは、取り出す順番が違う

どちらもデータを一時的に入れておく容器だが、取り出す順番が異なる。Pythonの標準ライブラリには、それぞれに対応するクラスがある。

データ構造 取り出す順番 Pythonのクラス
スタック 最後に入れたものから取り出す。後入れ先出し(LIFO) LifoQueue
キュー 最初に入れたものから取り出す。先入れ先出し(FIFO) Queue

探索では、この違いが「新しく見つけた頂点を先に調べるか」「先に見つけて待たせている頂点から調べるか」という違いになる。

データ構造だけで切り替えるPythonコード

今回は、次の木をAから探索する。辺は親から子へ向かうものとする。

        A
       / \
      B   C
     / \ / \
    D  E F  G

LifoQueueとQueueは、どちらもput()で追加し、get()で取り出し、empty()で空かどうかを確認できる。探索側は、容器の種類を判定する必要がない。

以下がコード全体。

from queue import LifoQueue, Queue


def traverse(
    graph: dict[str, list[str]],
    start: str,
    frontier_type: type[Queue[str]],
) -> list[str]:
    """開始頂点から到達可能な頂点を、訪問順に返す。"""
    frontier = frontier_type()
    frontier.put(start)

    visited: set[str] = set()
    order: list[str] = []

    while not frontier.empty():
        node = frontier.get()

        # 同じ頂点が複数回入っていても、処理するのは最初の1回だけ。
        if node in visited:
            continue

        # 「追加したとき」ではなく「取り出したとき」に訪問済みにする。
        visited.add(node)
        order.append(node)

        for neighbor in graph.get(node, []):
            if neighbor not in visited:
                frontier.put(neighbor)

    return order


if __name__ == "__main__":
    graph = {
        "A": ["B", "C"],
        "B": ["D", "E"],
        "C": ["F", "G"],
        "D": [],
        "E": [],
        "F": [],
        "G": [],
    }

    # 変えるのは、候補を管理するデータ構造だけ。
    print("DFS:", " -> ".join(traverse(graph, "A", LifoQueue)))
    print("BFS:", " -> ".join(traverse(graph, "A", Queue)))

実行結果は次のとおり。

DFS: A -> C -> G -> F -> B -> E -> D
BFS: A -> B -> C -> D -> E -> F -> G

変更したのは、第3引数のLifoQueueとQueueだけ。

traverse()の中には、DFSかBFSかを判定する条件分岐はない。隣接頂点を追加する順番も、訪問済みを管理する処理も共通。

なぜ、これだけで探索方法が変わるのか

スタックなら、新しい候補を優先する

Aを処理すると、B、Cの順に追加される。

スタックでは最後に入れたものから取り出すので、次はC。CからF、Gを追加すると、今度はGが先に取り出される。

容器の右側から取り出すものとして書くと、次のようになる。

Aを処理した後    [B, C]
Cを処理した後    [B, F, G]
Gを処理した後    [B, F]
Fを処理した後    [B]

Bは先に見つかっているが、C側の探索が優先された。

新しく見つけた候補が、待っている候補より先に処理される。この順番によって、枝を深くたどる動きになる。

なお、このコードで右側から探索するのは、隣接頂点を左から右へ追加しているため。「左から探索すること」がDFSの条件ではない。

キューなら、待っている候補を優先する

キューでは最初に入れたものから取り出すので、Aの次はB。

BからD、Eが見つかっても、すでに待っているCより後ろに並ぶ。

今度は容器の左側から取り出す。

Aを処理した後    [B, C]
Bを処理した後    [C, D, E]
Cを処理した後    [D, E, F, G]

新しく見つけた候補は、待っている候補を追い越さない。この順番によって、浅い層から順に探索が進む。

一般のグラフでも、BFSは始点からの最短距離、つまり必要な辺数が少ない頂点から訪問する。重みなしグラフの最短経路探索に使われるのは、この性質があるため。DFSには、最初にたどった経路が最短になる保証はない。

ただし、今回の関数が返すのは訪問順であり、経路そのものではない。

重要なのは、取り出したときに訪問済みにすること

共通化のポイントは、次の部分。

node = frontier.get()

if node in visited:
    continue

visited.add(node)

このコードでは、候補を容器に追加しただけでは、訪問済みにしない。実際に取り出したときに記録する。

BFS専用の実装では、容器に追加する時点で訪問済みにする書き方も一般的。しかし、その実装のキューだけをスタックに変えると、一般のグラフでは正しいDFSにならない場合がある。後から深い経路を通って到達するはずの頂点を、浅い頂点の隣接先として早々に確定してしまうため。

今回の実装では、「候補に入っている」と「訪問済み」を区別することで、この問題を避けている。

同じ頂点が候補として複数回追加されることはあるが、2回目以降に取り出されたものはcontinueで無視。循環があっても、同じ頂点の隣接先を何度も展開することはない。

計算量と、この共通実装のトレードオフ

頂点数をV、辺数をEとします。集合・辞書の操作を平均O(1)として考えると、本コードの時間計算量はDFS・BFSともにO(V + E)

各頂点の隣接リストを調べるのは1回だけ。候補の重複があっても、追加回数の合計は辺数に比例する範囲に収まる。

一方、このコードの補助メモリ上限は、どちらもO(V + E)。 訪問済み集合と結果にO(V)、重複を含む候補の保持に最大O(E)を使う。取り出し時に訪問済みを判定する実装では、候補の重複によって必要なメモリが増える点に注意が必要。

また、この例は1スレッドで動かしている。QueueとLifoQueueは同期機構を備えていますが、empty()による確認とget()を組み合わせた処理を、そのまま複数スレッドで共有してよいわけではない。

まとめ

今回の探索関数が行うのは、候補を取り出し、未訪問なら処理し、隣接する頂点を候補に追加することだけ。

探索順序を決めているのは、関数内の条件分岐ではなく、渡されたデータ構造。

新しい候補を優先するスタックならDFS。待っている候補を優先するキューならBFS。

2つのコードを丸暗記するより、1つの探索処理と、2つの取り出し方として理解すると、違いが見通しやすくなる。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?