数独をSATで解く ― 「ちょうど1つ」をCNFで表す#

この章で学ぶこと#

数独を、SAT(充足可能性問題)ソルバーで解きます。問題をブール式に翻訳する流れを、小さな推理パズルから始めて、数独まで段階的に進めます。

  • SAT と CNF(連言標準形)とは何か

  • 選言標準形(DNF)を CNF に変換する方法(SymPy と手作り)

  • 「グループの中で1つだけが True」という制約の書き方

  • pysat で数独を解き、assumptions で初期盤面を与える

充足可能性問題(SAT)#

充足可能性問題(SATisfiability problem、SAT)は、「与えられたブール式を真にする変数の値の組み合わせはあるか?」という問題です。

SAT は最初にNP完全であることが証明された問題で、計算機科学の基礎理論、アルゴリズム、人工知能、ハードウェア設計など、多くの分野に関係しています。そして、実用的な SAT ソルバーは非常に高速なので、「解きたい問題を SAT に変換する」という手法が広く使われています。

この章では、pysat で数独を解きます。まず小さな推理パズルで、ブール式と CNF の書き方を確認しましょう。

推理パズル

4人の容疑者が、次のように供述しました。

  • 甲:「私が犯人ではない」

  • 乙:「丁が犯人だ」

  • 丙:「乙が犯人だ」

  • 丁:「乙は私を陥れようとしている」

4人のうち、真実を話しているのは1人だけです。犯人は誰でしょう。

甲・乙・丙・丁について、「その人が犯人である」を表すブール変数を A, B, C, D とします。~A は「甲は犯人ではない」です。

各供述は、次のようなブール式になります。

容疑者

供述

ブール式

甲

私が犯人ではない

S1 = ~A

乙

丁が犯人だ

S2 = D

丙

乙が犯人だ

S3 = B

丁

乙は私を陥れようとしている

S4 = ~D

真実を話している人が1人だけなので、S1〜S4 のうちちょうど1つが真になります。

「ちょうど1つが真」を、まず素直に書いてみます(& は論理積、| は論理和)。

 S1 & ~S2 & ~S3 & ~S4 |
~S1 &  S2 & ~S3 & ~S4 |
~S1 & ~S2 &  S3 & ~S4 |
~S1 & ~S2 & ~S3 &  S4

これは「OR でつないだ AND の集まり」で、選言標準形(DNF, Disjunctive Normal Form)と呼びます。

SAT ソルバーが受け取れるのは連言標準形(CNF, Conjunctive Normal Form)だけです。CNF は「OR でつないだ節を、AND で並べたもの」です。そこで、「ちょうど1つが真」を別の形で書き直します。

  1. 2つ以上が真になってはいけない → ~(Si & Sj)

  2. 全部が偽でもいけない → ~( ~S1 & ~S2 & ~S3 & ~S4 )

~(S1 & S2) & ~(S1 & S3) & ~(S1 & S4) &
~(S2 & S3) & ~(S2 & S4) & ~(S3 & S4) &
~(~S1 & ~S2 & ~S3 & ~S4)

ド・モルガンの法則(~(X & Y) = ~X | ~Y)で変形すると、CNF になります。

変換結果(S1 = ~A, S2 = D, S3 = B, S4 = ~D を代入)

~S1 | ~S2   →   A  | ~D
~S1 | ~S3   →   A  | ~B
~S1 | ~S4   →   A  |  D
~S2 | ~S3   →  ~D  | ~B
~S2 | ~S4   →  ~D  |  D     ← 常に真(何も制約しない)
~S3 | ~S4   →  ~B  |  D
 S1 |  S2 | S3 | S4  →  ~A | D | B | ~D

~D | D は常に真なので、この節は何も制約しません。それでも害はないので、そのまま入れています。

SymPy で DNF から CNF へ#

手で変換するのは間違えやすいので、SymPy に任せます。

from sympy import symbols
from sympy.logic.boolalg import to_cnf

A, B, C, D = symbols("A:D")

# 各人の供述
S1 = ~A
S2 = D
S3 = B
S4 = ~D

# ちょうど1人だけが真実を話す(DNF)
dnf = ((S1 & ~S2 & ~S3 & ~S4) |
       (~S1 & S2 & ~S3 & ~S4) |
       (~S1 & ~S2 & S3 & ~S4) |
       (~S1 & ~S2 & ~S3 & S4))

