插入运算符凑出 99:让 Z3 决定括号的位置#

本章的内容#

数的顺序已经固定,要在相邻的数之间插入 +、-、*,再用括号改变运算顺序。

  • 反复执行把相邻的两个数算成一个,就能表达任何加括号的方式

  • 把每一层的数组都做成 Z3 的变量,用 If 选择运算

  • 最后把找到的步骤还原成算式

与数的顺序可以自由调换的 24 点问题相比,区别在于:数序固定之后,排列那一整块约束消失,变量只剩每一层的位置与运算符。

对下面的式子,在 ? 处填入 +、-、* 中的某一个,并适当地加括号,使式子的值为 99。

1 ? 2 ? 3 ? 4 ? 5 ? 6 ? 7 ? 8 ? 9 = 99

思路#

不要把一个一个数顺着算下去,而是反复执行把相邻的两个算成一个的操作,直到只剩一个数为止。

例如从 1 2 3 4 5 出发,

  1. 计算 3 + 4,得到 1 2 7 5

  2. 计算 1 * 2,得到 2 7 5

  3. 计算 7 + 5,得到 2 12

  4. 计算 2 - 12,得到 -10

这样每做一层,数就减少一个,最后剩下的那个数就是式子的值。反复合并相邻两项,正好对应一棵二叉树自底向上的构造,因此任何一种加括号的方式都能被这一串操作写出来。

        graph
a1[1]
a2[2]
a3[3+4]
a4[5]
b1[1*2]
b2[3+4]
b3[5]
c1[1*2]
c2[3+4-5]
d1[1*2+3+4-5]
1 --> a1
2 --> a2
3 --> a3
4 --> a3
5 --> a4
a1 --> b1
a2 --> b1
a3 --> b2
a4 --> b3
b1 --> c1
b2 --> c2
b3 --> c2
c1 --> d1
c2 --> d1
    

只要让 Z3 决定在哪一处用哪个运算符合并,就等于把所有的加括号方式都试过了,不必真的把括号的写法一一列举出来。共 \(n-1\) 层,每一层可选的位置数是当时的元素个数减 1。

逐层计算#

把第 l 层的数组记为 f[l],最初一层 f[0] 是 [1, 2, …, 9],每做一层就短一个元素。

calc_tree(f, locs, ops) 把每一层的计算写成约束。在第 l 层选择位置 p = locs[l-1] 时,

  • i < p:f[l][i] == f[l-1][i],即原样留下

  • i == p:f[l][p] == f[l-1][p] op f[l-1][p+1],即在这里计算

  • i > p:f[l][i] == f[l-1][i+1],即从左边错一位留下

这三条覆盖了第 l 层的每一个下标,因此每一层的值都被唯一确定。

from z3 import *
from helper.z3 import all_solutions

OP_SYMBOLS = '+-*'   # 0:+, 1:-, 2:*


def calc_op(x1, x2, op):
    """按 op 的编号选择加、减、乘"""
    return If(op == 0, x1 + x2, If(op == 1, x1 - x2, x1 * x2))


def calc_tree(f, locs, ops):
    """把每个阶段的计算整理成约束列表返回"""
    exprs = []
    for l in range(1, len(f)):
        prev = f[l - 1]
        for i in range(len(f[l])):
            exprs.extend([
                Implies(i < locs[l - 1], f[l][i] == prev[i]),
                Implies(i == locs[l - 1], f[l][i] == calc_op(prev[i], prev[i + 1], ops[l - 1])),
                Implies(i > locs[l - 1], f[l][i] == prev[i + 1]),
            ])
    return exprs

用 If 选择运算符#

If(op == 0, x1 + x2, If(op == 1, x1 - x2, x1 * x2))

把 If(条件, 真时的值, 假时的值) 嵌套起来,切换三种运算。

op

计算

0

x1 + x2

1

x1 - x2

2

x1 * x2

条件一多嵌套就深,因此选项较多时用 Function 更好读;本章只有三个,用 If 就足够。嵌套的 If 在约束里是一串条件式,读的时候要一层层往下对照。

显示成算式#

Z3 给出的是数,因此需要一套还原成算式的机制。Expr 是一个持有左式、运算符与右式的小类,它的 output() 沿着各层的步骤拼出字符串,缺的括号由它自己补上。

当 * 或 - 的右侧出现 + 或 -,或者 * 的左侧出现 + 或 - 时,要加上括号,以便把 1 + 2 * 3 与 (1 + 2) * 3 区分开。输出要能被 Python 重新解析,因此只在优先级不足以表达原结构的地方补括号。

class Expr:
    """把计算树变成带括号的字符串"""
    def __init__(self, left, op, right):
        self.left = left
        self.op = op
        self.right = right

    def __str__(self):
        # 左侧:* 的左边是 + - 时要加括号
        if isinstance(self.left, Expr) and self.op == '*' and self.left.op in '+-':
            left = f'({self.left})'
        else:
            left = f'{self.left}'

        # 右侧:* 或 - 的右边是 + - 时要加括号
        if isinstance(self.right, Expr) and self.op in '*-' and self.right.op in '+-':
            right = f'({self.right})'
        else:
            right = f'{self.right}'

        return f'{left} {self.op} {right}'


