架构设计与分析
整体思想当然是大家熟知的递归下降法。
考虑到单一职责原则,在三次作业中,我均将字符串的解析与表达式的化简分开进行。相应地,除 Main 之外的类也可以分为表达式与自定义函数类(存储表达式解析的结果)、简化的表达式类(存储表达式化简的结果)以及其它辅助类。
表达式与自定义函数类
有关表达式的类
初看 UML 类图,可能觉得些许复杂。但是,不妨先抛开自定义函数类不看,可以发现用于处理表达式的类仅有 Expression 类、Term 类,以及 Power 类(幂函数类)、EFactor 类(指数函数因子类)、ExpFactor 类(表达式因子类)、Deriv 类(求导因子类)四个因子类(常数因子则直接使用了 Num 类,在辅助类一部分中介绍)。
(没错,有一件尴尬的事情就是我在第一次作业中把 Exp 当成 Expression 的缩写,创建了 ExpFactor 表达式因子类,于是第二次作业时只能将指数函数因子类命名为 EFactor. )
对于每个类,都有两个比较重要的方法:一个是 new xxx(StringReader) 的构造方法(上图中未展示);一个是 SimExpression simplify() 的化简方法。
其中,new xxx(StringReader) 的构造方法允许直接从读取器 StringReader 类(类似大部分同学使用的词法解析器 Lexer 类)中直接构造对象,例如 new EFactor(StringReader) 的代码如下:
// EFactor.java
public EFactor(StringReader sr) {
sr.MoveCur(4); //将读取器的指针向右移动 4 个字符,跳过 'exp('
expr = new Expression(sr); //以当前读取器指针的位置创建表达式对象,创建完成后,读取器指针应该指向 ')'
if (sr.At(1) == '^') {
sr.MoveCur(2); //跳过 ')^'
if (sr.At(0) == '+') {
sr.MoveCur(1); //跳过 '+'
}
num = new Num(sr); //创建 Num 对象存储指数
} else {
sr.MoveCur(1); //跳过 ')'
num = new Num(1); //若没有 '^' 字符,则指数为 1
}
}
通俗地说,即是将大部分同学的 Lexer 类命名为了 StringReader 类,并将大部分同学在 Parser 类实现的功能转移到了各个类的构造方法中。
而各个类中的 simplify() 方法则是代码的核心部分,承担了括号展开与函数调用的全部工作。它将表达式、项、因子通过各自的运算规则转化为多项式,等同于大部分同学使用的 toPoly() 方法,只是名称不同。
自定义函数类(优缺点、可扩展性、迭代过程)
在第二次作业新增自定义函数时,我自然地想到应该沿袭第一次作业化简表达式的方法,即首先在读入时对自定义函数的函数表达式进行解析,并使用特定的类存储,然后调用 simplify() 方法得到化简后的多项式,唯一不同的是 simplify() 方法需要外界提供参数,指定各个形参对应的实参因子。
但是,我很快进一步发现,函数表达式并不能用第一次作业建立的 Expression 表达式类进行存储,因为其下的 Power 类仅保存了幂的指数,并不保存形参自变量的类型;而如果将 Power 类的功能进行扩展,使其具备表示函数表达式的功能,则表示普通表达式时又显冗余。
因此,我几乎十分愚蠢地把第一次作业中的全部类复制了一遍(除 Power 类改动),形成了 FuncExpression 类、FuncTerm 类、FuncPower 类 、FuncEFactor 类、FuncExpFactor 类,对函数表达式进行存储;并新建了 Param 类,保存自定义函数调用的实参,与 FuncExpression 类共同构成自定义函数调用的因子 (自定义函数调用的因子并未创建新的因子类,而是直接使用了 Pair<Param,FuncExpression> ,现在想来,也许创建一个新的因子类会更简洁)。
直到后续的互测和研讨课环节,经讨论得知大部分同学使用了字符串替换处理自定义函数(如果没理解错的话,即先使用字符串存储函数表达式,然后把函数表达式中的形参字符串替换为实参字符串,将替换后的函数表达式作为普通表达式解析)。相对于本架构,使用字符串替换很好地复用了原有的类和方法,在开发过程中无需将所有类复制一遍的繁杂操作,然而却在运行过程中需要诸如把 exp 先替换为 e ,再替换回 exp 的冗余步骤;并且,如果表达式中多次出现对同一个自定义函数的调用,则需要对函数表达式的字符串进行重复解析,降低效率。如果进一步考虑效率优化,本架构还可以通过新增函数多项式类 SimFuncExpression、函数单项式类 SimFuncTerm 实现对函数表达式的预化简,如将 f(x,y)=2*x-x+y 化简为 f(x,y)=x+y 再代入实参,而字符串替换的思路似乎无法进一步优化效率,存在较大的局限性。
简化的表达式类
首先说一下命名。在命名时,考虑到 SimExpression 类是由 Expression(及其它类)的 simplify() 方法生成的,故将其命名为 Simplified/Simple Expression ,取缩写为 SimExpression,但其实就是大部分同学代码中的 Poly 类(表达式化简后就是多项式嘛,只不过起名时没想到);同样,单项式 Mono 类也被我命名成了 SimTerm 。(命名的问题大家就不要纠结了)
然后说下创建这两个类的原因。在研讨课的讨论中,我了解到有同学直接使用 Expression 、Term 等类表示化简后的表达式,当时没有想到更好的词,于是使用 “大材小用” 一词表达了我对这种架构的感受。在我的架构中,Term 类中使用了诸多 ArrayList 来分别存放项中的各类因子,而对于化简后表达式的项(即单项式)来说,只有固定的三个因子(一个常数因子、一个幂函数因子、一个指数函数因子,若没有某个因子,则认为这个因子是 1),因此,不需要动用 ArrayList 存放,使用三个属性存放即可。进而考虑到新建类表示化简后的表达式可以进行复杂度的解耦,故建立 SimExpression 类和 SimTerm 类单独表示化简后的表达式,并规定所有 simplify() 方法返回 SimExpression 类。
做完第三次作业的同学在观察上一小节的类图后可能会心生疑惑,为何这么多类几乎全都没有 der()(deriv())方法?求导这么重要的方法当然不会不写啦,只不过我主要将他们写在了 SimExpression 和 SimTerm 中,这和我们班研讨课做主题分享同学的想法一致:表达式的括号展开和化简工作最好是同时进行的,对于求导因子,我们最好也先将其内部的表达式化简,再进行求导,这样自然就只有 SimExpression 和 SimTerm 类需要 der() 方法了(其余唯一有 der() 方法的 Power 类是因为 SimTerm 的 der() 会调用 Power 类的 der())。
至于 SimExpression 类中对 TreeSet 的使用,在后续的部分中会详细说明。
辅助类
StringReader 类
和大部分同学的 Lexer 类类似,前文已有提及,不再赘述。
PFormat 类
记录形参的位置,辅助函数表达式的解析工作。
解释这个类前,需要首先介绍 FuncPower 类(函数表达式中的幂函数类)。显然,这个类需要记录两个信息:形参自变量类型和指数。在第一张 UML 类图中可以看到,对于形参自变量类型,我使用了 int 型而不是 char 型进行存储,这主要考虑到形参的具体字符对于函数本身不会产生任何影响,产生影响的只是形参在定义时的位置。举例来说,下面两个自定义函数应该被存储为相同的形式:
f(x,y)=x+2*y
f(y,z)=y+2*z
于是,我选择使用 int 型属性存储形参在定义时的位置,而不是使用 char 型属性存储形参的具体字符。这就需要对于每个自定义函数,新建一个 PFormat 对象辅助存储 x,y,z 三个形参自变量在定义时的位置,并在 FuncPower 的构造方法中使用 get() 方法进行获取。
FuncRecorder 类
存储 f,g,h 三个函数名对应的函数表达式,在 Pair<Param,FuncExpression> 和 Pair<FuncParam,FuncExpression> 的构造过程中进行获取。
SimParam 类 (优缺点、重构、迭代过程)
此类的设计遵循的是前文提到的原则 —— 表达式的括号展开和化简工作最好同时进行。
在第二次作业中,Param 类存储自定义函数调用中未经化简的实参,而对于 FuncExpression 类的 simplify() 方法接收的参数,我们更希望它是经过化简的实参,而不是 Param 类中未经化简的实参。因此,我们创建 SimParam 类存储化简后的实参,作为 simplify() 方法接收的参数,并在 Param 类中添加 toSimParam() 方法,层次清晰地完成实参在代入前的化简工作。
插句题外话:SimParam 类实际上并不是我在第二次作业中引入的,而是来源于第三次作业动工前的小重构。重构前,我在 Param 类的构造方法中直接进行了实参的化简,并只保存化简后的表达式。这导致实参的化简工作在表达式解析时完成,而其余部分的化简工作在表达式化简(调用 simplify() 方法)时完成,业务逻辑较为混乱。而重构后的架构(即上一段说的架构)先在表达式解析时用 Param 类保存未经化简的实参,直到表达式化简时才调用 toSimParam() 对实参进行化简,逻辑更为合理。
而在第三次作业中,为了支持自定义函数的嵌套调用,新建 FuncParam 类。与 Param 类的区别是:Param 类中存储 Expression 对象,而 FuncParam 类中存储的是 FuncExpreesion 对象(因为嵌套调用的实参是包含 x,y,z 的函数表达式,前文提到,函数表达式并不能用 Expression 类存储)。此时,只需要在 FuncParam 类中同样实现 toSimParam() 方法,便完美地将 Param 类和 FuncParam 类相统一。唯一与 Param 不同的是,FuncParam 的 toSimParam() 方法需要接收一个 SimParam 对象作为参数 …… 怎么样,是不是又晕了。 还是举例说明吧。
2
f(x,y)=x+y
g(x,y)=2*f(x^2,y+2) //嵌套调用的实参用 FuncParam 存储
g(x+x,exp(x))+1 //表达式中直接调用的实参用 Param 存储
考虑如上例子,表达式解析工作全部完成后,Param 对象中存储了 x+x,exp(x) 两个表达式,FuncParam 对象中存储了 x^2,y+2 两个函数表达式。首先调用 Param 对象的 toSimParam() 方法,返回 SimParam1 对象,其内容为 2*x,exp(x) 两个化简后的表达式;然后以 SimParam1 为参数,调用 FuncParam 对象的 toSimParam() 方法,返回 SimParam2 对象,其内容为 4*x^2,exp(x)+2 两个化简后的表达式。SimParam1 为调用 g 函数的实参,SimParam2 为调用 f 函数的实参。(就这样吧,我尽力了。)
(注:虽然在作业要求中,自定义函数调用的实参必须是因子,但我仍将其作为表达式进行处理。这样并不会增加额外的复杂度,还能使代码处理作业要求之外的业务,何乐而不为。)
MultiGcdCounter 类
化简指数因子的辅助类,将在优化设计部分介绍。
Num 类(优点、可扩展性)
此类仅有一个 BigInteger 属性和多个根据实际需求添加的方法。其它类中有关数字的属性(常数因子、指数等)均使用此类,而不直接使用 BigInteger 类,主要考虑到:
- 如果出现新的迭代情景,比如要求所有数字可为浮点数,则只需要将 Num 类的 BigInteger 属性改为 BigDecimal 属性,而无需对其它类的代码做任何修改(除指数可能需要特殊处理)。
- 可以方便地包装一些常用方法。如在指数因子的优化中,多处使用了判断一个数是否可被另一个数整除的操作,于是,在 Num 类中添加如下方法:
public boolean divide_able(Num ynum) {
return num.mod(ynum.num).compareTo(BigInteger.valueOf(0)) == 0;
}
可以想到,如果没有 Num 类的设计,且多个类的方法中都用到这一操作,则很难找到一个合适的地方添加这个方法(且只能定义为 public static boolean divide_able(BigInteger,BigInteger)
);而如果不使用方法封装,则每次都需要用一长串代码完成这一操作,十分冗余。
量化分析
使用 Statistic 插件进行代码规模统计
Source File | Total Lines | Source Code Lines |
---|---|---|
Deriv.java | 13 | 11 |
EFactor.java | 25 | 23 |
ExpFactor.java | 29 | 27 |
Expression.java | 19 | 16 |
FuncEFactor.java | 25 | 23 |
FuncExpFactor.java | 29 | 27 |
FuncExpression.java | 19 | 16 |
FuncParam.java | 15 | 13 |
FuncPower.java | 28 | 26 |
FuncRecorder.java | 11 | 9 |
FuncTerm.java | 75 | 72 |
Main.java | 28 | 25 |
MultiGcdCounter.java | 80 | 75 |
Num.java | 99 | 76 |
Param.java | 15 | 13 |
PFormat.java | 15 | 13 |
Power.java | 48 | 40 |
SimExpression.java | 302 | 278 |
SimParam.java | 23 | 20 |
SimTerm.java | 136 | 118 |
StringReader.java | 29 | 25 |
Term.java | 82 | 79 |
使用 MetricsReloaded 插件进行复杂度分析
class | OCavg | OCmax | WMC |
---|---|---|---|
Deriv | 1.0 | 1.0 | 2.0 |
EFactor | 2.0 | 3.0 | 4.0 |
ExpFactor | 2.5 | 3.0 | 5.0 |
Expression | 2.0 | 2.0 | 4.0 |
FuncEFactor | 2.0 | 3.0 | 4.0 |
FuncExpFactor | 2.5 | 3.0 | 5.0 |
FuncExpression | 2.0 | 2.0 | 4.0 |
FuncParam | 1.5 | 2.0 | 3.0 |
FuncPower | 2.5 | 3.0 | 5.0 |
FuncRecorder | 1.0 | 1.0 | 2.0 |
FuncTerm | 9.0 | 11.0 | 18.0 |
Main | 1.5 | 2.0 | 3.0 |
MultiGcdCounter | 3.2 | 11.0 | 16.0 |
Num | 1.045 | 2.0 | 23.0 |
Param | 1.5 | 2.0 | 3.0 |
PFormat | 1.5 | 2.0 | 3.0 |
Power | 1.375 | 3.0 | 11.0 |
SimExpression | 4.05 | 15.0 | 81.0 |
SimExpression.MyArr | 1.0 | 1.0 | 2.0 |
SimParam | 2.333 | 3.0 | 7.0 |
SimTerm | 1.889 | 9.0 | 34.0 |
StringReader | 1.5 | 2.0 | 6.0 |
Term | 10.0 | 12.0 | 20.0 |
Total | 265.0 | ||
Average | 2.366 | 4.261 | 11.522 |
可以看到,大部分类的代码规模和复杂度处于正常范围。对于 SimExpression 类,由于其内部实现了 exp 因子优化的功能(将在下文中提及),代码规模和复杂度较大,后续可考虑将优化逻辑转移至新建类实现;对于 SimTerm 类,由于其内部添加了较多方法,且缺少接口的使用,代码规模和复杂度较大,后续可考虑添加 Factor 接口优化(将在下文中提及);对于 Num 类,由于其内部包装了较多的常用方法(上文已有提及),代码规模较大,但复杂度正常,并未影响简洁性;对于 MultiGcdCounter 类,由于 exp 因子优化算法本身具有的复杂性,可以认为其代码规模和复杂度在合理范围;对于 Term 类和 FuncTerm 类,同样因为缺少接口使用,代码规模较大,后续可考虑改进。
架构亮点与反思
TreeSet 的使用(优点、重构、迭代过程)
回顾三次迭代开发中经历的重构,最重要的应该是从 ArrayList 到 TreeSet 的变化了。
第一次作业中,我在 SimExpression 类中使用了普通的 ArrayList 存储多项式包含的单项式对象,并添加了 merge() 方法实现项之间的合并化简。在 merge() 方法中,首先根据幂次对 ArrayList 中的项进行排序,再逐个检查相邻的项进行合并。这种做法效率并不高,而且在括号展开的过程中需要频繁调用 merge() 函数(前文介绍求导方法时已提及:表达式的括号展开和化简工作最好同时进行)。
// SimExpression.java in hw1
public void merge() {
terms.sort(new Comparator<SimTerm>() {
public int compare(SimTerm a, SimTerm b) {
return b.comparePowerTo(a);
}
});
for (int i = 0; i < terms.size(); i++) {
if (i > 0 && terms.get(i).addmerge(terms.get(i - 1))) {
terms.set(i - 1, null); //若合并成功,则把被合并的对象标记为 null
}
}
terms.removeIf(Objects::isNull); //删除被标记为 null 的对象
}
在第二次作业中,指数函数因子的引入使项之间的合并化简变得复杂。在此之前,我已然想使用 TreeSet 或 HashSet 对第一次作业的设计进行优化。考虑到第二次作业项之间的合并需要判断指数因子中的多项式是否相等,而判断多项式的相等性需要根据特定的顺序枚举单项式一一比较。于是,我选用了更易控制枚举顺序的 TreeSet 进行优化重构(没错,在判断多项式是否相等时,我仍然没有使用在我看来不太优雅的字符串)。
将 ArrayList 改为 TreeSet 后,我们便可以根据 TreeSet 的特性,在 addmerge() 方法中直接进行同类项的合并,而不需要额外调用 merge() 方法。即:将 SimExpression 类包装成了一个神奇的黑盒,调用者每次往其中添加项后,黑盒会自动保证盒内的项有序并且合并到了最简(并且以 O(logn) 的较高效率进行),不需要调用者额外调用其它方法进行合并化简操作。
首先,为了使用 TreeSet 容器存储 SimTerm,我们需要使 SimTerm 类继承 Comparable 接口,并实现 compareTo() 方法:
// SimTerm.java
@Override
public int compareTo(Object x) {
if (x == null) {
return -1;
}
int diff = comparePowerTo((SimTerm) x); //比较幂次
if (diff != 0) {
return -diff;
}
return ef.compareTo(((SimTerm) x).ef); //比较指数因子的表达式
}
然后,我们便可以用 TreeSet 容器存储 SimTerm,实现 addmerge() 方法(为了便于理解,将源代码做了部分修改):
// SimExpression.java
private final TreeSet<SimTerm> terms = new TreeSet<>();
public void addmerge(SimTerm sim) {
if (!sim.empty()) { //新加入的项不能为空(即常数因子不为 0)
if (terms.contains(sim)) { // TreeSet 中是否存在同类项(幂次相同且指数因子表达式相同的项)
SimTerm tmp = terms.floor(sim); //获取 TreeSet 中已经存在的同类项,使用 terms.ceiling(sim) 效果相同
tmp.addmerge(sim); //合并同类项,此处调用的是 SimTerm 类中的同名方法 void addmerge(SimTerm)
if (tmp.empty()) {
terms.remove(tmp); //如果合并后项变为空(即常数因子为 0),则直接删除
}
} else {
terms.add(sim);
}
}
}
从 compareTo() 方法中可以看到,在进行项之间的比较时,仅对幂次和指数因子进行了比较,而并未对常数因子进行比较。即:TreeSet 会将两个幂次、指数因子相同但常数因子不同的项看作相同的对象。这么做的巧妙之处在于:Set 类容器不允许存在相同元素,对于本设计来说即是不允许两个幂次、指数因子均相同的项同时出现在 TreeSet 容器中,恰好贴合化简的要求。
(以下同类项指幂次、指数因子相同的项,即被 TreeSet 看作相同对象的项。)
于是,在进行 addmerge(SimTerm) 操作时,首先检查是否已存在同类项。若不存在,直接将其加入 TreeSet 容器;若存在,显然不能直接 terms.add(sim) 将其加入,这样 TreeSet 会认为你添加了一个重复的对象,不会对容器内部进行任何修改。
正确的做法是:首先获取 TreeSet 中与此项同类的项,然后对此项进行 addmerge() 操作,合并两者常数因子。最后别忘了检查常数因子是否变为 0,以保证多项式始终处于最简状态。由于 TreeSet 并未提供获取容器内与某个对象相等的对象的方法,因此只能使用 floor() 或 ceiling() 方法代替,两者效果一样。
另外需要指出,在本设计中,对 TreeSet 容器内部的对象进行了修改(调用其 addmerge() 方法)。这是一个比较危险的行为,因为如果在修改的过程中改变了对象与 TreeSet 容器中其它对象的相对大小,则会造成严重的混乱和错误。在本设计中,addmerge() 方法仅会对 SimTerm 对象的常数因子进行修改,不会改变其与其它任何对象的相对大小,故不存在问题。(同样,如果使用 HashSet,则需要保证对容器内部对象进行修改时不能改变其 HashCode. )
(这部分有一点小绕,但相信深入了解 TreeSet 原理后应该可以理解。)
深拷贝与浅拷贝(优点)
在研讨课上,作主题分享的同学向我们分享了一个避免深/浅拷贝问题的绝佳思路 —— 将需要考虑深/浅拷贝问题的类设置为一旦创建便不可修改的类。在我的架构中,同样有部分类的设计使用了此思路:除 SimExpression、SimTerm 之外,所有表达式与自定义函数类和 Num 类的属性均使用 final 修饰,且不提供任何修改方法。这样一来,便可仅进行浅拷贝操作,而不会出现任何问题。
对于 SimExpression 和 SimTerm,我在第一次作业中并未有先见地将其设置为不可修改的类,而是提供了例如 addmerge() 等方法向已经存在的 SimExpression 对象中添加新项。这主要考虑到在 Expression 的 simplify() 方法中,使用 addmerge() 可以方便地完成工作:
// Expression.java
public SimExpression simplify() {
SimExpression sim = new SimExpression();
for (Term term : terms) {
sim.addmerge(term.simplify());
}
return sim;
}
直到第二次作业,我才发现这种设计可能导致的混乱。于是,为了规范深/浅拷贝的使用,我设计并遵循了如下规范:
- 对于会修改对象自身的方法,将其名称后加 merge 以区分,如 addmerge()、mulmerge(),其返回类型设为 void ,并且,方法不会对传入的参数进行任何深拷贝操作(如果调用者希望参数被深拷贝,则需要调用者实现)。这是考虑到 —— 使用这类方法后,对象自身会被改变,说明调用者并不需要保存原对象,只需要修改后的新对象参与下一步计算。所以调用者往往也不需要保存传入的参数,无需进行深拷贝操作。
// SimExpression.java
public void addmerge(SimExpression sim) {
for (SimTerm term : sim.terms) {
addmerge(term); //此处调用的是 void addmerge(SimTerm) 同名方法
}
}
- 对于不会修改对象自身的方法,如 add()、mul(),则需要通过返回值传递结果,规定方法内部应该在必要时对自身以及传入的参数进行深拷贝,以保证返回的对象与自身和参数对象所指向的内存空间不发生重叠。这是考虑到 —— 使用这类方法时,调用者往往在需要返回值的同时,仍需要保存对象自身和传入的参数进行下一步计算。所以,方法内部应该通过深拷贝,保证返回的对象与对象自身以及参数对象相互独立,防止后续对返回的对象进行修改操作时,意外修改对象自身和参数对象。
// SimExpression.java
public SimExpression add(SimExpression sim) {
SimExpression res = copy();
res.addmerge(sim.copy());
return res;
}
遵循以上两条规范,我顺利地编写出思路清晰的代码,通过了强测和互测,说明此设计规范在本次作业中具有一定的合理性。然而,在其它项目中这两条规范能否解决所有关于深/浅拷贝的问题,还有待进一步探索。
接口和继承的使用(缺点、可扩展性)
编写代码时,我并未刻意考虑接口和继承的使用,于是写出了如下代码:
// Term.java
private final boolean rev; //存储项的符号
private final ArrayList<Num> nums = new ArrayList<>();
private final ArrayList<Power> pows = new ArrayList<>();
private final ArrayList<ExpFactor> expfs = new ArrayList<>();
private final ArrayList<EFactor> efs = new ArrayList<>();
private final ArrayList<Pair<Param, FuncExpression>> funcs = new ArrayList<>();
private final ArrayList<Deriv> derivs = new ArrayList<>();
.....
public SimExpression simplify() {
Num tnum = new Num(1);
for (Num num : nums) {
tnum = tnum.mul(num);
}
Power tpow = new Power(0);
for (Power pow : pows) {
tpow = tpow.add(pow);
}
......
for (ExpFactor expf : expfs) {
expr = expr.mul(expf.simplify()); //此处没有使用 mulmerge() 方法,是因为 SimExpression 类并未实现 mulmerge() 方法(表达式的 mulmerge() 方法没有太多实现的意义)
}
for (Deriv deriv : derivs) {
expr = expr.mul(deriv.simplify());
}
......
}
对如此繁杂的代码进行反思过后,我发现使用 Factor 接口能够极大简化代码逻辑:
//修改后的 Term.java
private final boolean rev;
private final ArrayList<Factor> facs = new ArrayList<>();
......
public SimExpression simplify() {
SimExpression expr = new SimExpression(rev); //只有 1 或 -1 一项的表达式
for (Factor fac : facs) {
expr = expr.mul(fac.simplify());
}
return expr;
}
但可以注意到,对于一些很方便处理的因子,如常数因子、幂函数因子,在使用接口前,我们仅需要把所有常数因子相乘、所有幂函数因子的幂次相加即可完成对它们的合并化简;而在使用接口后,我们需要在这些因子类中实现并没有什么意义的 simplify() 方法,并将返回值作为一个表达式进行处理,浪费了部分效率 —— 这是我在后两次作业中并未对此问题进行重构的主要原因。然而,如果考虑在后续迭代中实现更复杂的表达式解析,我认为仍然有必要将所有因子类重构为接口形式,以追求逻辑的简结。
优化设计
此处就不再重复已经写过的内容了,直接上讨论区帖子。
帖子原文
由于 hw2 强测结果公布后,我非常遗憾地发现竟然有大佬把性能卷到了如此极致自己的程序在 strong_13
数据点中并没有将表达式化到最简,于是对 exp 因子的化简问题再次进行了深入思考,结合其他大佬对这一问题的分析后,我认为 exp 因子的化简有以下四个由易到难的层次:
1、尝试将表达式整体的最大公因数提出;
2、根据 cxc 大佬的分享,尝试将表达式整体的最大公因数/个位数因子提出,如 exp((20*x^2+30*x^3+40*x^4))
化简为 exp((4*x^2+6*x^3+8*x^4))^5
;
3、尝试将表达式内的所有项分组,每组单独提取公因数,如 exp((698789872*x^3+78631413879132*x^2+61157766350436*x+524092404))
化简为 exp((9*x^2+7*x))^8736823764348*exp((8*x^3+6))^87348734
;
4、尝试将表达式内的某些项拆开再分组,如 exp((2+100000000*x+100000000*x^2+100000001*x^3+100000000*x^4))
化简为 exp((2+x^3))*exp((x+x^2+x^3+x^4))^100000000
;
其中,不乏一些大佬用高深的数学知识和搜索算法将层次 3、4 同时实现。但我个人认为,对于前三个层次,影响化简结果优劣性的几乎仅是各项中常数因子的长度,而层次 4 化简结果的优劣性还与各项的非常数因子的长度有关。如示例中将 100000001*x^3
拆为了 100000000*x^3
和 x^3
,但如果将 100000001*exp(x^3)
拆为 100000000*exp(x^3)
和 exp(x^3)
,则拆分带来的代价会增加很多。
基于此,我计划暂时只实现第三个层次的化简。可以想到,化简的关键在于如何找到尽可能大的某些公因数。于是,抱着能卷一点是一点试试看的心态,我设计了如下算法:
首先,确定表达式拆解的组数 k ,即维护 k 个可提取的公因数;
然后,读取表达式中前 k 个不同的常数因子作为这 k 个公因数的初始值(显然,相同的常数因子不必重复作为公因数);
考虑继续添加一个不同的常数因子,则此时存在 k+1 个公因数,需要拆分为 k+1 组项,因此,为了保证拆解出的组数仍然是 k ,我们需要把其中两组项合并为同一组项进行公因数提取,对于我们维护的 k 个公因数来说,即是尝试将其中的两个数合并为他们的最大公因数。显然,我们需要希望合并出来的公因数尽可能大,因此我们遍历 k*(k+1)/2
组可以合并的数,选取合并后的数最大的那组(即选取公因数最大的两个数进行合并);
如果遇到两组数的最大公因数相同,则选取合并后能使 k 个公因数的和最大的两个进行合并(我是这么做的,随机选问题应该也不大);
将所有项的常数因子添加后,根据得到的 k 个公因数将表达式的项分成 k 组,每组单独提取公因数。
以上是表达式拆分组数固定时的算法,为了遍历所有可能的情况,我们只需设定一个表达式拆分的最大组数 n(在程序中我设为 9,但根据强测结果来看,设 2 就够了),从 1 到 n 遍历后选出最佳的化简即可。
最终的化简结果————
hw2_strong_13 :
exp((2*x^6*exp(1)-2*x^5-2*x^4+3*x+2*exp(x^3))) //原式
exp(x)^3*exp((x^6*exp(1)-x^5-x^4+exp(x^3)))^2 //化简式
hw2_strong_4 :
exp((44427946663936*x^6-94409386660864*x^4+50154986663585*x^2)) //原式
exp(x^2)^50154986663585*exp((8*x^6-17*x^4))^5553493332992 //化简式
可惜 hw2 时还没有想到这个算法,错失了一次以一己之力降低全 OO 课程均分的机会()
hw3_strong_9 :
exp((1-100656875*x^16-161051000*x^13-96630600*x^10-25768160*x^7-2576816*x^4))^4 //原式
exp((-625*x^16-1000*x^13-600*x^10-160*x^7-16*x^4))^644204*exp(4) //化简式
dhj 大佬的数据 :
exp((2+100000000*x+100000000*x^2+100000001*x^3+100000000*x^4)) //原式
exp(x^3)^100000001*exp((x^4+x^2+x))^100000000*exp(2) //化简式
exp((2+x^3))*exp((x+x^2+x^3+x^4))^100000000 //理论最简式
(虽然因为不具有拆分项的功能,无法化简到最优解,但已经很可以接受了)
于是,我们使用了一个简单的线性复杂度的乱搞方法(系数取决于设定的最大组数 n )实现了较为优秀的化简。
至于架构方面的分享,已经有许多大佬发表了个人见解,故留到博客作业中写,此处不再赘述。
评论区补充
“是的,这个算法目前还有很多局限性,没有找到 exp(x^2)*exp((289*x^2+256*x^6-544*x^4))^173546666656
这个解的原因仍然是文中提到的 不具有拆分项的功能。
然而,排除这个原因后,我仍然发现,对于 exp((2+100000000*x+100000000*x^2+100000001*x^3+100000000*x^4))
,这个算法只能将其优化为 exp(x^3)^100000001*exp((x^4+x^2+x))^100000000*exp(2)
而非 exp((2+100000001*x^3))*exp((x^4+x^2+x))^100000000
,原因是合并时的贪心策略过于单一:当 k=2,目前维护的两个公因数是 2 和 100000000,新加入 100000001 时,算法会根据公因数最大的贪心策略将 2 和 100000000 合并为 2,导致最后得出的两个公因数为 2 和 100000001 ,而不是 1 和 100000000。或许可以在本算法基础上考虑更加全面的贪心策略,如综合考虑公因数大小和公因数被使用的次数进行合并,设计出更加优秀的算法。”
简洁性分析
对于我使用的优化设计,可以发现其中有一个较为独立的功能模块 —— 根据若干常数因子确定 k 个可提取的公因数。于是,我设计了 MultiGcdCounter 类封装此功能,大体如下:
// MultiGcdCounter.java
public class MultiGcdCounter {
private final int length; //即为帖子中提到的 k
private final ArrayList<Num> nums;
public MultiGcdCounter(int tlength) {
length = tlength;
nums = new ArrayList<>(tlength);
}
public void add(Num tnum) {...} //添加一个常数因子
public ArrayList<Num> sort_get() {...} //获取计算得到的 k 个公因数,从大到小排序
}
而对于优化需要的其它步骤,如将 k 从 1 到 n 遍历,记录每个 k 优化后输出内容的长度,并从中选择长度最小的进行输出等等一系列操作,我并未想到较为优秀的面向对象设计思路,于是选择在 SimExpression 的 expmultiprint() 中使用面向过程的方法,编写了冗长的代码进行实现(使用 private 方法定义了在过程中用到的函数),仍需后续改进。
互测体验
第一次互测体验:无,整个房间无一人 hack 成功。
第二次互测体验:一般,和房间内其它人共同发现了两处 bug 。
第三次互测体验:舍友提出可以卡 TLE 后,根据 cost 计算方式的漏洞 使用 exp 因子和自定义函数成功构造出把房间内 4 个人卡 TLE 的数据。刀人的感觉就是爽。
总结来说,我认为 bug 可以分为以下两类:
一是代码逻辑混乱导致的 bug ,如深/浅拷贝使用不当。这类 bug 一般可以通过评测机构造足够多的大数据测出。
二是边界情况考虑不全导致的 bug ,如在第三次强测中,我遇到了如下代码导致的 bug :
//某开阳星的 Lexer.java
public void next() {
if (pos >= input.length()) {
return;//输入完了
}
while (input.charAt(pos) == ' ' || input.charAt(pos) == '\t') {
pos += 1;
}
char c = input.charAt(pos);
if (Character.isDigit(c)) {
curToken = this.getNumber();
}
else if ......
}
可以发现,该同学考虑到了输入中可能存在的空白字符,但却没考虑到一行的最后存在空白字符的情况,导致了错误。对于这类错误,若评测机构造的数据较为全面,则有概率测出。但最有效的方法或许是手动构造特殊数据进行测试,如 (0)^0,保证测试覆盖所有边界情况。
未来方向
或许是平衡三次作业的难度,优化互测时的得分机制(比如测 TLE 可能带来过多的 hack 得分;分到无 bug 房的同学无论如何努力都比分到有 bug 房的同学得分低)。