# simplify=True:式を簡単にしてから返す
print("CNF:", to_cnf(dnf, simplify=True))

# simplify=False:分配法則でそのまま展開する
cnf_raw = to_cnf(dnf, simplify=False)
print("simplify=False だと", len(cnf_raw.args), "個の節になる")
CNF: A & ~B
simplify=False だと 41 個の節になる

simplify=True にすると、式を簡単にしてから返してくれます。結果は A & ~B、つまり「甲が犯人で、乙は犯人ではない」です。

simplify=False にすると、式を分配法則でそのまま展開するので、節の数が何十個にも膨らみます。A | ~A のような常に真の節も消されません。数独のような大きな問題では、この膨らみがそのまま解く時間になります。

どちらの CNF でも、答えは同じになるはずです。

from sympy.logic.inference import satisfiable

print(satisfiable(to_cnf(dnf, simplify=True)))
print(satisfiable(cnf_raw))
{A: True, B: False}
{A: True, D: False, B: False}

satisfiable() は、式を真にする変数の割り当てを返します。A が真なので、甲が犯人です。C は式に出てこないので、どちらでもよい(制約なし)になっています。

pysat で解く#

CNF は、入れ子のリストで表せます。整数1つがブール変数1つに対応し、正の数はその変数が真、負の数は偽(否定)を意味します。たとえば 1 は A、-1 は ~A です。

上の CNF を、そのまま数値のリストにします(A=1, B=2, C=3, D=4)。

  1. append_formula() で CNF をソルバーに追加する(何度呼んでもよい)

  2. solve() で解を求める

  3. get_model() で解を取り出す(正の数が真)

from pysat.solvers import Solver

sat = Solver()
problem = [[1, -4], [1, -2], [1, 4],            # ~S1 | ~S2 など
           [-4, -2], [-4, 4], [-2, 4],          # ~D | D は常に真
           [-1, 4, 2, -4]]                      # S1 | S2 | S3 | S4

sat.append_formula(problem)
print(sat.solve())
print(sat.get_model())
True
[1, -2, -3, -4]

[1, -2, -3, -4] は「A が真、B・C・D は偽」です。SymPy と同じ結論になりました。

数独#

数独は、9×9 のマスに1〜9の数字を入れ、次の条件を満たすパズルです。

  • 各行に1〜9が1回ずつ

  • 各列に1〜9が1回ずつ

  • 各3×3ブロックに1〜9が1回ずつ

数独ゲーム

黒い数字が問題、灰色の数字が答えです。

SAT の変数は True / False の2値なので、「マス (r, c) に数字 n が入る」を1つのブール変数にします。3次元配列 bools を使います。

  • 第0軸 … 行

  • 第1軸 … 列

  • 第2軸 … 数字(1〜9)

たとえば bools[4, 1, 3] が真なら、「5行目・2列目に数字4が入る」という意味です。数独は1始まりですが、配列の添字は0始まりであることに注意してください。

bools には、SAT の変数番号を入れます(変数番号は1始まり)。

import numpy as np

# 9×9×9 = 729 個の変数(1〜729)
bools = np.arange(1, 9 * 9 * 9 + 1).reshape(9, 9, 9)

数独の制約は、4つに整理できます。

#

制約

グループの作り方

1

各マスには1つの数字だけ

数字の軸(第2軸)をグループにする

2

各行に同じ数字は1つだけ

行と数字でグループにする

3

各列に同じ数字は1つだけ

列と数字でグループにする

4

各ブロックに同じ数字は1つだけ

ブロックと数字でグループにする

どれも「あるグループの中で、ちょうど1つだけが True」という形です。

「ちょうど1つ」をCNFで表す#

変数 A, B, C のうち1つだけが真、という制約は、2つの条件に分けられます。

  1. 少なくとも1つは真: \(A | B | C\)

  2. 2つ以上が真にならない: \(\neg(A \& B) \land \neg(A \& C) \land \neg(B \& C)\)

ド・モルガンの法則で、2番目は \( (\neg A | \neg B) \land (\neg A | \neg C) \land (\neg B | \neg C) \) になります。

\[ (A | B | C) \land (\neg A | \neg B) \land (\neg A | \neg C) \land (\neg B | \neg C) \]

要素が N 個なら、「すべてを OR でつないだ節」1つと、「2つの組すべてについて ~X | ~Y」が \(N(N-1)/2\) 個です。2つの組は itertools.combinations で作れます。

