コーディング · アルゴリズム
ダイクストラ法とは?仕組み・コード・計算量をわかりやすく解説
結論
ダイクストラ法は、辺の重みがすべて非負のグラフで、1つの開始ノードから他のすべてのノードへの最短経路を求めるアルゴリズムです。まだ確定していないノードのうち、現時点の距離が最小のものを選んで確定させ、そこから出る辺を緩和する、という操作を繰り返します。二分ヒープの優先度付きキューを使うと、計算量は O((V + E) log V) です。辺の重みに負の値があると、誤った答えになります。
ダイクストラ法が解く問題
幅優先探索は辺の数が最小の経路を見つけますが、道路、ネットワークの回線、航空便には、それぞれ異なる距離、コスト、遅延があります。ダイクストラ法は、こうした重み付きグラフでの単一始点最短経路問題を解きます。開始ノードを与えると、他のすべてのノードへの最小の合計コストと、それを実現する経路が求まります。
エッガー・ダイクストラ(Edsger W. Dijkstra)は、1959年に論文 "A note on two problems in connexion with graphs" でこのアルゴリズムを発表しました。
考え方を言葉で説明すると
- 開始ノードの距離を 0、ほかのすべてのノードの距離を無限大にします。
- 未確定のノードのうち距離が最小のものを選びます。そのノードの距離はここで確定します。
- 重み w の各出辺 u → v を緩和します。
dist[u] + w < dist[v]ならdist[v] = dist[u] + wに更新し、v の親として u を記録します。 - 到達可能なすべてのノードが確定するまで、手順2と3を繰り返します。
なぜ最小の距離は確定してよいのでしょうか。そのノードに至るほかの経路は、距離がすでに同じかそれ以上のノードを経由しなければならず、重みが非負なら残りの辺は費用を増やすだけだからです。
具体例で一歩ずつ確認
辺 A→B(4)、A→C(1)、C→B(2)、B→D(1)、C→D(5)、D→E(3)を持つ有向グラフです。A から始めます。
| ステップ | 確定するノード | A | B | C | D | E |
|---|---|---|---|---|---|---|
| 開始 | — | 0 | ∞ | ∞ | ∞ | ∞ |
| 1 | A (0) | 0 | 4 | 1 | ∞ | ∞ |
| 2 | C (1) | 0 | 3(C 経由) | 1 | 6(C 経由) | ∞ |
| 3 | B (3) | 0 | 3 | 1 | 4(B 経由) | ∞ |
| 4 | D (4) | 0 | 3 | 1 | 4 | 7 |
| 5 | E (7) | 0 | 3 | 1 | 4 | 7 |
B は、直接の辺でまず距離 4 になり、その後 C 経由で 3 に改善されます。E への最短経路は A → C → B → D → E で、合計コストは 1 + 2 + 1 + 3 = 7 です。
heapqを使ったPythonでの実装
Pythonの heapq モジュールは二分最小ヒープを提供します。「キーの減少」操作の代わりに、距離が改善されるたびに新しい (distance, node) のペアをプッシュし、古くなったペアは取り出したときに読み飛ばします。これを遅延削除(lazy deletion)と呼びます。
import heapq
def dijkstra(graph, start):
dist = {start: 0}
parent = {start: None}
heap = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # outdated entry
for v, w in graph[u]:
nd = d + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
parent[v] = u
heapq.heappush(heap, (nd, v))
return dist, parent
graph = {
"A": [("B", 4), ("C", 1)], "B": [("D", 1)],
"C": [("B", 2), ("D", 5)], "D": [("E", 3)], "E": [],
}
dist, parent = dijkstra(graph, "A")
print(dist) # {'A': 0, 'B': 3, 'C': 1, 'D': 4, 'E': 7}特定の目的地までの経路を得るには、親へのリンクを開始ノードまでたどります。
def path_to(parent, target):
path = []
while target is not None:
path.append(target)
target = parent[target]
return path[::-1]
print(path_to(parent, "E")) # ['A', 'C', 'B', 'D', 'E']1つの目的地までの距離だけが必要なら、その目的地がヒープから取り出された時点で探索を打ち切れます。その時点で距離は確定しているからです。
時間計算量と空間計算量
| 優先度付きキュー | 時間 | 備考 |
|---|---|---|
| 二分ヒープ(heapq) | O((V + E) log V) | 通常はこれを選びます。遅延削除では、ヒープに最大 E 個の要素が入りますが、log E は高々 2 log V なので、1操作あたり O(log V) のままです。 |
| 単純な配列で最小値を走査 | O(V²) | E が V² に近い非常に密なグラフに向いています。 |
| フィボナッチヒープ | O(E + V log V) | 理論上は最良ですが、定数が大きいため実際に速いことはほとんどありません。 |
空間計算量は、グラフ、距離と親の対応表、ヒープを合わせて O(V + E) です。
バリエーションとよくある間違い
if d > dist[u]: continueのチェックを忘れても誤りにはなりませんが、古い要素まで処理してしまい、大幅に遅くなることがあります。(distance, node)ではなくノードだけをプッシュすると、ヒープの順序が意味をなさなくなります。- ノードが比較できないオブジェクトの場合、距離が同じだと
heapqがノード同士を比較してTypeErrorを起こします。一意のカウンタを使って(distance, counter, node)をプッシュしてください。 - A*探索は、優先度にヒューリスティックを加えたダイクストラ法です。残りの距離のよい推定値があれば、探索するノード数を減らせます。
- 双方向ダイクストラ法は、開始ノードとゴールの両方から探索を行い、2つの探索が出会ったところで終了します。
使う場面と実世界での用途
| グラフ | アルゴリズム |
|---|---|
| 重みなし、またはすべての重みが等しい | BFS、O(V + E) |
| 重みが非負で、始点が1つ | ダイクストラ法 |
| 負の重みがあるが、負閉路はない | ベルマン–フォード法、O(V × E) |
| 全点対、小さく密なグラフ | フロイド–ワーシャル法、O(V³) |
| 目的地が1つで、距離の推定値がある | A*探索 |
- ルーティング:OSPFやIS-ISなどのリンクステート型ルーティングプロトコルは、ダイクストラ法に基づく最短経路優先(SPF)計算を行います。
- 地図とナビゲーション:実運用のシステムでは、ダイクストラ型の探索に前処理を組み合わせています。
- コーディング面接の問題:ネットワーク遅延時間、最安の航空券(経由回数の制限付き)、グリッドでの最小労力の経路、グラフ上の「合計コスト最小」を求めるあらゆる問題。
よくある質問
ダイクストラ法は負の重みでも動きますか?
いいえ。最小の暫定距離を持つノードは、あとからどの経路を通ってもそれ以上安くならない、という前提に立っており、負の辺はこの前提を壊します。代わりにベルマン–フォード法を使ってください。
ダイクストラ法の時間計算量はどれくらいですか?
二分ヒープなら O((V + E) log V) です。単純な配列なら O(V²) で、密なグラフではこちらのほうが有利な場合があります。
ダイクストラ法は貪欲法ですか?
はい。各ステップで局所的に最良の選択、つまり最も近い未確定ノードの確定を行います。すべての重みが非負であれば、この選択が正しいことが証明されています。
ダイクストラ法とBFSの違いは何ですか?
BFSは通常のキューを使って辺の数を数えるため、すべての辺の重みが等しい場合にしか正しくありません。ダイクストラ法は合計距離で順序づけた優先度付きキューを使うため、異なる非負の重みを扱えます。
ダイクストラ法は全ノード間の最短経路を求められますか?
1回の実行で求まるのは1つの始点からの最短経路です。全点対が必要なら、すべてのノードから実行するか、小さく密なグラフにはフロイド–ワーシャル法を使います。