マインスイーパー ― 数字のヒントから安全なマスを推理する#
この章で学ぶこと#
マインスイーパーは、開いたマスの数字を手がかりに、地雷の場所を推理するパズルです。この章では、その推理を Z3 に任せます。
未開封のマスを
Bool変数で表す「周囲の数字=周囲の地雷の数」を制約にする
背理法で「絶対に安全なマス」と「絶対に地雷のマス」を決める(
push()/check()/pop())
人間が指で開けていく作業を、Z3 が代わりにやってくれるわけです。
マインスイーパーのルール#
盤面の数字は、そのマスの周囲8マスにある地雷の数を表します。数字のマスは開いていて安全、. はまだ開けていないマスです。
この章で作るプログラムは、与えられた盤面から地雷でないと断定できるマスを探します。
解き方の考え方#
マス1つ1つについて、次の2通りの仮定を試します。
「このマスに地雷がある」と仮定して、制約と矛盾するか調べる
矛盾する(
unsat)→ 地雷はありえない → 安全なマス
「このマスに地雷はない」と仮定して、制約と矛盾するか調べる
矛盾する(
unsat)→ 地雷が確定 → 地雷のマス
どちらも矛盾しない → まだ決められない(運が悪ければ開けると爆発する)
これを「背理法」と呼びます。仮定を足して矛盾が出れば、仮定が間違っていたと分かる、という考え方です。
一時的に足した仮定は、solver.push()(状態を保存)と solver.pop()(保存した状態に戻す)ではさんで、次のマスの判定に持ち越さないようにします。
コード#
3つの関数を作ります。
get_neighbors()… あるマスの周囲8マスを返すsolve_board()… 盤面を解いて、安全なマスと地雷のマスを返すshow_result()… 結果を盤面の形で表示する
from z3 import *
# 8方向(上下左右+斜め)
DIRECTIONS = [
(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1),
]
def get_neighbors(x, y, h, w):
"""マス (x, y) の周囲8マスのうち、盤面の中にあるものを返す"""
neighbors = []
for dx, dy in DIRECTIONS:
nx, ny = x + dx, y + dy
if 0 <= nx < w and 0 <= ny < h:
neighbors.append((nx, ny))
return neighbors
def solve_board(board_text):
"""盤面を解いて (安全なマス, 地雷のマス) を返す"""
rows = board_text.strip().split()
h, w = len(rows), len(rows[0])
# 数字マスの周囲にある未開封マスに、「地雷がある」を表す Bool 変数を作る
mine = {}
for y, row in enumerate(rows):
for x, v in enumerate(row):
if v == '.':
continue # 未開封マス自身は飛ばす
for nx, ny in get_neighbors(x, y, h, w):
if rows[ny][nx] == '.':
mine[nx, ny] = Bool(f'mine_{nx}_{ny}')
# 制約:数字マスの周囲で True になっている変数の数 = その数字
solver = Solver()
for y, row in enumerate(rows):
for x, v in enumerate(row):
if v == '.':
continue
around = [mine[loc] for loc in get_neighbors(x, y, h, w) if loc in mine]
if around:
solver.add(Sum(around) == int(v))
# 各マスに仮定を足して、矛盾するかどうかを調べる
safe, mines = [], []
for (x, y), var in mine.items():
solver.push()
solver.add(var) # 仮定:ここに地雷がある
if solver.check() == unsat: # 矛盾 → 地雷ではない
safe.append((x, y))
solver.pop()
solver.push()
solver.add(Not(var)) # 仮定:ここに地雷はない
if solver.check() == unsat: # 矛盾 → 地雷で確定
mines.append((x, y))
solver.pop()
return safe, mines
def show_result(board_text, safe, mines):
"""'#' は安全なマス、'*' は地雷のマス"""
board = [list(row) for row in board_text.strip().split()]
for x, y in mines:
board[y][x] = '*'
for x, y in safe:
board[y][x] = '#'
print("\n".join(" ".join(row) for row in board))
実行例#

