Skilore

最短経路アルゴリズム

重み付きグラフにおける最短経路問題を、Dijkstra・Bellman-Ford・Floyd-Warshallの3大アルゴリズムで解く手法を体系的に理解する

81 分で読めます40,012 文字

最短経路アルゴリズム

重み付きグラフにおける最短経路問題を、Dijkstra・Bellman-Ford・Floyd-Warshallの3大アルゴリズムで解く手法を体系的に理解する

この章で学ぶこと

  1. Dijkstra法の原理と優先度キュー実装で単一始点最短経路を効率的に求められる
  2. Bellman-Ford法で負の辺重みを扱い、負閉路の検出ができる
  3. Floyd-Warshall法で全点対最短経路を動的計画法で求められる
  4. Johnson法0-1 BFSA*探索などの発展的アルゴリズムを理解する
  5. 各アルゴリズムの適用条件と使い分けを正確に判断できる

前提知識

このガイドを読む前に、以下の知識があると理解が深まります:


1. 最短経路問題の分類

最短経路問題
単一始点全点対間
(SSSP)(APSP)
DijkstraFloyd-Warshall
(負辺なし)O(V³)
O((V+E) log V)
Bellman-FordJohnson
(負辺あり)(疎グラフ向け)
O(VE)O(V² log V + VE)
DAG 最短経路
(DAG限定)
O(V + E)
A* 探索
(ヒューリスティック)
O(E) 最良ケース

最短経路問題の前提知識

緩和(Relaxation)操作 — 全アルゴリズムに共通する基本操作:

  if dist[u] + weight(u, v) < dist[v]:
      dist[v] = dist[u] + weight(u, v)
      prev[v] = u

  「u を経由して v に行く方が近いなら、v の距離を更新する」

  この操作を:
  - Dijkstra  → 頂点を確定する度に実行
  - Bellman   → 全辺に対して V-1 回繰り返す
  - Floyd     → 中継点を増やしながら実行
  - DAG       → トポロジカル順に実行

2. Dijkstra法

非負の辺重みを持つグラフで、単一始点からの最短経路を求める。「確定していない頂点の中で最短距離の頂点を確定する」を繰り返す。

グラフ:
      2        3
  A ────→ B ────→ D
  │       ↑       ↑
  │1      │1      │1
  ↓       │       │
  C ────→ E ────→ F
      4        2

始点 A からの最短距離:
  Step 0: A=0, B=inf, C=inf, D=inf, E=inf, F=inf
  Step 1: A=0* → C=1, B=2
  Step 2: C=1* → E=5
  Step 3: B=2* → D=5, E=min(5,3)=3
  Step 4: E=3* → F=5
  Step 5: D=5* (B経由: 2+3=5)
  Step 6: F=5* (E経由: 3+2=5)

  最短距離: A=0, B=2, C=1, D=5, E=3, F=5

2.1 優先度キュー実装

import heapq
from collections import defaultdict
 
def dijkstra(graph: dict, start: str) -> tuple:
    """Dijkstra法 - O((V+E) log V)
    graph: {u: [(v, weight), ...], ...}
    返り値: (距離辞書, 前任者辞書)
    """
    dist = defaultdict(lambda: float('inf'))
    prev = {}
    dist[start] = 0
 
    # (距離, 頂点) の最小ヒープ
    pq = [(0, start)]
 
    while pq:
        d, u = heapq.heappop(pq)
 
        # 古い情報はスキップ
        if d > dist[u]:
            continue
 
        for v, weight in graph.get(u, []):
            new_dist = dist[u] + weight
            if new_dist < dist[v]:
                dist[v] = new_dist
                prev[v] = u
                heapq.heappush(pq, (new_dist, v))
 
    return dict(dist), prev
 
def reconstruct_path(prev: dict, start: str, end: str) -> list:
    """前任者辞書から経路を復元"""
    path = []
    current = end
    while current != start:
        if current not in prev:
            return []  # 到達不可能
        path.append(current)
        current = prev[current]
    path.append(start)
    return path[::-1]
 
# 使用例
graph = {
    'A': [('B', 2), ('C', 1)],
    'B': [('D', 3)],
    'C': [('E', 4)],
    'E': [('B', 1), ('F', 2)],
    'F': [('D', 1)],
}
dist, prev = dijkstra(graph, 'A')
print(dist)                            # {'A': 0, 'B': 2, 'C': 1, 'D': 5, 'E': 5, 'F': 7}
print(reconstruct_path(prev, 'A', 'D'))  # ['A', 'B', 'D']

2.2 Dijkstra法の正当性の証明(概要)

Dijkstra法の貪欲選択性質:

  定理: 非負辺グラフにおいて、未確定頂点の中で距離が最小の頂点 u を
        確定すると、dist[u] は u への真の最短距離である。

  証明の概要(帰納法・背理法):
  1. dist[u] が最短でないと仮定する
  2. すると、別の未確定頂点 w を経由するより短い経路が存在する
  3. しかし w は未確定なので dist[w] >= dist[u]
  4. 辺の重みが非負なので、w を経由する経路は dist[u] 以上
  5. 矛盾 → dist[u] は最短距離

  この証明が成り立つ条件: 全辺の重みが非負
  負辺があると Step 4 が成立しなくなる

2.3 Dijkstra法のバリエーション

visitedフラグ版(メモリ効率版)

def dijkstra_with_visited(graph: dict, start: str) -> dict:
    """visited フラグを使った Dijkstra(ヒープに古いエントリが溜まりにくい)"""
    dist = defaultdict(lambda: float('inf'))
    dist[start] = 0
    visited = set()
    pq = [(0, start)]
 
    while pq:
        d, u = heapq.heappop(pq)
        if u in visited:
            continue
        visited.add(u)
 
        for v, weight in graph.get(u, []):
            if v not in visited:
                new_dist = d + weight
                if new_dist < dist[v]:
                    dist[v] = new_dist
                    heapq.heappush(pq, (new_dist, v))
 
    return dict(dist)

k番目に短い経路を求めるDijkstra

