フリーキーズ | 独学プログラミング

DFS(深さ優先探索)とBFS(幅優先探索)比較!グラフ探索アルゴリズムの基礎と使い分け

リンドくん

リンドくん

たなべ先生、DFSとBFSって名前をよく聞くんですけど、何が違うんですか?どっちも探索するアルゴリズムですよね?

たなべ

たなべ

確かにどちらもグラフやツリーを探索するアルゴリズムなんだけど、探索の仕方が全く違うんだよ。
例えるなら、迷路を探索するときに「とにかく奥まで進む派」と「周りを丁寧に見る派」の違いみたいなものかな。

プログラミングを学んでいると、必ず出会うのがDFS(深さ優先探索)BFS(幅優先探索)です。
この2つのアルゴリズムは、ゲーム開発やルート探索、AIの実装など、様々な場面で活躍します。

しかし、「どちらも探索するアルゴリズム」という点では同じなのに、なぜ2つも存在するのでしょうか?
それは、それぞれが得意とする問題が異なるからなのです。

本記事では、DFSとBFSの基本的な仕組みから実際のコード例、そして実務での使い分けまで、プログラミング初心者の方でも理解できるよう丁寧に解説していきます。

オンラインコミュニティ運営しています

HackATAは、IT技術を習得する人のために広く開かれたオンラインコミュニティです。 現在、無料コミュニティとして開放していますので、ご気軽に参加してください。

✓ 再立ち上げ

✓ コミュニティの方向性について意見募集中

HackATA公式Webサイト

DFSとBFSの基本概念を理解しよう

リンドくん

リンドくん

そもそも「深さ優先」と「幅優先」って、具体的にどう違うんですか?

たなべ

たなべ

わかりやすく説明すると、DFSは「一本道を突き進む」探索方法で、BFSは「同心円状に広がる」探索方法なんだ。迷路で例えると分かりやすいよ。

DFS(深さ優先探索)とは

DFS(Depth-First Search)は、その名の通り「深さ」を優先する探索アルゴリズムです。スタート地点から始めて、行ける限り奥まで進み、行き止まったら一つ前に戻って別の道を試すという動作を繰り返します。

迷路を探索する場面を想像してみてください。DFSを使うと、まず右の道を選び、その先でまた右を選び...と、とにかく一つの道を突き詰めます。行き止まりになったら、分岐点まで戻って別の道を試します。

DFSの主な特徴は以下の通りです。

  • スタック構造(後入れ先出し)を使用する
  • 再帰的な実装が自然でシンプル
  • メモリ使用量が少ない(深さに比例)
  • 全ての経路を探索するのに適している

BFS(幅優先探索)とは

一方、BFS(Breadth-First Search)は「幅」を優先する探索アルゴリズムです。スタート地点から同じ距離にあるノードを順番に調べていくという特徴があります。

同じ迷路の例で考えると、BFSではまずスタート地点から1歩で行ける全ての場所を調べます。次に、それらの場所から1歩で行ける全ての場所を調べる...という風に、波紋が広がるように探索していきます。

BFSの主な特徴は以下の通りです。

  • キュー構造(先入れ先出し)を使用する
  • 最短経路を見つけるのに適している
  • メモリ使用量が多い(幅に比例)
  • レベル順(階層順)の処理に最適

2つのアルゴリズムの根本的な違い

DFSとBFSの最も大きな違いは、使用するデータ構造にあります。
DFSはスタック(後に入れたものを先に取り出す)を、BFSはキュー(先に入れたものを先に取り出す)を使います。

この違いにより、探索の順序が大きく変わります。DFSは「深く狭く」探索し、BFSは「浅く広く」探索するのです。
どちらが優れているということはなく、解決したい問題によって最適な選択が変わるということを覚えておきましょう。

DFSの仕組みと実装方法

リンドくん

リンドくん

DFSって実際にはどうやってプログラムで書くんですか?

たなべ

たなべ

DFSには再帰を使う方法スタックを使う方法の2つがあるんだ。まずは再帰版から見てみようか。これが一番シンプルで理解しやすいよ。

DFSの動作イメージ

