首页 > > > 10897 如何不重复地枚举 24 点算式?(下)
本系列的中篇暂时忽略了减法和除法,介绍了如何避免交换律、结合律、独立运算顺序不唯一造成的重复。本文将重新引入减法和除法,研究如何避免「去括号」和「反转减号」造成的重复。其中,「反转减号」的处理尤为困难。
首先,我们把减法和除法重新加入 actions 函数:
def actions(left, right):
for op in '+*':
if op != left.ch and (op != right.ch or left.id < right.left.id):
yield Node(op, left, right)
for op in '-/':
yield Node(op, left, right)
yield Node(op, right, left)当若干个加、减法运算混合时,减法的去括号可以产生 a - (b + c - d) 与 a - b - c + d 这种等价算式,我们要从中选一个做代表。一个很诱人的想法是选择 a - b - c + d 这个无括号的算式为代表。可惜的是,无括号算式对应的树是向左分叉的,而我们针对交换律的改进已经排除了根结点为加法且向左分叉的树了。好在我们还可以选择形如 (a + d) - (b + c) 的算式做代表,即只允许最上层运算为减法,它的两棵子树必须都若干项相加而成。换句话说,就是在树中,加号和减号的孩子都不能是减号。
上面的分析同样适用于乘法和除法。我们选择形如 (a * d) / (b * c) 的算式,作为乘、除法混合运算的代表。体现在树中,就是乘号和除号的孩子都不能是除号。
加入了以上限制条件的 actions 函数如下所示。由于条件开始变得复杂了,我们就把加、减、乘、除四种运算分开来写。
def actions(left, right):
# 加法:两个孩子都不能是减号;左孩子还不能是加号;
# 若右孩子是加号,则左孩子和右孩子的左孩子要满足单调性
if left.ch not in '+-' and right.ch != '-' and (right.ch != '+' or left.id < right.left.id):
yield Node('+', left, right)
# 减法:两个孩子都不能是减号
if left.ch != '-' and right.ch != '-':
yield Node('-', left, right)
yield Node('-', right, left)
# 乘法:两个孩子都不能是除号;左孩子还不能是乘号;
# 若右孩子是乘号,则左孩子和右孩子的左孩子要满足单调性
if left.ch not in '*/' and right.ch != '/' and (right.ch != '*' or left.id < right.left.id):
yield Node('*', left, right)
# 除法:两个孩子都不能是除号
if left.ch != '/' and right.ch != '/':
yield Node('/', left, right)
yield Node('/', right, left)为了避免「反转减号」造成的重复,我们定义一个概念,叫算式的「极性」。a - b 和 b - a 这两个算式只差一个负号,我们就称它们是有极性的,并从中任选一者称为正极性,另一者称为负极性。而像 a + b、a * b 这种算式,找不到与它们只差一个负号的算式,那么就称为无极性。更一般的极性定义为:
如果一个算式可以通过交换若干减号的被减数与减数,或者把加号改成减号、减号改成加号而变成自己的相反数,那么就称一个算式是有极性的;这个算式和它的相反数中,任选一者称为正极性,另一者称为负极性。
这个极性有什么用呢?举个例子:我们定义 a - b 为正极性,b - a 为负极性;c - d 为正极性,d - c 为负极性。把 a - b 与 c - d 这两个正极性的算式相乘,可以得到 (a - b) * (c - d);把 b - a 与 d - c 这两个负极性的算式相乘,可以得到等价算式 (b - a) * (d - c)。极性可以帮助我们识别出这样的等价算式,并在其中选择一者为代表,而舍弃另一者。
我们往 Node 类的定义中再添加一个属性 polar,用来表示算式的极性。正、负、无极性分别用 +1、-1、0 表示。
class Node:
def __init__(self, ch = None, left = None, right = None, polar = 0, id = 0):
self.ch = ch
self.left = left
self.right = right
self.polar = polar
self.id = id显然,初始时由单个变量组成的算式,都没有极性。我们下面要解决的问题,就是如何计算两个算式经过一步运算之后结果的极性,以及如何利用极性识别出等价算式。
这里,乘、除法是相对简单的。我们分三种情况讨论:
下面看加、减法,它们必须放到一起来看。同样分三种情况:
我们在 actions 函数中实现上面的讨论,这包括计算两个算式运算结果的极性,以及识别、舍弃等价算式。很不幸,代码变得十分冗长了。代码的逻辑顺序与上面的讨论不尽相同,请逐条对照。
def actions(left, right):
# 加法:两个孩子都不能是减号;左孩子还不能是加号;
# 若右孩子是加号,则左孩子和右孩子的左孩子要满足单调性
if left.ch not in '+-' and right.ch != '-' and (right.ch != '+' or left.id < right.left.id):
if left.polar == 0 or right.polar == 0:
yield Node('+', left, right, left.polar + right.polar) # 无极性 + 无极性 = 无极性
# 有极性 + 无极性 = 有极性者的极性
else:
yield Node('+', left, right, right.polar) # 有极性 + 有极性 = 右子树极性
# 减法:两个孩子都不能是减号
if left.ch != '-' and right.ch != '-':
if left.polar == 0 and right.polar == 0: # 无极性 - 无极性:
yield Node('-', left, right, 1) # 正过来减是正极性
yield Node('-', right, left, -1) # 反过来减是负极性
else:
if left.polar == 0:
yield Node('-', right, left, right.polar) # 有极性 - 无极性 = 有极性者的极性
# (无极性 - 有极性 = 舍弃)
# (有极性 - 有极性 = 舍弃)
if right.polar == 0:
yield Node('-', left, right, left.polar) # 同上
# 乘法:两个孩子都不能是除号;左孩子还不能是乘号;
# 若右孩子是乘号,则左孩子和右孩子的左孩子要满足单调性
if left.ch not in '*/' and right.ch != '/' and (right.ch != '*' or left.id < right.left.id):
if left.polar == 0 or right.polar == 0:
yield Node('*', left, right, left.polar + right.polar) # 无极性 * 无极性 = 无极性
# 有极性 * 无极性 = 有极性者的极性
elif left.polar > 0:
yield Node('*', left, right, right.polar) # 正极性 * 有极性 = 右子树极性
# (负极性 * 有极性 = 舍弃)
# 除法:两个孩子都不能是除号
if left.ch != '/' and right.ch != '/':
if left.polar == 0 or right.polar == 0:
yield Node('/', left, right, left.polar + right.polar) # 无极性 / 无极性 = 无极性
# 有极性 / 无极性 = 有极性者的极性
# 无极性 / 有极性 = 有极性者的极性
yield Node('/', right, left, left.polar + right.polar) # 同上
else:
if left.polar > 0:
yield Node('/', left, right, right.polar) # 正极性 / 有极性 = 右子树极性
# (负极性 / 有极性 = 舍弃)
if right.polar > 0:
yield Node('/', right, left, left.polar) # 同上到此,我们终于避免了所有五种原因造成的重复。可以验证,最终版程序可以不重不漏地枚举四则运算式!
本文解决了减法和除法中由「去括号」和「反转减号」造成的重复,修改主要体现在 actions 函数中。为了排除由「反转减号」造成的重复,我们定义了算式的极性。极性在运算中的传递规律非常复杂,造成 actions 函数冗长而不优雅,我也很无奈。
用最终版程序可以算出,由 n 个变量经四则运算可以组成的算式个数为:

这个数列也被 The Online Encyclopedia of Integer Sequences 收录:
A140606 - OEIS值得一提的是,这个数列是由一位中国网友杜朝晖(音)贡献的;页面上两个参考文献链接,一个(已失效)指向一个中文数学论坛,一个指向百度贴吧。
本系列文章虽然解决了枚举由 n 个变量组成的四则运算式时的去重问题,但仍有许多有趣的扩展问题有待研究。它们包括:
本系列文章的完整代码,可以在 GitHub 上获得:
MaigoAkisame/enumerate-expressions
10901 更新: @终军弱冠 给出了算式计数的递推算法,详见他的专栏文章:
终军弱冠:关于四则运算表达式计数问题的讨论
10921 更新: @终军弱冠 利用 Smooth implicit-function schema,进一步给出了算式数目的渐进表达式:
终军弱冠:关于四则运算表达式数量的渐进近似
11730 更新:关于「有极性 ± 无极性」运算结果的极性判断,我最初写的是规定 [1] X + Y 与 [2] (-X) + Y 两个相加的算式为正极性,[3] X - Y 与 [4] (-X) - Y 两个相减的算式为负极性。在这种规则下,我验证了 6 个变量以内,算式枚举不重不漏。
但是, @hqztrue 在 GitHub 上指出,当有 7 个变量时,我的程序会漏解:
https://github.com/MaigoAkisame/enumerate-expressions/issues/2漏掉的算式均为以下形式之一:
漏解的原因在于,(a - b) * c + d - e 及其相反数算式 (b - a) * c + e - d,都是由「有极性 - 无极性」运算得来的,它们都被标记为负极性,这违反了「互为相反数的算式极性一正一负」的初衷。当它们与有极性算式 f - g 相除时,就都落入了「负极性 / 有极性 = 舍弃」的陷阱,造成了漏解。这种情况至少需要 7 个变量才会暴露出来。
在本次更新中,对于「有极性 ± 无极性」运算结果的极性,我改为规定 [1]、[3] 为正极性,[2]、[4] 为负极性,即让结果继承有极性算式的极性。文中与 GitHub 上的代码均做了相应的更新,GitHub 上还添加了一个专用于计数的 C++ 版本。经 C++ 版本验证,到 9 个变量为止,算式总数均与 A140606 一致,且正、负极性的算式数目相同。不过,我仍无法证明修改后的规则对于任意多的变量都是正确的。
原文作者:;如有侵权,请联系管理员删除。