get_conditions(bools) は、bools の最終軸をグループとして、この制約を返します。

  1. 最終軸の要素数 n に対して、2つの組すべてを作る

  2. 各グループを OR でつないだ節を作る(少なくとも1つ真)

  3. 各組について ~X | ~Y の節を作る(2つ同時に真にならない)

from itertools import combinations


def get_conditions(bools):
    """最終軸をグループとして、「ちょうど1つだけ True」の CNF を作る"""
    n = bools.shape[-1]
    pairs = np.array(list(combinations(range(n), 2)))

    conditions = []
    # 1. 少なくとも1つは True
    conditions.extend(bools.reshape(-1, n).tolist())
    # 2. 2つ同時に True にならない
    conditions.extend((-bools[..., pairs].reshape(-1, 2)).tolist())
    return conditions

小さな例で動きを確かめます。[[1, 2, 3], [4, 5, 6]] は「2つのグループがあり、それぞれ3つの変数を持つ」という意味です。

  • [1, 2, 3], [4, 5, 6] … それぞれのグループで、少なくとも1つは真

  • [-1, -2], [-1, -3], [-2, -3] … 1〜3 のうち2つが同時に真にならない

  • [-4, -5], [-4, -6], [-5, -6] … 4〜6 についても同じ

get_conditions(np.array([[1, 2, 3], [4, 5, 6]]))
[[1, 2, 3],
 [4, 5, 6],
 [-1, -2],
 [-1, -3],
 [-2, -3],
 [-4, -5],
 [-4, -6],
 [-5, -6]]

数独の制約を作る#

get_conditions() は最終軸をグループにするので、行・列・ブロックの制約は、swapaxes() でグループにしたい軸を最後に移動してから渡します。

  • 制約1(各マスに1つ)… そのまま(最終軸が数字)

  • 制約2(各行に1つ)… 列と数字を入れ替える

  • 制約3(各列に1つ)… 行と数字を入れ替える

c1 = get_conditions(bools)                     # 各マスには1つの数字だけ
c2 = get_conditions(np.swapaxes(bools, 1, 2))  # 各行で同じ数字は1つだけ
c3 = get_conditions(np.swapaxes(bools, 0, 2))  # 各列で同じ数字は1つだけ

制約4(ブロック)は少し複雑です。bools は (行, 列, 数字) なので、行と列を「ブロック番号」と「ブロック内の位置」に分解します。

  1. reshape(3, 3, 3, 3, 9) … (ブロック行, 行内, ブロック列, 列内, 数字)

  2. swapaxes(1, 2) … (ブロック行, ブロック列, 行内, 列内, 数字)

  3. reshape(9, 9, 9) … (ブロック番号, ブロック内の9マス, 数字)

  4. swapaxes(1, 2) … (ブロック番号, 数字, ブロック内の9マス) ← グループは最後の軸

これで「ブロックごと・数字ごとに、9マスのうち1つだけが真」という制約になります。

# ブロックごとに (ブロック, 9マス, 数字) の形にする
blocks = np.swapaxes(bools.reshape(3, 3, 3, 3, 9), 1, 2).reshape(9, 9, 9)
c4 = get_conditions(np.swapaxes(blocks, 1, 2))   # 各ブロックで同じ数字は1つだけ

conditions = c1 + c2 + c3 + c4
print(len(conditions), "個の節ができました")
11988 個の節ができました

空の盤面を解く#

まず、ヒントが何もない状態で解いてみます。数独の制約だけを満たす答えが1つ出てきます(ヒントがないので、答えは無数にあります)。

モデルは変数番号のリストなので、(9, 9, 9) に戻して、真になっている変数の数字(最終軸の添字 + 1)を取り出します。

def format_solution(model, shape=(9, 9, 9)):
    """モデル(変数番号のリスト)を 9×9 の数字の盤面にする"""
    grid = np.array(model).reshape(shape)
    return np.where(grid > 0)[2].reshape(9, 9) + 1


sat = Solver()
sat.append_formula(conditions)
print(sat.solve())
format_solution(sat.get_model())
True
array([[2, 4, 6, 1, 3, 5, 8, 7, 9],
       [1, 3, 5, 7, 8, 9, 2, 4, 6],
       [8, 7, 9, 2, 4, 6, 1, 3, 5],
       [5, 6, 3, 4, 1, 2, 7, 9, 8],
       [4, 1, 2, 9, 7, 8, 5, 6, 3],
       [7, 9, 8, 5, 6, 3, 4, 1, 2],
       [6, 5, 4, 3, 2, 1, 9, 8, 7],
       [3, 2, 1, 8, 9, 7, 6, 5, 4],
       [9, 8, 7, 6, 5, 4, 3, 2, 1]])