DFSは以下のような手順で動作します。

  1. スタート地点を訪問済みにマークする
  2. 隣接するノード(まだ訪問していない)を1つ選ぶ
  3. そのノードに移動して、同じ処理を繰り返す(再帰)
  4. 全ての隣接ノードを訪問したら、1つ前に戻る

この「行けるところまで行って、戻る」という動作が、DFSの本質です。

再帰を使ったDFSの実装

最もシンプルなDFSの実装は、再帰を使った方法です。以下はグラフを探索するPythonコードの例です。

def dfs_recursive(graph, node, visited=None):
    """
    再帰を使ったDFSの実装
    
    Parameters:
    graph: 隣接リストで表現されたグラフ
    node: 現在のノード
    visited: 訪問済みノードの集合
    """
    if visited is None:
        visited = set()
    
    # 現在のノードを訪問済みにマーク
    visited.add(node)
    print(f"訪問: {node}")
    
    # 隣接ノードを探索
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited)
    
    return visited

# グラフの定義(隣接リスト形式)
graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D', 'E'],
    'C': ['A', 'F'],
    'D': ['B'],
    'E': ['B', 'F'],
    'F': ['C', 'E']
}

# DFSの実行
print("=== DFS(再帰版)の実行結果 ===")
dfs_recursive(graph, 'A')

このコードでは、訪問したノードをvisitedセットに記録しながら、再帰的に隣接ノードを探索していきます。

スタックを使ったDFSの実装

再帰を使わない方法として、スタックを明示的に使った実装もあります。これは再帰の深さ制限を回避したい場合に有効です。

def dfs_stack(graph, start):
    """
    スタックを使ったDFSの実装
    
    Parameters:
    graph: 隣接リストで表現されたグラフ
    start: スタート地点のノード
    """
    visited = set()
    stack = [start]  # スタックの初期化
    
    while stack:
        # スタックから取り出す(後入れ先出し)
        node = stack.pop()
        
        if node not in visited:
            visited.add(node)
            print(f"訪問: {node}")
            
            # 隣接ノードをスタックに追加
            for neighbor in graph[node]:
                if neighbor not in visited:
                    stack.append(neighbor)
    
    return visited

# DFSの実行
print("\n=== DFS(スタック版)の実行結果 ===")
dfs_stack(graph, 'A')

どちらの実装も基本的な動作は同じですが、再帰版はコードがシンプルで理解しやすく、スタック版は処理の流れが明示的という特徴があります。

DFSの計算量

DFSの計算量を理解しておくことも重要です。

  • 時間計算量: O(V + E) - V はノード数、E はエッジ(辺)数
  • 空間計算量: O(V) - 最悪の場合、全てのノードがスタックに積まれる

グラフの全てのノードとエッジを1回ずつ訪問するため、効率的なアルゴリズムと言えます。

BFSの仕組みと実装方法

リンドくん

リンドくん

BFSはどう実装するんですか?DFSと大きく違いますか?

たなべ

たなべ

構造は似ているんだけど、使うデータ構造がスタックからキューに変わるだけで、探索の順序が全く変わるんだよ。面白いでしょ?

BFSの動作イメージ

BFSは以下のような手順で動作します。

  1. スタート地点をキューに追加する
  2. キューから1つ取り出して訪問する
  3. その隣接ノード(まだ訪問していない)を全てキューに追加する
  4. キューが空になるまで2-3を繰り返す

この「近い順に訪問する」という動作が、BFSの本質です。

キューを使ったBFSの実装

BFSの実装には、Pythonのcollections.dequeを使うのが一般的です。

from collections import deque

def bfs(graph, start):
    """
    キューを使ったBFSの実装
    
    Parameters:
    graph: 隣接リストで表現されたグラフ
    start: スタート地点のノード
    """
    visited = set()
    queue = deque([start])  # キューの初期化
    visited.add(start)
    
    while queue:
        # キューから取り出す(先入れ先出し)
        node = queue.popleft()
        print(f"訪問: {node}")
        
        # 隣接ノードをキューに追加
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    
    return visited

# 同じグラフでBFSを実行
print("=== BFS の実行結果 ===")
bfs(graph, 'A')

DFSとの違いは、stack.pop()(後から追加したものを取り出す)がqueue.popleft()(先に追加したものを取り出す)に変わっただけです。しかし、この小さな違いが探索順序を大きく変えるのです。

