ノノグラムを解く ― 位置変数でCNFを小さくする#

この章で学ぶこと#

ノノグラム(イラストロジック)を SAT で解きます。この章の主役は、CNF をいかに小さく作るかです。

  • 1行分のヒントから、塗り方の候補をすべて列挙する

  • 候補をそのまま CNF にすると、組み合わせ爆発で遅くなる

  • 各数字の「開始位置」を変数にすると、CNF が桁違いに小さくなる

最後に、45×60 の大きなパズルも解きます。

ノノグラムのルール#

ノノグラムは、数字を手がかりにマスを塗るパズルです。

  • 盤面は正方形のグリッドで、各マスは黒か白に塗る

  • 行の左にある数字は、その行の連続する黒マスの長さを順番に並べたもの

  • 列の上(下)にある数字も同じ

  • すべての黒マスを決めれば完成

ノノグラムゲーム

各マスを「黒なら True」のブール変数で表し、行・列のヒントを制約にします。

from matplotlib import pyplot as plt
import numpy as np
import itertools

1行分の解をすべて列挙する#

paint_numbers(numbers, count) は、長さ count の1行に numbers の数字を並べるすべての塗り方を作ります。塗るマスを 1、塗らないマスを -1 で表します。

再帰で書けます。終了条件は2つです。

  1. 数字を並べるのに必要なマス数が count より多い … 解なし(例:[2, 1, 5] には最低 2+1+5+2 = 10 マス必要)

  2. 残りの数字が1つ … その長さのブロックを、置けるすべての位置に置く

終了しない場合は、2つに分岐します。

  1. 最初の数字をここに置いて、残りを再帰で解く(numbers[0] マス塗って、1マス空ける)

  2. このマスを空けて、残りを再帰で解く

def paint_numbers(numbers, count):
    """numbers の数字を幅 count に並べる、すべての塗り方を返す(1=黒, -1=白)"""
    if sum(numbers) + len(numbers) - 1 > count:
        return                      # 1. マスが足りない

    if len(numbers) == 1:
        n = numbers[0]
        for i in range(count - n + 1):          # 2. 最後の1つは、置ける位置すべて
            yield [-1] * i + [1] * n + [-1] * (count - i - n)
        return

    # 3. 先頭の数字を今の位置に置く(その後ろに1マス空ける)
    for rest in paint_numbers(numbers[1:], count - numbers[0] - 1):
        yield [1] * numbers[0] + [-1] + rest

    # 4. このマスを空けて、1つ右にずらす
    for rest in paint_numbers(numbers, count - 1):
        yield [-1] + rest

[2, 1, 5] を12マスに並べる例です。読みやすいように、-1 は 0 にして表示します。

for line in paint_numbers([2, 1, 5], 12):
    print([int(x > 0) for x in line])
[1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0]
[1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0]
[1, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1]
[1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0]
[1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1]
[1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1]
[0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0]
[0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1]
[0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1]
[0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1]

9通りの塗り方があります。人間が1行ずつ見ると「ここは必ず黒」が分かりますが、SAT ソルバーには、このすべての候補を教える必要があります。

DNF から CNF へ#

paint_numbers() の返す塗り方は、それぞれが「このマスは黒で、このマスは白」という AND の条件です。それが OR で並ぶので、これは DNF(選言標準形)です。SAT ソルバーは CNF(連言標準形)しか解けないので、変換が必要です。

DNF → CNF の素朴な変換(分配法則)は、式が爆発します。そこで新しい変数を導入します。たとえば「A, B, C のうちちょうど1つが偽」という DNF

\[ (\neg A \wedge B \wedge C) \vee (A \wedge \neg B \wedge C) \vee (A \wedge B \wedge \neg C) \]

の各項に新しい変数 X, Y, Z を割り当て、等価(\(\Leftrightarrow\))で結びます。

\[ (X \vee Y \vee Z) \wedge (X \Leftrightarrow \neg A \wedge B \wedge C) \wedge (Y \Leftrightarrow A \wedge \neg B \wedge C) \wedge (Z \Leftrightarrow A \wedge B \wedge \neg C) \]

\(P \Leftrightarrow Q\) は CNF で

\[ (\neg P \vee Q) \wedge (P \vee \neg Q) \]