def dijkstra_kth_shortest(graph: dict, start: str, end: str, k: int) -> list:
    """k番目に短い経路の距離を返す
    各頂点に最大 k 回到達するまで探索を続ける
    """
    count = defaultdict(int)  # 各頂点への到達回数
    pq = [(0, start)]
    distances = []
 
    while pq:
        d, u = heapq.heappop(pq)
        count[u] += 1
 
        if u == end:
            distances.append(d)
            if len(distances) == k:
                return distances
 
        if count[u] > k:
            continue
 
        for v, weight in graph.get(u, []):
            heapq.heappush(pq, (d + weight, v))
 
    return distances
 
# 使用例
graph_multi = {
    'A': [('B', 1), ('C', 3)],
    'B': [('C', 1), ('D', 4)],
    'C': [('D', 1)],
    'D': [],
}
print(dijkstra_kth_shortest(graph_multi, 'A', 'D', 3))
# [3, 4, 5]  (A→B→C→D=3, A→C→D=4, A→B→D=5)

2.4 グリッド上の Dijkstra

def dijkstra_grid(grid: list, start: tuple, end: tuple) -> int:
    """2Dグリッド上の Dijkstra(各セルにコストがある場合)"""
    rows, cols = len(grid), len(grid[0])
    directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
 
    dist = [[float('inf')] * cols for _ in range(rows)]
    dist[start[0]][start[1]] = grid[start[0]][start[1]]
 
    pq = [(grid[start[0]][start[1]], start[0], start[1])]
 
    while pq:
        d, r, c = heapq.heappop(pq)
 
        if (r, c) == end:
            return d
 
        if d > dist[r][c]:
            continue
 
        for dr, dc in directions:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols:
                new_dist = d + grid[nr][nc]
                if new_dist < dist[nr][nc]:
                    dist[nr][nc] = new_dist
                    heapq.heappush(pq, (new_dist, nr, nc))
 
    return -1
 
# 各セルの通過コスト
cost_grid = [
    [1, 3, 1, 2],
    [1, 5, 1, 1],
    [4, 2, 1, 3],
    [1, 1, 1, 1],
]
print(dijkstra_grid(cost_grid, (0, 0), (3, 3)))  # 7

3. Bellman-Ford法

負の辺重みを許容する単一始点最短経路アルゴリズム。全辺を V-1 回緩和し、V 回目の緩和で負閉路を検出する。

緩和(Relaxation)の概念:
  if dist[u] + weight(u,v) < dist[v]:
      dist[v] = dist[u] + weight(u,v)

  A ──(5)──→ B
  ↓           ↓
 (2)        (-3)
  ↓           ↓
  C ──(4)──→ D

  Step 0: A=0, B=inf, C=inf, D=inf
  Step 1: A→B: B=5, A→C: C=2
  Step 2: B→D: D=2, C→D: D=min(2,6)=2
  Step 3: 変化なし → 収束

なぜ V-1 回で十分か:
  最短経路は最大 V-1 本の辺を含む(サイクルがない場合)。
  各回のイテレーションで、最短経路の辺を少なくとも1本確定させる。
  したがって V-1 回のイテレーションで全ての最短経路が求まる。

3.1 基本実装

def bellman_ford(vertices: list, edges: list, start) -> tuple:
    """Bellman-Ford法 - O(VE)
    edges: [(u, v, weight), ...]
    返り値: (距離辞書, 前任者辞書, 負閉路フラグ)
    """
    dist = {v: float('inf') for v in vertices}
    prev = {v: None for v in vertices}
    dist[start] = 0
 
    # V-1 回の緩和
    for _ in range(len(vertices) - 1):
        updated = False
        for u, v, w in edges:
            if dist[u] != float('inf') and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                prev[v] = u
                updated = True
        if not updated:  # 早期終了
            break
 
    # V 回目:負閉路の検出
    has_negative_cycle = False
    for u, v, w in edges:
        if dist[u] != float('inf') and dist[u] + w < dist[v]:
            has_negative_cycle = True
            break
 
    return dist, prev, has_negative_cycle
 
# 使用例(負の辺あり)
vertices = ['A', 'B', 'C', 'D', 'E']
edges = [
    ('A', 'B', 4), ('A', 'C', 2),
    ('B', 'D', 3), ('C', 'B', -1),
    ('C', 'D', 5), ('D', 'E', 1),
]
dist, prev, neg_cycle = bellman_ford(vertices, edges, 'A')
print(dist)       # {'A': 0, 'B': 1, 'C': 2, 'D': 4, 'E': 5}
print(neg_cycle)  # False
 
# 負閉路の例
edges_neg = [('A', 'B', 1), ('B', 'C', -3), ('C', 'A', 1)]
_, _, neg = bellman_ford(['A','B','C'], edges_neg, 'A')
print(neg)  # True

3.2 負閉路に影響される頂点の特定

def bellman_ford_with_negative_cycle_detection(vertices, edges, start):
    """負閉路の影響を受ける全頂点を特定"""
    dist = {v: float('inf') for v in vertices}
    prev = {v: None for v in vertices}
    dist[start] = 0
 
    # V-1 回の緩和
    for _ in range(len(vertices) - 1):
        for u, v, w in edges:
            if dist[u] != float('inf') and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                prev[v] = u
 
    # V 回目で更新される頂点 → 負閉路の影響を受ける
    affected = set()
    for u, v, w in edges:
        if dist[u] != float('inf') and dist[u] + w < dist[v]:
            affected.add(v)
 
    # 負閉路から到達可能な全頂点も影響を受ける
    # BFS で伝播
    queue = deque(affected)
    while queue:
        node = queue.popleft()
        for u, v, w in edges:
            if u == node and v not in affected:
                affected.add(v)
                queue.append(v)
 
    return dist, affected
 
vertices_nc = ['A', 'B', 'C', 'D', 'E']
edges_nc = [
    ('A', 'B', 1), ('B', 'C', -3), ('C', 'B', 1),  # B-C間に負閉路
    ('C', 'D', 2), ('D', 'E', 1),
]
dist, affected = bellman_ford_with_negative_cycle_detection(vertices_nc, edges_nc, 'A')
print(f"負閉路の影響を受ける頂点: {affected}")
# {'B', 'C', 'D', 'E'} — B,C が負閉路上、D,E は到達可能

