使用 Groovy 和约束编程(Choco、JaCoP 和 OR-Tools)解决数独谜题

作者:Paul King
发布:2022-09-05 01:43PM


简介

在编写问题的解决方案时,我们经常努力隐藏实现细节。在面向对象(OO)编程中,我们可能会构建一个层次丰富的类,并精心设计方法,以便我们的最终解决方案可以用我们领域模型中的简单名词和动词(方法和类实例)来表达。在应用函数式编程习惯用法时,我们将努力强调输入和输出之间的关系,并隐藏副作用和迭代步骤。

约束编程(与逻辑编程属于同一个家族)也致力于隐藏细节。它不表达迭代实现,而是专注于表达解决方案的声明性属性。求解器负责找出确切的实现步骤。

在使用约束编程时,我们开发一个模型,该模型包含变量、每个变量可以容纳的值的以及变量之间的附加约束。求解器完成所有工作。它可能会使用启发式搜索、推理、传播、对称性和回溯来查找可能的解决方案。我们可能能够(或者想要,或者需要)指导求解器使用哪些技术和策略。约束编程已被用于解决各种问题,包括调度问题,并且在组合可能性对于其他数学优化来说过于不规则的问题方面表现出色。经常使用的示例问题是数独和 Wordle 求解器、N 皇后问题以及其他类型的谜题。我们只看数独谜题。

数独谜题

数独谜题(也称为字母算术、文字算术、数独和单词加法)是一种数学游戏,其中数学方程式中数字被字母替换。传统上,每个字母通常代表一个唯一的数字,并且数字不以数字零开头。如果我们看一个示例问题

T

O

+

G

O

=

O

U

T

我们可以用手推算出解决方案是什么

  • T、O、U 和 G 必须全部不同(游戏规则)

  • T、G 和 O 将介于 1 到 9 之间,而 U 将介于 0 到 9 之间(游戏规则)

  • 如果我们加上两个最大的两位数(99 + 99),我们会得到 198,所以O 必须是 1

  • 观察最右边的“个位”列,1 + 1 等于 2,所以T 必须是 2

  • 观察“十位”列,我们知道有一个进位 1(因为 O 是 1),并且我们知道 T 是 2,所以 G 必须是 8 或 9。如果 G 是 9,则 U 将是 1,但它不能与 O 相同,所以G 必须是 8,并且U 必须是 0

用手解决时,我们通常会对各个列进行推理,并考虑进位到下一列。我们稍后会回到这一点,但首先,让我们看一下稍微大一点的问题

S

E

N

D

+

M

O

R

E

=

M

O

N

E

Y

使用蛮力解决

这个问题并不大,所以我们可以用蛮力来解决。我们只需尝试谜题中所有字母的所有可能值

for (s in 1..9)
  for (e in 0..9)
    for (n in 0..9)
      for (d in 0..9)
        for (m in 1..9)
          for (o in 0..9)
            for (r in 0..9)
              for (y in 0..9)
                if ([s, e, n, d, m, o, r, y].toSet().size() == 8) {
                  def send = 1000 * s + 100 * e + 10 * n + d
                  def more = 1000 * m + 100 * o + 10 * r + e
                  def money = 10000 * m + 1000 * o + 100 * n + 10 * e + y
                  if (send + more == money) {
                    println "s = $s, e = $e, n = $n, d = $d"
                    println "m = $m, o = $o, r = $r, y = $y"
                  }
                }

但这效率不高。它为变量计算了 8100 万种组合,然后跳过了除 150 万种组合之外的所有组合(因为大多数组合不唯一)。总的来说,它可能在几秒钟内执行。

或者,Groovy 支持计算排列,因此我们可以将我们的解决方案简化为单个 for 循环(并进行一些测试以消除无用的迭代)

def digits = 0..9
for (p in digits.permutations()) {
    if (p[-1] < p[-2]) continue
    def (s, e, n, d, m, o, r, y) = p
    if (s == 0 || m == 0) continue
    def send = 1000 * s + 100 * e + 10 * n + d
    def more = 1000 * m + 100 * o + 10 * r + e
    def money = 10000 * m + 1000 * o + 100 * n + 10 * e + y
    if (send + more == money) {
        println "s = $s, e = $e, n = $n, d = $d"
        println "m = $m, o = $o, r = $r, y = $y"
    }
}

这样做的好处是只生成唯一的组合。它将在几秒钟内执行。

运行任何一个解决方案都会产生

