DFS, BFSがわかりづらかったので、いくつかの記事を見て個人的に感じた疑問や 「こういうコード例が欲しい」という要望を踏まえて生成AIに生成してもらった。

生成された内容を検証し、コードを実際に動かして確認したところ、 自分の理解が深まる良い記事になったので、このまま公開することにした。

はじめに

LeetCodeでMedium問題を解いていると、必ず遭遇するのがDFS(深さ優先探索)とBFS(幅優先探索)だ。

「Dは深さ、Bは幅」というのは知っている。でも、なぜスタックとキューを使い分けるのか?その本質を理解している人は意外と少ない。

今回は、入れ子リストの例を使って、DFS/BFSの動作原理とデータ構造の関係を視覚的に解説する。

問題設定:入れ子リストをフラット化する

以下のような入れ子構造のリストがあるとする。

data = [1, [4, 5, [6, 7, 8], 2], 3]

これをフラットな配列にしたい。このとき、「どの順番で要素を取り出すか」がDFS/BFSの違いだ。

ツリー構造として可視化する

入れ子リストは、実はツリー構造として表現できる。

        root
       / | \
      1  []  3
         |
        /|\ \
       4 5 [] 2
           |
          /|\
         6 7 8

この木をどう巡回するかで、DFSとBFSが決まる。

DFS(深さ優先探索):とにかく深く潜る

動作イメージ

「見つけた枝があれば、まずそこを最後まで探索する」

訪問順序:

1 → [中に入る] → 4 → 5 → [さらに中] → 6 → 7 → 8 
→ [戻る] → 2 → [戻る] → 3

結果:[1, 4, 5, 6, 7, 8, 2, 3]

実装:スタックまたは再帰

再帰版

def dfs_recursive(data):
    result = []
    
    def helper(item):
        if isinstance(item, list):
            for sub in item:
                helper(sub)  # 再帰で潜る
        else:
            result.append(item)
    
    helper(data)
    return result

スタック版

def dfs_stack(data):
    result = []
    stack = [data]
    
    while stack:
        item = stack.pop()  # 後入れ先出し(LIFO)
        
        if isinstance(item, list):
            # reversed()で逆順に追加 → pop()で元の順序を保つ
            # [4, 5]を処理する場合: 5→4の順でpush → 4→5の順でpop
            for sub in reversed(item):
                stack.append(sub)
        else:
            result.append(item)
    
    return result

重要:なぜreversed()が必要か?

スタックは「後入れ先出し」なので、そのまま追加すると逆順になってしまう。

# reversed()なしの場合
stack.append([4, 5, 6])
# → pop()で 6, 5, 4 の順に取り出される(逆順!)

# reversed()ありの場合
stack.append([6, 5, 4])  # 逆順で追加
# → pop()で 4, 5, 6 の順に取り出される(正順!)

なぜスタックなのか?

「深く潜って、戻る」という動きがLIFO(後入れ先出し)だから。

スタックは「最後に入れたものを最初に取り出す」データ構造。DFSの「深さ優先」の動きと完全に一致する。

BFS(幅優先探索):同じ階層を先に見る

動作イメージ

「同じ深さのノードを全部見てから、次の階層へ進む」

訪問順序:

レベル0: 1, [中身], 3
  → 数値だけ取り出す: 1, 3
レベル1: 4, 5, [中身], 2
  → 数値だけ取り出す: 4, 5, 2
レベル2: 6, 7, 8
  → 数値だけ取り出す: 6, 7, 8

結果:[1, 3, 4, 5, 2, 6, 7, 8]

実装:キュー

from collections import deque

def bfs(data):
    result = []
    queue = deque([data])
    
    while queue:
        item = queue.popleft()  # 先入れ先出し(FIFO)
        
        if isinstance(item, list):
            for sub in item:
                queue.append(sub)
        else:
            result.append(item)
    
    return result

なぜキューなのか?

「同じ階層を順番に処理する」という動きがFIFO(先入れ先出し)だから。

キューは「最初に入れたものを最初に取り出す」データ構造。BFSの「幅優先」の動きと完全に一致する。

二重ループではダメな理由

初学者がやりがちなミス:

# ❌ これは深さ2までしか対応できない
for item in data:
    if isinstance(item, list):
        for sub in item:
            print(sub)

問題点:入れ子の深さが3以上になると対応不可能

data = [1, [2, [3, [4, [5]]]]]

# 二重ループの場合
for item in data:
    if isinstance(item, list):
        for sub in item:
            print(sub)  # 3までしか到達できない

# 三重ループにしても...
for item in data:
    if isinstance(item, list):
        for sub in item:
            if isinstance(sub, list):
                for subsub in sub:
                    print(subsub)  # 4までしか到達できない

深さが不定の場合、ループのネストを事前に決められない。

これが、再帰やスタック/キューといった動的なデータ構造が必要な理由だ。

実際のツリー問題での違い

       1
      / \
     2   3
    / \
   4   5

DFS(深さ優先)

訪問順: 1 → 2 → 4 → 5 → 3
(左の枝を全部探索してから右へ)

BFS(幅優先)

訪問順: 1 → 2 → 3 → 4 → 5
(階層ごとに左から右へ)

まとめ

項目 DFS BFS
データ構造 スタック(再帰) キュー
動作原理 LIFO(後入れ先出し) FIFO(先入れ先出し)
探索方向 深さ優先(縦) 幅優先(横)
用途 経路探索、トポロジカルソート 最短経路、レベル順探索

核心:データ構造の選択が、探索の動きを決定する。

スタックを使えば自動的に深さ優先になり、キューを使えば自動的に幅優先になる。これがDFS/BFSの本質だ。

次にツリーやグラフ問題に出会ったとき、「スタックかキューか」を考えるだけで、解法の方向性が見えてくる。

補足:「listでキューを実装してはいけない」理由

よくある疑問

「キューってlistのpop(0)でも実現できるよね?」

答え:できるが、絶対にやるな。

計算量の罠

# ✓ 動作はする
queue = []
queue.append(1)  # enqueue
item = queue.pop(0)  # dequeue

# しかし...

時間計算量の比較

操作 list deque
append() O(1) O(1)
pop(0) / popleft() O(n) O(1)

なぜO(n)になるのか?

Pythonのlistは内部的に連続配列として実装されている。

list = [A, B, C, D, E]

pop(0)を実行すると:

Before: [A, B, C, D, E]
Step 1: Aを削除
Step 2: B, C, D, E を全て左にシフト ← O(n)
Final:  [B, C, D, E]

要素数nに比例して処理時間が増える。

実際の速度差:キューサイズによる影響

小規模キュー(1,000要素)

N = 1,000,000
QSIZE = 1,000

結果
list:  0.367deque: 0.264比率約1.4

小規模なキューではPythonの最適化により、差は比較的小さい。

中規模キュー(10,000要素)

N = 1,000,000
QSIZE = 10,000

結果
list:  2.156deque: 0.233比率約9.3

キューサイズが10倍になると、速度差も約10倍に拡大。

理論的な説明

list.pop(0)の計算量:O(QSIZE)
→ キューサイズに比例して遅くなる

deque.popleft()の計算量:O(1)
→ キューサイズに影響されない

LeetCodeでの実害

Binary Tree Level Order Traversalのような問題では、ツリーのノード数が10,000を超えることは珍しくない。

  • QSIZE = 1,000: 両方ともAC(ただしlistは遅い)
  • QSIZE = 10,000: listでTLE(Time Limit Exceeded)の可能性
  • QSIZE = 100,000: listは確実にTLE

結論:LeetCodeでキューを使う場合、必ずcollections.dequeを使うこと。

「動く」と「効率的」は別物。

キューを実装するときは、必ずcollections.dequeを使うこと。これがアルゴリズム問題を解く上での鉄則だ。


記事の正しい使い分け

# スタック(LIFO)→ list
stack = []
stack.append(1)  # O(1)
stack.pop()      # O(1) ← 末尾から取るので速い

# キュー(FIFO)→ deque
from collections import deque
queue = deque()
queue.append(1)    # O(1)
queue.popleft()    # O(1) ← 先頭から取るのも速い

データ構造の選択ミスは、コードを遅くする最大の要因になる。