22. 組合せ最適化のヒューリスティクス
テーマ: 配送ルート最適化(巡回セールスマン問題)を貪欲法+2-opt で解く
学習点: 総当たりの計算量, 近傍探索, 局所最適とランダム再スタート, itertools.permutations, 計算時間の計測
依存: 標準ライブラリのみ / 難易度: 上級
実行方法
uv run 22_tsp_heuristic.py
スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは
uv が自動的に仮想環境へ導入します。事前の pip install は不要です。
解説
何をするプログラムか
営業所を出発して 10 か所の配送先をすべて 1 回ずつ回り、営業所に戻る最短ルートを探す――物流業界で日常的に現れる巡回セールスマン問題(TSP)です。このスクリプトは同じ 11 拠点の問題を 4 つの方法で解き、解の品質と計算時間を比較します。
比較するのは、(1) 全経路を調べ上げる総当たりの厳密解、(2) 「いま居る場所から最も近い未訪問地点へ行く」を繰り返す最近傍法(貪欲法)、(3) その結果を 2-opt 近傍探索で磨いた解、(4) 出発点を変えて繰り返すマルチスタート、の 4 つです。最後に総当たりの経路数が n とともに爆発する様子を示し、なぜ実務でヒューリスティクスが不可欠なのかを体感させます。
コードの読みどころ
brute_force()はitertools.permutations(range(1, n))で出発点 0 を固定した全順列 (n−1)! = 3,628,800 通りを列挙します。標準ライブラリだけで全探索が書ける好例です。tour_length()は添字(i + 1) % len(tour)の剰余演算で「最後の拠点から出発点へ戻る辺」も含めて合計します。nearest_neighbor()は未訪問拠点をsetで管理し、min(unvisited, key=lambda j: D[last][j])の key 引数で最も近い次の訪問先を選びます。貪欲法の典型的な書き方です。two_opt()は経路中の 2 辺を繋ぎ替えたときの距離変化をdelta = (D[a][c] + D[b][d]) - (D[a][b] + D[c][d])と差分だけ計算し、改善するならbest[i:j + 1] = reversed(best[i:j + 1])とスライス代入で区間を反転します。全長を再計算しないため高速です。- 各手法を
time.perf_counter()で計測し、解の品質(厳密解比)と計算時間をセットで報告します。最適化アルゴリズムを評価する基本作法です。 - マルチスタートは
for s in range(n)で出発点を変えて最近傍+2-opt を繰り返し、局所最適への「はまり込み」を軽減します。
理論的背景
TSP は NP 困難で、n 都市の巡回路は向きの対称性を考慮して (n−1)!/2 通りあります。n=12 で約 2,000 万通り、n=25 では宇宙年齢を超える計算時間になり、厳密解法は小規模問題にしか使えません。2-opt は経路中の 2 辺を繋ぎ替える局所探索(Croes 1958)で、理論保証はないものの実用上は最適解の数 % 以内に収まることが多いとされます。局所最適に陥る弱点は、初期解を変えるランダム再スタート(マルチスタート)で補います。
実行結果の見方
厳密解は距離 310.001 ですが 6.38 秒かかっています。一方、最近傍法は 0.00004 秒で解を出すものの +3.13% 長い経路です。ここに 2-opt を加えると、ほぼゼロ秒のまま厳密解と同じ 310.001 に到達しました。「最適経路」と「2-opt 解」の並びが逆順なのは、同じ巡回路を逆回りに表示しているだけで実質同一です。
最後の「計算量の壁」の表が本題です。総当たりは n=12 で 4 秒、n=15 で 2.4 時間、n=20 で 386 年、n=25 では約 20 億年と爆発します。拠点が数十を超える現実の配送計画では、厳密解をあきらめてヒューリスティクスや分枝限定法を使う理由がこの数字に集約されています。
ソースコード
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""22: 組合せ最適化のヒューリスティクス -----------------------------------
テーマ: 配送ルート最適化(巡回セールスマン問題)を貪欲法+2-opt で解く
学習点: 総当たりの計算量, 近傍探索, 局所最適とランダム再スタート,
itertools.permutations, 計算時間の計測
根拠: TSP は NP困難で、n都市の総経路数は (n-1)!/2。n=12 で約2000万通り。
2-opt は経路中の2辺を繋ぎ替える局所探索で、実用上 最適解の
数%以内に収まることが多い(Croes 1958)。
"""
import itertools
import math
import random
import time
random.seed(3)
def distance_matrix(pts):
n = len(pts)
return [[math.dist(pts[i], pts[j]) for j in range(n)] for i in range(n)]
def tour_length(tour, D):
return sum(D[tour[i]][tour[(i + 1) % len(tour)]] for i in range(len(tour)))
def brute_force(D):
n = len(D)
best, best_len = None, float("inf")
for perm in itertools.permutations(range(1, n)): # 出発点を固定
t = (0,) + perm
L = tour_length(t, D)
if L < best_len:
best, best_len = t, L
return list(best), best_len
def nearest_neighbor(D, start=0):
n = len(D)
unvisited = set(range(n)) - {start}
tour = [start]
while unvisited:
last = tour[-1]
nxt = min(unvisited, key=lambda j: D[last][j])
tour.append(nxt)
unvisited.remove(nxt)
return tour
def two_opt(tour, D, max_pass=100):
"""辺 (i-1,i) と (j,j+1) を反転して繋ぎ替える局所探索。"""
best = tour[:]
improved = True
n = len(best)
while improved and max_pass > 0:
improved = False
max_pass -= 1
for i in range(1, n - 1):
for j in range(i + 1, n):
a, b = best[i - 1], best[i]
c, d = best[j], best[(j + 1) % n]
if a == c or b == d:
continue
delta = (D[a][c] + D[b][d]) - (D[a][b] + D[c][d])
if delta < -1e-12:
best[i:j + 1] = reversed(best[i:j + 1])
improved = True
return best
def main() -> None:
n = 11
pts = [(random.uniform(0, 100), random.uniform(0, 100)) for _ in range(n)]
D = distance_matrix(pts)
print(f"配送先 {n} 拠点(1拠点目が営業所)")
print(f"総経路数 = (n-1)!/2 = {math.factorial(n-1)//2:,} 通り\n")
t0 = time.perf_counter()
opt, opt_len = brute_force(D)
t_bf = time.perf_counter() - t0
print(f"[厳密解 総当たり] 距離 {opt_len:8.3f} ({t_bf:.2f} 秒)")
t0 = time.perf_counter()
nn = nearest_neighbor(D)
nn_len = tour_length(nn, D)
print(f"[貪欲法 最近傍] 距離 {nn_len:8.3f} "
f"(厳密解比 +{nn_len/opt_len - 1:.2%}, {time.perf_counter()-t0:.5f} 秒)")
t0 = time.perf_counter()
imp = two_opt(nn, D)
imp_len = tour_length(imp, D)
print(f"[貪欲法 + 2-opt] 距離 {imp_len:8.3f} "
f"(厳密解比 +{imp_len/opt_len - 1:.2%}, {time.perf_counter()-t0:.5f} 秒)")
t0 = time.perf_counter()
best, best_len = None, float("inf")
for s in range(n): # 出発点を変えたマルチスタート
t = two_opt(nearest_neighbor(D, s), D)
L = tour_length(t, D)
if L < best_len:
best, best_len = t, L
print(f"[マルチスタート] 距離 {best_len:8.3f} "
f"(厳密解比 +{best_len/opt_len - 1:.2%}, {time.perf_counter()-t0:.5f} 秒)")
print(f"\n最適経路: {' -> '.join(map(str, opt))} -> {opt[0]}")
print(f"2-opt解 : {' -> '.join(map(str, best))} -> {best[0]}")
print("\n[計算量の壁] 総当たりの経路数")
for k in [8, 10, 12, 15, 20, 25]:
c = math.factorial(k - 1) // 2
secs = c / 5e6 # 1秒500万経路と仮定
unit = (f"{secs:.1f}秒" if secs < 60 else
f"{secs/60:.1f}分" if secs < 3600 else
f"{secs/3600:.1f}時間" if secs < 86400 * 365 else
f"{secs/(86400*365):.3g}年")
print(f" n={k:>3}: {c:>25,} 通り 概算計算時間 {unit}")
print(" ※ だからこそ実務ではヒューリスティクスや分枝限定法が使われる。")
if __name__ == "__main__":
main()
実行結果
配送先 11 拠点(1拠点目が営業所)
総経路数 = (n-1)!/2 = 1,814,400 通り
[厳密解 総当たり] 距離 310.001 (6.38 秒)
[貪欲法 最近傍] 距離 319.693 (厳密解比 +3.13%, 0.00004 秒)
[貪欲法 + 2-opt] 距離 310.001 (厳密解比 +0.00%, 0.00004 秒)
[マルチスタート] 距離 310.001 (厳密解比 +0.00%, 0.00046 秒)
最適経路: 0 -> 3 -> 1 -> 9 -> 8 -> 5 -> 6 -> 7 -> 10 -> 2 -> 4 -> 0
2-opt解 : 0 -> 4 -> 2 -> 10 -> 7 -> 6 -> 5 -> 8 -> 9 -> 1 -> 3 -> 0
[計算量の壁] 総当たりの経路数
n= 8: 2,520 通り 概算計算時間 0.0秒
n= 10: 181,440 通り 概算計算時間 0.0秒
n= 12: 19,958,400 通り 概算計算時間 4.0秒
n= 15: 43,589,145,600 通り 概算計算時間 2.4時間
n= 20: 60,822,550,204,416,000 通り 概算計算時間 386年
n= 25: 310,224,200,866,619,719,680,000 通り 概算計算時間 1.97e+09年
※ だからこそ実務ではヒューリスティクスや分枝限定法が使われる。