s = 9, e = 5, n = 6, d = 7
m = 1, o = 0, r = 8, y = 2

使用约束编程

对于蛮力方法,我们有一个条件,用于检查任何潜在的候选答案以查看它是否是正确答案。我们必须非常明确地说明我们希望如何创建潜在候选者。对于约束编程,我们改为定义变量来表示问题、这些变量的任何已知边界,以及我们指定的任何其他已知解决方案属性,在本例中,这将类似于我们之前用来检查答案是否正确的条件。让我们检查如何使用三个库来做到这一点,其中一个库带有一个变体。

Choco

以下是使用 Choco 库的代码

new Model("SEND+MORE=MONEY").with {
    def (S, M) = ['S', 'M'].collect { intVar(it, 1, 9) }
    def (E, N, D, O, R, Y) = ['E', 'N', 'D', 'O', 'R', 'Y'].collect { intVar(it, 0, 9) }

    allDifferent(S, E, N, D, M, O, R, Y).post()

    IntVar[] ALL = [
            S, E, N, D,
            M, O, R, E,
         M, O, N, E, Y ]
    int[] COEFFS = [
            1000,  100,  10,  1,
            1000,  100,  10,  1,
   -10000, -1000, -100, -10, -1 ]

    scalar(ALL, COEFFS, "=", 0).post()

    println solver.findSolution()
}

我们定义了我们的变量及其边界(域)。我们使用一个allDifferent 全局约束来指定唯一性要求,以及一个scalar 约束,该约束确保我们的变量乘以它们各自的标量系数等于 0。这使我们能够考虑特定变量是否代表“个位”列、“十位”列、“百位”列等。这捕获了“谜题加法”约束。然后,我们要求求解器找到解决方案。我们也可以轻松地要求所有解决方案(如果存在多个解决方案)。

这正是我们解决此类问题的方式。我们要么直接在一个或多个变量之间定义约束,要么使用库支持的任何全局约束。如果我们的库不支持我们需要的约束,我们会找到一种方法,使用多个更简单的约束来表达它。

最终结果是,我们的代码比蛮力方法更具声明性,并且解决方案在几毫秒内找到。求解器具有非常有效的策略来解决此类难题。

JaCoP

我们可以使用 JaCoP 解决相同的问题

def store = new Store()
def (S, M) = ['S', 'M'].collect { new IntVar(store, it, 1, 9) }
def (E, N, D, O, R, Y) = ['E', 'N', 'D', 'O', 'R', 'Y'].collect { new IntVar(store, it, 0, 9) }
var ctr = new Alldifferent(S, E, N, D, M, O, R, Y)
store.impose(ctr)

IntVar[] ALL = [
                S,   E,   N,   D,
                M,   O,   R,   E,
           M,   O,   N,   E,   Y ]
int[] COEFFS = [
             1000,  100,  10,  1,
             1000,  100,  10,  1,
    -10000, -1000, -100, -10, -1 ]
var lin = new LinearInt(ALL, COEFFS, "==", 0)
store.impose(lin)

var label = new DepthFirstSearch()
var select = new InputOrderSelect(store, ALL, new IndomainMin())
label.labeling(store, select)

此 API 中有一些细微的差异,但几乎所有内容都与我们之前看到的内容一一对应。我们在这里明确地选择了搜索策略和选择策略,而在 Choco 中,默认值已为我们选择。在这两种情况下,显式创建此类类允许根据需要为特定场景更改策略。

运行时,输出如下所示

Labeling has finished with return value of true
DFS1: DFS([S = 9, E = 5, N = 6, D = 7, M = 1, O = 0, R = 8, Y = 2], InputOrder, (org.jacop.search.IndomainMin@45394b31))

我们可以看到这里的代码非常类似,执行时间也是如此。

OR-Tools

我们可以使用 OR-Tools 重复解决方案。以下是代码

Loader.loadNativeLibraries()

