4. 演習 1 - 画像入力式数独ソルバーの作成#
ご存知の通り、数独というのは 9x9 のマスの中に 1-9 の数字を一定のルールのもとで入れていくパズルである。このルールとはすなわち、
同じ行に同じ数字が存在しない
同じ列に同じ数字が存在しない
3x3 の 9 領域に同じ数字が存在しない
の 3 つである。数独の解き方にはいろいろなやり方があり、
バックトラック法
整数計画問題として解く
Exact Cover 問題として解く
等のやり方がある。9x9 の問題であれば、最も単純なバックトラック法でも十分高速に解くことができる(一般的なパズル本の最難問なら Python 実装でも 1 秒以下で解ける)。
以下では、数独の問題を写真で撮って、その画像の中から問題を抜き出し、自動的に問題を解くプログラムを作成してみよう。
警告
「数独」という呼び名はパズル製作会社のニコリによって商標登録されている呼び名であり、別の呼び名として「ナンバープレイス」あるいは「ナンプレ」と呼ばれることもある。本資料では、一般的に最も浸透していると思われる呼び名である「数独」を用いる。
4.1. 数独を解く#
まずは数独の問題がテキストで与えられている場合について、解き方を見ていこう。
今回は数独の問題が以下の形式のテキストとして与えられることとする。Python は複数行のテキストを以下のように定義することができる。
problem = """
-35-9--48
--9--8--3
-4-6-5--1
----74---
-2-----6-
---15----
8--9-2-7-
9--5--2--
61--4-53-
"""
これを扱いやすくするために NumPy の配列に直してみる。まずはテキストを行ごとの配列に変換する。Pythonの文字列型のメソッドであるsplit
は空白文字や改行文字ごとに文字列を区切ってくれるのでこれを用いる。
problem = problem.split()
print(problem)
['-35-9--48', '--9--8--3', '-4-6-5--1', '----74---', '-2-----6-', '---15----', '8--9-2-7-', '9--5--2--', '61--4-53-']
するとproblemの長さは9となる。各行を文字ごとに分割するには、同じようにリスト内包表記を用いると簡単にできる。以下に二重ループを用いたリスト内包表記の例を示す。
problem = [c for line in problem for c in line]
print(problem)
['-', '3', '5', '-', '9', '-', '-', '4', '8', '-', '-', '9', '-', '-', '8', '-', '-', '3', '-', '4', '-', '6', '-', '5', '-', '-', '1', '-', '-', '-', '-', '7', '4', '-', '-', '-', '-', '2', '-', '-', '-', '-', '-', '6', '-', '-', '-', '-', '1', '5', '-', '-', '-', '-', '8', '-', '-', '9', '-', '2', '-', '7', '-', '9', '-', '-', '5', '-', '-', '2', '-', '-', '6', '1', '-', '-', '4', '-', '5', '3', '-']
すると今度はproblemが81個の要素を持つ一次元配列になる。現在、各数字は文字であり、数字ではないので、各文字を数字に置き換える。また、まだ埋まっていないセルを表す-
は 0 に置き替える。
このような置き換えは次のようなハッシュ(Python の用語では dict)を使うと簡単に書ける。
numbers = {"-": 0}
numbers.update({str(i): i for i in range(1, 10)})
print(numbers)
{'-': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}
このハッシュを使って文字を数字に置き換えるには、やはりリスト内包表記を用いる。その後、NumPy の配列に直しておく。
ここまで一次元配列で作業をしてきたが、9x9 の二次元配列の方が以後の作業がやりやすいので、配列の形も直しておく。
import numpy as np
problem = np.array([numbers[c] for c in problem], dtype="uint8")
problem = problem.reshape((9, 9))
print(problem)
[[0 3 5 0 9 0 0 4 8]
[0 0 9 0 0 8 0 0 3]
[0 4 0 6 0 5 0 0 1]
[0 0 0 0 7 4 0 0 0]
[0 2 0 0 0 0 0 6 0]
[0 0 0 1 5 0 0 0 0]
[8 0 0 9 0 2 0 7 0]
[9 0 0 5 0 0 2 0 0]
[6 1 0 0 4 0 5 3 0]]
とすると、先ほどまでの文字が数字に置き換わるはずだ。なお、ここまで繰り返しリスト内包表記も用いたが、これらをまとめて 1 行で書くこともできる。
problem = """
-35-9--48
--9--8--3
-4-6-5--1
----74---
-2-----6-
---15----
8--9-2-7-
9--5--2--
61--4-53-
"""
problem = np.array(
[numbers[c] for line in problem.split() for c in line],
dtype="uint8",
).reshape((9, 9))
print(problem)
[[0 3 5 0 9 0 0 4 8]
[0 0 9 0 0 8 0 0 3]
[0 4 0 6 0 5 0 0 1]
[0 0 0 0 7 4 0 0 0]
[0 2 0 0 0 0 0 6 0]
[0 0 0 1 5 0 0 0 0]
[8 0 0 9 0 2 0 7 0]
[9 0 0 5 0 0 2 0 0]
[6 1 0 0 4 0 5 3 0]]
改行文字
改行文字はOSによってデフォルトで使われているものが異なり、Windowsは\r\n
、Macは\r
、Linuxは\n
が用いられている。これらの改行文字は\r
がcarriage return (CR)と呼ばれ、\n
がline feed (LF)と呼ばれることから、Windows、Mac、Linuxで用いられる改行をそれぞれCRLF、CR、LF改行とも呼ぶ。
プログラミングにおいては、改行文字の違いが問題となることも多いので、LFに統一する、あるいはCRLFに統一する、など自分でルールを決めてエディタ(VSCodeなど)を設定しておくと良い。
4.1.1. 使用済みの数字を調べる#
問題が数字の配列として表せたところで、使用済みの数字を調べる方法を見ていく。今i
行j
列にある数字の候補にどの数字が残っているのかを調べたいとする。
NumPy を用いると、i
行目にある数字はproblem[i, :]
という配列で、j
列目にある数字はproblem[:, j]
という配列で表せる。また 3x3 のブロックについては、ブロックの右上の座標が(3 * (i // 3), 3 * (j // 3))
と表せることに注目すれば、以下でブロック内の数字が得られる。
k = 3 * (i // 3)
l = 3 * (j // 3)
blk_nums = problem[k:k+3, l:l+3].flatten() # 一次元配列として取り出す
一例として 1 行 1 列の空マスに何が入るのかを調べて見る。行、列、ブロックに使われている数字は、それぞれ以下のようになっている。
row_nums = problem[0, :]
print(" Row #1:", row_nums)
col_nums = problem[:, 0]
print(" Col #1:", col_nums)
blk_nums = problem[0:3, 0:3].flatten()
print("Block #1-1:", blk_nums)
Row #1: [0 3 5 0 9 0 0 4 8]
Col #1: [0 0 0 0 0 0 8 9 6]
Block #1-1: [0 3 5 0 0 9 0 4 0]
まだ使える数字は 1-9 の数字の中で、これらの配列に現れていない数字である。配列中に数字があるかどうかはin
を使うと調べられるので、リスト内包表記を使えば、未使用の数字が簡単に調べられる。
used_nums = np.concatenate([row_nums, col_nums, blk_nums])
unused_nums = np.array([i for i in range(10) if not i in used_nums])
print("Unused numbers:", unused_nums)
Unused numbers: [1 2 7]
このように、(0, 0)
の空マスに入りうる候補の数字は{1, 2, 7}
の 3 つであることが分かる。
4.1.2. バックトラック法による解法#
最初に、数独をコンピュータに解かせる最も単純な方法を考えてみよう。
上記の使用済みの数字を調べる方法を少し変更すれば、各行、各列、各ブロックで数字が正しく 1 度だけ使われているかどうかを調べることができそうだ。とすれば、全ての空きマスに適当に数字を入れて、その結果が正しいかどうかを順に試せば良さそうに思える。
ただ、これでは正しい解が見つかる確率が低すぎるため、計算に時間がかかりすぎる。各マスに入れられる数字の候補は 9 通りでマスが 81 マスあるので、最悪の場合には\(9^{81} \approx 2.0 \times 10^{77}\)個の候補について調べる必要がある。
一方で、実際にはいくつかのマスにはすでに数字が入っており、上記のように何も考えずに全てのパターンを試すのは非常に効率が悪いことが分かる。そこで、可能な候補についてだけ、しらみ潰しに調べることを考える。これを実現するのがバックトラック法である。
これでも効率がとても良いとは言えないが、実際には最初の方の数字がある程度決まると、後の方の数字は急速に候補が少なくなるため、実用的にはそれなりに高速に動作する。少なくとも 9x9 の問題であれば、十分に速い(16x16 の問題になるとそうはいかない!)
バックトラック法は深さ優先探索の一種で、数独の例で言えば、候補となる数字を仮に入れる操作をできる限り繰り返し、もし最後のマスまで数字が入れば成功、途中で数字が入れられなくなったら失敗として違う候補を試す、というものである。
深さ優先探索の実装方法は再帰関数を使う方法と FILO (first in last out, 最初に入れたものが最後に取り出されるということ) データ構造 (一般にはスタックと呼ぶ) を用いる方法の 2 つがある。
どちらの方法にも一長一短があるが、今回はスタックを用いる前提で話を進める。
バックトラック法による数独の解法の流れは次のようになる。
初期化: スタックに入力の問題を追加
深さ優先探索:
スタックに積まれた問題の途中経過を取り出す
マスの中で、まだ空マスになっている最初のマスの候補を割り出す
その空マスに各候補を入れたものをスタックに追加
この際、もしマスが全て埋まっていたら、成功と見なし処理を終了
この部分の実装は演習課題とするが、上手く問題が解ければ、以下のような解答が得られるはずである。