演算子を入れて99を作る ― 括弧の付け方を Z3 に探させる#
この章で学ぶこと#
数値の並びは決まっていて、間に +, -, * を入れて、括弧で計算の順序を変える問題です。
「隣り合う2つを計算して1つに置き換える」を繰り返すと、どんな括弧の付け方も表せる
各段階の配列を Z3 の変数にして、
Ifで演算を選ぶ最後に、見つけた手順を数式の文字列に戻す
数の順序が自由な「24を作る」問題との違いも見えてきます。
次の式に対して、
?に+,-,*のいずれかを入れ、括弧を適切に配置して式の値が 99 になるようにしてください。
1 ? 2 ? 3 ? 4 ? 5 ? 6 ? 7 ? 8 ? 9 = 99
考え方#
数を1つずつ計算していくのではなく、隣り合う2つを計算して1つにまとめる操作を繰り返します。
たとえば 1 2 3 4 5 から始めて、
3 + 4を計算 →1 2 7 51 * 2を計算 →2 7 57 + 5を計算 →2 122 - 12を計算 →-10
のように、1段ごとに数が1つ減っていきます。最後に1つ残った数が式の値です。
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」通りです。
1段ずつ計算する#
段階 l の配列を f[l] とします。最初の段階 f[0] は [1, 2, …, 9] で、段階が進むごとに1つ短くなります。
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](1つ左からずれて残る)
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(条件, 真のときの値, 偽のときの値) を入れ子にして、3つの演算を切り替えています。
|
計算 |
|---|---|
0 |
|
1 |
|
2 |
|
条件が増えると入れ子が深くなるので、選択肢が多いときは Function を使うほうが読みやすくなります(この章では3つなので If で十分です)。
数式として表示する#
Z3 が出すのは数だけなので、元の数式に戻す仕組みを用意します。Expr は「左の式、演算子、右の式」を持つ小さなクラスで、output() が段階の手順をたどって文字列を組み立てます。
* や - の右側に + や - が来るときは括弧を付けます。1 + 2 * 3 と (1 + 2) * 3 を取り違えないようにするためです。
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) です)。各段階の値も、1つずつ数が減っていく様子が分かります。
99になる組み合わせを探す#
こんどは位置と演算子を Z3 に決めさせます。9つの数なので、制約は「位置 locs[i] は 0 以上 9-i-1 未満」「演算子は 0〜2」です。
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 が見つけるのは条件を満たす最初の1つなので、式の形は毎回同じとは限りません。どれも 99 になります。
まとめ#
固定された数の並びに対する括弧の付け方は、「隣り合う2つをまとめる」操作の列で表せる
段階ごとに配列を用意し、
calc_tree()で「残る・計算する・ずれる」の3通りをImpliesで書く演算子は整数の番号にして、
Ifで計算を切り替える見つけた手順は、計算の木(
Expr)に戻せば数式として読める数の順序も自由な「24を作る」問題では、順列の制約がもう1つ増える(前の章)