3.3 SPFA(Shortest Path Faster Algorithm)

Bellman-Ford の改良版。更新された頂点のみをキューに入れて処理する。平均的には高速だが、最悪は O(VE)。

from collections import deque
 
def spfa(graph: dict, start) -> tuple:
    """SPFA - Bellman-Ford の改良版
    graph: {u: [(v, weight), ...]}
    平均計算量は Bellman-Ford より大幅に速い
    """
    all_vertices = set(graph.keys())
    for neighbors in graph.values():
        for v, _ in neighbors:
            all_vertices.add(v)
 
    dist = {v: float('inf') for v in all_vertices}
    dist[start] = 0
    in_queue = {v: False for v in all_vertices}
    count = {v: 0 for v in all_vertices}  # 各頂点のキュー入り回数
 
    queue = deque([start])
    in_queue[start] = True
    count[start] = 1
 
    while queue:
        u = queue.popleft()
        in_queue[u] = False
 
        for v, w in graph.get(u, []):
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                if not in_queue[v]:
                    queue.append(v)
                    in_queue[v] = True
                    count[v] += 1
                    if count[v] >= len(all_vertices):
                        return dist, True  # 負閉路検出
 
    return dist, False
 
graph_spfa = {
    'A': [('B', 4), ('C', 2)],
    'B': [('D', 3)],
    'C': [('B', -1), ('D', 5)],
    'D': [('E', 1)],
    'E': [],
}
dist, has_neg = spfa(graph_spfa, 'A')
print(dist)      # {'A': 0, 'B': 1, 'C': 2, 'D': 4, 'E': 5}
print(has_neg)   # False

4. Floyd-Warshall法

全頂点対の最短経路を動的計画法で求める。中継点を1つずつ増やしながら距離行列を更新する。

初期距離行列:              k=1 (A経由):
     A    B    C    D         A    B    C    D
A [  0,   3, inf,   7]   A [  0,   3, inf,   7]
B [  8,   0,   2, inf]   B [  8,   0,   2,  15]
C [  5, inf,   0,   1]   C [  5,   8,   0,   1]
D [  2, inf, inf,   0]   D [  2,   5, inf,   0]

k=2 (B経由):              最終結果:
     A    B    C    D         A    B    C    D
A [  0,   3,   5,   7]   A [  0,   3,   5,   6]
B [  8,   0,   2,   3]   B [  5,   0,   2,   3]
C [  5,   8,   0,   1]   C [  3,   6,   0,   1]
D [  2,   5,   7,   0]   D [  2,   5,   7,   0]

DP の遷移式:
  dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

  「i から j への距離を、k を中継点として使った場合と使わなかった場合で比較」

4.1 基本実装

def floyd_warshall(n: int, edges: list) -> list:
    """Floyd-Warshall法 - O(V³)
    edges: [(u, v, weight), ...]
    返り値: 距離行列
    """
    INF = float('inf')
    dist = [[INF] * n for _ in range(n)]
 
    # 初期化
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        dist[u][v] = w
 
    # DP: 中継点 k を順に追加
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
 
    return dist

4.2 経路復元付き実装

def floyd_warshall_with_path(n: int, edges: list) -> tuple:
    """Floyd-Warshall + 経路復元"""
    INF = float('inf')
    dist = [[INF] * n for _ in range(n)]
    nxt = [[None] * n for _ in range(n)]
 
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        dist[u][v] = w
        nxt[u][v] = v
 
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
                    nxt[i][j] = nxt[i][k]
 
    return dist, nxt
 
def get_path(nxt: list, u: int, v: int) -> list:
    """経路復元"""
    if nxt[u][v] is None:
        return []
    path = [u]
    while u != v:
        u = nxt[u][v]
        path.append(u)
    return path
 
# 使用例
edges = [(0,1,3), (0,3,7), (1,0,8), (1,2,2), (2,0,5), (2,3,1), (3,0,2)]
dist, nxt = floyd_warshall_with_path(4, edges)
print(dist[1][3])           # 3
print(get_path(nxt, 1, 3))  # [1, 2, 3]

4.3 Floyd-Warshall による負閉路検出

def floyd_warshall_with_negative_cycle(n: int, edges: list) -> tuple:
    """Floyd-Warshall + 負閉路検出"""
    INF = float('inf')
    dist = [[INF] * n for _ in range(n)]
 
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        dist[u][v] = min(dist[u][v], w)
 
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if dist[i][k] != INF and dist[k][j] != INF:
                    if dist[i][k] + dist[k][j] < dist[i][j]:
                        dist[i][j] = dist[i][k] + dist[k][j]
 
    # 対角成分が負 → 負閉路が存在
    has_negative_cycle = any(dist[i][i] < 0 for i in range(n))
 
    return dist, has_negative_cycle

4.4 Floyd-Warshall の応用: 推移閉包

def transitive_closure(n: int, edges: list) -> list:
    """推移閉包: i から j に到達可能かどうかの行列を求める
    Floyd-Warshall の変形(重みの代わりに到達可能性を管理)
    """
    reach = [[False] * n for _ in range(n)]
 
    for i in range(n):
        reach[i][i] = True
    for u, v in edges:
        reach[u][v] = True
 
    for k in range(n):
        for i in range(n):
            for j in range(n):
                reach[i][j] = reach[i][j] or (reach[i][k] and reach[k][j])
 
    return reach
 
edges_reach = [(0, 1), (1, 2), (2, 3)]
reach = transitive_closure(4, edges_reach)
print(reach[0][3])  # True (0→1→2→3)
print(reach[3][0])  # False (3から0へは到達不可)

5. DAG 最短経路

DAG(有向非巡回グラフ)では、トポロジカル順序で緩和すれば O(V+E) で求まる。

from collections import defaultdict, deque
 
