用 SAT 解数独:把恰好一个写成 CNF#

本章的内容#

本章用 SAT 求解器解数独,从一个小推理谜题开始,逐步过渡到数独,看问题如何被翻译成布尔式。

  • SAT 与 CNF 的含义,后者即合取范式

  • 把析取范式变成 CNF 的两种做法,用 SymPy 与手工推导

  • 一组之中只有一个为真这条约束的写法

  • 用 pysat 解数独,用 assumptions 给出初始盘面

本章的重点是最后一件事:把“恰好一个”写成 CNF,其余部分都是它的重复应用。

可满足性问题#

可满足性问题,即 SATisfiability problem,简称 SAT,问的是能否给出一组变量的值,使给定的布尔式为真。

SAT 是最早被证明为 NP 完全的问题,与计算科学的理论基础、算法、人工智能、硬件设计等许多领域都相关,而实用的 SAT 求解器速度极快,因此把待解问题转换成 SAT 这一手法被广泛使用。NP 完全意味着没有已知的多项式时间算法,实用求解器靠冲突学习与启发式把常见情形做得很快。

本章用 pysat 解数独,先用一个小的推理谜题,确认布尔式与 CNF 的写法,从这里可以看出 SAT 表述问题的方式。推理谜题只有四个变量,数独有 729 个,两者写法完全一样,只是规模不同。

推理谜题

四名嫌疑人作了如下的供述。

  • 甲:我不是犯人

  • 乙:丁是犯人

  • 丙:乙是犯人

  • 丁:乙想陷害我

四人之中只有一个人说了真话,犯人是哪一位?

布尔变量 A、B、C、D 分别表示甲、乙、丙、丁是犯人,而 ~A 表示甲不是犯人,只要把各人的供述分别写成布尔式即可。

嫌疑人

供述

布尔式

甲

我不是犯人

S1 = ~A

乙

丁是犯人

S2 = D

丙

乙是犯人

S3 = B

丁

乙想陷害我

S4 = ~D

说真话的只有一个人,因此 S1〜S4 之中恰好一个为真。这一步把口语里的“只有一个人说真话”翻译成了计数条件,后面的变形都围绕它展开。

恰好一个为真,先直白地写出来,其中 & 是合取,| 是析取。

 S1 & ~S2 & ~S3 & ~S4 |
~S1 &  S2 & ~S3 & ~S4 |
~S1 & ~S2 &  S3 & ~S4 |
~S1 & ~S2 & ~S3 &  S4

这是用 OR 连起来的若干 AND,称为析取范式,即 DNF。SAT 求解器只接受合取范式,即 CNF,也就是把 OR 连成的子句再用 AND 并起来,两者的差别只在括号的层次,因此要把恰好一个为真换一种写法。

  1. 不能有两个以上为真 → ~(Si & Sj)

  2. 不能全部为假 → ~( ~S1 & ~S2 & ~S3 & ~S4 )

~(S1 & S2) & ~(S1 & S3) & ~(S1 & S4) &
~(S2 & S3) & ~(S2 & S4) & ~(S3 & S4) &
~(~S1 & ~S2 & ~S3 & ~S4)

用德摩根律,即 ~(X & Y) = ~X | ~Y,变形之后就是 CNF。

转换结果,代入 S1 = ~A、S2 = D、S3 = B、S4 = ~D

~S1 | ~S2   →   A  | ~D
~S1 | ~S3   →   A  | ~B
~S1 | ~S4   →   A  |  D
~S2 | ~S3   →  ~D  | ~B
~S2 | ~S4   →  ~D  |  D     ← 恒为真(什么也约束不了)
~S3 | ~S4   →  ~B  |  D
 S1 |  S2 | S3 | S4  →  ~A | D | B | ~D

~D | D 恒为真,因此这个子句不构成任何约束,即使留在里面也没有害处,求解器会自行把它忽略掉。手工推导的价值在于看清哪些子句会相互抵消,这比只看结果更清楚。

用 SymPy 从 DNF 变到 CNF#

手工变换容易出错,因此交给 SymPy。

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

A, B, C, D = symbols("A:D")

# 每人的供述
S1 = ~A
S2 = D
S3 = B
S4 = ~D

# 只有 1 个人说真话(DNF)
dnf = ((S1 & ~S2 & ~S3 & ~S4) |
       (~S1 & S2 & ~S3 & ~S4) |
       (~S1 & ~S2 & S3 & ~S4) |
       (~S1 & ~S2 & ~S3 & S4))

# simplify=True:先化简式子再返回
print("CNF:", to_cnf(dnf, simplify=True))

