巡回セールスマン問題¶
巡回セールスマン問題(TSP)は,すべての頂点をちょうど1回ずつ訪問して出発点に戻る最短巡回路を求める問題です. 頂点は平面上に配置され,巡回路の長さはユークリッド距離で測られるものとします.
以下の図は,9頂点と最適巡回路の例を示しています:
TSPのQUBO定式化
巡回路は頂点の順列として表現できます. そこで,TSPの解を符号化するために置換行列を使用します.
\(X=(x_{i,j})\)(\(0\leq i,j\leq n-1\))を \(n\times n\) のバイナリ値の行列とします. 行列 \(X\) は置換行列であり,各行と各列にちょうど1つの1が含まれます.以下に例を示します.
\(x_{k,i}\) を「巡回路の \(k\) 番目の位置が頂点 \(i\) である」と解釈します. したがって,\(X\) のすべての行とすべての列はone-hotでなければなりません.すなわち以下の制約が成り立つ必要があります:
\(d_{i,j}\) を頂点 \(i\) と \(j\) の間の距離とします. 置換行列 \(X\) に対する巡回路の長さは次のように書けます:
この式は,頂点 \(i\) が位置 \(k\) で訪問され,頂点 \(j\) が次の位置(\((k+1)\bmod n\))で訪問されるときにちょうど \(d_{i,j}\) を加算するので,巡回路の総距離に等しくなります.
TSPのHi-QUBOプログラム
上記の置換行列による定式化を用いて,TSPのHi-QUBOプログラムを以下のように記述できます:
#include <cmath>
#include <qbpp/qbpp.hpp>
#include <qbpp/easy_solver.hpp>
#include <qbpp/graph.hpp>
class Nodes {
std::vector<std::pair<int, int>> nodes{{10, 12}, {33, 125}, {12, 226},
{121, 11}, {108, 142}, {111, 243},
{220, 4}, {210, 113}, {211, 233}};
public:
const std::pair<int, int>& operator[](std::size_t index) const {
return nodes[index];
}
std::size_t size() const { return nodes.size(); }
int dist(std::size_t i, std::size_t j) const {
auto [x1, y1] = nodes[i];
auto [x2, y2] = nodes[j];
const int dx = x1 - x2;
const int dy = y1 - y2;
return static_cast<int>(
std::llround(std::sqrt(static_cast<double>(dx * dx + dy * dy))));
}
};
int main() {
auto nodes = Nodes{};
auto x = qbpp::var("x", nodes.size(), nodes.size());
auto constraint = qbpp::sum(qbpp::vector_sum(x, 1) == 1) +
qbpp::sum(qbpp::vector_sum(x, 0) == 1);
auto objective = qbpp::toExpr(0);
for (size_t i = 0; i < nodes.size(); ++i) {
auto next_i = (i + 1) % nodes.size();
for (size_t j = 0; j < nodes.size(); ++j) {
for (size_t k = 0; k < nodes.size(); ++k) {
if (k != j) {
objective += nodes.dist(j, k) * x[i][j] * x[next_i][k];
}
}
}
}
auto f = objective + constraint * 1000;
f.simplify_as_binary();
auto solver = qbpp::EasySolver(f);
auto sol = solver.search({{"time_limit", 1.0}});
auto tour = qbpp::onehot_to_int(sol(x));
std::cout << "Tour: " << tour << "\n";
auto graph = qbpp::graph::GraphDrawer();
for (size_t i = 0; i < nodes.size(); ++i) {
graph.add(qbpp::graph::Node(i).position(nodes[i].first, nodes[i].second));
}
for (size_t i = 0; i < nodes.size(); ++i) {
auto from = tour[i];
auto to = tour[(i + 1) % nodes.size()];
graph.add(qbpp::graph::Edge(from, to).color("red").penwidth(2).directed());
}
graph.write("tsp_solution.svg");
}
このプログラムでは,頂点 0 から 8 の座標が Nodes オブジェクト nodes に格納されています.
バイナリ変数の2次元配列 x を作成し,one-hot制約と巡回路長の目的関数を構成します.
これらの項は,制約にペナルティ重み(ここでは 1000)を付けて加算することで,1つのQUBO式 f にまとめられます.実行可能性が優先されます.
次に,1.0秒の制限時間で EasySolver を使って f を解きます.
得られた割り当て sol(x) は置換行列を形成します.
この行列は qbpp::onehot_to_int() を使って整数のリスト(順列)tour に変換され,出力されます.
最後に,計算された tour が有向グラフとして描画され,ファイル tsp_solution.svg に保存されます.
このプログラムは以下の出力を生成します:
Tour: {7,8,5,2,4,1,0,3,6}
正確な距離(double)で最適化する
上の dist 関数は,デフォルトの coeff_t が整数型のため,各ユークリッド距離を std::llround で最も近い整数に丸めています.丸めずに正確なツアー長を最適化したい場合は,実数(double)係数 を使います.ヘッダのインクルード前に DOUBLE_TYPE を定義し,dist が double を返すようにするだけです:
#define DOUBLE_TYPE // #include <qbpp/qbpp.hpp> の前
double dist(std::size_t i, std::size_t j) const {
auto [x1, y1] = nodes[i];
auto [x2, y2] = nodes[j];
const double dx = x1 - x2, dy = y1 - y2;
return std::sqrt(dx * dx + dy * dy); // 丸めなし
}
プログラムの残りは変更不要です.ソルバーは同じ最適巡回路を発見し,sol.energy() は丸めた距離の和ではなく,正確なツアー長を double(例: 960.443)で返します.
slice、concat、einsum を使った簡潔な目的関数
objective を構築する三重 for ループは数式
\(\sum_{i,j,k} d_{j,k}\, x_{i,j}\, x_{(i+1) \bmod n, k}\) をそのまま書き下したものですが,
2 つの補助配列を用意すれば
qbpp::einsum の 1 行で書き換えられます.
距離行列
d(形状 \(n \times n\),d[j][k] = nodes.dist(j, k))xを軸 0 で巡回シフトした変数行列x_next(x_next[i][k] = x[(i+1) % n][k],slice と concat で構築)
これらを使うと,目的関数は次の 1 行
qbpp::einsum<0>("jk,ij,ik->", d, x, x_next) に置き換わり,三重ループ全体が
不要になります.
// 距離行列を 2 次元の整数定数配列として作成
auto d = qbpp::array<qbpp::coeff_t>(nodes.size(), nodes.size());
for (size_t j = 0; j < nodes.size(); ++j)
for (size_t k = 0; k < nodes.size(); ++k)
d[j][k] = nodes.dist(j, k);
// x を軸 0 で巡回シフト:
// x_next[i, k] = x[(i+1) % n, k]
auto x_next = qbpp::concat(
x(qbpp::slice(1, qbpp::end), qbpp::all),
x(qbpp::slice(0), qbpp::all));
// Σ_{i,j,k} d[j,k] * x[i,j] * x_next[i,k]
auto objective = qbpp::einsum<0>("jk,ij,ik->", d, x, x_next);
qbpp::array<qbpp::coeff_t>(nodes.size(), nodes.size()) は 0 で初期化された
2 次元の Array<2, coeff_t> を確保し,そこへ実行時に計算した距離値を
代入します.slice と concat で x の先頭行を末尾に移動し,巡回シフト
された行列 x_next を作っています.subscript "jk,ij,ik->" によって
d と x の間で j を,d と x_next の間で k を,x と x_next
の間で i を共有させ,i, j, k すべてを総和してスカラー目的関数を得ます.
ループ版にあった if (k != j) の対角項スキップは不要です.
nodes.dist(j, j) は常に 0 なので,対角項は自動的に消えます.
得られる QUBO 式の項集合はループ版と完全に同じですが,構築コードは大幅に
短くなり,しかも einsum は内部で複数 CPU スレッドを使って並列に式を構築
するため,\(n\) が大きい場合は高速化も期待できます.
最初の頂点の固定
一般性を失うことなく,頂点0を巡回路の出発点と仮定できます. TSPの巡回路は巡回シフトに対して不変であるため,出発位置を固定しても最適巡回路長は変わりません.
出発頂点を固定することで,QUBO式中のバイナリ変数の数を削減できます. 具体的には,置換行列において頂点0を位置0に割り当てることを強制します. そのために,以下のバイナリ変数を固定します:
これらの割り当てにより,頂点0は位置0にのみ現れ,他の頂点は位置0に割り当てられません. 結果として,実効的な問題サイズが削減され,局所探索ベースのソルバーにとってQUBOが解きやすくなります.
最初の頂点を固定するHi-QUBOプログラム
Hi-QUBOでは,固定された変数の割り当てを qbpp::replace() 関数を使って適用できます:
qbpp::MapList ml;
ml.push_back({x[0][0], 1});
for (size_t i = 1; i < nodes.size(); ++i) {
ml.push_back({x[i][0], 0});
ml.push_back({x[0][i], 0});
}
auto g = qbpp::replace(f, ml);
g.simplify_as_binary();
auto solver = qbpp::EasySolver(g);
auto sol = solver.search({{"time_limit", 1.0}});
auto full_sol = qbpp::Sol(f).set(sol).set(ml);
auto tour = qbpp::onehot_to_int(full_sol(x));
std::cout << "Tour: " << tour << "\n";
まず,変数の固定割り当てを格納する qbpp::MapList オブジェクト ml を作成します.
各割り当ては push_back() メンバ関数で追加されます.
次に,qbpp::replace(f, ml) を呼び出します.これは ml で指定された固定値を元のQUBO式 f に代入して得られる新しい式を返します.
結果の式は g に格納され,簡約化されます.
次に,g に対するソルバーを作成して解 sol を得ます.
sol は縮小された問題に対応するため,f に対する qbpp::Sol オブジェクトを作成し,ソルバーの出力 sol と固定割り当て ml の両方を設定します.
結果の full_sol は x のすべての変数に対する完全な割り当てを格納します.
最後に,full_sol(x) で表される置換行列が qbpp::onehot_to_int() を使って順列に変換され,出力されます.
このプログラムは頂点0から始まる以下の巡回路を生成します:
Tour: {0,3,6,7,8,5,2,4,1}
巡回セールスマン問題(TSP)は,すべての頂点をちょうど1回ずつ訪問して出発点に戻る最短巡回路を求める問題です. 頂点は平面上に配置され,巡回路の長さはユークリッド距離で測られるものとします.
以下の図は,9頂点と最適巡回路の例を示しています:
TSPのQUBO定式化
巡回路は頂点の順列として表現できます. そこで,TSPの解を符号化するために置換行列を使用します.
\(X=(x_{i,j})\)(\(0\leq i,j\leq n-1\))を \(n\times n\) のバイナリ値の行列とします. 行列 \(X\) は置換行列であり,各行と各列にちょうど1つの1が含まれます.以下に例を示します.
\(x_{k,i}\) を「巡回路の \(k\) 番目の位置が頂点 \(i\) である」と解釈します. したがって,\(X\) のすべての行とすべての列はone-hotでなければなりません.すなわち以下の制約が成り立つ必要があります:
\(d_{i,j}\) を頂点 \(i\) と \(j\) の間の距離とします. 置換行列 \(X\) に対する巡回路の長さは次のように書けます:
この式は,頂点 \(i\) が位置 \(k\) で訪問され,頂点 \(j\) が次の位置(\((k+1)\bmod n\))で訪問されるときにちょうど \(d_{i,j}\) を加算するので,巡回路の総距離に等しくなります.
TSPのPyQBPPプログラム
上記の置換行列による定式化を用いて,TSPのPyQBPPプログラムを以下のように記述できます:
import math
import pyqbpp as qbpp
nodes = [(10, 12), (33, 125), (12, 226),
(121, 11), (108, 142), (111, 243),
(220, 4), (210, 113), (211, 233)]
def dist(i, j):
dx = nodes[i][0] - nodes[j][0]
dy = nodes[i][1] - nodes[j][1]
return round(math.sqrt(dx * dx + dy * dy))
n = len(nodes)
x = qbpp.var("x", shape=(n, n))
constraint = qbpp.sum(qbpp.vector_sum(x, axis=1) == 1) + \
qbpp.sum(qbpp.vector_sum(x, axis=0) == 1)
objective = qbpp.expr()
for i in range(n):
next_i = (i + 1) % n
for j in range(n):
for k in range(n):
if k != j:
objective += dist(j, k) * x[i][j] * x[next_i][k]
f = objective + constraint * 1000
f.simplify_as_binary()
solver = qbpp.EasySolver(f)
sol = solver.search(time_limit=1.0)
# 置換行列から巡回路(頂点番号のリスト)を抽出
tour = []
for i in range(n):
for j in range(n):
if sol(x[i][j]) == 1:
tour.append(j)
break
print(f"Tour: {tour}")
このプログラムでは,頂点 0 から 8 の座標がリスト nodes に格納されており,ヘルパー関数 dist(i, j) が2頂点間の丸めたユークリッド距離を計算します.
バイナリ変数の2次元配列 x を作成し,one-hot制約と巡回路長の目的関数を構成します.
これらの項は,制約にペナルティ重み(ここでは 1000)を付けて加算することで,1つのQUBO式 f にまとめられます.実行可能性が優先されます.
次に,1.0秒の制限時間で EasySolver を使って f を解きます.
得られた割り当て sol(x) は置換行列を形成します.
この行列を,各行で値が1のエントリを探すことで整数のリスト(順列)tour に変換し,出力します.
このプログラムは以下の出力を生成します:
Tour: [7, 8, 5, 2, 4, 1, 0, 3, 6]
正確な距離(double)で最適化する
上の dist 関数は,デフォルトの係数型が整数のため,各ユークリッド距離を round() で最も近い整数に丸めています.丸めずに正確なツアー長を最適化したい場合は,実数(double)係数 を使います.pyqbpp.d をインポートし,round() を外すだけです:
import pyqbpp.d as qbpp # import pyqbpp as qbpp の代わりに
def dist(i, j):
dx = nodes[i][0] - nodes[j][0]
dy = nodes[i][1] - nodes[j][1]
return math.sqrt(dx * dx + dy * dy) # round() なし
プログラムの残りは変更不要です.ソルバーは同じ最適巡回路を発見し,sol.energy は丸めた距離の和ではなく,正確なツアー長を float(例: 960.443)で返します.
スライス、concat、einsum を使った簡潔な目的関数
objective を構築する三重 for ループは数式
\(\sum_{i,j,k} d_{j,k}\, x_{i,j}\, x_{(i+1) \bmod n, k}\) をそのまま書き下したものですが,
2 つの補助配列を用意すれば
qbpp.einsum の 1 行で書き換えられます.
距離行列
d(形状 \(n \times n\),d[j, k] = dist(j, k))xを軸 0 で巡回シフトした変数行列x_next(x_next[i, k] = x[(i+1) % n, k],スライスとconcatで構築)
これらを使うと,目的関数は次の 1 行
qbpp.einsum("jk,ij,ik->", d, x, x_next) に置き換わり,三重ループ全体が
不要になります.
# 距離行列を 2 次元の整数配列として作成
d = qbpp.array([dist(j, k) for j in range(n) for k in range(n)],
shape=(n, n))
# x を軸 0 で巡回シフト:
# x_next[i, k] = x[(i+1) % n, k]
x_next = qbpp.concat([x[1:], x[:1]], axis=0)
# Σ_{i,j,k} d[j,k] * x[i,j] * x_next[i,k]
objective = qbpp.einsum("jk,ij,ik->", d, x, x_next)
qbpp.array(..., shape=(n, n)) は平坦化したリストから \(n \times n\) の距離行列を
作成します.x[1:] と x[:1] で x の行をスライスし,qbpp.concat([...], axis=0) で連結することで,x を 1 行だけ巡回シフトした x_next が得られます.
subscript "jk,ij,ik->" によって d と x の間で j を,
d と x_next の間で k を,x と x_next の間で i を共有させ,
i, j, k すべてを総和してスカラー目的関数を得ます.
ループ版にあった if k != j の対角項スキップは不要です.
dist(j, j) は常に 0 なので,対角項は自動的に消えます.
得られる QUBO 式の項集合はループ版と完全に同じですが,構築コードは大幅に
短くなります.さらに \(n\) が大きい場合は,einsum 版は縮約全体が C++
バックエンド内でマルチスレッド実行されるため,ループ版で 1 反復ごとに
発生する Python の ctypes オーバーヘッドを回避でき,大幅に高速になります.
最初の頂点の固定
一般性を失うことなく,頂点0を巡回路の出発点と仮定できます. TSPの巡回路は巡回シフトに対して不変であるため,出発位置を固定しても最適巡回路長は変わりません.
出発頂点を固定することで,QUBO式中のバイナリ変数の数を削減できます. 具体的には,置換行列において頂点0を位置0に割り当てることを強制します. そのために,以下のバイナリ変数を固定します:
これらの割り当てにより,頂点0は位置0にのみ現れ,他の頂点は位置0に割り当てられません. 結果として,実効的な問題サイズが削減され,局所探索ベースのソルバーにとってQUBOが解きやすくなります.
最初の頂点を固定するPyQBPPプログラム
PyQBPPでは,固定された変数の割り当てを,変数から値へのPython辞書を qbpp.replace() 関数に渡すことで適用できます:
ml = {x[0][0]: 1}
ml.update({x[i][0]: 0 for i in range(1, n)})
ml.update({x[0][i]: 0 for i in range(1, n)})
g = qbpp.replace(f, ml)
g.simplify_as_binary()
solver = qbpp.EasySolver(g)
sol = solver.search(time_limit=1.0)
full_sol = qbpp.Sol(f).set(sol, ml)
# 置換行列から巡回路(頂点番号のリスト)を抽出
tour = []
for i in range(n):
for j in range(n):
if full_sol(x[i][j]) == 1:
tour.append(j)
break
print(f"Tour: {tour}")
まず,変数の固定割り当てを格納するPython辞書 ml を作成します.
各キーはバイナリ変数,各値はその固定値(0 または 1)です.
次に,qbpp.replace(f, ml) を呼び出します.これは ml で指定された固定値を元のQUBO式 f に代入して得られる新しい式を返します.
結果の式は g に格納され,簡約化されます.
次に,g に対するソルバーを作成して解 sol を得ます.
sol は縮小された問題に対応するため,f に対する qbpp.Sol オブジェクトを作成し,ソルバーの出力 sol と固定割り当て ml の両方を set(sol, ml) で設定します.
結果の full_sol は x のすべての変数に対する完全な割り当てを格納します.
最後に,full_sol(x) で表される置換行列を各行の走査により順列に変換し,出力します.
このプログラムは頂点0から始まる以下の巡回路を生成します:
Tour: [0, 3, 6, 7, 8, 5, 2, 4, 1]
matplotlibによる可視化
以下のコードはTSPの解を可視化します.各頂点はラベル付きの点として描画され,巡回路の各辺は赤い有向矢印として表示されます:
import matplotlib.pyplot as plt
plt.figure(figsize=(6, 6))
for i, (nx_, ny) in enumerate(nodes):
plt.plot(nx_, ny, "ko", markersize=8)
plt.annotate(str(i), (nx_, ny), textcoords="offset points", xytext=(5, 5))
for i in range(n):
fr = tour[i]
to = tour[(i + 1) % n]
plt.annotate("", xy=(nodes[to][0], nodes[to][1]),
xytext=(nodes[fr][0], nodes[fr][1]),
arrowprops=dict(arrowstyle="->", color="#e74c3c", lw=2))
plt.title("TSP Tour")
plt.savefig("tsp.png", dpi=150, bbox_inches="tight")
plt.show()
巡回路は,訪問順に頂点を結ぶ赤い有向矢印で表示されます.