def dag_shortest_path(graph: dict, start) -> dict:
    """DAG上の最短経路 - O(V + E)"""
    # トポロジカルソート(Kahn)
    in_degree = defaultdict(int)
    all_verts = set(graph.keys())
    for u in graph:
        for v, _ in graph[u]:
            in_degree[v] += 1
            all_verts.add(v)
 
    queue = deque([v for v in all_verts if in_degree[v] == 0])
    topo_order = []
    while queue:
        v = queue.popleft()
        topo_order.append(v)
        for neighbor, _ in graph.get(v, []):
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
 
    # トポロジカル順に緩和
    dist = {v: float('inf') for v in all_verts}
    dist[start] = 0
    for u in topo_order:
        if dist[u] != float('inf'):
            for v, w in graph.get(u, []):
                if dist[u] + w < dist[v]:
                    dist[v] = dist[u] + w
 
    return dist
 
dag = {
    'A': [('B', 2), ('C', 6)],
    'B': [('C', 1), ('D', 3)],
    'C': [('D', 1)],
    'D': [],
}
print(dag_shortest_path(dag, 'A'))  # {'A': 0, 'B': 2, 'C': 3, 'D': 4}

DAG 最長経路

DAG では重みを反転させるか、max を使って最長経路も O(V+E) で求められる。クリティカルパス分析に使われる。

def dag_longest_path(graph: dict, start) -> dict:
    """DAG上の最長経路 - O(V + E)
    プロジェクト管理のクリティカルパス分析に使用
    """
    in_degree = defaultdict(int)
    all_verts = set(graph.keys())
    for u in graph:
        for v, _ in graph[u]:
            in_degree[v] += 1
            all_verts.add(v)
 
    queue = deque([v for v in all_verts if in_degree[v] == 0])
    topo_order = []
    while queue:
        v = queue.popleft()
        topo_order.append(v)
        for neighbor, _ in graph.get(v, []):
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
 
    dist = {v: float('-inf') for v in all_verts}
    dist[start] = 0
 
    for u in topo_order:
        if dist[u] != float('-inf'):
            for v, w in graph.get(u, []):
                if dist[u] + w > dist[v]:
                    dist[v] = dist[u] + w
 
    return dist
 
# プロジェクトタスクのDAG(タスク名: [(次タスク, 所要日数)])
project = {
    '設計':     [('開発', 5), ('テスト設計', 3)],
    '開発':     [('テスト', 8)],
    'テスト設計': [('テスト', 2)],
    'テスト':   [('リリース', 3)],
    'リリース': [],
}
longest = dag_longest_path(project, '設計')
print(longest)
# {'設計': 0, '開発': 5, 'テスト設計': 3, 'テスト': 13, 'リリース': 16}
# クリティカルパス: 設計→開発→テスト→リリース = 16日

6. Johnson法(全点対最短経路 — 疎グラフ向け)

Bellman-Ford で辺の重みを非負に変換し、各頂点から Dijkstra を実行する。疎グラフでは Floyd-Warshall より高速。

def johnson(n: int, edges: list) -> list:
    """Johnson法 - O(V² log V + VE)
    edges: [(u, v, weight), ...]
    """
    INF = float('inf')
 
    # Step 1: 仮想頂点 s を追加し、全頂点へ重み 0 の辺を張る
    new_edges = edges + [(n, v, 0) for v in range(n)]
 
    # Step 2: Bellman-Ford で s からの最短距離 h を求める
    h = [INF] * (n + 1)
    h[n] = 0
    for _ in range(n):
        for u, v, w in new_edges:
            if h[u] != INF and h[u] + w < h[v]:
                h[v] = h[u] + w
 
    # 負閉路チェック
    for u, v, w in new_edges:
        if h[u] != INF and h[u] + w < h[v]:
            raise ValueError("負閉路が存在します")
 
    # Step 3: 辺の重みを非負に変換: w'(u,v) = w(u,v) + h[u] - h[v]
    reweighted_graph = defaultdict(list)
    for u, v, w in edges:
        new_w = w + h[u] - h[v]
        reweighted_graph[u].append((v, new_w))
 
    # Step 4: 各頂点から Dijkstra
    dist = [[INF] * n for _ in range(n)]
    for s in range(n):
        d = dijkstra_array(reweighted_graph, s, n)
        for t in range(n):
            if d[t] != INF:
                # 元の重みに戻す
                dist[s][t] = d[t] - h[s] + h[t]
 
    return dist
 
def dijkstra_array(graph, start, n):
    """配列版 Dijkstra(Johnson用)"""
    INF = float('inf')
    dist = [INF] * n
    dist[start] = 0
    pq = [(0, start)]
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue
        for v, w in graph.get(u, []):
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(pq, (dist[v], v))
    return dist

7. A* 探索

Dijkstra にヒューリスティック関数を加えた探索。ゴールが明確な場合に Dijkstra より高速に最短経路を見つけられる。

Dijkstra: f(n) = g(n)        ← 始点からの実コスト
A*:       f(n) = g(n) + h(n) ← 実コスト + ゴールまでの推定コスト

ヒューリスティック h(n) の条件:
  - 許容性 (Admissible): h(n) <= 実際のコスト(過大評価しない)
  - 一貫性 (Consistent): h(n) <= cost(n, n') + h(n')

  許容的なら最適解が保証される
def astar(graph: dict, start, goal, heuristic) -> tuple:
    """A* 探索
    graph: {u: [(v, weight), ...]}
    heuristic: h(node) → ゴールまでの推定コスト
    返り値: (距離, 経路)
    """
    open_set = [(heuristic(start), 0, start)]  # (f, g, node)
    came_from = {}
    g_score = defaultdict(lambda: float('inf'))
    g_score[start] = 0
    closed = set()
 
    while open_set:
        f, g, current = heapq.heappop(open_set)
 
        if current == goal:
            path = [current]
            while current in came_from:
                current = came_from[current]
                path.append(current)
            return g, path[::-1]
 
        if current in closed:
            continue
        closed.add(current)
 
        for neighbor, weight in graph.get(current, []):
            if neighbor in closed:
                continue
            tentative_g = g + weight
            if tentative_g < g_score[neighbor]:
                g_score[neighbor] = tentative_g
                came_from[neighbor] = current
                f = tentative_g + heuristic(neighbor)
                heapq.heappush(open_set, (f, tentative_g, neighbor))
 
    return float('inf'), []
 