# simplify=False:按分配律直接展开
cnf_raw = to_cnf(dnf, simplify=False)
print("simplify=False 时会有", len(cnf_raw.args), "个子句")
CNF: A & ~B
simplify=False 时会有 41 个子句

设 simplify=True,它会先把式子化简再返回,结果为 A & ~B,即甲是犯人、乙不是犯人。

设 simplify=False,它就用分配律直接展开,子句的个数会膨胀到几十个,像 A | ~A 这样恒为真的子句也不会被消去。数独这类较大的问题,这种膨胀就直接体现为求解时间。因此实务上先让 SymPy 化简,再交给求解器。

两种 CNF 应当得到相同的答案。

from sympy.logic.inference import satisfiable

print(satisfiable(to_cnf(dnf, simplify=True)))
print(satisfiable(cnf_raw))
{A: True, B: False}
{A: True, B: False, D: False}

satisfiable() 返回使式子为真的变量赋值。这里 A 为真,因此甲是犯人,而 C 没有出现在式子中,取哪个值都可以,即没有约束。

用 pysat 求解#

CNF 可以用嵌套的列表表示,一个整数对应一个布尔变量,正数表示该变量为真,负数表示取反,因此 1 就是 A,-1 就是 ~A,读起来很直接。

把上面的 CNF 原样写成数值列表即可,此时 A=1, B=2, C=3, D=4。

  1. 用 append_formula() 把 CNF 加入求解器,可以反复调用

  2. 用 solve() 求解

  3. 用 get_model() 取出解,正数表示真

编号与布尔变量的对应关系要自己记住,SAT 求解器只认整数。

from pysat.solvers import Solver

sat = Solver()
problem = [[1, -4], [1, -2], [1, 4],            # ~S1 | ~S2 等
           [-4, -2], [-4, 4], [-2, 4],          # ~D | D 恒为真
           [-1, 4, 2, -4]]                      # S1 | S2 | S3 | S4

sat.append_formula(problem)
print(sat.solve())
print(sat.get_model())
True
[1, -2, -3, -4]

[1, -2, -3, -4] 表示 A 为真、B 与 C 与 D 为假,结论与 SymPy 相同。

数独#

数独是在 9×9 的格中填入 1〜9 的数字,并满足下面的条件。

  • 每一行中 1〜9 各出现一次

  • 每一列中 1〜9 各出现一次

  • 每个 3×3 区块中 1〜9 各出现一次

数独游戏

黑色的数字是题面,灰色的数字是答案。

SAT 的变量只有 True 与 False 两个值,因此把格 (r, c) 中填入数字 n 做成一个布尔变量,用三维数组 bools 表示。一个格填哪个数字、一行里某个数字放在哪,这类选择都可以拆成一组布尔变量。

  • 第 0 轴:行

  • 第 1 轴:列

  • 第 2 轴:数字,1〜9

例如 bools[4, 1, 3] 为真,表示第 5 行第 2 列填入数字 4。数独从 1 开始计数,而数组下标从 0 开始,换算时容易错位。

bools 里放的是 SAT 的变量编号,编号从 1 开始。

import numpy as np

# 9×9×9 = 729 个变量(1〜729)
bools = np.arange(1, 9 * 9 * 9 + 1).reshape(9, 9, 9)

数独的约束可以整理成四条。

#

约束

分组的做法

1

每格只有一个数字

把数字所在的轴,即第 2 轴作为一组

2

每行中同一数字只有一个

按行与数字分组

3

每列中同一数字只有一个

按列与数字分组

4

每个区块中同一数字只有一个

按区块与数字分组

这四条都是同一个形状,即某一组之中恰好一个为真。把四种分组化成同一种形状之后,写约束的代码只需要一个函数。

用 CNF 表达恰好一个#

变量 A、B、C 中只有一个为真,这条约束可以分成两个条件。

  1. 至少有一个为真:\(A | B | C\)

  2. 不能有两个以上为真:\(\neg(A \& B) \land \neg(A \& C) \land \neg(B \& C)\)

用德摩根律,第二个条件变成 \( (\neg A | \neg B) \land (\neg A | \neg C) \land (\neg B | \neg C) \)。

\[ (A | B | C) \land (\neg A | \neg B) \land (\neg A | \neg C) \land (\neg B | \neg C) \]

若元素有 N 个,则有一个全用 OR 连起来的子句,与两两组合对应的 \(N(N-1)/2\) 个子句,即 ~X | ~Y 这样的子句,两两组合用 itertools.combinations 生成。N 一大,子句数目按平方增长,数独里子句最多的部分正在这里。

