サーキット・ツアー制約 ― 1本のループで順番を決める#
この章で学ぶこと#
CP-SAT の add_circuit() は、すべての頂点を1回ずつ通る1本のループを作る制約です。経路や順番を決める問題は、これ1つで書けます。
add_circuit()の使い方(辺のリストとブール変数)通らなくてよい頂点を、自己ループ
(i, i)で表す特定の辺を必ず通す、特定の頂点だけ省く、
src → dstの経路を探すナイトツアーとグレーコードを、回路として解く
add_circuit() とは#
経路やツアーを決める問題は、DNA の断片をどの順でつなぐか、実験の順番をどう組むか、といった場面で出てきます。
add_circuit() は (始点, 終点, ブール変数) のリストを受け取り、変数が真の辺だけを使ったときに、すべての頂点をちょうど1回ずつ通る1本の閉路になることを保証します。
頂点は 0 から連番で指定する(抜けがあると回路が作れない)
(i, i, var)の自己ループを入れると、その頂点を回路から外せる
まず、次の無向グラフで試します。
graph
0 <--> 1
0 <--> 2
0 <--> 3
1 --> 2
1 <--> 3
2 <--> 3
from ortools.sat.python import cp_model
from helper.ortools import get_all_solutions
import numpy as np
# 無向グラフを、両方向の有向辺にする
edges = [(0, 1), (0, 2), (0, 3), (1, 0), (1, 2), (1, 3),
(2, 0), (2, 3), (3, 0), (3, 1), (3, 2)]
def create_circuit_model(edges, include_edges=[]):
"""辺のリストから、回路を探すモデルを作る"""
edges = set(edges)
model = cp_model.CpModel()
if include_edges:
for edge in include_edges:
if edge not in edges:
edges.add(edge)
variables = {key: model.new_bool_var(str(key)) for key in edges}
if include_edges:
# 必ず通す辺は True に固定する
model.add_bool_and([variables[edge] for edge in include_edges])
model.add_circuit([(s, e, v) for (s, e), v in variables.items()])
return model, variables
1つの回路を求める#
solver.solve() で解き、各辺のブール変数の値を取り出します。
model, variables = create_circuit_model(edges)
solver = cp_model.CpSolver()
solver.solve(model)
[(s, e, solver.value(v)) for (s, e), v in variables.items()]
[(0, 1, 0),
(1, 2, 0),
(3, 1, 1),
(0, 3, 0),
(2, 0, 0),
(3, 0, 0),
(2, 3, 1),
(0, 2, 1),
(1, 0, 1),
(3, 2, 0),
(1, 3, 0)]
True の辺だけを見ると、回路になっているはずです。ただ、この形では読みにくいので、頂点の並びに変換する関数を用意します。
def get_circuit(solution, start=None):
"""True の辺から、頂点の順番を取り出す"""
# 自己ループ(通らない頂点)は除く
solution = {s: e for (s, e), v in solution.items() if v and s != e}
if start is None:
start = next(iter(solution.keys()))
path = [start]
while True:
if start not in solution:
break
start = solution[start]
if start in path:
break
path.append(start)
return path
def find_all_circuits(edges, include_edges=[], start=None):
"""すべての回路を求める"""
model, variables = create_circuit_model(edges, include_edges)
solutions = get_all_solutions(model, variables)
return [get_circuit(sol, start) for sol in solutions]
def find_one_circuit(edges, include_edges=[], start=None):
"""1つの回路を求める"""
model, variables = create_circuit_model(edges, include_edges)
solver = cp_model.CpSolver()
solver.solve(model)
sol = {key: solver.value(v) for key, v in variables.items()}
return get_circuit(sol, start)
すべての回路を表示します。
for circuit in find_all_circuits(edges):
print(circuit)
[3, 1, 0, 2]
[1, 2, 0, 3]
[0, 1, 3, 2]
[0, 1, 2, 3]
特定の辺を必ず通す#
include_edges に辺を渡すと、その辺を True に固定します。以下は、必ず辺 (2, 1) を通る回路です。
for circuit in find_all_circuits(edges, include_edges=[(2, 1)]):
print(circuit)
[2, 1, 3, 0]
[2, 1, 0, 3]
頂点を通らなくてよいことにする#
(n, n) という自己ループを辺のリストに足すと、その頂点を回路から外せます。以下は、頂点2と3を省略できるようにした例です。
for circuit in find_all_circuits(edges + [(2, 2), (3, 3)]):
print(circuit)
[3, 1, 0]
[3, 1, 0, 2]
[1, 2, 0, 3]
[0, 1, 3]
[0, 1, 3, 2]
[0, 1]
[0, 1, 2]
[0, 1, 2, 3]
src から dst への経路を探す#
src から dst への経路は、「dst → src の辺を必ず通る回路」として求められます。経由しない頂点には自己ループを足しておきます。
def find_all_paths(edges, src, dst):
return find_all_circuits(edges, include_edges=[(dst, src)], start=src)
for path in find_all_paths(edges + [(1, 1), (2, 2)], 0, 3):
print(path)
[0, 2, 3]
[0, 3]
[0, 1, 3]
[0, 1, 2, 3]
例:ナイトツアー問題#
ナイトツアー問題は「すべてのマスを1回ずつ訪れる」問題なので、回路そのものです。チェスのナイトが1歩で移動できるマス同士を辺にします。
n = 8
moves = [(1, 2), (2, 1), (-1, 2), (2, -1), (-2, 1), (1, -2), (-1, -2), (-2, -1)]
board = np.arange(0, n ** 2).reshape(n, n)
# ナイトが1歩で行けるマスを辺にする
edges = []
for (r, c), v in np.ndenumerate(board):
for dr, dc in moves:
r2, c2 = r + dr, c + dc
if 0 <= r2 < n and 0 <= c2 < n:
edges.append((int(v), int(board[r2, c2])))
steps = find_one_circuit(edges)
# 何番目に訪れたかを盤面に書き込む
solution = np.zeros((n, n), np.uint8)
for i, step in enumerate(steps):
r, c = step // n, step % n
solution[r, c] = i
print(solution)
[[18 39 20 57 16 53 2 55]
[41 58 17 52 21 56 45 4]
[38 19 40 15 44 3 54 1]
[59 42 27 22 51 46 5 10]
[26 37 60 43 14 9 0 47]
[31 28 33 50 23 6 11 8]
[36 25 30 61 34 13 48 63]
[29 32 35 24 49 62 7 12]]
例:グレーコード#
グレーコードも「1ビットだけ違う数を順に並べる」問題なので、回路で表せます。1ビットだけ違う整数同士を辺にします。
n = 4
bits = {2 ** i for i in range(n)}
edges = []
for i in range(2 ** n):
for j in range(2 ** n):
if i != j and (i ^ j) in bits:
edges.append((i, j))
codes = find_one_circuit(edges, start=0)
for code in codes:
print(f"{code:0{n}b}")
0000
1000
1100
1101
1001
1011
1111
1110
1010
0010
0110
0111
0011
0001
0101
0100
まとめ#
やりたいこと |
書き方 |
|---|---|
すべての頂点を通る回路 |
|
特定の辺を必ず通す |
その辺の変数を |
頂点を通らなくてよい |
自己ループ |
|
|
ツアーの順番を取り出す |
真の辺をたどる( |
頂点は 0 から連番で指定する
自己ループは「その頂点を使わない」という意味になる
ナイトツアーやグレーコードのように、「順番に1回ずつ」という問題は回路に翻訳できる