と書けます。これを \(X \Leftrightarrow (\neg A \wedge B \wedge C)\) に当てはめると、次の4つの節になります。

\[ (\neg X \vee \neg A) \wedge (\neg X \vee B) \wedge (\neg X \vee C) \wedge (X \vee A \vee \neg B \vee \neg C) \]

SymPy で確かめてみます。

from sympy import symbols, Equivalent
from sympy.logic.boolalg import to_cnf

X, A, B, C = symbols("X A B C")
to_cnf(Equivalent(X, ~A & B & C))
\[\displaystyle \left(B \vee \neg X\right) \wedge \left(C \vee \neg X\right) \wedge \left(\neg A \vee \neg X\right) \wedge \left(A \vee X \vee \neg B \vee \neg C\right)\]

同じ形になっています(節の順番は違います)。この変換は「Tseitin 変換」と呼ばれ、式の長さを抑えられるのが利点です。

dnf_to_cnf(dnf) は、DNF の各項に新しい変数を割り当てて、上の4つの節を作ります。

  • 各項について、新しい変数 z を作る

  • z → 各リテラル … [-z, v] を作る(v は項に含まれる変数)

  • 項 → z … [z] + [-v for v in term] を作る

  • 最後に z をすべて OR でつないだ節を1つ作る

新しい変数は、既存の変数の最大番号より大きくする必要があります。new_vars を渡すと、その番号の続きを使ってくれます。

def dnf_to_cnf(dnf, new_vars=None):
    """DNF を Tseitin 変換で CNF にする"""
    if new_vars is None:
        # 既存の変数の最大番号より後ろを使う
        start = max(max(map(abs, term)) for term in dnf) + 1
        new_vars = iter(range(start, start + len(dnf)))

    zlist = []      # 新しい変数のリスト
    cnf = []
    for term in dnf:
        z = next(new_vars)
        zlist.append(z)
        cnf.append([z] + [-v for v in term])   # 項なら z(項 → z)
        for v in term:
            cnf.append([-z, v])                # z なら各リテラル(z → リテラル)
    cnf.append(zlist)                          # z のどれかは真
    return cnf

3つの変数のうち1つだけが偽、という DNF で試します。得られた解が「ちょうど1つが偽」になっていることを確認してください。enum_models() は、条件を満たす解をすべて返します。

from pprint import pprint
from pysat.solvers import Solver

dnf = [[-1, 2, 3], [1, -2, 3], [1, 2, -3]]
cnf = dnf_to_cnf(dnf)

print("CNF")
pprint(cnf)

print("solutions")
sat = Solver()
sat.append_formula(cnf)
for sol in sat.enum_models():
    print([int(x > 0) for x in sol])
CNF
[[4, 1, -2, -3],
 [-4, -1],
 [-4, 2],
 [-4, 3],
 [5, -1, 2, -3],
 [-5, 1],
 [-5, -2],
 [-5, 3],
 [6, -1, -2, 3],
 [-6, 1],
 [-6, 2],
 [-6, -3],
 [4, 5, 6]]
solutions
[0, 1, 1, 1, 0, 0]
[1, 0, 1, 0, 1, 0]
[1, 1, 0, 0, 0, 1]

新しい変数 4, 5, 6 が増え、最初の3つ(元の変数)はどれも「1つだけ偽」になっています。

paint_numbers() の結果と一致するかも試します。DNF の解と、CNF にして解いた結果が同じ集合になるはずです。

n = 12
solutions = list(paint_numbers([2, 1, 5], n))
variables = list(range(1, n + 1))

# 塗り方を DNF にする(変数番号 × 1 or -1)
dnf = [[v * t for v, t in zip(variables, term)] for term in solutions]