# グリッド上の A* 探索例(マンハッタン距離をヒューリスティックに)
def manhattan_distance(node, goal=(9, 9)):
    return abs(node[0] - goal[0]) + abs(node[1] - goal[1])
 
# グリッドグラフの構築
def build_grid_graph(rows, cols, blocked=set()):
    graph = {}
    for r in range(rows):
        for c in range(cols):
            if (r, c) in blocked:
                continue
            neighbors = []
            for dr, dc in [(0,1), (0,-1), (1,0), (-1,0)]:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in blocked:
                    neighbors.append(((nr, nc), 1))
            graph[(r, c)] = neighbors
    return graph
 
grid_graph = build_grid_graph(10, 10, blocked={(3,3), (3,4), (3,5)})
dist, path = astar(grid_graph, (0, 0), (9, 9), manhattan_distance)
print(f"最短距離: {dist}")  # 18
print(f"経路長: {len(path)}")

A* vs Dijkstra の比較

Dijkstra                    A*
○ ○ ○ ○. . . .
○ ○ ○ ○. ○ ○ .
○ ○ ○ ○. ○ ○ .
○ ○ ○ ○. . . .
探索頂点数: 多い          探索頂点数: 少ない

  ○ = 探索された頂点
  . = 探索されなかった頂点

  A* は h(n) によってゴール方向に探索を集中させるため、
  大規模なグラフで大幅に高速になる

8. 特殊な最短経路アルゴリズム

8.1 0-1 BFS

辺の重みが 0 か 1 のみの場合、deque を使って O(V+E) で最短距離を求める。

def bfs_01(graph: dict, start) -> dict:
    """0-1 BFS - O(V + E)
    graph: {u: [(v, weight), ...]}  weight は 0 or 1
    """
    dist = defaultdict(lambda: float('inf'))
    dist[start] = 0
    dq = deque([start])
 
    while dq:
        u = dq.popleft()
        for v, w in graph.get(u, []):
            new_dist = dist[u] + w
            if new_dist < dist[v]:
                dist[v] = new_dist
                if w == 0:
                    dq.appendleft(v)  # 重み0 → 先頭に追加
                else:
                    dq.append(v)      # 重み1 → 末尾に追加
 
    return dict(dist)

8.2 双方向 Dijkstra

始点と終点の両方から Dijkstra を行い、探索領域が重なった時点で最短距離を確定させる。大規模グラフでの1対1最短経路に有効。

def bidirectional_dijkstra(graph: dict, reverse_graph: dict, start, end) -> int:
    """双方向 Dijkstra
    graph: 順方向の隣接リスト
    reverse_graph: 逆方向の隣接リスト
    """
    INF = float('inf')
 
    dist_f = defaultdict(lambda: INF)
    dist_b = defaultdict(lambda: INF)
    dist_f[start] = 0
    dist_b[end] = 0
 
    pq_f = [(0, start)]
    pq_b = [(0, end)]
 
    visited_f = set()
    visited_b = set()
 
    best = INF
 
    while pq_f or pq_b:
        # 前方から探索
        if pq_f:
            d, u = heapq.heappop(pq_f)
            if d <= best:
                visited_f.add(u)
                for v, w in graph.get(u, []):
                    new_d = d + w
                    if new_d < dist_f[v]:
                        dist_f[v] = new_d
                        heapq.heappush(pq_f, (new_d, v))
                    if v in visited_b:
                        best = min(best, dist_f[v] + dist_b[v])
 
        # 後方から探索
        if pq_b:
            d, u = heapq.heappop(pq_b)
            if d <= best:
                visited_b.add(u)
                for v, w in reverse_graph.get(u, []):
                    new_d = d + w
                    if new_d < dist_b[v]:
                        dist_b[v] = new_d
                        heapq.heappush(pq_b, (new_d, v))
                    if v in visited_f:
                        best = min(best, dist_f[v] + dist_b[v])
 
        # 両方の最小距離が best 以上なら終了
        min_f = pq_f[0][0] if pq_f else INF
        min_b = pq_b[0][0] if pq_b else INF
        if min_f + min_b >= best:
            break
 
    return best

