数独を Backtracking で解く

# 概要

数独を自動的に解く solver を作る。アルゴリズムは Backtracking.

その中でも、戦略を 3 つで考えた。

* `FOOLISH` ... バカみたいに左から順番に空いているところから試していく
* `SORT_BEFOREHAND` ... 候補が少ないところを予め算出しておいて、その順番で試していく
* `DYNAMIC` ... 探すところを動的に変える。どのように変えるかというと、その瞬間で候補が一番少ないところを見つけて、そこから探す


[こちら](https://dl.ndl.go.jp/view/download/digidepo_8559202_po_r8-2-10.pdf?contentNo=1&alternativeNo=) の論文に寄り添った言い方をすれば、全体を通してフォワードチェッキングを行っている(つまり、数字を更新したら他の cell の domain から違反するものを削除したりしている)のと、 DYNAMIC は変数の選択順序が動的順序なバージョンである、ということ。

さらに言えば、 [この本の中にある](http://cse.unl.edu/~choueiry/Documents/TsangTextbook/Tsang-Fcs1993-Toc.pdf) fail-first principle が、もろ DYNAMIC のアルゴリズムに相当する。

# code

```python
from copy import deepcopy
from typing import List
from collections import deque
from itertools import product

FOOLISH = 'FOOLISH'
SORT_BEFOREHAND = 'SORT_BEFOREHAND'
DYNAMIC = 'DYNAMIC'


class SudokuSolver():
    def __init__(self) -> None:
        self.backtrack_count = 0
        self.strategy = FOOLISH  # FOOLISH, SORT_BEFOREHAND, DYNAMIC
        self.cells_to_be_checked: deque = None

    def _remove_candidates_per_row(self, candidate_nums, board):
        """行を見て既にある数字を候補から削除する
        """
        for row in range(0, 9):
            filled_num_wo_zero = set(board[row]) - {0}
            for col in range(0, 9):
                candidate_nums[(row, col)] -= filled_num_wo_zero
        return candidate_nums

    def _remove_candidates_per_colmun(self, candidate_nums, board):
        """列を見て、すでにある数字を候補から削除する
        """
        for col in range(0, 9):
            filled_num_wo_zero = set([board[_row][col] for _row in range(0, 9)]) - {0}
            for row in range(0, 9):
                candidate_nums[(row, col)] -= filled_num_wo_zero
        return candidate_nums

    def _remove_candidates_per_block(self, candidate_nums, board):
        """ブロックを見て、すでにある数字を候補から削除する
        """
        # ブロックごとに対応する cell のリストを準備する
        _cells_per_block = {(_block_row, _block_col): set()
                            for (_block_row, _block_col) in product(range(0, 3), range(0, 3))}
        for row, col in product(range(0, 9), range(0, 9)):
            _block_index = (row // 3, col // 3)
            _cells_per_block[_block_index] = _cells_per_block[_block_index] | {board[row][col]} - {0}
        for row, col in product(range(0, 9), range(0, 9)):
            candidate_nums[(row, col)] -= _cells_per_block[(row // 3, col // 3)]

        return candidate_nums

    def generate_candidate_nums(self, board):
        """候補となる数字の集合を作る
        """
        # あらかじめすべての cell の候補は 1,2,3,...,9 であるとしておく
        candidate_nums = {(row, col): set([i for i in range(1, 10)])
                          for (row, col) in product(range(0, 9), range(0, 9))}

        # board の数字から、予め候補にならない数字を取り除く
        candidate_nums = self._remove_candidates_per_row(candidate_nums, board)
        candidate_nums = self._remove_candidates_per_colmun(candidate_nums, board)
        candidate_nums = self._remove_candidates_per_block(candidate_nums, board)

        return candidate_nums

    def initialize_cells_to_be_checked(self, candidate_nums):
        """候補の数の集合に基づいて、確認していく cell の順番を決める
        """
        # 候補が少ないところから.
        if self.strategy in [SORT_BEFOREHAND, DYNAMIC]:
            self.cells_to_be_checked = deque([k[0] for k in sorted(candidate_nums.items(), key=lambda t: len(t[1]))])
        # 一番バカな方法から。
        else:  # FOOLISH
            self.cells_to_be_checked = deque(candidate_nums.keys())
        return self.cells_to_be_checked

    def is_valid(self, board, candidate_nums, cell):
        """矛盾がないかをしらべる
        """
        # 矛盾がないの定義: ボードは埋まっていないのに、候補となる数が存在しないこと
        row, col = cell
        return not (board[row][col] == 0 and not candidate_nums[cell])

    def update(self, board, candidate_nums, cell, num):
        updated_cells = set()
        row, col = cell
        assert board[row][col] == 0, f'not empty, {(row, col, board[row][col])}'

        # board を更新
        board[row][col] = num

        # これで影響のある candidate を取り除く。まずは行から
        for c in range(0, 9):
            if num not in candidate_nums[(row, c)]:
                continue
            candidate_nums[(row, c)] -= {num}
            updated_cells.add((row, c))
        # 次に列。
        for r in range(0, 9):
            if num not in candidate_nums[(r, col)]:
                continue
            candidate_nums[(r, col)] -= {num}
            updated_cells.add((r, col))
        # 最後にブロック
        for r, c in product(range(0, 9), range(0, 9)):
            if (r // 3, c // 3) != (row // 3, col // 3):
                continue
            if num not in candidate_nums[(r, c)]:
                continue
            candidate_nums[(r, c)] -= {num}
            updated_cells.add((r, c))

        return candidate_nums, board, updated_cells

    def revert(self, board, candidate_nums, cell, num, updated_cells):
        row, col = cell

        # board を戻す
        board[row][col] = 0

        # update した cell だけもとに戻す
        for ucell in updated_cells:
            candidate_nums[ucell].add(num)

        return candidate_nums, board

    def popleft(self, candidate_nums):
        """strategy に応じて次に探索する cell を取得する
        """
        # 動的であれば、現在の状態の中でも一番候補が少ないところを選ぶ
        if self.strategy == DYNAMIC:
            rest_checked_cells = {cell: candidates for cell,
                                  candidates in candidate_nums.items() if cell in self.cells_to_be_checked}
            self.cells_to_be_checked = deque([k[0] for k in sorted(
                rest_checked_cells.items(), key=lambda t: len(t[1]))])

        return self.cells_to_be_checked.popleft()

    def appendleft(self, cell):
        """cell を元に戻す
        """
        return self.cells_to_be_checked.appendleft(cell)

    def backtrack(self, board, candidate_nums, history: List):
        self.backtrack_count += 1
        if self.backtrack_count % 100 == 0:
            print('count', self.backtrack_count)

        # 次がないならそれで終わり
        if not self.cells_to_be_checked:
            return True, board

        # 次に探索する cell を取り出す。
        cell = self.popleft(candidate_nums)
        row, col = cell

        # 矛盾していたら False を返す
        if not self.is_valid(board, candidate_nums, cell):
            self.appendleft(cell)
            return False, board

        # もし既に数字が埋まっているなら次へ
        if board[row][col] != 0:
            return self.backtrack(board, candidate_nums, history)

        # 以下は数字が埋まっていない場合、埋めてみる。
        to_be_checked = deepcopy(candidate_nums[cell])
        for num in to_be_checked:
            # この数字を入れて、全ての candidate を更新する
            candidate_nums, board, updated_cells = self.update(board, candidate_nums, cell, num)

            # debug
            if self.backtrack_count % 100 == 0:
                self.show(board)
                print()

            # その前提で次を試す
            result_bool, board = self.backtrack(board, candidate_nums, history)
            # もしもここで True が返ってくるならば、それはすべて成功した、ということだから、もう何もせず上に返す
            if result_bool:
                return True, board
            # だめだったら、 revert する
            candidate_nums, board = self.revert(board, candidate_nums, cell, num, updated_cells)

            # debug
            if self.backtrack_count % 100 == 0:
                self.show(board)
                print()

        # ここまで来るとその前がだめだった、ということだから、だめだと返す
        self.appendleft(cell)
        return False, board

    def solve(self, board) -> List[List]:
        # それぞれの cell の候補となる数字を格納する dict を作る。
        # これはずっと更新されていく。終了判定も必要になるであろう。そういう使われ方も OK
        # TODO: board は cell を index とした dict にしたほうがよさそうだな。 I/F が若干複雑。
        # TODO: popleft しているところは全部 next という関数にしよう。ということは内部に deque を持つことになるから
        #       class にする必要があるであろう。
        candidate_nums = self.generate_candidate_nums(board)

        # 調べる順番を定義する
        self.initialize_cells_to_be_checked(candidate_nums)

        # backtrack で埋めていく
        history = []
        _, result_board = self.backtrack(board, candidate_nums, history)

        return result_board

    def show(self, board):
        """ボードを見やすいように表示する

        53 | 7 |
        6  |195|
         98|   | 6
        ---+---+---
        8  | 6 |  3
        4  |8 3|  1
        7  | 2 |  6
        ---+---+---
         6 |   |28
           |419|  5
           | 8 | 79

        Args:
            board (List[List]): ボード
        """
        for row_index, row in enumerate(board):
            # print する 1 行の文字列
            print_row = ''
            for cell_index, cell in enumerate(row):
                # 0 の値は空白にする
                value = str(cell) if cell != 0 else ' '
                # 3 マスで区切りが入る
                value = value + '|' if cell_index in [2, 5] else value + ''
                print_row += value
            print(print_row)
            if row_index in [2, 5]:
                # 3 行目、また 6 行目であれば区切り線をいれる
                sep_list = ['-' for i in range(11)]
                sep_list[3], sep_list[7] = '+', '+'
                print(''.join(sep_list))
```

# 実験

```python
from time import time
# 手で解いたらだいたい 10 分のやつ
board_0 = [
    [9, 8, 4, 0, 3, 1, 0, 7, 2],
    [6, 1, 0, 0, 0, 7, 0, 0, 0],
    [2, 5, 7, 0, 0, 9, 8, 0, 0],
    [3, 0, 0, 0, 6, 0, 0, 1, 0],
    [0, 0, 0, 3, 7, 0, 9, 2, 0],
    [0, 0, 9, 0, 0, 5, 0, 0, 0],
    [0, 3, 0, 0, 0, 6, 0, 0, 0],
    [0, 4, 5, 0, 1, 8, 0, 9, 6],
    [1, 9, 6, 7, 0, 0, 2, 8, 0]
]

# Wikipedia の例題: https://en.wikipedia.org/wiki/Backtracking
board_1 = [
    [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]
]

# 世界一難しい: https://abcnews.go.com/blogs/headlines/2012/06/can-you-solve-the-hardest-ever-sudoku
board_2 = [
    [8, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 3, 6, 0, 0, 0, 0, 0],
    [0, 7, 0, 0, 9, 0, 2, 0, 0],
    [0, 5, 0, 0, 0, 7, 0, 0, 0],
    [0, 0, 0, 0, 4, 5, 7, 0, 0],
    [0, 0, 0, 1, 0, 0, 0, 3, 0],
    [0, 0, 1, 0, 0, 0, 0, 6, 8],
    [0, 0, 8, 5, 0, 0, 0, 1, 0],
    [0, 9, 0, 0, 0, 0, 4, 0, 0]
]

for board in [board_0, board_1, board_2]:
    original_board = deepcopy(board)
    for strategy in [FOOLISH, SORT_BEFOREHAND, DYNAMIC]:
        board = deepcopy(original_board)
        print('# strategy', strategy)
        solver = SudokuSolver()
        print('# before')
        solver.show(board)
        print()
        solver.strategy = strategy
        _start = time()
        print('# after')
        result_board = solver.solve(board)
        _end = time()
        solver.show(result_board)
        print('# summary')
        print('result_count', solver.backtrack_count, 'duration', _end - _start)
        print()
```

結果

```
# strategy FOOLISH
# before
984| 31| 72
61 |  7|   
257|  9|8  
---+---+---
3  | 6 | 1 
   |37 |92 
  9|  5|   
---+---+---
 3 |  6|   
 45| 18| 96
196|7  |28 

# after
984|531|672
613|827|549
257|649|831
---+---+---
378|962|415
561|374|928
429|185|763
---+---+---
832|496|157
745|218|396
196|753|284
# summary
result_count 229 duration 0.005127906799316406

# strategy SORT_BEFOREHAND
# before
984| 31| 72
61 |  7|   
257|  9|8  
---+---+---
3  | 6 | 1 
   |37 |92 
  9|  5|   
---+---+---
 3 |  6|   
 45| 18| 96
196|7  |28 

# after
984|531|672
613|827|549
257|649|831
---+---+---
378|962|415
561|374|928
429|185|763
---+---+---
832|496|157
745|218|396
196|753|284
# summary
result_count 101 duration 0.0019109249114990234

# strategy DYNAMIC
# before
984| 31| 72
61 |  7|   
257|  9|8  
---+---+---
3  | 6 | 1 
   |37 |92 
  9|  5|   
---+---+---
 3 |  6|   
 45| 18| 96
196|7  |28 

# after
984|531|672
613|827|549
257|649|831
---+---+---
378|962|415
561|374|928
429|185|763
---+---+---
832|496|157
745|218|396
196|753|284
# summary
result_count 82 duration 0.007035255432128906

# strategy FOOLISH
# before
53 | 7 |   
6  |195|   
 98|   | 6 
---+---+---
8  | 6 |  3
4  |8 3|  1
7  | 2 |  6
---+---+---
 6 |   |28 
   |419|  5
   | 8 | 79

# after
534|678|912
672|195|348
198|342|567
---+---+---
859|761|423
426|853|791
713|924|856
---+---+---
961|537|284
287|419|635
345|286|179
# summary
result_count 4463 duration 0.09597992897033691

# strategy SORT_BEFOREHAND
# before
53 | 7 |   
6  |195|   
 98|   | 6 
---+---+---
8  | 6 |  3
4  |8 3|  1
7  | 2 |  6
---+---+---
 6 |   |28 
   |419|  5
   | 8 | 79

# after
534|678|912
672|195|348
198|342|567
---+---+---
859|761|423
426|853|791
713|924|856
---+---+---
961|537|284
287|419|635
345|286|179
# summary
result_count 152 duration 0.002769947052001953

# strategy DYNAMIC
# before
53 | 7 |   
6  |195|   
 98|   | 6 
---+---+---
8  | 6 |  3
4  |8 3|  1
7  | 2 |  6
---+---+---
 6 |   |28 
   |419|  5
   | 8 | 79

# after
534|678|912
672|195|348
198|342|567
---+---+---
859|761|423
426|853|791
713|924|856
---+---+---
961|537|284
287|419|635
345|286|179
# summary
result_count 82 duration 0.006140947341918945

# strategy FOOLISH
# before
8  |   |   
  3|6  |   
 7 | 9 |2  
---+---+---
 5 |  7|   
   | 45|7  
   |1  | 3 
---+---+---
  1|   | 68
  8|5  | 1 
 9 |   |4  

# after
812|753|649
943|682|175
675|491|283
---+---+---
154|237|896
369|845|721
287|169|534
---+---+---
521|974|368
438|526|917
796|318|452
# summary
result_count 61986 duration 1.3313939571380615

# strategy SORT_BEFOREHAND
# before
8  |   |   
  3|6  |   
 7 | 9 |2  
---+---+---
 5 |  7|   
   | 45|7  
   |1  | 3 
---+---+---
  1|   | 68
  8|5  | 1 
 9 |   |4  

# after
812|753|649
943|682|175
675|491|283
---+---+---
154|237|896
369|845|721
287|169|534
---+---+---
521|974|368
438|526|917
796|318|452
# summary
result_count 8526403 duration 184.81602668762207

# strategy DYNAMIC
# before
8  |   |   
  3|6  |   
 7 | 9 |2  
---+---+---
 5 |  7|   
   | 45|7  
   |1  | 3 
---+---+---
  1|   | 68
  8|5  | 1 
 9 |   |4  

# after
812|753|649
943|682|175
675|491|283
---+---+---
154|237|896
369|845|721
287|169|534
---+---+---
521|974|368
438|526|917
796|318|452
# summary
result_count 1395 duration 0.11001706123352051


```

表にすると以下。 result_count は合計何回 backtrack の method を通ったか、 duration は処理にかかった合計秒数。

||`FOOLISH`|`SORT_BEFOREHAND`|`DYNAMIC`|
|---|---|---|---|
|10分で解ける|result_count 229 duration 0.005127906799316406|result_count 101 duration 0.0019109249114990234|result_count 82 duration 0.007035255432128906|
|Wikipedia|result_count 4463 duration 0.09597992897033691|result_count 152 duration 0.002769947052001953|result_count 82 duration 0.006140947341918945|
|世界一むずい|result_count 61986 duration 1.3313939571380615|result_count 8526403 duration 184.81602668762207|result_count 1395 duration 0.11001706123352051|


注目すべきは
* 特に世界一難しい問題は SORT_BEFOREHAND で 3 分ほどかかってる。
* `DYNAMIC` は安定して速い。