24 点:寻找四个数与三个运算符的组合#
本章的内容#
本章用 Z3 解「24 点」这道谜题:四个数、三个运算符,加上运算顺序,共同决定一个算式。
四个数的排列用排列的约束来表达,即使有重复的数也能正确工作
中间结果会出现分数,因此变量要取
Real,即实数运算符用 0〜5 的整数表示,再用
Implies写出该编号对应的计算
同一个问题还会用 Function() 再写一种表示。
问题#
用四个数与 +、-、*、/ 与括号,组成一个值为 24 的式子。
例如 [1, 5, 5, 5] 时
(5 - (1 / 5)) * 5 = 24
这就是一个满足条件的算式,而数的排列有 \(4! = 24\) 种,有重复的数时相应减少,运算符的选法有 \(6^3\) 种,具体取法后面会说明。两者相乘,最多有 \(24 \times 216 = 5184\) 种组合,这个数目用纸笔逐一核对并不现实,因此交给 Z3 去搜索。
用约束表达数的排列#
solve_24 中希望把四个数当成排好序的一组来用,因此要写出排列的约束。
permutation(xs, values) 返回一组约束,使 xs 成为 values 的一个排列。返回的是约束列表,需要逐条交给求解器,而不是在 Python 里真的去重排数组。
xs的每个元素都取自values中出现的值对每个值
v,xs中出现的次数与values中的次数相同
以 [1, 5, 5, 5] 为例,就是 1 出现 1 次、5 出现 3 次;与使用 Distinct() 的写法不同,这种做法在有重复的数时也能正确处理,因为 Distinct() 要求两两不同,只适合四个数互不相同的情形。
from z3 import *
from helper.z3 import all_solutions
def permutation(variables, values):
"""返回 variables 是 values 的一个排列这一约束"""
distinct_values = set(values)
# 1. 每个元素都是 values 中含有的值之一
exprs = [Or([x == v for v in distinct_values]) for x in variables]
# 2. 每个值的出现次数与 values 相同
exprs += [Sum([x == v for x in variables]) == values.count(v) for v in distinct_values]
return And(exprs)
xs = IntVector('x', 4)
solver = Solver()
solver.add(permutation(xs, [1, 5, 5, 5]))
for m in all_solutions(solver):
print([m[x] for x in xs])
[5, 5, 5, 1]
[1, 5, 5, 5]
[5, 5, 1, 5]
[5, 1, 5, 5]
[1, 5, 5, 5] 的排列共 \(4!/3! = 4\) 种,因此输出 4 行。重复的数越多,排列越少;若改用 Distinct(),这 4 种排列会减少到 0 种。
组装算式#
计算不按通常的优先级,而是按从左往右逐个运算的形式。之所以这样安排,是因为括号的位置本来就决定了运算顺序,把顺序固定成层序之后,全部括号写法就可以用“第几层合并哪两个”来描述。
s0 = n0 op0 n1
s1 = s0 op1 n2
s2 = s1 op2 n3 ← 算到 24 就成
这样只要四个数、三个运算符与三个中间结果,就能表示整个算式。
最后再由 s2 的值把原算式造出来,即还原成字符串的形式。
# 运算符编号 → 符号
# 0:+ 1:- 2:* 3:/ 4:反向的- 5:反向的/
OP_SYMBOLS = {0: '+', 1: '-', 2: '*', 3: '/', 4: '-', 5: '/'}
def output(numbers, ops):
"""从计算步骤(数的顺序和运算符序列)生成算式字符串"""
def combine(n1, n2, op):
if op in (4, 5):
n1, n2 = n2, n1 # 反向的 - 和 / 交换左右再显示
if isinstance(n1, str):
n1 = f'({n1})'
if isinstance(n2, str):
n2 = f'({n2})'
return f'{n1} {OP_SYMBOLS[op]} {n2}'
expr = numbers[0]
for n2, op in zip(numbers[1:], ops):
expr = combine(expr, n2, op)
return expr
print(output([1, 5, 5, 5], [3, 4, 2])) # (5 - (1 / 5)) * 5
print(output([8, 3, 3, 8], [3, 4, 5])) # 8 / (3 - (8 / 3))
(5 - (1 / 5)) * 5
8 / (3 - (8 / 3))
运算符的编号取到 0〜5,是为了把减法与除法左右对调的写法也各用一个编号表示。
-需要a - b与b - a两种,这样不必增加数的排列也能两种都试到/同样需要a / b与b / a两种
只有减法与除法会因顺序不同而结果不同,因此把编号按左右两种写法分开,就不必再让数的排列去承担这部分工作。加与乘有交换律,a + b 与 b + a 同值,不必为此各留一个编号。
用 Z3 求解#
关键之处在于把四个数取为 Real,即实数,因为计算中途会出现 8 / 3 这样的分数,若取整数就无法表示。Z3 的整数除法会向下取整,8 / 3 得到的是 2,与实数运算的结果相差很远。
solve_24 在有解时返回算式的字符串,无解时返回 None。
def solve_24(*numbers):
solver = Solver()
n = RealVector('n', 4) # 重排后的 4 个数(实数)
op = IntVector('op', 3) # 3 个运算符(0〜5)
acc = RealVector('s', 3) # 中途的计算结果
solver.add(permutation(n, numbers))
for x in op:
solver.add(0 <= x, x < 6)
def calc_op(n1, o, n2, res):
"""res == n1 (o) n2 这个约束"""
return And(
Implies(n2 == 0, o != 3), # 不能除以 0
Implies(n1 == 0, o != 5),
Implies(o == 0, res == n1 + n2),
Implies(o == 1, res == n1 - n2),
Implies(o == 2, res == n1 * n2),
Implies(o == 3, res == n1 / n2),
Implies(o == 4, res == n2 - n1), # 左右交换的减法
Implies(o == 5, res == n2 / n1), # 左右交换的除法
)
solver.add(calc_op(n[0], op[0], n[1], acc[0])) # s0 = n0 op0 n1
solver.add(calc_op(acc[0], op[1], n[2], acc[1])) # s1 = s0 op1 n2
solver.add(calc_op(acc[1], op[2], n[3], acc[2])) # s2 = s1 op2 n3
solver.add(acc[2] == 24)
if solver.check() != sat:
return None
m = solver.model()
numbers_used = [m.eval(x).as_long() for x in n]
ops_used = [m.eval(x).as_long() for x in op]
return output(numbers_used, ops_used)
下面解几道题,并用 eval() 确认造出的算式是否真的等于 24,也就是让 Python 再算一遍。这一步与 Z3 无关:eval() 走的是 Python 的算术,因此算式拼装时的笔误可以在这里暴露出来。
questions = [
[1, 5, 5, 5],
[3, 3, 8, 8],
[3, 3, 7, 7],
[1, 4, 5, 6],
[2, 2, 2, 9],
[2, 7, 8, 9],
[6, 9, 9, 10],
[1, 2, 7, 7],
[4, 4, 10, 10],
[2, 5, 5, 10],
]
for q in questions:
expr = solve_24(*q)
if expr is None:
print(f'{q}: 无解')
else:
print(f'{q}: {expr} = {eval(expr):g}')
[1, 5, 5, 5]: (5 - (1 / 5)) * 5 = 24
[3, 3, 8, 8]: 8 / (3 - (8 / 3)) = 24
[3, 3, 7, 7]: ((3 / 7) + 3) * 7 = 24
[1, 4, 5, 6]: 6 / ((5 / 4) - 1) = 24
[2, 2, 2, 9]: ((2 + 9) * 2) + 2 = 24
[2, 7, 8, 9]: ((7 + 9) * 2) - 8 = 24
[6, 9, 9, 10]: (9 / (6 / 10)) + 9 = 24
[1, 2, 7, 7]: ((7 * 7) - 1) / 2 = 24
[4, 4, 10, 10]: ((10 * 10) - 4) / 4 = 24
[2, 5, 5, 10]: (5 - (2 / 10)) * 5 = 24
每道题都解出来了,不过 Z3 返回的是满足条件的第一个解,因此同一道题每次运行都可能得到不同的式子。要复现某个特定的式子,就得把它的形状作为约束写进去。例如 [1, 4, 5, 6] 可以写成下面两个式子,结果都是 24。
6 / ((5 / 4) - 1)4 / (1 - (5 / 6))
用 Function 改写#
数的排列也可以用 Function() 表示成编号到数的对应表。
number_map = Function('number_map', IntSort(), RealSort())
这是接收整数、返回实数的函数,用 number_map(0) == numbers[0] 这样的写法把编号与数连接起来。
使用时给 indices = IntVector('i', 4) 加上 Distinct(indices) 的约束,使四个编号互不相同,而编号不同时从 number_map 取出的数自然就是一个排列。
def solve_24_by_function(*numbers):
# 编号 → 数 的对应表
number_map = Function('number_map', IntSort(), RealSort())
solver = Solver()
for i, v in enumerate(numbers):
solver.add(number_map(i) == v)
# 取出数的顺序(4 个编号互不相同)
indices = IntVector('i', 4)
for x in indices:
solver.add(0 <= x, x < 4)
solver.add(Distinct(indices))
op = IntVector('op', 3)
for x in op:
solver.add(0 <= x, x < 6)
acc = RealVector('s', 3)
def calc_op(n1, o, n2, res):
return And(
Implies(n2 == 0, o != 3),
Implies(n1 == 0, o != 5),
Implies(o == 0, res == n1 + n2),
Implies(o == 1, res == n1 - n2),
Implies(o == 2, res == n1 * n2),
Implies(o == 3, res == n1 / n2),
Implies(o == 4, res == n2 - n1),
Implies(o == 5, res == n2 / n1),
)
n = [number_map(i) for i in indices]
solver.add(calc_op(n[0], op[0], n[1], acc[0]))
solver.add(calc_op(acc[0], op[1], n[2], acc[1]))
solver.add(calc_op(acc[1], op[2], n[3], acc[2]))
solver.add(acc[2] == 24)
if solver.check() != sat:
return None
m = solver.model()
numbers_used = [m.eval(x).as_long() for x in n]
ops_used = [m.eval(x).as_long() for x in op]
return output(numbers_used, ops_used)
for q in questions:
expr = solve_24_by_function(*q)
print(f'{q}: {expr} = {eval(expr):g}')
[1, 5, 5, 5]: (5 - (1 / 5)) * 5 = 24
[3, 3, 8, 8]: 8 / (3 - (8 / 3)) = 24
[3, 3, 7, 7]: ((3 / 7) + 3) * 7 = 24
[1, 4, 5, 6]: 6 / ((5 / 4) - 1) = 24
[2, 2, 2, 9]: ((2 + 9) * 2) + 2 = 24
[2, 7, 8, 9]: ((7 + 9) * 2) - 8 = 24
[6, 9, 9, 10]: (10 / (6 / 9)) + 9 = 24
[1, 2, 7, 7]: ((7 * 7) - 1) / 2 = 24
[4, 4, 10, 10]: ((10 * 10) - 4) / 4 = 24
[2, 5, 5, 10]: (5 - (2 / 10)) * 5 = 24
得到的答案相同,两种方法只有排列的表示方式不同。
用 |
用 |
|
|---|---|---|
数的排列 |
用各值的出现次数约束 |
编号到数的对应表加 |
有重复的数时 |
数出现次数,自然可以处理 |
编号不同即可取相同的值,同样自然 |
式子的长度 |
较短 |
稍长 |
两种写法都能解,而数的种类较多时,用对应表更好把握整体结构。代价是多一组 IntVector 与 Distinct,约束的数目比用出现次数时略多。
小结#
四个数的排列,可以用
permutation()按出现次数约束,也可以用Function加Distinct表示中间结果会出现分数,因此变量要取
Real运算符用 0〜5 的整数表示,再用
Implies把该编号对应的计算逐个列出减法与除法把左右对调的写法也各留一个编号,就不必增加数的排列
解不一定只有一个,Z3 返回的是满足条件的第一个解