new Solver('Send+More=Money').with {
    def s = makeIntVar(1, 9, 's')
    def e = makeIntVar(0, 9, 'e')
    def n = makeIntVar(0, 9, 'n')
    def d = makeIntVar(0, 9, 'd')
    def m = makeIntVar(1, 9, 'm')
    def o = makeIntVar(0, 9, 'o')
    def r = makeIntVar(0, 9, 'r')
    def y = makeIntVar(0, 9, 'y')

    IntVar[] all = [s, e, n, d, m, o, r, y]
    IntVar[] scalar = [s, e, n, d, m, o, r, e, m, o, n, e, y]
    int[] coeffs = [
                 1000,  100,  10,  1,  //  S E N D +
                 1000,  100,  10,  1,  //  M O R E =
        -10000, -1000, -100, -10, -1   //  M O N E Y
    ]

    addConstraint(makeScalProdEquality(scalar, coeffs, 0))
    addConstraint(makeAllDifferent(all))

    def db = makePhase(all, INT_VAR_DEFAULT, INT_VALUE_DEFAULT)
    newSearch(db)
    while (nextSolution()) {
        println all.join(' ')
    }
    endSearch()

    // Statistics
    println "Solutions: ${solutions()}"
    println "Failures: ${failures()}"
    println "Branches: ${branches()}"
    println "Wall time: ${wallTime()}ms"
}

运行时,它具有以下输出

s(9) e(5) n(6) d(7) m(1) o(0) r(8) y(2)
Solutions: 1
Failures: 5
Branches: 10
Wall time: 60ms

OR-Tools 是用 C++ 编写的,但它为多种语言(包括 Java)提供了接口 - 这非常适合 Groovy 使用。

使用 JSR331 的 Choco

能够从多个库中进行选择非常棒,但拥有一个标准 API 可以帮助在这些库之间进行切换。这就是 JSR331 的用武之地。它定义了一个与约束求解器和线性求解器交互的标准 API。在这里,我们使用一个JSR331 实现,该实现由 Choco 库的早期版本支持。代码如下所示

import javax.constraints.*

ProblemFactory.newProblem('SEND+MORE=MONEY').with {
    def (S, M) = ['S', 'M'].collect { variable(it, 1, 9) }
    def (E, N, D, O, R, Y) = ['E', 'N', 'D', 'O', 'R', 'Y'].collect { variable(it, 0, 9) }

    postAllDifferent(S, E, N, D, M, O, R, Y)

    Var[] ALL = [
            S, E, N, D,
            M, O, R, E,
            M, O, N, E, Y]
    int[] COEFFS = [
            1000, 100, 10, 1,
            1000, 100, 10, 1,
            -10000, -1000, -100, -10, -1]

    post(COEFFS, ALL, '=', 0)

    def solver = getSolver()
    def solution = solver.findSolution()
    println solution ?: 'No solution'
    solver.logStats()
}

它与之前的示例非常相似,但现在专门使用 javax.constraint 包中的 JSR331 类。这些类的实现由多个实现支持。因此,实际上可以互换使用它们。运行时,输出为

Solution #1:
 S[9] M[1] E[5] N[6] D[7] O[0] R[8] Y[2]

话虽如此,在撰写本文时,JSR331 的流行度似乎没有上升。大多数使用约束编程库的人似乎都在使用直接库类。事实上,JSR331 实现使用的 Choco 实现版本已经超过 10 年了。

合并进位

我们在前面示例中使用的标量积全局约束非常强大,并且可能是我们解决此问题的首选方法。但是,我们可以通过多种方式对约束编程问题进行建模,因此让我们看一下避免该全局约束的解决方案。

相反,我们将开发一个模型,该模型反映了我们用手解决原始的TO + GO = OUT 问题的方式。为此,我们只考虑一次一列,并考虑进位。我们将明确地引入变量来保存进位(如果没有进位则为 0,如果有进位则为 1)到我们的模型中。然后,我们将表达适用于每一列的数学约束。

以下是代码

new Model("SEND+MORE=MONEY").with {
    def (S, M) = ['S', 'M'].collect { intVar(it, 1, 9) }
    def (E, N, D, O, R, Y) = ['E', 'N', 'D', 'O', 'R', 'Y'].collect { intVar(it, 0, 9) }
    def C = (0..3).collect{ intVar("C$it", 0, 9) }

    allDifferent(S, E, N, D, M, O, R, Y).post()
    C[3]              .eq(M).post()                         //  C3 C2 C1 C0
    C[2].add(S).add(M).eq(O.add(C[3].mul(10))).post()       //      S  E  N  D
    C[1].add(E).add(O).eq(N.add(C[2].mul(10))).post()       //      M  O  R  E
    C[0].add(N).add(R).eq(E.add(C[1].mul(10))).post()       //   -------------
             D .add(E).eq(Y.add(C[0].mul(10))).post()       //   M  O  N  E  Y

    println solver.findSolution()
}