最短経路を見つけるBFS

BFSの最も重要な特性は、最初に目的地に到達したときの経路が必ず最短経路になるということです。この性質を活かした実装例を見てみましょう。

def bfs_shortest_path(graph, start, goal):
    """
    最短経路を見つけるBFS
    
    Parameters:
    graph: 隣接リストで表現されたグラフ
    start: スタート地点
    goal: ゴール地点
    
    Returns:
    最短経路のリスト(見つからなければNone)
    """
    visited = set()
    queue = deque([[start]])  # 経路のリストをキューに保存
    
    if start == goal:
        return [start]
    
    visited.add(start)
    
    while queue:
        # 現在の経路を取り出す
        path = queue.popleft()
        node = path[-1]  # 経路の最後のノード
        
        # 隣接ノードを探索
        for neighbor in graph[node]:
            if neighbor not in visited:
                new_path = path + [neighbor]
                
                # ゴールに到達したら経路を返す
                if neighbor == goal:
                    return new_path
                
                visited.add(neighbor)
                queue.append(new_path)
    
    return None  # 経路が見つからない

# 最短経路の探索
print("\n=== 最短経路探索の実行結果 ===")
path = bfs_shortest_path(graph, 'A', 'F')
if path:
    print(f"AからFへの最短経路: {' -> '.join(path)}")
    print(f"距離: {len(path) - 1}")

このコードでは、経路そのものをキューに保存することで、ゴールに到達したときに最短経路を取得できるようにしています。

BFSの計算量

BFSの計算量もDFSと同様です。

  • 時間計算量: O(V + E)
  • 空間計算量: O(V) - 最悪の場合、全てのノードがキューに入る

ただし、実際のメモリ使用量はDFSより多くなる傾向があります。これは、同じレベルの全ノードをキューに保持する必要があるためです。

DFSとBFSの使い分けと実用例

リンドくん

リンドくん

2つのアルゴリズムの違いは分かったんですけど、実際にはどう使い分けるんですか?

たなべ

たなべ

それが一番大事なポイントだね!解きたい問題の性質によって、どちらが適しているか変わってくるんだ。具体的な例を見ながら考えてみよう。

DFSが適している場面

DFSは以下のような問題に適しています。

全ての経路を列挙したい場合

迷路の全ての解を見つけたい、組み合わせを全て試したいといった場面では、DFSが活躍します。

def find_all_paths_dfs(graph, start, goal, path=[]):
    """
    DFSを使って全ての経路を見つける
    """
    path = path + [start]
    
    if start == goal:
        return [path]
    
    paths = []
    for neighbor in graph[start]:
        if neighbor not in path:  # 循環を避ける
            new_paths = find_all_paths_dfs(graph, neighbor, goal, path)
            paths.extend(new_paths)
    
    return paths

# 全経路の探索
print("\n=== 全経路の探索(DFS)===")
all_paths = find_all_paths_dfs(graph, 'A', 'F')
for i, path in enumerate(all_paths, 1):
    print(f"経路{i}: {' -> '.join(path)}")

トポロジカルソート

タスクの依存関係を解決する場合など、順序付けが必要な問題にはDFSが使われます。

メモリ使用量を抑えたい場合

グラフが非常に広い(多くの分岐がある)場合、BFSはメモリを大量に消費しますが、DFSは比較的少ないメモリで動作します。

BFSが適している場面

一方、BFSは以下のような問題に適しています。

最短経路を見つけたい場合

ゲームのキャラクターの移動経路、ナビゲーションシステムなど、最短距離が重要な場面ではBFSが最適です。

def bfs_shortest_distance(graph, start, goal):
    """
    BFSで最短距離を計算
    """
    visited = {start: 0}  # ノード: 距離
    queue = deque([start])
    
    while queue:
        node = queue.popleft()
        
        if node == goal:
            return visited[goal]
        
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited[neighbor] = visited[node] + 1
                queue.append(neighbor)
    
    return -1  # 到達不可能

# 最短距離の計算
print("\n=== 最短距離の計算(BFS)===")
distance = bfs_shortest_distance(graph, 'A', 'F')
print(f"AからFまでの最短距離: {distance}")

レベル順の処理が必要な場合

