numpy&numbaで世界最小ヒントの数独を解いた

# はじめに
Pythonで数独を解くってやり尽くされている感があるけど、このままだと自分の記憶にしか存在しないコードになってしまうので記事作成することにしました。

# 方針

深さ優先探索。  
空欄のマスに数独の条件を満たすような数字を小さい順に仮に入れてみる。仮に入れた状態で他のマス目でも同じことをしてみる。どこかで矛盾したら直前に仮定したところを修正する。これをひたすら繰り返す。いつか矛盾が必ず起きなくなり、空欄が全て仮定の値で埋まる。それが正解。

![image.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fupload.wikimedia.org%2Fwikipedia%2Fcommons%2F8%2F8c%2FSudoku_solved_by_bactracking.gif?ixlib=rb-1.2.2&auto=format&gif-q=60&q=75&s=7d4a1f069877e9124e6322335e8809c0)
[図の引用元](https://qiita.com/chinbaa_chi/items/4ba84c1c957473c7512d)

# 実装

## 仮定が数独の条件を満たすか検証

配列のインデックスをタプル型で指定するよりも整数型で指定する方が実装しやすかったので、数独の配列は9×9行列を1×81ベクトルにしたもの（flatten）を想定している。`index`の数え方は0〜81を左上から順に右に数えていく。上図を例にすると、左上の3×3ブロックのピンク色の数字「5, 3, 6, 9, 8」の`index`はそれぞれ「0, 1, 9, 19, 20」となる。


```python
def fillable(table, index, value):
    """計算中の行列において、正解だと仮定した値が数独の条件を満たしているか検証する。

    Parameters
    -------
        table : np.ndarray of int
            計算中の行列。2D-array。
        index : int
            仮定したマス目の場所。1行目は0~8, 2行目は9~17, ...
        value : int
            仮定した数。

    Returns
    -------
        bool
            計算中の行列において、仮定が正解である可能性があるかないか。
    """

    row = index // 9
    col = index % 9

    fillable_in_row = value not in table[row, :]
    fillable_in_col = value not in table[:, col]
    fillable_in_block = value not in table[
                                     (row // 3) * 3: (row // 3 + 1) * 3,
                                     (col // 3) * 3: (col // 3 + 1) * 3,
                                     ]

    return fillable_in_row and fillable_in_col and fillable_in_block
```

Pythonの基本構文しか使っていないので結構自然な実装だと思う。  

## 仮定しまくり

```python
def fill(table_flat):
    """深さ優先探索により、数独を解く。

    Parameters
    -------
        table_flat : np.ndarray of int
            数独の問題。1D-array

    Returns
    -------
        np.ndarray of int
            仮定が代入された行列。shape = (9, 9)
    """

    for tmp_i, tmp_val in enumerate(table_flat):

        if tmp_val == 0:
            fillable_vals = [val
                             for val in range(tmp_val + 1, 10)
                             if fillable(table_flat.reshape(9, 9), tmp_i, val)]

            for new_val in fillable_vals:
                table_flat[tmp_i] = new_val

                if fill(table_flat).all():
                    break
            else:
                table_flat[tmp_i] = 0
                break
    return table_flat.reshape(9, 9)
```

1行ずつ解説していく。

indexが小さいマス目から順に検討をする。

```python
for tmp_i, tmp_val in enumerate(table_flat):
```

`問題の空欄マス == 0`として入力することを想定している。なので、以下の処理はマスが空欄の時だけ検討するということを意味する。

```python
if tmp_val == 0:
```

ちょっと長めのリスト内包表記。
仮置きする値は現在の値よりも大きい数にする。しかし、数独の条件を満たさないような仮定は除去する。その検証は先ほどの`fillable`関数を使う。
問題の入力は9×9行列を1×81ベクトルにしたもの（flatten）を想定しているが、ルール上3×3のブロックでの抽出の必要があるのでこの時だけreshapeする。

```python
fillable_vals = [val
                 for val in range(tmp_val + 1, 10)
                 if fillable(table_flat.reshape(9, 9), tmp_i, val)]
```

正解の可能性がある数をマスに代入してみる。

```python
for new_val in fillable_vals:
    table_flat[tmp_i] = new_val
```

**ここが肝。**
まず、`for/else`文について説明する。`for`文が正常終了した場合に`else`文に入る。正常終了ではない場合とは、`for`文がまだ終わっていないのに`break`するようなとき。ちなみに、`for`文が空回りした時も正常終了なので`else`文に入る。なので、ここでは`break`の位置に注目すればいい。（ちなみに、Effective Python によると`for/else`分の使用は非推奨）

仮定した行列を`fill`関数の引数として再帰する。`numpy.ndarray.all()`は0でない数字が1つもなければ`True`を返す。今回、`問題の空欄マス == 0`としてるので、`all()`の挙動を数独で言い換えると、空欄がなくなる、つまり、数独が解けたら`for`文を抜ける。
解けていなければ、仮定したマスの値を0に戻して検討を終了する。0に戻した状態で`return`をするとそれが`if fill(table_flat).all():`に伝わり仮定を修正する処理が行われる。

```python
    for ~~~ 略 ~~~:
     ~~~ 略 ~~~
        if fill(table_flat).all():
            break
    else:
        table_flat[tmp_i] = 0
        break
return table_flat.reshape(9, 9)
```

## 実行方法
`fill`関数と`fillable`関数を同じファイルに並べて、`fill`関数の引数に数独を1行にした81次ベクトルを引数に入れれば実行できる。

# 高速化(世界最小ヒント問題)
現状でも、普通の問題なら数ミリ秒〜数秒で解けるが、[世界最小ヒントの数独](https://www.nature.com/news/mathematician-claims-breakthrough-in-sudoku-puzzle-1.9751)を解こうとすると、いつまで経っても解が求められなかった。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488916/fa3ae207-b8f3-d445-6a67-240d02b2cabb.png)

現状のコードにこれを解かせようとするとパソコンがうなるだけなので、numbaによる高速化を試みる。

## 変更点① import
ファイルの先頭にnumbaを`import`

```python
from numba import njit, int64, bool_
```

## 変更点② デコレータをつけた
`@njit(output, input)`のようにして入力と出力の型を定義する。
**今回1番詰まったのがここだった。**`numpy.ndarray.reshape()`を使いたかったら`int64[::1]`とする必要があるらしい。（numbaの公式ドキュメントに書いてあった。[ここらへん](http://numba.pydata.org/numba-doc/0.12.1/tutorial_firststeps.html#signature)。）
本当は`int64`ではなく`int8`で良さそうだけど、上手くいかなかったのでデフォルトのままにした。（`dtype=int8`とか指定するのだろうか。）

```python
# intの2次元配列とint2つを入力してboolを返す関数
@njit(bool_(int64[:, :], int64, int64))
def fillable(table, index, value):

# intの1次元配列を入力してintの2次元配列を返す関数
@njit(int64[:, :](int64[::1]))
def fill(table_flat):
```

## 変更点③ `in`演算を除去
numbaはPythonの構文はかなりサポートしているはずだが、`fillable`関数内の`in`演算でエラーが起きた。（`fillable_in_row = value not in table[row, :]`など。）
そのため`fillable`関数を`in`演算を使わずに実装し直した。

```python
def fillable(table, index, value):

    row = index // 9
    col = index % 9
    block = lambda x: ((x // 3) * 3, (x // 3 + 1) * 3)

    same_in_areas = False
    for area in [
        table[row, :], table[:, col],
        table[block(row)[0]:block(row)[1], block(col)[0]:block(col)[1]].flatten()
    ]:
        for i in area:
            same_in_areas |= (i == value)

    return not same_in_areas
```
仮定したマスの縦横と3×3ブロックにの数字を`i`に入れて回す。その`i`が仮定の値`value`と一致する、つまり、数独の条件を満たさない場合があるか検証する。

# コード全体

```suudoku.py
def fillable(table, index, value):
    """計算中の行列において、正解だと仮定した値が数独の条件を満たしているか検証する。

    Parameters
    -------
        table : np.ndarray of int
            計算中の行列。2D-array。
        index : int
            仮定したマス目の場所。1行目は0~8, 2行目は9~17, ...
        value : int
            仮定した数。

    Returns
    -------
        bool
            計算中の行列において、仮定が正解である可能性があるかないか。
    """

    row = index // 9
    col = index % 9

    fillable_in_row = value not in table[row, :]
    fillable_in_col = value not in table[:, col]
    fillable_in_block = value not in table[
                                     (row // 3) * 3: (row // 3 + 1) * 3,
                                     (col // 3) * 3: (col // 3 + 1) * 3,
                                     ]

    return fillable_in_row and fillable_in_col and fillable_in_block


def fill(table_flat):
    """深さ優先探索により、数独を解く。

    Parameters
    -------
        table_flat : np.ndarray of int
            数独の問題。1D-array

    Returns
    -------
        np.ndarray of int
            仮定が代入された行列。shape = (9, 9)
    """

    for tmp_i, tmp_val in enumerate(table_flat):

        if tmp_val == 0:
            fillable_vals = [val
                             for val in range(tmp_val + 1, 10)
                             if fillable(table_flat.reshape(9, 9), tmp_i, val)]

            for new_val in fillable_vals:
                table_flat[tmp_i] = new_val

                if fill(table_flat).all():
                    break
            else:
                table_flat[tmp_i] = 0
                break
    return table_flat.reshape(9, 9)
```

```suudoku_faster.py
from numba import njit, int64, bool_


@njit(bool_(int64[:, :], int64, int64))
def fillable(table, index, value):

    row = index // 9
    col = index % 9
    block = lambda x: ((x // 3) * 3, (x // 3 + 1) * 3)

    same_in_areas = False
    for area in [
        table[row, :], table[:, col],
        table[block(row)[0]:block(row)[1], block(col)[0]:block(col)[1]].flatten()
    ]:
        for i in area:
            same_in_areas |= (i == value)

    return not same_in_areas


@njit(int64[:, :](int64[::1]))
def fill(table_flat):

    for tmp_i, tmp_val in enumerate(table_flat):

        if tmp_val == 0:
            fillable_vals = [val
                             for val in range(tmp_val + 1, 10)
                             if fillable(table_flat.reshape(9, 9), tmp_i, val)]

            for new_val in fillable_vals:
                table_flat[tmp_i] = new_val

                if fill(table_flat).all():
                    break
            else:
                table_flat[tmp_i] = 0
                break
    return table_flat.reshape(9, 9)
```

```suudoku_input.py
from suudoku import fill as fill_slow
from suudoku_faster import fill as fill_fast
import matplotlib.pyplot as plt
import numpy as np
from time import time

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

# numbaの1周目は計算時間だけでなくコンパイル時間を含むので1回空回りさせる。
fill_fast(np.array(table).copy())

start = time()
ans = fill_fast(np.array(table).copy())
finish = time()

print("解答")
print(ans)
print()
print(f"計算時間(sec): {finish - start:5.7}")

fig, ax = plt.subplots(figsize=(6, 6))
fig.patch.set_visible(False)
ax.axis("off")

colors = np.where(table, "#F5A9BC", "#ffffff").reshape(9, 9)
picture = ax.table(cellText=ans, cellColours=colors, loc='center')
picture.set_fontsize(25)
picture.scale(1, 3)

ax.axhline(y=0.340, color="b")
ax.axhline(y=0.665, color="b")
ax.axvline(x=0.335, color="b")
ax.axvline(x=0.665, color="b")

fig.tight_layout()
plt.show()
```

# 出力
普通の問題なら0.01秒くらいなのにnumbaを持ってしても、最小ヒント問題は100秒かかってしまうのね…
ちなみにこっちの[世界一難しい数独](https://gigazine.net/news/20100822_hardest_sudoku/)は普通に解けた。

```
解答
[[2 3 4 8 9 1 5 6 7]
 [1 6 9 7 2 5 4 3 8]
 [5 7 8 3 4 6 9 1 2]
 [3 1 6 5 7 4 8 2 9]
 [4 9 7 6 8 2 1 5 3]
 [8 2 5 1 3 9 7 4 6]
 [6 4 2 9 1 8 3 7 5]
 [9 5 3 4 6 7 2 8 1]
 [7 8 1 2 5 3 6 9 4]]

計算時間(sec): 101.6835
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488916/af985b5e-bc89-d744-9237-d5765c524b30.png)