8.3 ダイヤル法(Dial's Algorithm)

辺の重みが小さな非負整数の場合、バケットを使って O(V + E + W_max) で動作する。

def dial_shortest_path(graph: dict, start, n: int, max_weight: int) -> list:
    """ダイヤル法 - O(V + E + W_max * V)
    辺の重みが [0, max_weight] の非負整数の場合に有効
    """
    INF = float('inf')
    dist = [INF] * n
    dist[start] = 0
 
    # バケット(循環バッファ)
    num_buckets = max_weight * n + 1
    buckets = [[] for _ in range(num_buckets)]
    buckets[0].append(start)
 
    idx = 0
    found = 0
 
    while found < n:
        while not buckets[idx % num_buckets]:
            idx += 1
 
        u = buckets[idx % num_buckets].pop()
        if dist[u] != idx:
            continue
 
        found += 1
 
        for v, w in graph.get(u, []):
            new_dist = dist[u] + w
            if new_dist < dist[v]:
                dist[v] = new_dist
                buckets[new_dist % num_buckets].append(v)
 
    return dist

9. 実務応用パターン

9.1 カーナビの経路検索

# 実際のカーナビでは以下の手法が使われる:
#
# 1. Contraction Hierarchies (CH)
#    - 前処理で重要度の低い頂点を「収縮」し、ショートカット辺を追加
#    - クエリは双方向 Dijkstra で、前処理済みグラフ上で高速に探索
#    - 前処理: O(n log n) 〜 O(n²), クエリ: 数ミリ秒
#
# 2. ALT (A*, Landmarks, Triangle inequality)
#    - ランドマーク頂点への距離を前処理で計算
#    - 三角不等式を使ってヒューリスティックを計算
#    - A* のヒューリスティックとして使用
 
def travel_time_dijkstra(road_network, start, end, departure_time):
    """時間依存の経路検索(ラッシュアワーを考慮)"""
    dist = defaultdict(lambda: float('inf'))
    dist[start] = departure_time
    pq = [(departure_time, start)]
 
    while pq:
        time, u = heapq.heappop(pq)
        if u == end:
            return time - departure_time
        if time > dist[u]:
            continue
        for v, travel_time_func in road_network.get(u, []):
            # travel_time_func(t) は時刻 t における移動時間を返す
            arrival = time + travel_time_func(time)
            if arrival < dist[v]:
                dist[v] = arrival
                heapq.heappush(pq, (arrival, v))
    return float('inf')

9.2 ネットワークルーティング

def ospf_routing(network: dict, router_id: str) -> dict:
    """OSPF(Open Shortest Path First)のルーティングテーブル計算
    実際の OSPF は Dijkstra 法をベースにしている
    """
    dist, prev = dijkstra(network, router_id)
 
    routing_table = {}
    for dest in dist:
        if dest == router_id:
            continue
        # 次ホップを求める
        next_hop = dest
        while prev.get(next_hop) != router_id:
            next_hop = prev.get(next_hop)
            if next_hop is None:
                break
        routing_table[dest] = {
            'next_hop': next_hop,
            'cost': dist[dest],
            'path': reconstruct_path(prev, router_id, dest),
        }
 
    return routing_table

9.3 為替レートの裁定取引検出

import math
 
def detect_arbitrage(currencies: list, rates: dict) -> list:
    """為替レートの裁定取引を検出(Bellman-Ford で負閉路を検出)
    rates: {(from, to): rate, ...}
 
    裁定取引: A → B → C → A で利益が出る状態
    rates を -log(rate) に変換すると、負閉路 = 裁定取引機会
    """
    n = len(currencies)
    currency_idx = {c: i for i, c in enumerate(currencies)}
 
    # 辺リストの作成(重み = -log(rate))
    edges = []
    for (src, dst), rate in rates.items():
        if rate > 0:
            edges.append((currency_idx[src], currency_idx[dst], -math.log(rate)))
 
    # Bellman-Ford
    dist = [float('inf')] * n
    prev = [None] * n
    dist[0] = 0
 
    for _ in range(n - 1):
        for u, v, w in edges:
            if dist[u] != float('inf') and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                prev[v] = u
 
    # 負閉路の検出
    for u, v, w in edges:
        if dist[u] != float('inf') and dist[u] + w < dist[v]:
            # 負閉路を見つけた → 裁定取引が可能
            # サイクルを復元
            cycle = []
            visited = set()
            x = v
            for _ in range(n):
                x = prev[x]
            start = x
            cycle.append(currencies[start])
            x = prev[start]
            while x != start:
                cycle.append(currencies[x])
                x = prev[x]
            cycle.append(currencies[start])
            return cycle[::-1]
 
    return []  # 裁定取引なし
 
currencies = ['USD', 'EUR', 'GBP', 'JPY']
rates = {
    ('USD', 'EUR'): 0.85, ('EUR', 'USD'): 1.20,  # 1.20 > 1/0.85
    ('EUR', 'GBP'): 0.86, ('GBP', 'EUR'): 1.17,
    ('GBP', 'USD'): 1.30, ('USD', 'GBP'): 0.78,
    ('USD', 'JPY'): 110.0, ('JPY', 'USD'): 0.0091,
}
cycle = detect_arbitrage(currencies, rates)
if cycle:
    print(f"裁定取引: {' → '.join(cycle)}")

10. アルゴリズム比較表

アルゴリズム 計算量 負辺 負閉路検出 種別 用途
Dijkstra O((V+E) log V) 不可 不可 単一始点 非負辺グラフ
Bellman-Ford O(VE) 単一始点 負辺あり
SPFA O(VE) 最悪 単一始点 BFの改良版
Floyd-Warshall O(V³) 可(対角負) 全点対 小規模・密グラフ
Johnson O(V² log V + VE) 全点対 疎グラフ
DAG 最短経路 O(V+E) 不要(DAG) 単一始点 DAG限定
BFS O(V+E) 不可(重み=1) 不可 単一始点 重みなし
0-1 BFS O(V+E) 不可(重み0/1) 不可 単一始点 重み0or1
A* O(E) 最良 不可 不可 1対1 ヒューリスティック可能

使い分けガイド

最短経路アルゴリズムの選択フロー:

  辺の重みは全て同じ(or なし)?
    ├─ YES → BFS  O(V+E)
    └─ NO  → 辺の重みは 0 or 1 のみ?
              ├─ YES → 0-1 BFS  O(V+E)
              └─ NO  → 負の辺がある?
                        ├─ NO  → DAG?
                        │        ├─ YES → DAGトポ順  O(V+E)
                        │        └─ NO  → ゴールが明確?
                        │                  ├─ YES → A*
                        │                  └─ NO  → Dijkstra  O((V+E)logV)
                        └─ YES → 全点対が必要?
                                  ├─ YES → 密? → Floyd  O(V³)
                                  │        疎? → Johnson  O(V²logV+VE)
                                  └─ NO  → Bellman-Ford  O(VE)
条件 推奨 理由
非負辺 + 単一始点 Dijkstra 最速
負辺あり + 単一始点 Bellman-Ford 負辺対応
全頂点対の最短距離 Floyd-Warshall 簡潔な実装
DAG トポロジカル順緩和 O(V+E)で最速
辺重み全て1 BFS O(V+E)
疎グラフ + 全点対 Johnson Dijkstra V回
明確なゴール A* ヒューリスティックで高速化
辺重み 0/1 0-1 BFS deque で O(V+E)

11. アンチパターン

アンチパターン1: 負の辺にDijkstraを使う

# BAD: 負辺のあるグラフにDijkstra
graph_neg = {
    'A': [('B', 1), ('C', 4)],
    'B': [('C', -3)],   # 負辺!
    'C': [],
}
# Dijkstra は A→C=4 を確定してしまうが、
# 実際は A→B→C = 1+(-3) = -2 が最短
 
# GOOD: Bellman-Ford を使う
vertices = ['A', 'B', 'C']
edges = [('A','B',1), ('A','C',4), ('B','C',-3)]
dist, _, _ = bellman_ford(vertices, edges, 'A')
# dist['C'] = -2 (正しい)

アンチパターン2: Floyd-Warshallを大規模グラフに使う

# BAD: V=10000 のグラフに Floyd-Warshall
# → O(V³) = 10^12 回の演算 → 数時間かかる
 
# GOOD: 単一始点なら Dijkstra を使う
# 全点対が本当に必要か再検討する
# 疎グラフなら Johnson のアルゴリズムを検討

アンチパターン3: 距離更新時にヒープの古いエントリを放置

# Dijkstra で距離更新時、古いエントリがヒープに残る
# → heappop 時に dist[u] との比較でスキップが必要
 
# BAD: スキップ処理を忘れる
def bad_dijkstra(graph, start):
    dist = defaultdict(lambda: float('inf'))
    dist[start] = 0
    pq = [(0, start)]
    while pq:
        d, u = heapq.heappop(pq)
        # スキップなし → 古い情報で無駄な処理
        for v, w in graph[u]:
            ...
 
# GOOD: 古いエントリをスキップ
def good_dijkstra(graph, start):
    dist = defaultdict(lambda: float('inf'))
    dist[start] = 0
    pq = [(0, start)]
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:  # 古い情報 → スキップ
            continue
        for v, w in graph[u]:
            ...

アンチパターン4: A* のヒューリスティックが非許容的

# BAD: ヒューリスティックが実際のコストを過大評価 → 最適解が保証されない
def bad_heuristic(node, goal):
    # ユークリッド距離 × 2 → 過大評価
    return 2 * math.sqrt((node[0]-goal[0])**2 + (node[1]-goal[1])**2)
 
# GOOD: 許容的なヒューリスティック
def good_heuristic(node, goal):
    # マンハッタン距離(グリッドでは許容的)
    return abs(node[0]-goal[0]) + abs(node[1]-goal[1])

アンチパターン5: 到達不可能な頂点の処理忘れ

# BAD: 到達不可能な場合のチェックなし
def bad_path_reconstruction(prev, start, end):
    path = [end]
    current = end
    while current != start:
        current = prev[current]  # KeyError の可能性!
        path.append(current)
    return path[::-1]
 
# GOOD: 到達可能性をチェック
def good_path_reconstruction(prev, start, end):
    if end not in prev and end != start:
        return []  # 到達不可能
    path = [end]
    current = end
    while current != start:
        if current not in prev:
            return []
        current = prev[current]
        path.append(current)
    return path[::-1]

12. 計算量の詳細分析

Dijkstra法の計算量の導出

優先度キュー(二分ヒープ)を使用した場合:

  操作                    回数          1回あたり    合計
  ─────────────────────────────────────────────────────
  heappush (初期化)       1             O(1)         O(1)
  heappop                 最大 V+E 回   O(log(V+E))  O((V+E) log V)
  heappush (緩和時)       最大 E 回     O(log(V+E))  O(E log V)
  ─────────────────────────────────────────────────────
  合計                                               O((V+E) log V)

  注: ヒープサイズは最大 V+E(古いエントリが残るため)
  log(V+E) = O(log V) (E ≤ V² なので log(V+E) ≤ log(V² + V) ≈ 2 log V)

  フィボナッチヒープを使った場合:
  - decrease-key が O(1)(償却)になるため
  - 合計 O(V log V + E)
  - 理論的には高速だが、実装が複雑で定数係数が大きい

Floyd-Warshall の空間最適化

# 標準版: O(V²) 空間(k の次元は不要 — 上書きしても正しい)
# 理由: dist[i][k] と dist[k][j] は k 番目の反復で変化しないため
 
# 注意: 経路復元が不要なら dist 行列だけで十分
# 経路復元が必要なら nxt 行列も必要で、合計 O(V²) 空間

実践演習

演習1: 基本的な実装

以下の要件を満たすコードを実装してください。

要件:

  • 入力データの検証を行うこと
  • エラーハンドリングを適切に実装すること
  • テストコードも作成すること
# 演習1: 基本実装のテンプレート
class Exercise1:
    """基本的な実装パターンの演習"""
 
    def __init__(self):
        self.data = []
 
    def validate_input(self, value):
        """入力値の検証"""
        if value is None:
            raise ValueError("入力値がNoneです")
        return True
 
    def process(self, value):
        """データ処理のメインロジック"""
        self.validate_input(value)
        self.data.append(value)
        return self.data
 
    def get_results(self):
        """処理結果の取得"""
        return {
            'count': len(self.data),
            'data': self.data
        }
 
# テスト
def test_exercise1():
    ex = Exercise1()
    assert ex.process(1) == [1]
    assert ex.process(2) == [1, 2]
    assert ex.get_results()['count'] == 2
 
    try:
        ex.process(None)
        assert False, "例外が発生するべき"
    except ValueError:
        pass
 
    print("全テスト合格!")
 
test_exercise1()

演習2: 応用パターン

基本実装を拡張して、以下の機能を追加してください。

# 演習2: 応用パターン
from typing import List, Dict, Optional
from datetime import datetime
 
class AdvancedExercise:
    """応用パターンの演習"""
 
    def __init__(self, max_size: int = 100):
        self._items: List[Dict] = []
        self._max_size = max_size
        self._created_at = datetime.now()
 
    def add(self, key: str, value: any) -> bool:
        """アイテムの追加(サイズ制限付き)"""
        if len(self._items) >= self._max_size:
            return False
        self._items.append({
            'key': key,
            'value': value,
            'timestamp': datetime.now().isoformat()
        })
        return True
 
    def find(self, key: str) -> Optional[Dict]:
        """キーによる検索"""
        for item in reversed(self._items):
            if item['key'] == key:
                return item
        return None
 
    def remove(self, key: str) -> bool:
        """キーによる削除"""
        for i, item in enumerate(self._items):
            if item['key'] == key:
                self._items.pop(i)
                return True
        return False
 
    def stats(self) -> Dict:
        """統計情報"""
        return {
            'total_items': len(self._items),
            'max_size': self._max_size,
            'usage_percent': len(self._items) / self._max_size * 100,
            'uptime': str(datetime.now() - self._created_at)
        }
 
# テスト
def test_advanced():
    ex = AdvancedExercise(max_size=3)
    assert ex.add("a", 1) == True
    assert ex.add("b", 2) == True
    assert ex.add("c", 3) == True
    assert ex.add("d", 4) == False  # サイズ制限
    assert ex.find("b")['value'] == 2
    assert ex.remove("b") == True
    assert ex.find("b") is None
    stats = ex.stats()
    assert stats['total_items'] == 2
    print("応用テスト全合格!")
 
test_advanced()

演習3: パフォーマンス最適化

以下のコードのパフォーマンスを改善してください。

# 演習3: パフォーマンス最適化
import time
from functools import lru_cache
 
# 最適化前(O(n^2))
def slow_search(data: list, target: int) -> int:
    """非効率な検索"""
    for i in range(len(data)):
        for j in range(i + 1, len(data)):
            if data[i] + data[j] == target:
                return (i, j)
    return (-1, -1)
 
# 最適化後(O(n))
def fast_search(data: list, target: int) -> tuple:
    """ハッシュマップを使った効率的な検索"""
    seen = {}
    for i, num in enumerate(data):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i
    return (-1, -1)
 
# ベンチマーク
def benchmark():
    import random
    data = list(range(5000))
    random.shuffle(data)
    target = data[100] + data[4000]
 
    start = time.time()
    result1 = slow_search(data, target)
    slow_time = time.time() - start
 
    start = time.time()
    result2 = fast_search(data, target)
    fast_time = time.time() - start
 
    print(f"非効率版: {slow_time:.4f}秒")
    print(f"効率版:   {fast_time:.6f}秒")
    print(f"高速化率: {slow_time/fast_time:.0f}倍")
 
benchmark()

ポイント:

  • アルゴリズムの計算量を意識する
  • 適切なデータ構造を選択する
  • ベンチマークで効果を測定する

13. FAQ

Q1: Dijkstra法でなぜ負辺が扱えないのか?

A: Dijkstra法は「一度確定した最短距離は変わらない」という貪欲な仮定に基づく。負辺があると、後から見つかる経路が既に確定した距離より短くなりうるため、この仮定が崩れる。例: A→C(重み4)を確定した後に A→B→C(1+(-3)=-2) が見つかっても修正できない。

Q2: ダイクストラ法の計算量 O((V+E) log V) はどこから来る?

A: 各頂点は最大1回ヒープから取り出され(V回のheappop = V log V)、各辺は最大1回の緩和でヒープへの挿入を引き起こす(E回のheappush = E log V)。合計 O((V+E) log V)。フィボナッチヒープを使うと O(V log V + E) に改善できるが、実装が複雑なため実用では二分ヒープが主流。

Q3: 経路復元はどう実装する?

A: 「前任者(predecessor)」を記録する。各頂点について「どの頂点から来たか」を保存し、ゴールからスタートまで辿ることで経路を復元する。上記の reconstruct_path 関数を参照。

Q4: 0-1 BFS とは何か?

A: 辺重みが0か1のグラフに特化した最短経路アルゴリズム。通常のキューの代わりにデック(両端キュー)を使い、重み0の辺は先頭に、重み1の辺は末尾に追加する。O(V+E)で動作する。壁を壊してグリッドを移動するような問題に有効。

Q5: A*探索のヒューリスティック関数はどう選ぶ?

A: グリッド上の問題では、4方向移動ならマンハッタン距離、8方向移動ならチェビシェフ距離、自由移動ならユークリッド距離が一般的。重要なのは「許容性」(真の距離を過大評価しない)を満たすこと。許容的でないヒューリスティックは高速だが最適解を見逃す可能性がある。

Q6: 負閉路があるとなぜ最短経路が定義できないのか?

A: 負閉路を何周もすれば距離を無限に小さくできるため、「最短」が定義できない。具体的には、始点から負閉路に到達でき、かつ負閉路から終点に到達できる場合、最短距離は -∞ になる。Bellman-Ford はこの状況を V 回目の緩和で検出する。


FAQ

Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?

実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。

Q2: 初心者がよく陥る間違いは何ですか?

基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。

Q3: 実務ではどのように活用されていますか?

このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。


14. まとめ

項目 要点
Dijkstra法 非負辺の単一始点最短経路。優先度キューで O((V+E) log V)
Bellman-Ford法 負辺を許容。全辺 V-1 回緩和で O(VE)。負閉路検出可能
Floyd-Warshall法 全点対最短経路。DP で O(V³)。小規模グラフ向け
Johnson法 疎グラフの全点対最短経路。BF + Dijkstra V回
DAG最短経路 トポロジカル順の緩和で O(V+E)。DAG限定
A*探索 ヒューリスティック付き Dijkstra。1対1の最短経路に最適
緩和操作 全アルゴリズムに共通する基本操作
経路復元 前任者配列を使ってゴールから逆順に辿る

次に読むべきガイド


参考文献

  1. Cormen, T. H. et al. (2022). Introduction to Algorithms (4th ed.). MIT Press. -- 第22-25章
  2. Dijkstra, E. W. (1959). "A note on two problems in connexion with graphs." Numerische Mathematik.
  3. Bellman, R. (1958). "On a routing problem." Quarterly of Applied Mathematics.
  4. Floyd, R. W. (1962). "Algorithm 97: Shortest Path." Communications of the ACM.
  5. Johnson, D. B. (1977). "Efficient algorithms for shortest paths in sparse networks." Journal of the ACM.
  6. Hart, P. E., Nilsson, N. J., & Raphael, B. (1968). "A Formal Basis for the Heuristic Determination of Minimum Cost Paths." IEEE Transactions on Systems Science and Cybernetics.