木構造のレベル順走査、ネットワークの階層的な処理など、「同じ深さのノードをまとめて処理したい」場合にはBFSが適しています。

最も近い要素を見つけたい場合

SNSの「友達の友達」を探す、最寄りの店舗を見つけるといった、「近さ」が重要な問題ではBFSが活躍します。

実用例:迷路の探索

具体的な例として、迷路を解く問題を考えてみましょう。

from collections import deque

def solve_maze_bfs(maze, start, goal):
    """
    BFSで迷路を解く(最短経路を見つける)
    
    maze: 2次元リスト(0=通路、1=壁)
    start: (row, col) のタプル
    goal: (row, col) のタプル
    """
    rows, cols = len(maze), len(maze[0])
    directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]  # 右、下、左、上
    
    queue = deque([(start, [start])])
    visited = {start}
    
    while queue:
        (row, col), path = queue.popleft()
        
        if (row, col) == goal:
            return path
        
        for dr, dc in directions:
            new_row, new_col = row + dr, col + dc
            
            # 範囲内かつ通路かつ未訪問
            if (0 <= new_row < rows and 
                0 <= new_col < cols and 
                maze[new_row][new_col] == 0 and 
                (new_row, new_col) not in visited):
                
                visited.add((new_row, new_col))
                queue.append(((new_row, new_col), path + [(new_row, new_col)]))
    
    return None

# 迷路の例(0=通路、1=壁)
maze = [
    [0, 1, 0, 0, 0],
    [0, 1, 0, 1, 0],
    [0, 0, 0, 1, 0],
    [1, 1, 0, 0, 0],
    [0, 0, 0, 1, 0]
]

start = (0, 0)
goal = (4, 4)

print("\n=== 迷路の最短経路探索 ===")
path = solve_maze_bfs(maze, start, goal)
if path:
    print(f"最短経路(長さ{len(path)}):")
    for pos in path:
        print(f"  {pos}")

この例では、BFSを使って迷路の最短経路を見つけています。同じ問題をDFSで解くこともできますが、DFSでは最短経路が保証されません。

選択の指針

以下の表を参考に、問題に応じてアルゴリズムを選びましょう。

要件推奨アルゴリズム理由
最短経路が必要BFS最初に見つかる経路が最短
全ての経路を列挙DFS深く探索して全パターンを試す
メモリ使用量を抑えたいDFSスタックの深さ分のみ
レベル順の処理BFS同じ深さを一度に処理
解の存在確認のみどちらでも好みで選択可能

プログラミングの面白いところは、同じ問題でも複数の解法があり、それぞれに長所と短所があるということです。DFSとBFSはその代表例と言えるでしょう。

まとめ

リンドくん

リンドくん

DFSとBFSの違いがよく分かりました!実際に自分でも実装してみたくなりました。

たなべ

たなべ

素晴らしい!実際に手を動かして試してみることが一番の学習になるよ。
最初は簡単なグラフから始めて、徐々に複雑な問題に挑戦していこう。アルゴリズムは理解するだけでなく、使いこなせるようになることが大切だからね。

今回は、グラフ探索の基本となるDFSとBFSについて、基礎から実装方法、使い分けまで詳しく解説してきました。

DFS(深さ優先探索)の特徴

  • スタック構造を使用(または再帰)
  • 「深く狭く」探索する
  • 全ての経路を見つけるのに適している
  • メモリ使用量が少ない

BFS(幅優先探索)の特徴

  • キュー構造を使用
  • 「浅く広く」探索する
  • 最短経路を見つけるのに適している
  • レベル順の処理に最適

これらのアルゴリズムは、単なる理論ではありません。実際のソフトウェア開発、特にゲーム開発やAI実装において、日常的に使われる技術です。
例えば、RPGゲームでキャラクターが敵を追いかける動き、パズルゲームの自動解法、SNSの友達推薦機能など、様々な場面で活用されています。

アルゴリズムの学習は、最初は難しく感じるかもしれません。しかし、一つ一つ理解して実装できるようになると、プログラミングの世界が大きく広がります。
コンピュータサイエンスの基礎を身につけることは、AI時代のエンジニアにとって大きな武器になるはずです。

この記事をシェア

関連するコンテンツ