ウェブエンジニア問題集
第6章

木とグラフ — 隣接リスト・DFS・BFSの使い分け

5
この章の目次開く

データ構造編の最後は木とグラフです。「グラフ問題は難しそう」と身構える人が多いのですが、コーディングテストで必要な道具は驚くほど少ない——**持ち方1つ(隣接リスト)と探索2つ(DFS/BFS)**だけです。

グラフを構成する語彙

グラフは**頂点(ノード)と、頂点同士をつなぐ辺(エッジ)**でできています。

  • 無向グラフ — 辺に向きがない(友達関係)
  • 有向グラフ — 辺に向きがある(フォロー関係)
  • — 閉路のない連結グラフ。N頂点なら辺はN-1本
  • 連結 — すべての頂点が辺をたどって行き来できる状態

問題文では「N個の都市とM本の道路」「人物AとBは友人」のような姿で登場します。「〇〇と△△がつながっている」という関係の集まりが出たらグラフです。

グラフの持ち方: 隣接リスト

コードでグラフを扱うには、まず入力をデータ構造に変換します。実戦ではほぼ一択で隣接リスト——「各頂点について、隣の頂点の一覧を持つ」形式を使います。

// N頂点、辺のリスト edges = [[0,1], [0,2], [1,3]] (無向)
const graph: number[][] = Array.from({ length: N }, () => []);
for (const [a, b] of edges) {
  graph[a].push(b);
  graph[b].push(a);   // 無向なら両方向に張る(有向ならこの行を消す)
}
// graph[0] → [1, 2]  … 頂点0の隣は1と2
typescript
学習者学習者

N×Nの表(隣接行列)で持つ方法を本で見たことあるけど、そっちじゃダメなの?

隣接行列はN ≤ 1,000程度なら使えますが、N = 10^5 だと10^10マスでメモリが即死します。辺の数Mに比例したメモリで済む隣接リストが実戦の標準です。この5行の変換はグラフ問題の儀式として体に入れてしまいましょう。

DFS — 行けるところまで潜る

**DFS(深さ優先探索)**は「行き止まりまで進み、戻って別の道へ」という探索です。再帰で書くのが最も素直です。

問題: N人の人物とM組の友人関係がある。友人の友人まで含めた「グループ」はいくつあるか。(連結成分の数)

function countGroups(N: number, graph: number[][]): number {
  const visited = new Array(N).fill(false);
 
  const dfs = (v: number) => {
    visited[v] = true;
    for (const next of graph[v]) {
      if (!visited[next]) dfs(next);   // 未訪問の隣へ潜る
    }
  };
 
  let groups = 0;
  for (let v = 0; v < N; v++) {
    if (!visited[v]) {
      groups++;        // 未訪問の頂点 = 新しいグループの発見
      dfs(v);          // そのグループ全体を訪問済みにする
    }
  }
  return groups;
}
typescript

「全頂点を起点候補にして、未訪問なら成分カウント+1して塗りつぶす」——連結成分カウントはグラフ問題の登竜門で、paiza・AtCoder両方で頻出です。

再帰の深さに注意

Node.jsの再帰は深さ1万〜数万程度でスタックオーバーフローします。N = 10^5 の一本道グラフだと危険です。その場合は、第5章のスタックを使った非再帰DFSに書き換えます。

const dfsIterative = (start: number) => {
  const stack = [start];
  visited[start] = true;
  while (stack.length > 0) {
    const v = stack.pop()!;
    for (const next of graph[v]) {
      if (!visited[next]) {
        visited[next] = true;
        stack.push(next);
      }
    }
  }
};
typescript

キューに変えればBFSになる——という対称性に気づくと、2つの探索が1つの型として整理できます。

BFS — 近い順に広がる

BFS(幅優先探索)は、スタートから「距離1の頂点→距離2の頂点→…」と近い順に探索します。第5章のキュー(添字方式)がここで主役になります。

BFSの最大の武器は、辺の重みがすべて等しいとき、最初に到達した時点の距離が最短距離だと保証されることです。

問題: 頂点0から各頂点への最短距離(辺の本数)を求めよ。

function bfsDistances(N: number, graph: number[][], start: number): number[] {
  const dist = new Array(N).fill(-1);   // -1 = 未到達
  const queue: number[] = [start];
  let head = 0;
  dist[start] = 0;
 
  while (head < queue.length) {
    const v = queue[head++];
    for (const next of graph[v]) {
      if (dist[next] === -1) {          // distがvisited判定を兼ねる
        dist[next] = dist[v] + 1;
        queue.push(next);
      }
    }
  }
  return dist;
}
typescript

dist 配列が訪問済みチェックと距離記録を兼ねるのがポイントです。前章のグリッド探索も、このBFSの「頂点=マス、辺=上下左右」版でした。迷路・グリッドの最短手数はBFSが絶対の定番です。

DFSとBFSの使い分け

2つの選択肢を比べる人
求めたいもの選ぶ探索理由
最短距離・最小手数(重みなし)BFS近い順に広がるので最初の到達が最短
連結成分・到達可能性どちらでも塗りつぶせれば十分
経路の全列挙・バックトラッキングDFS(再帰)「戻る」動きが再帰と相性◎
木の集計(部分木のサイズなど)DFS(再帰)帰りがけに子の結果を合成できる

迷ったときの合言葉は「最短ならBFS、全部見るだけならどちらでも、列挙ならDFS」です。

木の問題はDFSの帰りがけが鍵

木(閉路なし)の問題では、「子の結果を親でまとめる」集計が頻出です。再帰DFSの戻り値を使うと自然に書けます。

// 各頂点を根とする部分木のサイズを求める
const size = new Array(N).fill(0);
const dfs = (v: number, parent: number): number => {
  let s = 1;                            // 自分自身
  for (const c of graph[v]) {
    if (c !== parent) s += dfs(c, v);   // 子の部分木サイズを合算
  }
  return (size[v] = s);
};
dfs(0, -1);
typescript

木は閉路がないので、visited配列の代わりに「親に戻らない」(c !== parent)だけで探索が成立します。

この章のパターンの見分け方

問題文のシグナル使う道具
「AとBがつながっている」「友人関係」「道路」まず隣接リスト化
「グループはいくつ」「到達できるか」DFS/BFSで塗りつぶし(連結成分)
「最短」「最小手数」「最少ターン」(重みなし)BFS
「迷路」「グリッドで上下左右」マスを頂点とみなしてBFS
「部分木の〇〇」「子の合計」再帰DFSの戻り値集計
メンターメンター

グラフ問題の8割は「隣接リストに変換 → DFSかBFSを貼る」で骨格が完成します。残る2割——辺に重みがある最短路や、つながりの管理を高速化したい場面——は第11章のダイクストラ法とUnion-Findで拾います。

まとめ

  • グラフは「関係の集まり」。入力を隣接リストに変換するのが儀式
  • DFSは潜って戻る探索。再帰が素直だが、深いグラフでは非再帰版(スタック)に切り替える
  • BFSは近い順の探索。重みなし最短距離の絶対定番。キューは添字方式で
  • 使い分けは「最短ならBFS、列挙ならDFS、塗りつぶしはどちらでも」
  • 木の集計は再帰DFSの戻り値で「子→親」に情報を流す

これでデータ構造編は完了です。次章からPart 3・解法パターン編に入ります。まずはすべての解法の出発点、「全探索」からです。