我们可以看到,现在不再有标量积全局约束,而是每一列的约束。

运行时,输出如下所示

Solution: S=9, M=1, E=5, N=6, D=7, O=0, R=8, Y=2, C0=1, C1=1, C2=0, C3=1, sum_exp_1=9,
sum_exp_2=10, (C3*10)=10, sum_exp_3=10, sum_exp_4=6, sum_exp_5=6, (C2*10)=0, sum_exp_6=6,
sum_exp_7=7, sum_exp_8=15, (C1*10)=10, sum_exp_9=15, sum_exp_10=12, (C0*10)=10, sum_exp_11=12,

我们可以看到,当我们为每一列定义约束时,模型中会创建子表达式,这些子表达式反映在解决方案中。如果喜欢,它们是在获取答案的途中进行的临时计算 - 或者更准确地说,是不断变化的临时计算的快照。它们不构成我们感兴趣的答案的一部分,因此如果需要,我们可以随意只打印出我们感兴趣的解决方案部分。

创建 DSL

前面的示例有很多对addmul 方法的调用。我们可以创建一个小 DSL,为我们之前的示例提供一些语法糖,以允许使用 Groovy 的运算符重载,在指定变量的域时支持范围,以及其他一些便利功能。我们的代码变为

model("SEND+MORE=MONEY") {
    def (S, M) = ['S', 'M'].collect { intVar(it, 1..9) }
    def (E, N, D, O, R, Y) = ['E', 'N', 'D', 'O', 'R', 'Y'].collect { intVar(it, 0..9) }
    def C = intVarArray(4, 0..1)

    [allDifferent(S, E, N, D, M, O, R, Y),      //  C3 C2 C1 C0
     C[3]         .eq(M),                       //      S  E  N  D
    (C[2] + S + M).eq(O + C[3] * 10),           //      M  O  R  E
    (C[1] + E + O).eq(N + C[2] * 10),           //   -------------
    (C[0] + N + R).eq(E + C[1] * 10),           //   M  O  N  E  Y
           (D + E).eq(Y + C[0] * 10)]*.post()

    println solver.findSolution()
}

它具有与以前相同的输出。

你可能想知道求解器是如何找到解决方案的。你可以在调试器中观察变量,并使用 choco-cpviz 等工具,但这在你不习惯之前是一个相当复杂的流程。我们将尝试让你了解这里发生了什么。基本上,将会有各种尽可能地进行修剪和分支的步骤,并可能进行回溯。以下是一些我们上面示例的快照。

首先,我们有将近 90 个浅绿色方块,代表我们的问题搜索空间。我们遍历规则,寻找修剪搜索空间的方法

choco_step1

choco_step2

choco_step3

choco_step4

choco_step5

choco_step6

choco_step7

choco_step8

choco_step9

choco_step10

choco_step11

choco_step12

choco_step13

choco_step14

当我们锁定变量的值时,我们可以将它们代入并简化我们的约束。当我们重新应用它们时,它们的计算速度将更快,并且可能会揭示更多信息。

在这一点上,我们只有 2 个变量被锁定,但我们的搜索空间几乎是我们开始时的一半,并且我们简化了一些约束。我们将在这一点上继续分支和解决,直到我们找到解决方案或确定不存在解决方案。

查看其他语言

示例存储库 还包含此问题在其他语言中的解决方案,因此你可以进行比较和对比,包括 Clojure、Haskell (Frege)、Java、JavaScript (Nashorn)、Ruby (JRuby)、Python (Jython)、Kotlin、Lua (Luaj)、Prolog (tuprolog) 和 Scala

slides

最后,让我们看一下如何解决更多示例(使用 Choco)。我们将解决一篇有趣的关于数独谜题历史的博客中的一些示例

  • ABCD * 4 = DCBA

  • AA + BB + CC = ABC

  • HALF + HALF = WHOLE

  • HALF + FIFTH + TENTH + TENTH + TENTH = WHOLE

以下是代码

new Model("ABCD*4=DCBA").with {
    def (A, D) = ['A', 'D'].collect { intVar(it, 1, 9) }
    def (B, C) = ['B', 'C'].collect { intVar(it, 0, 9) }
    def R = (0..2).collect { intVar(0, 9) }

    allDifferent(A, B, C, D).post()
    R[2].add(A.mul(4)).eq(D).post()
    R[1].add(B.mul(4)).eq(C.add(R[2].mul(10))).post()
    R[0].add(C.mul(4)).eq(B.add(R[1].mul(10))).post()
    D.mul(4).eq(A.add(R[0].mul(10))).post()
    solver.findAllSolutions().each {
        println "$name: ${pretty(it, [A, B, C, D, ' * 4 = ', D, C, B, A])}\n$it\n"
    }
}

