ナイトツアー ― すべてのマスを1回ずつ訪れるルートを探す#
この章で学ぶこと#
チェスのナイト(騎士)が、8×8 の盤面のすべてのマスを1回ずつ訪れるルートを探します。最後はスタート地点に戻る「閉じたルート」にします。
各マスに「何番目に訪れたか」を表す整数を割り当てる
「隣り合う番号はナイトの移動先にある」を
PbEq()で書くルールをそのまま制約にすると、Z3 が数秒でルートを見つける
ナイトツアー問題は、OrToolsでナイトツアー問題を解く でも扱っています。
考え方#
各マスに 0 から 63 までの整数を割り当て、**その数が「何番目に訪れたか」**を表すようにします。
すべてのマスの値は 0〜63 で、すべて異なる(
Distinct)値が
cのマスのとなりのどこかに、値c+1のマスがある値が
cのマスのとなりのどこかに、値c-1のマスがある最後(63番目)のマスのとなりに、0番目のマスがある(閉じたルートにするため)
「となり」は、ナイトの動き(L字型の8方向)で行けるマスだけです。
この制約は、ナイトが移動できる範囲でしか満たせないので、自然にルートが決まります。
from z3 import *
from itertools import product
# ナイトの8方向の動き
KNIGHT_MOVES = [(1, 2), (2, 1), (-1, 2), (2, -1), (-2, 1), (1, -2), (-1, -2), (-2, -1)]
コード#
盤面の各マスに整数変数を作り、制約を追加します。
w, h = 8, 8
N = w * h
cells = {}
solver = Solver()
for i, j in product(range(h), range(w)):
c = cells[i, j] = Int(f'c_{i}_{j}')
solver.add(c >= 0, c <= N - 1)
# 訪れる順番はすべて異なる
solver.add(Distinct(list(cells.values())))
for i, j in product(range(h), range(w)):
c = cells[i, j]
neighbours = [cells[i + di, j + dj] for di, dj in KNIGHT_MOVES
if (i + di, j + dj) in cells]
next_is = [(x == c + 1, 1) for x in neighbours] # となりに c+1 があるか
prev_is = [(x == c - 1, 1) for x in neighbours] # となりに c-1 があるか
start_is = [(x == 0, 1) for x in neighbours] # となりに 0 があるか
# 最後のマス(63)以外は、となるどこに c+1 がある
solver.add(Implies(c < N - 1, PbEq(next_is, 1)))
# 最初のマス(0)以外は、となるどこに c-1 がある
solver.add(Implies(c > 0, PbEq(prev_is, 1)))
# 最後のマス(63)は、となるどこに 0 がある(閉じたルート)
solver.add(Implies(c == N - 1, PbEq(start_is, 1)))
%time solver.check()
m = solver.model()
for i in range(h):
for j in range(w):
print(f'{m[cells[i, j]].as_long():2d}', end=' ')
print()
CPU times: total: 1.86 s
Wall time: 1.88 s
63 60 45 10 47 58 23 20
44 11 62 59 24 21 48 57
61 0 9 46 27 56 19 22
12 43 36 3 54 25 28 49
37 8 1 26 35 4 55 18
42 13 40 53 2 31 50 29
7 38 15 34 5 52 17 32
14 41 6 39 16 33 30 51
表のように、各マスに「何番目に訪れたか」が入りました。0 から 63 までが1回ずつ現れていることを確認してください。
見つけたルートを検算する#
Z3 が出した答えが本当にナイトツアーになっているか、自分で確かめるコードも書いておきます。
def check_tour(board):
"""各マスの値を、ナイトの移動順に並べて確認する"""
h, w = len(board), len(board[0])
pos = {board[i][j]: (i, j) for i in range(h) for j in range(w)}
if sorted(pos) != list(range(h * w)):
return "番号が 0〜63 の1回ずつになっていません"
for k in range(h * w):
i, j = pos[k]
i2, j2 = pos[(k + 1) % (h * w)]
if (i2 - i, j2 - j) not in KNIGHT_MOVES:
return f"{k} 番目から {k + 1} 番目がナイトの動きになっていません"
return "OK: ナイトの動きで 0 → 1 → … → 63 → 0 と一周しています"
tour = [[m[cells[i, j]].as_long() for j in range(w)] for i in range(h)]
print(check_tour(tour))
OK: ナイトの動きで 0 → 1 → … → 63 → 0 と一周しています
制約の意味#
制約 |
意味 |
|---|---|
|
最後以外のマスは、となるどこに |
|
最初以外のマスは、となるどこに |
|
最後のマスは、となるどこに |
|
0〜63 が重複しない |
「c+1 のマスがある」と「c-1 のマスがある」は、片方だけでも同じ意味になります(c+1 のマスから見れば c は c-1 です)。両方書いておくと、Z3 が探索しやすくなります。
PbEq() とは#
PbEq(args, k) は、重み付きの合計がちょうど k になるという制約です。args は (条件, 重み) のリストで、条件が True なら重みが加算されます。
例:重みが 1 の条件が2つ真、重み 2 が1つ真なら合計 4 です。
x, y, z = Bools('x y z')
s = Solver()
s.add(PbEq([(x, 1), (y, 2), (z, 3)], 5))
s.check()
print(s.model())
[y = True, z = True, x = False]
x が1点、y が2点、z が3点で、合計がちょうど 5 になる組み合わせが選ばれました(y と z が真)。
ナイトツアーのコードでは、どの条件の重みも 1 にしています。つまり PbEq(..., 1) は**「条件が真になるのはちょうど1つ」**という意味になります。
Sum(...) == 1 でも同じことが書けますが、PbEq() のほうが「ちょうど1つ」という意図がはっきりして、Z3 も速く解けます。
まとめ#
移動の順番を各マスの整数にすると、「隣どうしがナイトの移動先」という制約でルートが決まる
Distinctで 0〜63 を1回ずつ使うことを保証する「ちょうど1つ」は
PbEq([...], 1)で書ける出てきた答えは、自分で検算するコードを用意すると安心(
check_tour())