def output(numbers, locs, ops):
    """从数的顺序和步骤生成算式字符串"""
    exprs = [str(x) for x in numbers]
    for loc, op in zip(locs, ops):
        exprs[loc] = Expr(exprs[loc], OP_SYMBOLS[op], exprs[loc + 1])
        del exprs[loc + 1]
    return str(exprs[0])

用小例子确认#

对 1 2 3 4 5 施加一个给定的步骤。

  • 第 1 层:位置 2 取 +,即 3 + 4

  • 第 2 层:位置 0 取 *,即 1 * 2

  • 第 3 层:位置 1 取 -,即 7 - 5

  • 第 4 层:位置 0 取 +,即 2 + 2

结果应当是 1 * 2 + ((3 + 4) - 5) = 4。

n = 5
f = [[Int(f'f{l}_{i}') for i in range(n - l)] for l in range(n)]

solver = Solver()
for i in range(n):
    solver.add(f[0][i] == i + 1)      # 一开始是 1, 2, 3, 4, 5

locs = [2, 0, 1, 0]                   # 合并哪个位置
ops = [0, 2, 1, 0]                    # +, *, -, +
solver.add(calc_tree(f, locs, ops))

assert solver.check() == sat
m = solver.model()

expr = output([i + 1 for i in range(n)], locs, ops)
print(expr, "=", eval(expr))

print("各阶段的值")
for level in range(n):
    print([m[f[level][i]] for i in range(n - level)])
1 * 2 + 3 + 4 - 5 = 4
各阶段的值
[1, 2, 3, 4, 5]
[1, 2, 7, 5]
[2, 7, 5]
[2, 2]
[4]

与手算一致,得到 1 * 2 + 3 + 4 - 5 = 4,按计算树来写就是 1 * 2 + ((3 + 4) - 5)。各层的元素个数也逐层减少。

寻找等于 99 的组合#

这一回让 Z3 决定位置与运算符,共有九个数,因此约束是位置 locs[i] 不小于 0 且小于 9-i-1,运算符取 0〜2,编号的含义与上一章相同。上界要减 1,是因为合并需要相邻两项:第 i 层还剩 9-i 个数,位置最大只能到倒数第二个。

n = 9
f = [[Int(f'f{l}_{i}') for i in range(n - l)] for l in range(n)]

solver = Solver()
for i in range(n):
    solver.add(f[0][i] == i + 1)          # 从 1 到 9

locs = IntVector('loc', n - 1)            # 各阶段合并的位置
ops = IntVector('op', n - 1)              # 各阶段的运算符

for i, loc in enumerate(locs):
    solver.add(0 <= loc, loc < n - i - 1)  # 到该阶段元素个数 - 1 为止

for op in ops:
    solver.add(0 <= op, op <= 2)           # 0:+, 1:-, 2:*

solver.add(calc_tree(f, locs, ops))
solver.add(f[-1][0] == 99)                 # 最后剩下的数是 99

print(solver.check())

m = solver.model()
print("各阶段的值")
for level in range(n):
    print([m[f[level][i]] for i in range(n - level)])

locs_used = [m[x].as_long() for x in locs]
ops_used = [m[x].as_long() for x in ops]
print("位置:", locs_used)
print("运算符:", [OP_SYMBOLS[x] for x in ops_used])
sat
各阶段的值
[1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 2, -1, 5, 6, 7, 8, 9]
[1, 2, -1, 5, 6, 7, 17]
[1, 2, -1, 5, 6, 119]
[1, 2, -1, 5, -113]
[1, 3, 5, -113]
[1, 15, -113]
[1, -98]
[99]
位置: [2, 6, 5, 4, 1, 1, 1, 0]
运算符: ['-', '+', '*', '-', '-', '*', '+', '-']

最后把找到的步骤还原成算式,便于核对结果。还原用的是求解器给出的位置与运算符,因此算式与约束描述的步骤完全一致。

expr = output(list(range(1, 10)), locs_used, ops_used)
print(expr, "=", eval(expr))
1 - ((2 - (3 - 4)) * 5 + 6 - 7 * (8 + 9)) = 99

例如会得到下面这样的式子。

1 - ((2 - (3 - 4)) * 5 + 6 - 7 * (8 + 9)) = 99

Z3 找出的是满足条件的第一个解,因此式子的形状未必每次都相同,但结果都是 99。

小结#

  • 数序固定时,加括号的方式可以表示成把相邻两个合并的操作序列

  • 每一层各准备一个数组,用 calc_tree() 把留下、计算、错位三种情形写成 Implies

  • 运算符换成整数编号,用 If 切换计算

  • 找到的步骤还原成计算树 Expr 之后,就能当算式读

  • 数序也可以自由调换的 24 点问题,会多出一条排列的约束,即前一章