この盤面を解いてみます。
board_str = """
...201..................
...201..................
...1013.................
...1102.................
....1011123.............
..21100012...22211122...
..2000012.33212.3111....
..421002.310002.3.122...
....3224.31000112111....
........4.20000001121...
.......43.20000012.11...
.......3122211001.212...
.......202.3.10011213...
......2103.42210001.213.
......1112.43.111233202.
......2.222..311.3..212.
.............2124.4.....
............111.3.......
........................
........................
"""
safe, mines = solve_board(board_str)
print(f"安全なマス: {len(safe)} 個")
print(f"地雷のマス: {len(mines)} 個")
show_result(board_str, safe, mines)
安全なマス: 43 個
地雷のマス: 54 個
. . * 2 0 1 # . . . . . . . . . . . . . . . . .
. . * 2 0 1 * # . . . . . . . . . . . . . . . .
. . # 1 0 1 3 * . . . . . . . . . . . . . . . .
. . # 1 1 0 2 * # # * # . . . . . . . . . . . .
. # # * 1 0 1 1 1 2 3 # # # * # # # * # # . . .
. . 2 1 1 0 0 0 1 2 * * * 2 2 2 1 1 1 2 2 . . .
. . 2 0 0 0 0 1 2 * 3 3 2 1 2 * 3 1 1 1 * # . .
. * 4 2 1 0 0 2 * 3 1 0 0 0 2 * 3 * 1 2 2 # . .
. . * * 3 2 2 4 * 3 1 0 0 0 1 1 2 1 1 1 * # . .
. . . * * # * * 4 * 2 0 0 0 0 0 0 1 1 2 1 # . .
. . . . . . * 4 3 * 2 0 0 0 0 0 1 2 * 1 1 # . .
. . . . . . * 3 1 2 2 2 1 1 0 0 1 * 2 1 2 * . .
. . . . . * * 2 0 2 * 3 * 1 0 0 1 1 2 1 3 * # .
. . . . . # 2 1 0 3 * 4 2 2 1 0 0 0 1 * 2 1 3 .
. . . . . # 1 1 1 2 * 4 3 * 1 1 1 2 3 3 2 0 2 .
. . . . . # 2 * 2 2 2 * * 3 1 1 * 3 * * 2 1 2 .
. . . . . . . # * # # # * 2 1 2 4 * 4 # . . # .
. . . . . . . . . . . # 1 1 1 * 3 * # # . . . .
. . . . . . . . . . . # # # # # # # . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . .
未開封のマスは111個あり、そのうち 43個が安全、54個が地雷と確定しました。残りの14個は、どちらと仮定しても矛盾しないので決められません。人間が遊ぶときも、この14個は最後まで分からない(運試しになる)マスです。
盤面の # が安全なマス、* が地雷のマスです。
コードの説明#
1. 変数を作る#
mine = {}
for y, row in enumerate(rows):
for x, v in enumerate(row):
if v == '.':
continue
for nx, ny in get_neighbors(x, y, h, w):
if rows[ny][nx] == '.':
mine[ny, nx] = Bool(f'mine_{ny}_{nx}')
mine は (x, y) → Bool 変数 の辞書です。変数の意味は「そのマスに地雷があるか」で、True が地雷あり、False が安全です。
数字マスの周囲にある未開封マスだけを作ります。数字と接していないマスは、ヒントが何もないので判定のしようがないからです。
2. 制約を追加する#
around = [mine[loc] for loc in get_neighbors(x, y, h, w) if loc in mine]
if around:
solver.add(Sum(around) == int(v))
数字マス v に対して、「周囲の未開封マスのうち地雷の数」が v と等しくなるように制約を追加します。
Sum(around) は Bool 変数の和です。Z3 では True を 1、False を 0 として足し算してくれるので、そのまま地雷の数になります。
around が空になるのは、数字の周りがすべて開いている場合です。そのときは足す制約がないので if around: で飛ばします。
3. 背理法で判定する#
solver.push()
solver.add(var) # 仮定:ここに地雷がある
if solver.check() == unsat: # 矛盾 → 地雷ではない
safe.append((x, y))
solver.pop()
呼び出し |
役割 |
|---|---|
|
今の制約を保存して、一時的な制約を足せるようにする |
|
仮定を追加する |
|
仮定を足した状態で解があるか調べる( |
|
保存した状態に戻す(仮定を捨てる) |
pop() を忘れると、前のマスの仮定が残ったまま次のマスを調べることになり、答えが狂います。push() と pop() は必ずペアで使ってください。
まとめ#
未開封マスを
Bool変数にすると、「周囲の地雷の数」はSum(...) == 数字と書ける「地雷がある」と仮定して
unsatなら安全、「地雷がない」と仮定してunsatなら地雷と確定する(背理法)一時的な仮定は
push()とpop()ではさんで、次の判定に持ち越さないどちらも矛盾しないマスは決められない。マインスイーパーで「運が悪いと爆発する」のはこのため