sat = Solver()
sat.append_formula(dnf_to_cnf(dnf))
# 変数 n 個分だけ取り出して、符号を -1/1 にそろえる
solutions_cnf = [[x // abs(x) for x in res[:n]] for res in sat.enum_models()]

print(sorted(solutions) == sorted(solutions_cnf))
True

ノノグラムを解く(素直な方法)#

行と列のヒントから、そのまま CNF を作ります。

  1. 各マスにブール変数(1〜幅×高さ)を割り当てる

  2. 変換で使う新しい変数の番号を用意する

  3. 行・列それぞれについて paint_numbers() で DNF を作り、CNF に変換して追加する

  4. Solver で解き、(高さ, 幅) の形に戻す

  5. plot() で描く

class NonogramSimpleSolver:
    """ヒントをそのまま CNF にする、素直なソルバー"""

    def __init__(self, rows, cols, width=None, height=None):
        if width is None:
            width = len(cols)
        if height is None:
            height = len(rows)
        self.rows = rows
        self.cols = cols
        self.width = width
        self.height = height
        self.var_count = width * height

        # 各マスの変数(行ごとに並べた2次元配列)
        self.vars = np.arange(1, self.var_count + 1).reshape(-1, width)
        # 変換で使う新しい変数の番号(マスの変数の次の番号から)
        self.new_vars = itertools.count(self.var_count + 1)

        self.cnf = []
        for i, row in enumerate(rows):
            self.add_constraints(row, width, self.vars[i, :].tolist())
        for j, col in enumerate(cols):
            self.add_constraints(col, height, self.vars[:, j].tolist())

    def add_constraints(self, numbers, count, variables):
        """1行(または1列)分のヒントを CNF に変換して追加する"""
        dnf = []
        for pattern in paint_numbers(numbers, count):
            dnf.append([v * p for v, p in zip(variables, pattern)])
        self.cnf.extend(dnf_to_cnf(dnf, new_vars=self.new_vars))

    def solve(self):
        sat = Solver()
        sat.append_formula(self.cnf)
        if not sat.solve():
            raise ValueError("解が見つかりませんでした")
        model = sat.get_model()
        solution = np.sign(np.array(model[:self.var_count])).reshape(self.height, self.width)
        self.solution = (solution + 1) // 2      # -1/1 を 0/1 に直す
        return self.solution

    def plot(self, font_size=9):
        from helper.plot import plot_nonogram_board
        return plot_nonogram_board({"rows": self.rows, "cols": self.cols},
                                   self.solution, font_size=font_size)

小さなパズルを解いてみます。

rows = [
    [3],
    [2, 1],
    [2],
    [7],
    [9],
    [2, 3, 2],
    [4, 4],
    [9],
    [2, 3, 2],
    [3, 3],
    [7],
    [5]
]

cols = [
    [6],
    [8],
    [2, 2, 3],
    [8, 2],
    [6, 2, 2],
    [1, 6, 2],
    [2, 2, 2, 3],
    [8],
    [6]
]

pbn = NonogramSimpleSolver(rows, cols)
pbn.solve()
pbn.plot();
_images/39edc1bf72e2a65d894c65b3ba620255988781aeb349d6d9e31f9da5d0f6c8a0.png

ファイルから読み込んで解く#

solve_and_paint(fn, solver) は、JSON ファイルのパズルを solver で解いて描きます。

ノノグラムパズルのサイト

https://www.nonograms.org/nonograms

helper.puzzle.extract_nonogram() を使うと、このサイトのノノグラムパズルの盤面を取得できます。ブラウザの開発者ツールを開き、盤面を表す <table> タグをコピーし、この関数を実行すると、盤面を表す辞書が返されます。

import json


def solve_and_paint(fn, solver, font_size=9):
    if isinstance(fn, dict):
        data = fn
    else:
        with open(fn) as f:
            data = json.load(f)

    pbn = solver(data["rows"], data["cols"])
    pbn.solve()
    pbn.plot(font_size=font_size)

nonogram02.json を解いて、時間を測ります。

%time solve_and_paint("data/nonogram02.json", solver=NonogramSimpleSolver)
CPU times: total: 15.3 s
Wall time: 16.1 s
_images/08d355fd9452ece1cd4072519a7fd03c2acf7e2d0b5a15ddfe3f26fe7bfe8671.png

10秒以上かかりました。マスが増えると、1行あたりの塗り方の候補が急増し、CNF の節の数も爆発するためです。

位置変数で速くする#

DNF → CNF の変換は、候補の数だけ新しい変数と節を作ります。たとえば数字が [1, 2, 3, 2, 1] で幅30なら、塗り方は 26,334 通りもあります。

len(list(paint_numbers([1, 2, 3, 2, 1], 30)))
26334

この組み合わせ爆発を避けるために、各数字の開始位置を変数にします。

幅10に [1, 2, 3] を置くなら、数字1の開始位置は 0, 1, 2 の3通りだけです。候補の「塗り方」を数えるのではなく、「各数字がどこから始まるか」を数えればよい、という発想です。

create_position_variables(numbers, count, new_bool_variables) は、各数字について {開始位置: 変数番号} の辞書を作ります。

  • start_pos() … 数字を詰めて並べたときの、各数字の開始位置

  • end_pos() … 右端をそろえたときの、各数字の開始位置(これが置ける最大の開始位置)

たとえば [1, 2, 3] を幅10に置く場合、数字3の開始位置は 5〜7 です。

def start_pos(numbers, count):
    """左に詰めたときの、各数字の開始位置"""
    s = 0
    for n in numbers:
        yield s
        s += n + 1


def end_pos(numbers, count):
    """右に詰めたときの、各数字の開始位置(= 置ける最大の開始位置)"""
    width = sum(numbers) + len(numbers) - 1
    start = count - width
    for pos in start_pos(numbers, count):
        yield start + pos


def create_position_variables(numbers, count, new_bool_variables):
    """{開始位置: 変数番号} のリストを作る"""
    pos_variables = []
    for a, b in zip(start_pos(numbers, count), end_pos(numbers, count)):
        pos_variables.append({j: next(new_bool_variables) for j in range(a, b + 1)})
    return pos_variables


numbers = [1, 2, 3]
count = 10
pos_variables = create_position_variables(numbers, count, itertools.count(1))
pos_variables
[{0: 1, 1: 2, 2: 3}, {2: 4, 3: 5, 4: 6}, {5: 7, 6: 8, 7: 9}]

数字1は 0〜2、数字2は 2〜4、数字3は 5〜7 から始められます。paint_numbers() の候補と見比べてみてください。

for line in paint_numbers([1, 2, 3], 10):
    print([int(x > 0) for x in line])
[1, 0, 1, 1, 0, 1, 1, 1, 0, 0]
[1, 0, 1, 1, 0, 0, 1, 1, 1, 0]
[1, 0, 1, 1, 0, 0, 0, 1, 1, 1]
[1, 0, 0, 1, 1, 0, 1, 1, 1, 0]
[1, 0, 0, 1, 1, 0, 0, 1, 1, 1]
[1, 0, 0, 0, 1, 1, 0, 1, 1, 1]
[0, 1, 0, 1, 1, 0, 1, 1, 1, 0]
[0, 1, 0, 1, 1, 0, 0, 1, 1, 1]
[0, 1, 0, 0, 1, 1, 0, 1, 1, 1]
[0, 0, 1, 0, 1, 1, 0, 1, 1, 1]

位置変数の制約#

位置変数には、2種類の制約が必要です。

  1. 各数字の開始位置は、候補のうちちょうど1つ

  2. 数字 i の開始位置を p1、次の数字の開始位置を p2 とすると、p2 >= p1 + num[i] + 1(1マス以上あける)

2番目は「p1 と p2 の組のうち、間隔が足りないものは同時に真にならない」という節 [-p1_var, -p2_var] で表せます。

only_one_true() は「ちょうど1つ」、pairwise() は隣り合う要素の組を作るヘルパーです。

def only_one_true(variables):
    """変数のうち、ちょうど1つが True になる CNF"""
    yield list(variables)                     # 少なくとも1つ
    for i, j in itertools.combinations(variables, 2):
        yield [-i, -j]                        # 2つ同時はダメ


def pairwise(iterable):
    """(a0,a1), (a1,a2), ... を作る"""
    a, b = itertools.tee(iterable)
    next(b, None)
    return zip(a, b)


def position_variables_constraint(pos_variables, numbers):
    cnf = []

    # 1. 各数字の開始位置は1つだけ
    for pos_var in pos_variables:
        cnf.extend(only_one_true(pos_var.values()))

    # 2. 隣り合う数字は、重ならないように離す
    for i, (pos_var1, pos_var2) in enumerate(pairwise(pos_variables)):
        gap = numbers[i] + 1
        for pos1, pos2 in itertools.product(pos_var1, pos_var2):
            if pos2 < pos1 + gap:
                cnf.append([-pos_var1[pos1], -pos_var2[pos2]])
    return cnf


cnf = position_variables_constraint(pos_variables, numbers)
cnf
[[1, 2, 3],
 [-1, -2],
 [-1, -3],
 [-2, -3],
 [4, 5, 6],
 [-4, -5],
 [-4, -6],
 [-5, -6],
 [7, 8, 9],
 [-7, -8],
 [-7, -9],
 [-8, -9],
 [-2, -4],
 [-3, -4],
 [-3, -5],
 [-5, -7],
 [-6, -7],
 [-6, -8]]

cnf を解いて、マスの塗り方に戻してみます。

  • 開始位置の変数が真 → その数字を、その位置から num[i] マス塗る

  • cell_map は「変数番号 → (開始位置, 数字)」の対応表

sat = Solver()
sat.append_formula(cnf)

cell_map = {}
for n, pos_var in zip(numbers, pos_variables):
    for pos, var in pos_var.items():
        cell_map[var] = (pos, n)

for sol in sat.enum_models():
    cells = [0] * count
    for var, v in enumerate(sol, 1):
        if v > 0:
            pos, n = cell_map[var]
            for j in range(pos, pos + n):
                cells[j] = 1
    print(cells)
[1, 0, 1, 1, 0, 1, 1, 1, 0, 0]
[1, 0, 1, 1, 0, 0, 1, 1, 1, 0]
[1, 0, 1, 1, 0, 0, 0, 1, 1, 1]
[1, 0, 0, 0, 1, 1, 0, 1, 1, 1]
[0, 0, 1, 0, 1, 1, 0, 1, 1, 1]
[0, 1, 0, 0, 1, 1, 0, 1, 1, 1]
[0, 1, 0, 1, 1, 0, 1, 1, 1, 0]
[1, 0, 0, 1, 1, 0, 1, 1, 1, 0]
[1, 0, 0, 1, 1, 0, 0, 1, 1, 1]
[0, 1, 0, 1, 1, 0, 0, 1, 1, 1]

同じ10通りが得られました。paint_numbers() と比べて、変数も節もずっと少なくて済んでいます。

マスの変数と位置変数をつなぐ#

ここまでは「1行の中の位置」を決めました。ノノグラムでは、行と列が同じマスを共有します。そこで、マスの変数と位置変数を結びつける制約を追加します。

マス j と位置の関係は、次の4通りです。

  1. 最初のブロックより前のマスは白 … pos[0] より前は cell = 0

  2. 最後のブロックより後のマスは白 … pos[-1] + num[-1] 以降は cell = 0

  3. ブロックの中は黒 … pos[i] <= j < pos[i] + num[i] は cell = 1

  4. ブロックの間は白 … pos[i] + num[i] <= j < pos[i+1] は cell = 0

「位置変数が真なら、マス変数はこうなる」という含意(-pos_var, cell_var など)で書けます。

def cell_variables_constraint(pos_variables, cell_variables, numbers, count):
    cnf = []

    # 3. ブロックの中は黒
    for i, pos_var in enumerate(pos_variables):
        for pos, var in pos_var.items():
            for j in range(numbers[i]):
                cnf.append([-var, cell_variables[pos + j]])

    # 1. 最初のブロックより前は白
    for pos, var in pos_variables[0].items():
        for j in range(pos):
            cnf.append([-var, -cell_variables[j]])

    # 2. 最後のブロックより後は白
    for pos, var in pos_variables[-1].items():
        for j in range(pos + numbers[-1], count):
            cnf.append([-var, -cell_variables[j]])

    # 4. ブロックの間は白
    for i, (pos_var1, pos_var2) in enumerate(pairwise(pos_variables)):
        for pos1, pos2 in itertools.product(pos_var1, pos_var2):
            if pos2 > pos1 + numbers[i]:
                for j in range(pos1 + numbers[i], pos2):
                    cnf.append([-pos_var1[pos1], -pos_var2[pos2], -cell_variables[j]])
    return cnf

10マス・[1, 2, 3] で、すべての制約を組み合わせて解きます。cell_variables はマスの変数(1〜10)、位置変数はその次の番号から作ります。

numbers = [1, 2, 3]
count = 10

cell_variables = list(range(1, count + 1))
pos_variables = create_position_variables(numbers, count, itertools.count(count + 1))

cnf = position_variables_constraint(pos_variables, numbers)
cnf += cell_variables_constraint(pos_variables, cell_variables, numbers, count)

sat = Solver()
sat.append_formula(cnf)
for sol in sat.enum_models():
    print([int(x > 0) for x in sol[:count]])
[1, 0, 1, 1, 0, 1, 1, 1, 0, 0]
[1, 0, 1, 1, 0, 0, 1, 1, 1, 0]
[1, 0, 1, 1, 0, 0, 0, 1, 1, 1]
[1, 0, 0, 1, 1, 0, 0, 1, 1, 1]
[0, 1, 0, 1, 1, 0, 0, 1, 1, 1]
[0, 1, 0, 1, 1, 0, 1, 1, 1, 0]
[1, 0, 0, 1, 1, 0, 1, 1, 1, 0]
[1, 0, 0, 0, 1, 1, 0, 1, 1, 1]
[0, 1, 0, 0, 1, 1, 0, 1, 1, 1]
[0, 0, 1, 0, 1, 1, 0, 1, 1, 1]

paint_numbers() と同じ塗り方が出ました。これで、行と列を別々に作っても、同じマスの変数を通して整合性が取れます。

速いソルバー#

NonogramFastSolver は NonogramSimpleSolver を継承し、add_constraints() だけを差し替えたものです。

class NonogramFastSolver(NonogramSimpleSolver):
    """位置変数を使う、速いソルバー"""

    def add_constraints(self, numbers, count, cell_variables):
        pos_variables = create_position_variables(numbers, count, self.new_vars)
        self.cnf.extend(position_variables_constraint(pos_variables, numbers))
        self.cnf.extend(cell_variables_constraint(pos_variables, cell_variables,
                                                 numbers, count))

同じパズルを解いて、時間を比べます。

%time solve_and_paint("data/nonogram02.json", solver=NonogramFastSolver)
CPU times: total: 203 ms
Wall time: 213 ms
_images/08d355fd9452ece1cd4072519a7fd03c2acf7e2d0b5a15ddfe3f26fe7bfe8671.png

NonogramSimpleSolver では10秒以上かかっていたパズルが、1秒もかからずに解けました。候補の塗り方を全部 CNF にする代わりに「開始位置」だけを変数にしたので、式が桁違いに小さくなったためです。

最後に、45×60 の大きなパズルを解いてみます。

%time solve_and_paint("data/nonogram03.json", solver=NonogramFastSolver, font_size=6)
CPU times: total: 3.98 s
Wall time: 4.15 s
_images/4cb40b766c0cdd505a23f83c79574a9a94ed047942d0b598905aa17485ede9e8.png

検算#

解いた盤面が、ヒントと本当に一致しているか確かめます。「連続する黒の長さ」を数えて、行・列のヒントと比べます。

def runs(line):
    """1行の並びから、連続する1の長さを順に取り出す"""
    result, n = [], 0
    for x in line:
        if x:
            n += 1
        elif n:
            result.append(n)
            n = 0
    if n:
        result.append(n)
    return result


def check_nonogram(rows, cols, solution):
    ok_rows = [runs(solution[i]) for i in range(len(rows))] == [list(r) for r in rows]
    ok_cols = [runs(solution[:, j]) for j in range(len(cols))] == [list(c) for c in cols]
    return ok_rows and ok_cols


print("小さなパズル:", check_nonogram(rows, cols, pbn.solution))

with open("data/nonogram02.json") as f:
    data02 = json.load(f)
pbn02 = NonogramFastSolver(data02["rows"], data02["cols"])
pbn02.solve()
print("nonogram02 :", check_nonogram(data02["rows"], data02["cols"], pbn02.solution))
小さなパズル: True
nonogram02 : True

まとめ#

  • 1行分のヒントは paint_numbers() ですべての塗り方に展開できる

  • そのまま CNF にすると、候補の数だけ節が増えて爆発する(幅30で2万通り超)

  • 各数字の開始位置を変数にすると、候補は「置ける位置」の数だけになり、式がとても小さくなる

  • 「ちょうど1つ」「同時に真にならない」は only_one_true() のような小さな部品で書ける

  • 行と列は、マスの変数を共有させてつなぐ

  • この章の高速化で、十数秒かかっていたパズルが 0.2 秒ほどになり、45×60 のパズルも数秒で解ける