get_conditions(bools) 以 bools 的最后一轴为组,返回这条约束,做法分三步。

  1. 对最后一轴的元素数 n,造出全部两两组合

  2. 把每一组用 OR 连成一个子句,即至少一个为真

  3. 对每个组合造出 ~X | ~Y 的子句,即两个不能同时为真

from itertools import combinations


def get_conditions(bools):
    """把最后一轴作为一组,生成“恰好一个为 True”的 CNF"""
    n = bools.shape[-1]
    pairs = np.array(list(combinations(range(n), 2)))

    conditions = []
    # 1. 至少一个为 True
    conditions.extend(bools.reshape(-1, n).tolist())
    # 2. 两个不能同时为 True
    conditions.extend((-bools[..., pairs].reshape(-1, 2)).tolist())
    return conditions

下面用小例子确认,[[1, 2, 3], [4, 5, 6]] 表示有两个组,每组各含三个变量。两组的子句互不相交,可以分别检查。

  • [1, 2, 3]、[4, 5, 6]:每组之中至少有一个为真

  • [-1, -2]、[-1, -3]、[-2, -3]:1〜3 中不能有两个同时为真

  • [-4, -5]、[-4, -6]、[-5, -6]:4〜6 也是同样

get_conditions(np.array([[1, 2, 3], [4, 5, 6]]))
[[1, 2, 3],
 [4, 5, 6],
 [-1, -2],
 [-1, -3],
 [-2, -3],
 [-4, -5],
 [-4, -6],
 [-5, -6]]

造出数独的约束#

get_conditions() 以最后一轴为组,因此行、列、区块的约束要先把想作为组的轴用 swapaxes() 移到最后,再传进去。轴的顺序决定分组方式,在区块的约束里体现得尤其明显。

  • 约束 1,每格一个数字:直接传入,最后一轴就是数字

  • 约束 2,每行一个数字:把列与数字对调

  • 约束 3,每列一个数字:把行与数字对调

c1 = get_conditions(bools)                     # 每个格子只有一个数字
c2 = get_conditions(np.swapaxes(bools, 1, 2))  # 每行中同一数字只有一个
c3 = get_conditions(np.swapaxes(bools, 0, 2))  # 每列中同一数字只有一个

约束 4,即区块的约束,略为复杂一些。bools 的轴是 (行, 列, 数字),因此要把行与列分解成区块编号与区块内的位置。

  1. reshape(3, 3, 3, 3, 9),即 (区块行, 行内, 区块列, 列内, 数字)

  2. swapaxes(1, 2),即 (区块行, 区块列, 行内, 列内, 数字)

  3. reshape(9, 9, 9),即 (区块编号, 区块内的 9 格, 数字)

  4. swapaxes(1, 2),即 (区块编号, 数字, 区块内的 9 格),组落在最后一轴

这样就得到每个区块、每个数字中,9 格之内只有一个为真的约束。三次 reshape 与两次 swapaxes 之后,组的轴落在最后,与行、列两种情形对齐。

# 按 (区块, 9 个格子, 数字) 的形状整理
blocks = np.swapaxes(bools.reshape(3, 3, 3, 3, 9), 1, 2).reshape(9, 9, 9)
c4 = get_conditions(np.swapaxes(blocks, 1, 2))   # 每个区块中同一数字只有一个

conditions = c1 + c2 + c3 + c4
print(len(conditions), "个子句")
11988 个子句

解一个空盘面#

先在没有提示的状态下求解,满足数独约束的答案有无数个,求解器只给出其中的一个。空盘面能解,说明约束没有写错,这一步相当于先验证模型再填题目。

模型是变量编号的列表,因此要还原成 (9, 9, 9),再取出为真的变量对应的数字,即最后一轴的下标加 1。还原的顺序必须与造变量时一致,否则数字会错位。

def format_solution(model, shape=(9, 9, 9)):
    """把模型(变量编号的列表)变成 9×9 的数字盘面"""
    grid = np.array(model).reshape(shape)
    return np.where(grid > 0)[2].reshape(9, 9) + 1


sat = Solver()
sat.append_formula(conditions)
print(sat.solve())
format_solution(sat.get_model())
True
array([[2, 4, 6, 1, 3, 5, 8, 7, 9],
       [1, 3, 5, 7, 8, 9, 2, 4, 6],
       [8, 7, 9, 2, 4, 6, 1, 3, 5],
       [5, 6, 3, 4, 1, 2, 7, 9, 8],
       [4, 1, 2, 9, 7, 8, 5, 6, 3],
       [7, 9, 8, 5, 6, 3, 4, 1, 2],
       [6, 5, 4, 3, 2, 1, 9, 8, 7],
       [3, 2, 1, 8, 9, 7, 6, 5, 4],
       [9, 8, 7, 6, 5, 4, 3, 2, 1]])

