OO 2022 第一单元(表达式变换)思路分析
去年的作业要求是求导,今年题目换成拆括号+内联自定义函数了。
补充一种今年想到的基于表达式树的表达式存储架构(去年本人的作业采用的是语法分析树,比今年想到的这个表达式树要复杂一些)。
架构示意
在此表达式树存储的基础上, 我们可以很方便地进行拆括号与化简操作。
在进入正题之前,首先补充一些基本设定:
- 在这个表达式树中是不存在减法的。如果要存储
a-b
,则以a + -1*b
的形式存储,为减数b
添加一个系数-1
。 - 每一种树节点均应实现
equals
和hashCode
这两个方法,其作用主要有以下两点:- 判断两个项是否是同类项,需要比较其非系数部分是否完全相等
- 能够使表达式树对象被装入
HashSet
或者作为HashMap
的 Key。
- 加法节点代表累加,乘法节点代表累乘,这两种节点内部都需要用容器来管理其子节点。
- 相比于有序的容器
List
,本人更加倾向使用无序的容器Set
或Map
(例如HashSet
),因为加法和乘法都满足交换律,顺序不重要。 - 使用
HashSet
存储子节点时,同一个累加运算下不能出现两个完全相同的项,如出现了,则将即将加入的项乘以2
加入,并从HashSet
中删除原有的旧元素。
例如x+x
,当准备添加第二个x
时,HashSet
中已经有了一个x
,此时将旧的x
删掉,并创建一个2*x
加入HashSet
中。对于乘法同理,sin(x)*sin(x)
将以sin(x)**2
存储。但x+2*x
可以存在,因为x
和2*x
是两个不同的项,可以在同一个加法运算节点的HashSet
下共存。
- 相比于有序的容器
括号拆解
首先说拆括号,在暂不考虑函数的前提下,必须拆括号的情形主要有以下几种:
(a+b)+c
, 即一个累加节点的子节点是累加节点。解决方案: 这种表达式树的累加和累乘节点均有多个子节点,其内部用容器来管理子节点(推荐用Set
或Map
而不是List
,因为加法和乘法满足交换律,顺序不重要)。我们只需让累加和累乘节点实现合并
操作(类似于 Java 容器的addAll
方法),将a
和b
并到上层,也就的到了a+b+c
(a*b)*c
, 即累乘节点有子节点是累乘。解决方案同上。(a+b)*c
, 即累乘节点有子节点是累加。解决方案: 这时需要利用乘法分配律,循环遍历累加节点下的子节点,并且对累加运算的每个项,复制一份累乘节点下其他的因子并与之相乘,最后将这些相乘的结果相加,即得到a*b + a*c
。(...)**n
, 乘幂节点的底数是累加或累乘。解决方案: 与乘法分配律类似,多次利用乘法分配律。
化简
接下来讨论化简。主要考虑的化简是合并同类项以及乘幂的折叠等。
在开始化简之前,我们需要做这样的一个准备工作: 为表达式树的每种节点实现 equals
和 hashCode
方法。这两个方法的含义不在此赘述,只需要确保实现之后,我们可以判断两棵树是相等的(例如两个 x*sin(x)*cos(x**2)
相等,更进一步, x**2+2*x+1
和 1+x**2+x*2
这两棵树也应该是相等的,因为加法和乘法顺序不重要)
有了判断两棵树相等的能力(以及将树存入 HashSet
或者作为 HashMap
的 key 的能力)后,就可以正式开始实现化简操作了。这里以合并同类项距离,乘幂折叠的操作与合并同类项相似。
同类项合并
例如 2*sin(x**2)*x**3 + 3*x**3*sin(x**2)
应化简为 5*sin(x**2)*x**3
。所谓同类项,也就是除了常数的系数以外,其余含有字母的部分是相同的。
首先,我们需要能够判断出来什么样的两个表达式是同类项,也就是将任何一棵表达式树表示成 系数 * 其他部分
的形式。因此,我们可以对每一种表达式树节点实现 查询系数
和 查询非系数部分
两个方法(注意我们并不需要真正把系数存下来,只需要能查询即可),其中 查询系数
返回一个整数,而 查询非系数部分
返回一棵表达式树。
- 累加节点: 系数为 1,非系数部分为自身。例如
a+b
等价于1*(a+b)
。注意,两个累加节点不可以合并同类项,否则会产生新的括号。但是可以将它们合并到一起。例如a+b + a+b
合并为a+b+a+b
。 - 累乘节点: 遍历存储其子节点的容器, 将所有的常数节点提取出来,计算其乘积作为系数。而剩下的节点用一个新的累乘号连接起来就是非系数部分。
- 常数节点: 系数是自身, 非系数部分无。
- 三角函数、变量等: 系数为 1,非系数部分为自身。
对于 Java 中遍历容器的操作,可以尝试采用
stream
简化代码。
实现了以上两个查询方法后,我们就可以对一个累加节点的子节点们进行合并同类项:
- 创建一个
HashMap<Node, Integer>
,Key 为代表非系数部分的表达式树,Value 为其对应的系数之和。 - 依次遍历累加节点的每个子节点, 分离其系数和非系数部分,并更新到上述
HashMap
中(类似于单词计数)。 - 最后遍历上述
HashMap
,将每一组系数 * 非系数部分
用一个新的累加节点连接起来,得到合并同类项后的结果。
特殊系数的处理:- 系数为
0
: 直接丢弃该项 - 系数为
1
: 直接保留非系数部分
而无需添加多余的乘法。
- 系数为
乘幂折叠
乘幂折叠与合并同类项在操作上相近,只不过操作是在累乘节点上进行,将底数相同的因子合并,指数相加。
输出
最后是表达式树的输出,对每种节点实现 toString
方法,递归遍历输出即可。在输出时可以针对特殊系数(例如 0
, 1
, -1
等)以及 x**2 -> x*x
等特殊情形进行特判以减少输出长度。实现累加节点的 toString
方法(也就是输出若干项之和)时,如存在至少一个正系数的项,可将该正项最先输出从而少输出一个 -
号。
自定义函数
另外,今年的作业中新增了自定义函数,先输入函数定义然后需要把表达式中的函数调用展开。这里也提供一种基于上述表达式树的参考思路: