HaskellとSATソルバーで数独を解く

「システム検証基礎演習」の課題で、SATソルバーを使って数独の解答を出力せよというものがあったのでそのレポートを載せてみます。

どう考えても以下のページの解説の方がわかりやすいし実装もスマートだと思うけれど。

http://d.hatena.ne.jp/ku-ma-me/20080108/p1

  • -

SATソルバーを用いて、数独を解いた。SATソルバーに与える連言標準形はHaskellプログラムで作成し、またSATソルバーの出力した一つの解を別のHaskellプログラムで目に見える形とした。

■原理

命題変数の値はtrueかfalseだけである。従って、1から9までの値を持つ数独の一つのマスを表すには、9つの命題変数を使うのがスマートであろう。命題変数p1からp9までがある一つのマスの数字を表し、もしこのマスに3が含まれるのであれば、p3がtrueで他はfalseになるとする。

数独のルールから、全てのマスには何らかの数字を埋めなければならないから、

p1 ∨ p2 ∨ p3 ∨ p4 ∨ p5 ∨ p6 ∨ p7 ∨ p8 ∨ p9

が成り立つ。一方別のマスがq1からq9までの命題変数で表され、やはりこのマスにも何らかの数字を埋めなければならないから、

q1 ∨ q2 ∨ q3 ∨ q4 ∨ q5 ∨ q6 ∨ q7 ∨ q8 ∨ q9

も成り立つ。全てのマスに対してこのような論理式を作る。

次に、同じ行、同じ列、同じブロックには、全て違った数字が選ばれるという制限を論理式で書く。なお、この制限を加えることにより、一つのマスを表す9つの命題変数の中の複数が同時にtrueとなることは自動的になくなる。一つのマスが状態の揺らぎを持ってしまうと、その列(行、ブロック)の他のマスの数字をそれぞれ重ならないように選択することができなくなってしまうからである。これは鳩ノ巣原理で証明できる。

さて、x列y行のマスを、p(x,y,1)からp(x,y,9)の命題変数で表すとする。x列y行のマスが1であった場合、その行の他のマスが1ではないのであれば、次の節たちが全て満たされなければならない。

p(x,y,1) → ¬(1,y,1)
p(x,y,1) → ¬(2,y,1)
    :
p(x,y,1) → ¬(x-1,y,1)
p(x,y,1) → ¬(x+1,y,1)
    :
p(x,y,1) → ¬(8,y,1)
p(x,y,1) → ¬(9,y,1)

or節をandで繋げる連言標準形に直すと、

  (¬p(x,y,1) ∨ ¬p(1,y,1) )
∧ (¬p(x,y,1) ∨ ¬p(2,y,1) )
      :
∧ (¬p(x,y,1) ∨ ¬p(x-1,y,1) )
∧ (¬p(x,y,1) ∨ ¬p(x+1,y,1) )
      :
∧ (¬p(x,y,1) ∨ ¬p(8,y,1) )
∧ (¬p(x,y,1) ∨ ¬p(9,y,1) )

となる。このような節を、1だけではなく2から9までの数字に対しても作る。また、その列の他のマスと数字が重ならないことは次のように書ける。

  (¬p(x,y,1) ∨ ¬p(x,1,1) )
∧ (¬p(x,y,1) ∨ ¬p(x,2,1) )
      :
∧ (¬p(x,y,1) ∨ ¬p(x,y-1,1) )
∧ (¬p(x,y,1) ∨ ¬p(x,y+1,1) )
      :
∧ (¬p(x,y,1) ∨ ¬p(x,8,1) )
∧ (¬p(x,y,1) ∨ ¬p(x,9,1) )

これもやはり1だけではなく、2から9までの数字に対しても同様に作る。更にブロック内の他のマスと数字が重ならないことを次のように書く。尚これは2列3行のマスの例である。

  (¬p(2,3,1) ∨ ¬p(1,1,1) )
∧ (¬p(2,3,1) ∨ ¬p(2,1,1) )
∧ (¬p(2,3,1) ∨ ¬p(3,1,1) )
∧ (¬p(2,3,1) ∨ ¬p(1,2,1) )
∧ (¬p(2,3,1) ∨ ¬p(2,2,1) )
∧ (¬p(2,3,1) ∨ ¬p(3,2,1) )
∧ (¬p(2,3,1) ∨ ¬p(1,3,1) )
∧ (¬p(2,3,1) ∨ ¬p(3,3,1) )

当然ながら、これも2から9までの数字に対しても同様に作る。

ここまでの、同じ行、列、ブロック内に違った数字が表れないという制限は、一つのマスに対してのものだから、更にこれらの節を全てのマスに対しても作らなければならない。

これで数独のルールは記述できたと言える。残るは固有の「問題」に拠る制限である。これは非常に単純で、単に命題変数だけの節を作ればいい。例えば2列3行のマスに既に6が埋まっていたのであれば、

p(2,3,6)

これだけである。埋まっているマスのぶんだけ、このような節を作る。

上記より必要な節の数は、

9*9 + 8*9*3*9*9 + 既に埋まっているマスの数 = 17577 + α

となる。

■実装

SATソルバーに与える連言標準形を作るHaskellプログラムは以下である。

-- solving sudoku for SAT solver
import List

problem = [5,3,0,0,7,0,0,0,0,
           6,0,0,1,9,5,0,0,0,
           0,9,8,0,0,0,0,6,0,
           8,0,0,0,6,0,0,0,3,
           4,0,0,8,0,3,0,0,1,
           7,0,0,0,2,0,0,0,6,
           0,6,0,0,0,0,2,8,0,
           0,0,0,4,1,9,0,0,5,
           0,0,0,0,8,0,0,7,9]

index (_, _, 0) = "0\n"
index (x, y, n) | n > 0 = (show $ (y-1) * 9 * 9 + (x-1) * 9 + (n-1) + 1) ++ " "
                | n < 0 = "-" ++ (show $ (y-1) * 9 * 9 + (x-1) * 9 + (-n-1) + 1) ++ " "

allGrid = [(x,y) | x <- [1..9], y <- [1..9] ]

main = do let a = makeProblem problem ++ g ++ concatMap (\(x,y) -> concat $ hrzn(x,y) ++ vtcl(x,y) ++ box(x,y)) allGrid
          putStrLn $ "p cnf " ++ (show $ 9*9*9) ++ " " ++ (show $ cc a)
          putStr $ concatMap index $ a

-- make problem restrict
makeProblem l = mp 1 1 l
 where next x y l | x == 9 = mp 1 (y+1) l
                  | otherwise = mp (x+1) y l
       mp x y (n:l) | n>0 = (x, y, n):((0,0,0):(next x y l))
                    | otherwise = next x y l
       mp _ _ _ = []

-- input number to all grids
g = concatMap g' allGrid
 where g' (x, y) = g'' 9
        where g'' 0 = [(x, y, 0)]
              g'' n = (x, y, n):(g'' $ n-1)

-- restrict number for sudoku rule
hrzn (x, y) = [[(x, y, -n), (x, y', -n), (0,0,0)] | n <- [1..9], y' <- [1..9]\\[y]]
vtcl (x, y) = [[(x, y, -n), (x', y, -n), (0,0,0)] | n <- [1..9], x' <- [1..9]\\[x]]
box (x, y) = [[(x, y, -n), (x', y', -n), (0,0,0)] | n <- [1..9], x' <- [(boxS x)..(boxS x + 2)], y' <- [(boxS y)..(boxS y + 2)], (x/=x' || y/=y')]
 where boxS n = n - ((n-1) `mod` 3)

-- count clause
cc ((_, _, 0):l) = 1 + (cc l)
cc (_:l) = cc l
cc _ = 0

このプログラムでやっていることは、ほぼ原理で説明したことそのままである。内部的に命題変数(列,行,数字)を表す(Int, Int, Int)の型を用いているが、これはややトリッキーな使い方をされており、最後の数字が負になっていれば、否定であることを表す。さらに0であれば、節の終端であることを示す。このようなデータを、関数indexにおいて、SATソルバーが扱う自然数のみで識別される命題変数へと変換している。

さて、このプログラムの出力(ここれはファイル名はsudokuとする)をMiniSatに与えてみた。

> ./MiniSat sudoku sudoku.out
(略)
SATISFIABLE

充足されたことがわかる。さて解はsudoku.outを見ればいいわけだが、命題変数が全て自然数になっており非常にわかりにくい。そこで、次のようなHaskellプログラムを用意した。

import Text.ParserCombinators.Parsec

main = do src <- getContents
          putStr $ concatMap indexr $ readResult src

readResult src = case parse (do string "SAT"
                                many space
                                num <- many readVar
                                return num) [] src of
    Right madeSrc -> madeSrc
    Left err -> error $ show err

readVar :: Parser Int
readVar = do string "-"
             n <- many1 digit
             many1 space
             return $ negate $ read n
      <|> do n <- many1 digit
             many1 space
             return $ read n

indexr m | m > 0 = let y = ((m-1) `div` (9 * 9)) + 1
                       m' = ((m-1) `mod` (9 * 9))
                       x = (m' `div` 9) + 1
                       n = (m' `mod` 9) + 1
                   in show n ++ if x==9 then "\n" else []
indexr _ = []

これは単純に、関数indexの逆関数を適用するプログラムである。このプログラムにsudoku.outを与えてみると、次のようになる。

>main < sudoku.out
534678912
672195348
198342567
859761423
426853791
713924856
961537284
287419635
345286179

これは我々一般人の理解できる数独の解答である。コンピュータが本問題に勝利したことが明らかとなった。

  • -

Haskellは良い言語だけれど、やっぱりIOが面倒ですね。SATソルバーの出力を読み込むのに、わざわざParsecを使う方がラクだ、と思ってしまうほどに。

そういえば8クイーンの解答を出力させるプログラムも書いたので、それも載せてみます。数独のプログラムも微妙だけれど、こちらはヨリひどいプログラムです。

data XY = Horizon | Vertical deriving Eq

main = do putStr "p cnf 64 1472\n"
          putStr $ (p Horizon 8) ++ (p Vertical 8) ++ (concat.concat $ map restrict (allPoint 8 8))

index x y = show $ (x-1) * 8 + y

p _ 0 = ""
p xy n = (p' 8) ++ (p xy $ n - 1)
 where p' 0 = "0\n"
       p' m = (if xy==Horizon then index n m else index m n)  ++ " " ++ (p' $ m - 1)

allPoint x y = [(x', y') | x' <- [1..x], y' <- [1..y]]

restrict (x, y) = restrictX(x, y) ++ restrictY(x, y) ++ restrictN(x, y)
 where
     restrictX (x, y) = ["-" ++ (index x y) ++ " -" ++ (index x' y) ++ " 0\n"| x' <- [1..8], x'/=x ]
     restrictY (x, y) = ["-" ++ (index x y) ++ " -" ++ (index x y') ++ " 0\n"| y' <- [1..8], y'/=y ]
     restrictN (x, y) = ["-" ++ (index x y) ++ " -" ++ (index x' y') ++ " 0\n"| x' <- [1..8], x'/=x,  y' <- [1..8], y'/=y, ((x'-x) == (y'-y) || (x'-x) == (y-y') )]