実際の問題を解く#

問題は文字列で与えます。0 が空きマスです。空きマスではないところは「その数字の変数が真」という制約を追加すれば、その数字に固定できます。

sudoku_str = """
000000185
007030000
000021400
800000020
003905600
050000004
004860000
000040300
931000000"""

puzzle = np.array([[int(x) for x in line]
                   for line in sudoku_str.strip().split()])

# ヒントのマスについて「その数字である」を制約にする
r, c = np.where(puzzle != 0)
v = puzzle[r, c] - 1
hints = [[int(x)] for x in bools[r, c, v]]

sat = Solver()
sat.append_formula(conditions + hints)
sat.solve()
solution = format_solution(sat.get_model())
solution
array([[3, 6, 2, 7, 9, 4, 1, 8, 5],
       [4, 1, 7, 5, 3, 8, 2, 6, 9],
       [5, 9, 8, 6, 2, 1, 4, 3, 7],
       [8, 7, 9, 4, 1, 6, 5, 2, 3],
       [2, 4, 3, 9, 7, 5, 6, 1, 8],
       [1, 5, 6, 3, 8, 2, 7, 9, 4],
       [7, 2, 4, 8, 6, 3, 9, 5, 1],
       [6, 8, 5, 1, 4, 9, 3, 7, 2],
       [9, 3, 1, 2, 5, 7, 8, 4, 6]])

hints の各節は、ブール変数1つだけの節([x])です。これは「x は必ず真」という意味になります。

解を検算する#

出てきた盤面が、本当に数独の条件を満たしているか確かめます。

def check_sudoku(grid):
    """各行・各列・各ブロックが 1〜9 の1回ずつになっているか"""
    target = set(range(1, 10))
    rows = [set(grid[i, :]) for i in range(9)]
    cols = [set(grid[:, j]) for j in range(9)]
    blocks = [set(grid[i:i+3, j:j+3].ravel())
              for i in range(0, 9, 3) for j in range(0, 9, 3)]
    return all(g == target for g in rows + cols + blocks)


print(check_sudoku(solution))

# ヒントと矛盾していないかも確認する
print(bool((puzzle[puzzle > 0] == solution[puzzle > 0]).all()))
True
True

assumptions でヒントを与える#

hints のように「変数1つだけの節」を追加する代わりに、solve() の assumptions に真にしたい変数のリストを渡すこともできます。

assumptions は解を求めたあとにクリアされるので、同じソルバーを使い回して、別の問題を続けて解けます。何問も解くときはこちらが便利です。

sat = Solver()
sat.append_formula(conditions)                     # 数独のルールだけ入れておく

sat.solve(assumptions=bools[r, c, v].tolist())     # ヒントは仮定として渡す
solution2 = format_solution(sat.get_model())
print(check_sudoku(solution2))
solution2
True
array([[3, 6, 2, 7, 9, 4, 1, 8, 5],
       [4, 1, 7, 5, 3, 8, 2, 6, 9],
       [5, 9, 8, 6, 2, 1, 4, 3, 7],
       [8, 7, 9, 4, 1, 6, 5, 2, 3],
       [2, 4, 3, 9, 7, 5, 6, 1, 8],
       [1, 5, 6, 3, 8, 2, 7, 9, 4],
       [7, 2, 4, 8, 6, 3, 9, 5, 1],
       [6, 8, 5, 1, 4, 9, 3, 7, 2],
       [9, 3, 1, 2, 5, 7, 8, 4, 6]])

まとめ#

  • SAT は「ブール式を真にする割り当てがあるか」を解く問題。NP完全だが、実用的なソルバーは速い

  • ソルバーが受け取れるのは CNF(連言標準形)。DNF はそのままでは渡せない

  • 「ちょうど1つが真」は「少なくとも1つ」+「2つの組すべてで ~X | ~Y」で CNF になる

  • 数独は「マス × 数字」をブール変数にして、マス・行・列・ブロックの4種類の制約を並べる

  • swapaxes() でグループにしたい軸を最後に移すと、同じ関数を使い回せる

  • 初期盤面は hints(変数1つの節)でも assumptions でも与えられる。使い回すなら assumptions