new Model("AA+BB+CC=ABC").with {
    def (A, B, C) = ['A', 'B', 'C'].collect { intVar(it, 1, 9) }
    allDifferent(A, B, C).post()
    A.mul(11).add(B.mul(11).add(C.mul(11))).eq(A.mul(100).add(B.mul(10)).add(C)).post()
    solver.findAllSolutions().each {
        println "$name: ${pretty(it, [A, A, ' + ', B, B, ' + ', C, C, ' = ', A, B, C])}\n$it\n"
    }
}

new Model("HALF+HALF=WHOLE").with {
    def (H, W) = ['H', 'W'].collect { intVar(it, 1, 9) }
    def (A, E, F, L, O) = ['A', 'E', 'F', 'L', 'O'].collect { intVar(it, 0, 9) }
    allDifferent(H, W, A, E, F, L, O).post()
    IntVar[] ALL = [
            H, A, L, F,
            W, H, O, L, E]
    int[] COEFFS = [
            2000, 200, 20, 2,
            -10000, -1000, -100, -10, -1]
    scalar(ALL, COEFFS, "=", 0).post()
    solver.findAllSolutions().each {
        println "$name: ${pretty(it, [H, A, L, F, ' + ', H, A, L, F, ' = ', W, H, O, L, E])}\n$it\n"
    }
}

new Model("HALF+FIFTH+TENTH+TENTH+TENTH=WHOLE").with {
    def (H, F, T, W) = ['H', 'F', 'T', 'W'].collect { intVar(it, 1, 9) }
    def (A, L, I, E, N, O) = ['A', 'L', 'I', 'E', 'N', 'O'].collect { intVar(it, 0, 9) }
    allDifferent(H, F, T, W, A, L, I, E, N, O).post()
    IntVar[] ALL = [
            H, A, L, F,
            F, I, F, T, H,
            T, E, N, T, H,
            T, E, N, T, H,
            T, E, N, T, H,
            W, H, O, L, E]
    int[] COEFFS = [
            1000, 100, 10, 1,
            10000, 1000, 100, 10, 1,
            10000, 1000, 100, 10, 1,
            10000, 1000, 100, 10, 1,
            10000, 1000, 100, 10, 1,
            -10000, -1000, -100, -10, -1]
    scalar(ALL, COEFFS, "=", 0).post()
    solver.findAllSolutions().each {
        def parts = [H, A, L, F, '+', F, I, F, T, H, '+', T, E, N, T, H, '+',
                     T, E, N, T, H, '+', T, E, N, T, H, '=', W, H, O, L, E]
        println "$name: ${pretty(it, parts)}\n$it\n"
    }
}

// helper method to print solutions
def pretty(model, parts) {
    parts.collect { p -> p instanceof IntVar ? model.getIntVal(p) : p }.join()
}

其输出为

ABCD*4=DCBA: 2178 * 4 = 8712
Solution: A=2, D=8, B=1, C=7, IV_1=3, IV_2=3, IV_3=0, (A*4)=8, sum_exp_4=8, (B*4)=4, …,

AA+BB+CC=ABC: 11 + 99 + 88 = 198
Solution: A=1, B=9, C=8, (A*11)=11, (B*11)=99, (C*11)=88, …,

HALF+HALF=WHOLE: 9604 + 9604 = 19208
Solution: H=9, W=1, A=6, E=8, F=4, L=0, O=2,

HALF+HALF=WHOLE: 9703 + 9703 = 19406
Solution: H=9, W=1, A=7, E=6, F=3, L=0, O=4,

HALF+HALF=WHOLE: 9802 + 9802 = 19604
Solution: H=9, W=1, A=8, E=4, F=2, L=0, O=6,

HALF+FIFTH+TENTH+TENTH+TENTH=WHOLE: 6701+14126+25326+25326+25326=96805
Solution: H=6, F=1, T=2, W=9, A=7, L=0, I=4, E=5, N=3, O=8,

你应该看到用于解决这些谜题的常见模式。

更多信息

结论

我们已经研究了使用 Groovy 和一些约束编程库来解决字谜。为什么不尝试解决一些你自己的谜题呢?