グラフ理論入門¶
1. グラフとは¶
グラフとは、いくつかの頂点(ノード)と、それらの間をつなぐ辺(エッジ)からなる接続構造である。棒グラフや折れ線グラフのことではなく、数学で扱われる用語である。ノードの集合を $V$、エッジの集合を $E$ として、グラフを $G = (V, E)$ と表記する。エッジはつなぐ2つのノードの組 $\{u, v\}$ で表す。
例えばノードが5個で
$$ V = \{1, 2, 3, 4, 5\}, \qquad E = \{\{1, 3\}, \{1, 4\}, \{2, 3\}, \{2, 4\}, \{4, 5\}\} $$
というグラフを考えることができる。道路ネットワーク(交差点がノード、道路がエッジ)、鉄道網、人間関係、作業の手順など、「つながり」を持つシステムはグラフで表現できる。都道府県の隣接関係のように各ノードに名称がある場合も、ノードは番号で持ち、名称は番号との対応表で管理すればよい。本研究室の教材とデータはすべてこの方針で統一している。
グラフの扱いにはPython のnetworkx ライブラリを使う。まず上のグラフを作って描いてみる。なおGoogle Colab で実行する際には、次のセルの2行目のコメントを外して実行すること。
# Google Colab の場合は2行目のコメントを外して実行: ライブラリインストール(1回だけ)
# !pip install networkx matplotlib
import networkx as nx
import matplotlib.pyplot as plt
G = nx.Graph()
G.add_edges_from([(1, 3), (1, 4), (2, 3), (2, 4), (4, 5)])
pos = {1: (0, 1), 2: (1, 1), 3: (0, 0), 4: (1, 0), 5: (2, 0.5)}
plt.figure(figsize=(5, 3))
nx.draw_networkx(G, pos, node_color='white', edgecolors='black', node_size=500)
plt.axis('off')
plt.tight_layout()
plt.show()
print('ノード集合 V =', sorted(G.nodes()))
print('エッジ集合 E =', sorted(G.edges()))
ノード集合 V = [1, 2, 3, 4, 5] エッジ集合 E = [(1, 3), (1, 4), (3, 2), (4, 2), (4, 5)]
2. 基本用語¶
- 位数: グラフ $G$ のノードの数 $|V|$。
- サイズ: グラフ $G$ のエッジの数 $|E|$。
- 隣接: エッジ $\{u, v\}$ があるとき、ノード $u$ と $v$ は互いに隣接しているという。
- 次数: ノード $v$ に接続しているエッジの数。$\deg(v)$ と書く。
- 自己閉路: 両端が同じノードのエッジ $\{u, u\}$。
- 多重枝: 同じ2ノードを結ぶ複数のエッジ。
- 単純グラフ: 自己閉路と多重枝を含まないグラフ。
- 連結グラフ: どの2つのノードの間にもエッジをたどる道があるグラフ(全体が1つにつながっているグラフ)。
- 有向グラフ: エッジに向きがあるグラフ。一方通行の道路や作業手順を表せる。向きのないグラフは無向グラフと呼ぶ。有向グラフでは、ノードから出るエッジの数を正の次数 $\delta^+(v)$、入るエッジの数を負の次数 $\delta^-(v)$ と区別する。
次数について、次の関係が成り立つ。
$$ \sum_{v \in V} \deg(v) = 2|E| $$
これは握手補題と呼ばれる。1本のエッジは必ず2つのノードに1ずつ次数を与えるから、次数の総和はエッジ数のちょうど2倍になる。「パーティーで各出席者が握手した回数の総和を取ると必ず偶数になる」という言い換えが名前の由来である(1回の握手には2人が関与するため、握手した回数の総和は握手の総回数の2倍になる)。
print('位数 |V| =', G.number_of_nodes())
print('サイズ |E| =', G.number_of_edges())
print('各ノードの次数:', dict(G.degree()))
print('次数の総和 =', sum(d for _, d in G.degree()), '= 2|E| =', 2 * G.number_of_edges())
print('連結か:', nx.is_connected(G))
位数 |V| = 5
サイズ |E| = 5
各ノードの次数: {1: 2, 3: 2, 4: 3, 2: 2, 5: 1}
次数の総和 = 10 = 2|E| = 10
連結か: True
3. 部分グラフとグラフの操作¶
$G = (V, E)$ に対して、$V' \subset V$、$E' \subset E$ を取り、$E'$ のどのエッジの両端も $V'$ に含まれるとき、$G' = (V', E')$ を $G$ の部分グラフと呼ぶ。大きなネットワークの一部だけを取り出して分析するときの基本操作である。
グラフの変形操作としては、ノードの除去(ノードとそれに接続するエッジをすべて取り去る)、エッジの除去(両端のノードは残す)などがある。networkx ではそれぞれ remove_node、remove_edge で行える。
H = G.copy()
H.remove_node(4) # ノード4と、接続するエッジ(1,4)、(2,4)、(4,5)が消える
print('ノード4を除去:', sorted(H.nodes()), sorted(H.edges()))
H = G.copy()
H.remove_edge(1, 3) # エッジだけ消え、ノード1、3は残る
print('エッジ(1,3)を除去:', sorted(H.nodes()), sorted(H.edges()))
ノード4を除去: [1, 2, 3, 5] [(1, 3), (3, 2)] エッジ(1,3)を除去: [1, 2, 3, 4, 5] [(1, 4), (3, 2), (4, 2), (4, 5)]
4. 一筆書きとオイラーグラフ¶
グラフ理論の起源とされるのが、ケーニヒスベルクの橋の問題である。川で隔てられた4つの地区が7本の橋で結ばれているとき、「すべての橋をちょうど1度だけ渡って出発点に戻ることはできるか」を問う。地区をノード、橋をエッジとするグラフ(多重枝を含む)に置き換えると、これは一筆書きの問題になる。
鍵になるのはノードの次数である。あるノードの次数が偶数なら、そのノードに入るたびに必ず出ていける(出入りが対になる)。奇数のノードが1つでもあると、どこかで「入ったきり出られない」か「出たきり戻れない」が起きる。したがって、あるノードから全エッジをちょうど1回ずつ通って元のノードに戻れる(このようなグラフをオイラーグラフと呼ぶ)ための必要十分条件は、次の2つである。
- すべてのノードの次数が偶数である。
- グラフが連結である。
ケーニヒスベルクの橋のグラフは4つのノードの次数が5、3、3、3とすべて奇数なので、一筆書きはできない。
# ケーニヒスベルクの橋(地区1〜4、橋7本。多重枝があるのでMultiGraphを使う)
K = nx.MultiGraph()
K.add_edges_from([(1, 3), (1, 3), (1, 2), (1, 2), (1, 4), (3, 4), (2, 4)])
print('各ノードの次数:', dict(K.degree()))
print('オイラーグラフか:', nx.is_eulerian(K))
各ノードの次数: {1: 5, 3: 3, 2: 3, 4: 3}
オイラーグラフか: False
4.1 応用: ごみ収集ルート¶
一筆書きは実務では「すべての道路を1回ずつ通る巡回ルート」として現れる。ごみ収集車や除雪車のルート設計が典型で、同じ道を2回通ること(回送)をできるだけ減らしたい。
4つの交差点(ノード1〜4)と7本の道路からなる次の道路網を考える。エッジの重みは道路の長さ [m] であり、ノード1と3、ノード3と4の間にはそれぞれ2本の道路がある。
# 道路網(エッジ: 始点、終点、長さ[m])
roads = [(1, 2, 100), (2, 3, 140), (2, 4, 130), (1, 3, 150), (1, 3, 250),
(3, 4, 120), (3, 4, 250)]
R = nx.MultiGraph()
for u, v, w in roads:
R.add_edge(u, v, weight=w)
pos = {1: (0, 0), 2: (1.2, 1.2), 3: (1.6, 0), 4: (3.2, 0)}
plt.figure(figsize=(6, 3.5))
nx.draw_networkx_nodes(R, pos, node_color='white', edgecolors='black', node_size=500)
nx.draw_networkx_labels(R, pos)
for i, (u, v, w) in enumerate(roads):
rad = 0.25 if (u, v) in [(1, 3), (3, 4)] and i in [4, 6] else 0.0
nx.draw_networkx_edges(R, pos, edgelist=[(u, v)],
connectionstyle=f'arc3,rad={rad}')
plt.axis('off')
plt.tight_layout()
plt.show()
print('各ノードの次数:', dict(R.degree()))
print('道路の総延長:', sum(w for _, _, w in roads), '[m]')
print('オイラーグラフか:', nx.is_eulerian(R))
各ノードの次数: {1: 3, 2: 3, 3: 5, 4: 3}
道路の総延長: 1140 [m]
オイラーグラフか: False
4つのノードの次数は3、3、5、3とすべて奇数なので、このままでは一筆書きできない。つまり、どうしても何本かの道路を2回通る必要がある。そこで「どの道を2回通せば追加距離が最小になるか」を考える。次数が奇数のノードを2個ずつ組にし、その間の道路を1本ずつ複製すれば、すべてのノードの次数が偶数になる。奇数次数のノードは4個(1、2、3、4)なので、組の作り方(マッチング)は3通りである。
| 組合せ | 追加距離 [m] |
|---|---|
| {1, 2} と {3, 4} | 100 + 120 = 220 |
| {1, 3} と {2, 4} | 150 + 130 = 280 |
| {1, 4} と {2, 3} | 230 + 140 = 370 |
{1, 4} の間に直接の道路はないため、最短経路(1から3を経て4、150 + 120 = 230 m)を使う。追加距離が最小になるのは {1, 2} と {3, 4} の組合せであり、道路(1, 2)と(3, 4)を1本ずつ複製すれば、総距離 $1140 + 220 = 1360$ [m] の一筆書きルートが作れる。
# 最小の組合せに対応する2本を複製してオイラーグラフにする
R2 = R.copy()
R2.add_edge(1, 2, weight=100)
R2.add_edge(3, 4, weight=120)
print('複製後の次数:', dict(R2.degree()))
print('オイラーグラフか:', nx.is_eulerian(R2))
route = [u for u, v in nx.eulerian_circuit(R2, source=1)] + [1]
print('一筆書きルート:', ' -> '.join(map(str, route)))
print('総距離:', sum(w for _, _, w in roads) + 100 + 120, '[m]')
複製後の次数: {1: 4, 2: 4, 3: 6, 4: 4}
オイラーグラフか: True
一筆書きルート: 1 -> 3 -> 4 -> 3 -> 4 -> 2 -> 3 -> 1 -> 2 -> 1
総距離: 1360 [m]
この例はノードも辺も少ないため手で試せたが、規模が大きくなると組合せの数が爆発的に増え、手計算はもちろんコンピュータでも素朴な全列挙では時間がかかりすぎる(組合せ爆発)。そこで、問題の構造を利用した効率のよいアルゴリズムが必要になる。次節では、その代表例として最短経路問題のダイクストラ法を学ぶ。
5. 最短経路問題とダイクストラ法¶
最短経路問題は、エッジに重み(距離やコスト)が付いたグラフで、始点から各ノードへの最短の道を求める問題である。エッジの重みが非負の場合、ダイクストラ法(Dijkstra's algorithm)で効率よく解ける。
グラフ $G = (V, E)$ のエッジ $(i, j) \in E$ のコストを $d_{ij}$ とする。始点を $s$、ノード $i$ までの暫定コストを $v_i$、ノード $i$ の1個前のノードを $p_i$、未確定ノードの集合を $M$ とすると、アルゴリズムは次のとおりである。
- [STEP1] $v_s = 0$、その他のノードは $v_j = \infty$ とする。$i := s$、$M := V - \{s\}$。
- [STEP2] $d_{ij} < \infty$ であるすべての $j \in M$ に対して、$v_j > v_i + d_{ij}$ ならば $v_j := v_i + d_{ij}$、$p_j = i$ と更新する。
- [STEP3] $M$ の中で $v_j$ が最小のノード $j_0$ を選ぶ。
- [STEP4] $M := M - \{j_0\}$。$M = \emptyset$ なら終了。それ以外は $i := j_0$ としてSTEP2へ戻る。
「暫定コストが最小のノードは、もうそれ以上短くならない(確定してよい)」という性質を利用して、ノードを1つずつ確定させていく。
5.1 例題¶
7つのノードからなる有向グラフで、ノード1からノード7への最短経路を求める。エッジとコストは次のコードのとおりである。手順を追うと、各反復で確定するノードと暫定コスト $v$ は次のように変化する。
| 反復 | 確定するノード | $v = (v_1, \dots, v_7)$ |
|---|---|---|
| 1 | 1 | $(0, 25, 10, \infty, \infty, \infty, \infty)$ |
| 2 | 3 | $(0, 25, 10, 45, 15, 30, \infty)$ |
| 3 | 5 | $(0, 20, 10, 45, 15, 25, 55)$ |
| 4 | 2 | $(0, 20, 10, 40, 15, 25, 55)$ |
| 5 | 6 | $(0, 20, 10, 40, 15, 25, 55)$ |
| 6 | 4 | $(0, 20, 10, 40, 15, 25, 50)$ |
| 7 | 7 | 終了 |
例えば3回目の反復では、ノード5が確定した直後にエッジ$(5, 2)$を使って $v_2$ が $25$ から $15 + 5 = 20$ に更新される。「ノード1から直接2へ行く(コスト25)より、3と5を経由した方が近い」ことをアルゴリズムが見つけた瞬間である。アルゴリズムを素直に実装して確かめる。
INF = float('inf')
# エッジ: (始点, 終点, コスト)
E = [(1, 2, 25), (1, 3, 10), (2, 3, 5), (2, 4, 20), (5, 2, 5),
(3, 4, 35), (3, 5, 5), (3, 6, 20), (5, 4, 30), (5, 6, 10),
(5, 7, 40), (4, 7, 10)]
n = 7
s = 1
# コスト行列(d[i][j] = エッジ(i,j)のコスト、なければ∞)
d = [[INF] * (n + 1) for _ in range(n + 1)]
for u, v_, w in E:
d[u][v_] = w
# STEP1
v = [INF] * (n + 1)
p = [0] * (n + 1)
v[s] = 0
M = set(range(1, n + 1)) - {s}
i = s
while True:
# STEP2: ノードi から行けるノードの暫定コストを更新
for j in M:
if d[i][j] < INF and v[j] > v[i] + d[i][j]:
v[j] = v[i] + d[i][j]
p[j] = i
# STEP3: 暫定コスト最小のノードを確定
j0 = min(M, key=lambda j: v[j])
# STEP4
M.remove(j0)
if not M:
break
i = j0
print('最短コスト v =', v[1:])
print('直前ノード p =', p[1:])
# p をたどって最短経路を復元
route = [7]
while route[-1] != s:
route.append(p[route[-1]])
route.reverse()
print('ノード1から7への最短経路:', ' -> '.join(map(str, route)), '(コスト', v[7], ')')
最短コスト v = [0, 20, 10, 40, 15, 25, 50] 直前ノード p = [0, 5, 1, 2, 3, 5, 4] ノード1から7への最短経路: 1 -> 3 -> 5 -> 2 -> 4 -> 7 (コスト 50 )
ノード1から7への最短コストは50であり、経路は $1 \to 3 \to 5 \to 2 \to 4 \to 7$ である。networkx のsingle_source_dijkstra でも同じ結果になることを確かめる。道路ネットワーク教材やVRP教材で使っている nx.shortest_path の内部で動いているのが、まさにこのダイクストラ法である。
D = nx.DiGraph()
D.add_weighted_edges_from(E)
dist, path = nx.single_source_dijkstra(D, s)
print('networkx の最短コスト:', [dist[j] for j in range(1, n + 1)])
print('networkx の最短経路(1から7):', path[7])
print('自作実装と一致:', [dist[j] for j in range(1, n + 1)] == v[1:] and path[7] == route)
networkx の最短コスト: [0, 20, 10, 40, 15, 25, 50] networkx の最短経路(1から7): [1, 3, 5, 2, 4, 7] 自作実装と一致: True
6. 隣接行列¶
グラフは行列でも表現できる。ノード数 $n$ のグラフに対して、ノード $i$ から $j$ へのエッジが存在するとき $A_{ij} = 1$、存在しないとき $A_{ij} = 0$ とする $n \times n$ 行列 $A$ を隣接行列と呼ぶ。例えばノード1〜4が環状につながったグラフ(1-2、2-3、3-4、4-1)の隣接行列は
$$ A = \begin{pmatrix} 0 & 1 & 0 & 1 \\ 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \\ 1 & 0 & 1 & 0 \end{pmatrix} $$
である。無向グラフの隣接行列は対称になる。
C = nx.Graph()
C.add_edges_from([(1, 2), (2, 3), (3, 4), (4, 1)])
A = nx.to_numpy_array(C, nodelist=[1, 2, 3, 4], dtype=int)
print(A)
[[0 1 0 1] [1 0 1 0] [0 1 0 1] [1 0 1 0]]
グラフを行列として表現すると、数学的な操作や解釈が可能になる。例えば道路ネットワーク上の施設配置問題は、隣接行列 $A$ と0-1変数のベクトルを使って行列形式で定式化できる。またスペクトルグラフ理論、グラフ深層学習、グラフデータマイニングといった発展分野も、この行列表現の上に築かれている。
7. 道路ネットワークへの応用¶
本資料で学んだ道具は、研究室の他の教材でそのまま使われている。道路ネットワークの持ち方(ノードとエッジのCSV、networkx でのグラフ構築)は「道路ネットワークデータの扱い方」(optimization/road-network/road_network.ipynb)にまとめてある。その上で、最短経路(ダイクストラ法)を使って距離を測り、配送ルートを組むのがVRP教材、交通流を捕捉する施設を置くのがFCLM教材とFRLM教材である。グラフ理論はこれらすべての土台になっている。
8. 注意事項¶
- 用語(次数、連結、単純グラフなど)は定義に立ち返って確認すること。グラフ理論は用語さえ正確に押さえれば、絵を描いて考えられる分野である。
- ダイクストラ法は必ず一度、この資料の例題を紙の上で手でなぞること。$v$ と $p$ の表が自力で再現できれば理解できている。
- 理解できない場合には指導教員まで相談すること。