OR-ToolsでABCプレースの問題を自動生成する

# はじめに

[OR-Toolsでナンバーリンクの問題を自動生成する](https://qiita.com/riverplus/items/45c4800362a92dc549c2) に続き、[OR-Tools](https://developers.google.com/optimization) （Operations Research Tools）を使ってパズルを自動生成する話です。

今回は、ABCプレースの問題自動生成を行います。

# ABCプレースとは

ルールについては [Wikipedia](https://ja.wikipedia.org/wiki/ABC%E3%83%97%E3%83%AC%E3%83%BC%E3%82%B9) をご覧になって下さい。
引用すると次の2点です。

* 各列に、指定された文字を1つずつ記入する（文字が入らないマスもある）。
* 枠外にある文字は、その列で最も辺に近い位置にある文字である。

例題と解答：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/2af7a2af-3b59-79ce-2209-a4b8c466c753.png" width="30%"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/43dc04e9-106a-475a-b44c-8bf3bca5001a.png" width="30%">


# ソルバを作る

パズル問題の自動生成では、とにかくソルバを作らないと始まりません。

問題のルールをOR-Toolsの制約に翻訳します。特にABCプレースの特徴と言えるのが、「枠外にある文字はその列で最も辺に近い位置にある文字である」というルールです。感覚的にはすぐ理解できるこのルールを、いかにOR-Toolsの制約に翻訳するかというのが考えどころです。

ある列のヒント「X」に対して、辺に近いマスの並びが `X`, `-X`, `--X`, `---X`, ‥‥ のいずれかに等しい（`-`は空マス）、と読み替えることにしました。（もうちょっと利口な方法があるかもしれません。）

もう一つのルールが「各列に、指定された文字を1つずつ記入する」です。全てのセルに文字を入れるのであれば、`CpModel`の`AddAllDifferent`という制約がそのまま使えるのですが、このパズルでは空セルがあり得るため、文字ごとに`AddExactlyOne`で制約を付けます。

以上の制約を用いたソルバのコードを以下に示します。なお、A,B,C,‥‥ の文字を 1,2,3,‥‥ の数字に置き換えています。また別解が存在する場合は最大4万件を得るようにしています。

``` python
from ortools.sat.python import cp_model

class TooManySolutionError(Exception):
    def __init__(self, message):
        self.message = message
        super().__init__(self.message)

class SolutionPrinter(cp_model.CpSolverSolutionCallback):
    def __init__(self, grid):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.grid = grid
        self.solutions = []

    def OnSolutionCallback(self):
        grid = [[self.Value(self.grid[r][c]) for c in range(len(self.grid))] for r in range(len(self.grid))]
        self.solutions.append(grid)
        if len(self.solutions) > 40000:
            raise TooManySolutionError('')
            
    def SolutionCount(self):
        return self.solution_count
    
def solve(clues, n, m):
    model = cp_model.CpModel()

    # 変数を定義 (各マスに0...Mの値を入れる)
    grid = [[model.NewIntVar(0, m, f"cell_{r}_{c}") for c in range(n)] for r in range(n)]

    # 1...m がそれぞれ1度ずつ現れるようにする制約
    def add_unique_constraints(array):
        for k in range(1, m+1):
            v = [model.NewBoolVar(f'v_{k}_{i}') for i in range(n)]
            for i in range(n):
                model.Add(array[i] == k).OnlyEnforceIf(v[i])
                model.Add(array[i] != k).OnlyEnforceIf(v[i].Not())
            model.AddExactlyOne(v)

    for c in range(n):
        add_unique_constraints([grid[r][c] for r in range(n)])
    for r in range(n):
        add_unique_constraints([grid[r][c] for c in range(n)])

    # 枠外にある文字はその列で最も辺に近い位置にある文字である制約
    def add_line_constraints(array, clue):
        if clue == 0:
            return

        w = [model.NewBoolVar(f'w_{i}') for i in range(n-m+1)]
        for k in range(n-m+1):
            u = [model.NewBoolVar(f'u_{k}_{i}') for i in range(k+1)]
            for i in range(k):
                model.Add(array[i] == 0).OnlyEnforceIf(u[i])
                model.Add(array[i] != 0).OnlyEnforceIf(u[i].Not())
            model.Add(array[k] == clue).OnlyEnforceIf(u[k])
            model.Add(array[k] != clue).OnlyEnforceIf(u[k].Not())
            
            model.AddBoolAnd(u).OnlyEnforceIf(w[k])
            model.AddBoolOr([b.Not() for b in u]).OnlyEnforceIf(w[k].Not())
        model.AddBoolOr(w)

    # 上側と下側のヒント
    for c in range(n):
        add_line_constraints([grid[r][c] for r in range(n)], clues['top'][c])
        add_line_constraints([grid[r][c] for r in reversed(range(n))], clues['bottom'][c])
    
    # 左側と右側のヒント
    for r in range(n):
        add_line_constraints([grid[r][c] for c in range(n)], clues['left'][r])
        add_line_constraints([grid[r][c] for c in reversed(range(n))], clues['right'][r])
    
    # ソルバーの作成と解の探索
    solver = cp_model.CpSolver()
    printer = SolutionPrinter(grid)

    # 解を求める (全解を探索)
    try:
        status = solver.SearchForAllSolutions(model, printer)
    except TooManySolutionError as e:
        pass

    return printer.solutions
    
if __name__ == '__main__':
    n, m = 5, 3
    clues = {
        'top':    [2, 0, 0, 0, 3],
        'bottom': [3, 0, 0, 0, 1],
        'left':   [0, 0, 0, 0, 1],
        'right':  [0, 0, 0, 3, 2],
    }

    solutions = solve(clues, n, m)

    for row in solutions[0]:
        print(row)
```

# 問題の自動生成

次に問題の自動生成です。今回は、ヒントをランダムに生成するアプローチを用いることにしました。すなわち、ヒントの数字をランダムに生成して、ソルバに解かせてみる。唯一解が得られればヒントと正答を出力して終了。得られなければ破棄して再トライするというものです。

しかしながら、実際のところ、ランダム生成で唯一解が得られる事はきわめて稀でした。解無しだったり別解ありだったりする場合がほとんどでした。

そこでさらなる工夫を行います。別解ありだった場合に、ヒントの数字を追加することで唯一解に絞り込むことを考えます。

具体例を示します。図のヒントをソルバに解かせてみると、4通りの別解が存在することが分かります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/4db6632c-fa90-ccf9-1aa0-24b3889c9b9d.png" width="50%">

ここで4通りの別解を注意深く見ると、赤字の位置にヒントを追加してやることで、唯一解に絞り込めることが分かります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/582ac85b-1627-3a05-0e84-e2dc2e0c155a.png" width="20%">

以上のアプローチをコードにしました。追加するヒントの個数ができるだけ少なくなるように工夫しています。ごちゃついたコードなのはごめんなさい。

``` python
def random_clue(m):
    # 60％の確率で0（ヒントなし）、40%の確率で1~mのいずれかを出力する
    rate = 60
    options = [i for i in range(m+1)]
    weights = [rate] + [(100-rate)/m for i in range(m)]
    return random.choices(options, weights=weights, k=1)[0]

def get_clue(array):
    for v in array:
        if v != 0: return v
    return -1

def output(clues, solution):
    n = len(solution)
    print('top: ', clues[0:n])
    print('bottom: ', clues[n:2*n])
    print('left: ', clues[2*n:3*n])
    print('right: ', clues[3*n:])
    print('solution:')
    for row in solution:
        print(row)

def provide(n, m):

    while True:
        while True:
            clues = {
                'top':    [random_clue(m) for i in range(n)],
                'bottom': [random_clue(m) for i in range(n)],
                'left':   [random_clue(m) for i in range(n)],
                'right':  [random_clue(m) for i in range(n)],
            }
            solutions = solve(clues, n, m)
            if len(solutions) > 0: break

        # ヒントを追加することで唯一解になる可能性を探る
        sol_dict = {}
        for sol in solutions:
            key = []
            key.extend([get_clue([sol[j][i] for j in range(n)]) for i in range(n)])    # top
            key.extend([get_clue(reversed([sol[j][i] for j in range(n)])) for i in range(n)])
            key.extend([get_clue([sol[j][i] for i in range(n)]) for j in range(n)])    # left
            key.extend([get_clue(reversed([sol[j][i] for i in range(n)])) for j in range(n)])
            key = tuple(key)

            if key not in sol_dict:
                sol_dict[key] = []
            sol_dict[key].append(sol)

        def clue_sub(depth, depth_max, i0, indexes):
            if depth == depth_max:
                counts = {}
                for s in sol_dict.keys():
                    t = tuple([s[j] for j in indexes])
                    if t not in counts:
                        counts[t] = []
                    counts[t].append((s, indexes))

                for k, count in counts.items():
                    if len(count) != 1: continue
                    clue, indexes = count[0][0], count[0][1]
                    sols = sol_dict[clue]
                    if len(sols) != 1: continue
                    hint = clues['top'] + clues['bottom'] + clues['left'] + clues['right']
                    for i in range(len(indexes)):
                        hint[indexes[i]] = k[i]
                    output(hint, sols[0])
                    return True
                return False

            else:
                for i in range(i0, 4*n):
                    indexes.append(i)
                    r = clue_sub(depth+1, depth_max, i+1, indexes)
                    indexes.pop()
                    if r: return True
                return False

        for depth_max in range(1, 5):
            r = clue_sub(0, depth_max, 0, [])
            if r: return

if __name__ == '__main__':
   
    provide(5, 3)
```

# 出力例

こんな感じの結果が出ました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/d4991eea-9aa9-964a-1105-37df437aeceb.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/f51abb47-452b-7937-b638-0a9d009a2ecf.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/1cc01985-c3f7-98b6-3319-24fbe804cf29.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/9a111105-b30e-7181-ecef-d17992e99545.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/433cf1d8-a446-4136-2660-791274721186.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/ba396e9e-680d-9f3f-0d41-128882be7e62.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285676/faff633f-2e00-fe22-5eb4-64ff94fbeb49.png" width="30%">

# 所感

実際に手で解いてみると分かりますが、どの問題もめちゃくちゃ難しいです。いわゆる解き味のようなものは完全無視された形です。あくまで唯一解を持つことが分かっているだけ。

サイズが大きくなると時間がかかります。始めにランダムなヒントでソルバに解かせたときに、解なしだったり、別解が異常に多かったりする場合がほとんどでした。8×8以上のサイズはほぼ無理でした。そもそも問題として成立する配置がレアなのだろうと想像しています。

# おわりに

OR-Toolsを使って、ABCプレースの問題の自動生成を行いました。