解一道实际的题目#

题目用字符串给出,0 表示空格,对不是空格的位置追加「该数字的变量为真」这样的约束,这一格就固定下来了。从字符串到子句的转换只做一次,之后求解器不需要知道数独的规则。

sudoku_str = """
000000185
007030000
000021400
800000020
003905600
050000004
004860000
000040300
931000000"""

puzzle = np.array([[int(x) for x in line]
                   for line in sudoku_str.strip().split()])

# 对提示格加上“就是那个数字”的约束
r, c = np.where(puzzle != 0)
v = puzzle[r, c] - 1
hints = [[int(x)] for x in bools[r, c, v]]

sat = Solver()
sat.append_formula(conditions + hints)
sat.solve()
solution = format_solution(sat.get_model())
solution
array([[3, 6, 2, 7, 9, 4, 1, 8, 5],
       [4, 1, 7, 5, 3, 8, 2, 6, 9],
       [5, 9, 8, 6, 2, 1, 4, 3, 7],
       [8, 7, 9, 4, 1, 6, 5, 2, 3],
       [2, 4, 3, 9, 7, 5, 6, 1, 8],
       [1, 5, 6, 3, 8, 2, 7, 9, 4],
       [7, 2, 4, 8, 6, 3, 9, 5, 1],
       [6, 8, 5, 1, 4, 9, 3, 7, 2],
       [9, 3, 1, 2, 5, 7, 8, 4, 6]])

hints 的每个子句都只含一个布尔变量,即 [x],表示 x 必须为真,写进求解器就等于把这一格固定住。这样的子句只有一个文字,求解器会在预处理阶段直接定下这个变量。

验算解#

确认得到的盘面是否真的满足数独的条件。

def check_sudoku(grid):
    """每行、每列、每个区块是否都是 1〜9 各一次"""
    target = set(range(1, 10))
    rows = [set(grid[i, :]) for i in range(9)]
    cols = [set(grid[:, j]) for j in range(9)]
    blocks = [set(grid[i:i+3, j:j+3].ravel())
              for i in range(0, 9, 3) for j in range(0, 9, 3)]
    return all(g == target for g in rows + cols + blocks)


print(check_sudoku(solution))

# 也确认与提示不矛盾
print(bool((puzzle[puzzle > 0] == solution[puzzle > 0]).all()))
True
True

用 assumptions 给出提示#

除了追加 hints 这样只含一个变量的子句,也可以把希望为真的变量列表交给 solve() 的 assumptions。

assumptions 在求出解之后会被清除,因此同一个求解器可以反复使用,连续解别的题目,要解许多题时这样更方便。与每次重建求解器相比,省掉的是重新装入子句的时间。

sat = Solver()
sat.append_formula(conditions)                     # 只放入数独的规则

sat.solve(assumptions=bools[r, c, v].tolist())     # 提示作为假设传入
solution2 = format_solution(sat.get_model())
print(check_sudoku(solution2))
solution2
True
array([[3, 6, 2, 7, 9, 4, 1, 8, 5],
       [4, 1, 7, 5, 3, 8, 2, 6, 9],
       [5, 9, 8, 6, 2, 1, 4, 3, 7],
       [8, 7, 9, 4, 1, 6, 5, 2, 3],
       [2, 4, 3, 9, 7, 5, 6, 1, 8],
       [1, 5, 6, 3, 8, 2, 7, 9, 4],
       [7, 2, 4, 8, 6, 3, 9, 5, 1],
       [6, 8, 5, 1, 4, 9, 3, 7, 2],
       [9, 3, 1, 2, 5, 7, 8, 4, 6]])

小结#

  • SAT 问的是是否存在使布尔式为真的赋值,虽然属于 NP 完全问题,但实用的求解器很快

  • 求解器只接受 CNF,即合取范式,析取范式不能直接传入

  • 恰好一个为真由至少一个为真与所有两两组合的 ~X | ~Y 合起来构成 CNF

  • 数独把格与数字的组合做成布尔变量,再排上格、行、列、区块四类约束

  • 用 swapaxes() 把要作为组的轴移到最后,同一个函数就能反复使用

  • 初始盘面可以用 hints 给出,也可以用 assumptions 给出,反复求解时用 assumptions 更方便