31
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsで学ぶ DFS / BFS 入門 〜「深さ優先」と「幅優先」で木構造をたどる〜

31
Posted at

お疲れ様です!
前回は計算量(Big-O)の話を書きましたが、今回はその流れで、探索アルゴリズムの基本 DFS(深さ優先探索)BFS(幅優先探索) をまとめてみました :pencil:
名前は難しそうですが、中身は 「スタックを使うか、キューを使うか」だけの違い です。
興味ある方は読んでいただけると嬉しいです :thumbsup:

前提:スタックとキューとは?

DFS / BFS の主役は、スタックキューという2つのデータ構造です。
違いは 「取り出す順番」 だけです。

スタック(Stack)= 後入れ先出し(LIFO)

最後に入れたものから取り出す。積み上げた本のイメージです :books:

   ↓入れる   ↑取り出す(最後に入れた[3]が先に出る)
  ┌─────┐
  │ [3] │ ← 最後に入れた
  │ [2] │
  │ [1] │ ← 最初に入れた
  └─────┘

身近な例:重ねたお皿、ブラウザの「戻る」ボタン

キュー(Queue)= 先入れ先出し(FIFO)

最初に入れたものから取り出す。レジの行列のイメージです。

 入れる→ [1][2][3] →取り出す(最初に入れた[1]が先に出る)

身近な例:レジ待ち、順番待ちの行列

Rubyだと

Array でどちらも表現でき、違いは 「取り出す側」だけです。

# スタック(LIFO)… 末尾から取り出す
stack = []
stack.push(1); stack.push(2)
stack.pop     # => 2(最後に入れたものが先)

# キュー(FIFO)… 先頭から取り出す
queue = []
queue.push(1); queue.push(2)
queue.shift   # => 1(最初に入れたものが先)

入れるのはどちらも末尾(push)。違いは取り出しが pop(末尾)か shift(先頭)か だけ。
この小さな差が、このあと DFS / BFS の違いにそのまま効いてきます。

どんなときに使う?

R「親子・階層構造をたどる」場面です。

  • カテゴリ(大カテゴリ → 中カテゴリ → 小カテゴリ)
  • 組織図(部長 → 課長 → メンバー)
  • コメントの返信ツリー(ネストする返信)
  • 何かの依存関係をたどる

こういう 木(ツリー)やグラフ を「全部たどる」「最短で目的地を探す」ときに使うのが DFS / BFS です。

まず木(ツリー)をイメージする

こんなカテゴリツリーを例にします。

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

このツリーを「上から全部たどる」とき、たどる順番が2種類あります。それが DFS と BFS です。

  • DFS(深さ優先) … 行けるところまで深く潜ってから戻る → A → B → E → F → C → D → G
  • BFS(幅優先)同じ階層を全部見てから次の階層へ → A → B → C → D → E → F → G

DFSは「とりあえず一番奥まで行ってみる人」、BFSは「同じ階を全部見てから次の階に行く人」、というイメージです。

DFS(深さ優先)= スタック / 再帰

DFSは 「行けるところまで潜る」 ので、一番手軽なのは再帰です。

def dfs(category)
  puts category.name
  category.children.each { |child| dfs(child) }  # 子に潜っていく
end

dfs(root)   # => A, B, E, F, C, D, G

再帰は、実は裏で コールスタック(メソッド呼び出しを積むスタック)を使っています。
つまり DFS の正体は「スタック」 です。自前のスタックでも書けます👇

def dfs(root)
  stack = [root]
  until stack.empty?
    node = stack.pop              # ★末尾から取り出す(あとに入れたものが先)
    puts node.name
    stack.concat(node.children)
  end
end

BFS(幅優先)= キュー

BFSは 「同じ階層から順に」 見ていくので、キューを使います。

def bfs(root)
  queue = [root]
  until queue.empty?
    node = queue.shift            # ★先頭から取り出す(先に入れたものが先)
    puts node.name
    queue.concat(node.children)
  end
end

bfs(root)   # => A, B, C, D, E, F, G

実は違いは「1行」だけ

さっきの2つのコードを並べると、違いはここだけです。

node = stack.pop     # DFS … 末尾から取り出す=スタック(LIFO)
node = queue.shift   # BFS … 先頭から取り出す=キュー(FIFO)

DFS と BFS の差は「スタックか、キューか」だけ。 pop(末尾)か shift(先頭)か、ただそれだけで、たどる順番がガラッと変わります。
(前回ふれた「スタックとキュー」がそのまま効いてくる話ですね)

どっちを使う?

DFS(深さ優先) BFS(幅優先)
データ構造 スタック(再帰) キュー
得意なこと 全部たどる/深い構造をたどる 最短経路を求める/浅いところを優先
「全カテゴリを列挙」「依存を全部たどる」 「最短で何ステップか」「一番近い該当を探す」

「最短距離・最小ステップ数」を知りたいなら BFS が定番です。
BFSは近い順に探すので、最初に見つかった経路が必ず最短になります。

計算量(Big-O)

どちらも、全ノード(頂点 V)と全つながり(辺 E)を1回ずつ見るので、計算量は O(V + E) です(木なら O(n))。
DFSもBFSも、たどる順番が違うだけで計算量は同じです。

注意点:Railsでやりがちな罠

children のたびにSQL → N+1

category.children.each { |child| dfs(child) }  # 階層をたどるたびにクエリが飛ぶ💥

ツリーをたどるコードは、ノードごとに children を引いてN+1になりがちです。
件数が増えると、まさに前回のBig-Oの世界(クエリが O(n) 回)。全件を1クエリで取ってからメモリ上でツリーを組む、もしくは ancestry などの階層管理gemを使うのが定番です。

② DFS(再帰)は深すぎると SystemStackError

再帰DFSはコールスタックを積むので、階層が極端に深いとスタックオーバーフローします。深さが読めないデータでは、自前スタックのループ版にすると安全です。

③ BFS は横に広いとキューが膨らむ

BFSは「次の階層のノード」をキューに溜めるので、横に広いツリーだとキューが大きくなり、メモリを食います
(このあたりはメモリの記事とも繋がります)

最後に

DFSとBFSは、名前こそ難しそうですが、「スタックで深く潜るか、キューで横に広げるか」だけの違いです。

  • DFS = スタック(再帰)。全部たどる・深い構造向き
  • BFS = キュー。最短経路・近い順に探すのが得意

そして実務で効いてくるのは、アルゴリズムそのものより 「ツリーをたどる=N+1になりやすい」「深い再帰=スタックオーバーフロー」「広いBFS=メモリ増」 といった周辺の罠だったりします。

N+1や深い再帰の地雷を踏んでいないかぜひ意識してみてください!

31
2
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
31
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?