302
社区成员
发帖
与我相关
我的任务
分享我的项目主要思路是对于输入内容,采用递归下降解析为抽象语法树,并对于根节点使用calculate方法进行递归求值,最后格式化打印这个结果。项目主要可以分为数据层、调度层和运算层

这部分是数据类,由于我们需要处理的结果是由加法、减法、乘法、幂运算、e指数运算和求导运算计算出来的,含有两个参数x和y,且常数都为整数,我们可以将它处理成如下的统一的形式:

也就是这里Map<TermKey, BigInteger>的形式,TermKey为定义的复合键类,包含m,n,E这三个指数的信息,这样定义的优势是键值相同的项可以合并同类项,与Map的自然分桶是一致的,只要正确实现了TermKay类的比较方法compareTo(),恒等的表达式都会被处理成相同的数据结构,保证了表达式的唯一性和运算的正确性

这部分是项目主体逻辑,包含项目入口、表达式解析和MathValue打印三个流程
主类(MainClass)仅负责提供入口和组装业务逻辑,不包含任何具体实现
表达式解析采用递归下降方法,对于每个文法结构都写了一个解析方法。由于对于函数定义、递推函数初始定义和递推函数递推表达式的解析规则都与表达式解析方式略有不同,为了扩展便捷性考虑,我采用了类似装饰器模式的继承方式包装了三种特殊Parser,重写了它们有改动的方法,例如:函数定义中不允许出现函数调用,所以我重写FuncParser的parseFuncVariable方法为直接抛出异常;又例如:递推函数的递推表达式中会出现f{n-1}(x)和f{n-2}(x),并解析成一个特殊的结构,所以RecFuncParser类会重写parseFuncVariable,让其支持对这种结构的解析等等
PrintManager 作为打印工具类,仅对外暴露 printMathValue 方法,但其内部逻辑较为复杂,主要实现了两类优化策略:
-1+2*x 会优化为 2*x-1)exp 内部表达式,尝试提取各系数的公因子(不一定是最大公因子),选择使整体输出长度最短的因子进行提取。例如:exp((48+32*x+32*x^2)) 可优化为 exp((6+4*x+4*x^2))^8exp((48+32*x+32*x^2)) 提取因子16会变为 exp((3+2*x+2*x^2))^16,长度反而增加,因此需要遍历所有可行因子择优此外,还有一种拆分指数函数的优化思路尚未实现:将 exp 内部多项式按不同因子拆分为多个 exp 相乘,例如 exp((23+23*x+29*x^2+29*x^3)) 可拆分为 exp((1+x))^23 * exp((x^2+x^3))^29,理论上可进一步缩短输出长度

这是抽象语法树(AST)相关的逻辑,我写了一个抽象节点类Node,实现了一个calculate方法,让每个节点通过方法重写来自行处理自己的运算逻辑,这样保证了单一职责原则和功能解耦,保证了新增运算逻辑时仅需增加新的Node实现类即可,体现了对扩展开放、对修改关闭的开闭原则

此外,该层还包含两个函数调用的工具类:FunctionEvaluator 和 RecFunctionEvaluator。
FunctionEvaluator 负责普通函数调用的求值管理。它通过静态成员 function 存储函数定义对应的 AST 根节点,并在 evaluate 方法中先将实参通过 ParaNode.setParaValue 传递给函数体,然后调用函数体根节点的 calculate 方法完成求值。值得注意的是参数传递依赖于ParaNode,它只会在函数定义的AST中出现,代替XNode被解析(因为函数调用时x需要作为形参被实参替换)。其利用一个静态属性paraValue储存实参值,并在所有节点的calculate方法中都返回这个静态属性的值
RecFunctionEvaluator 负责递归函数的求值管理,逻辑相对更复杂:
functionZero、functionOne 和 functionN 分别存储递归函数的基例(order=0、order=1)和递推表达式(order=n)对应的 AST 根节点orders 管理当前递归调用的深度,使得 DynamicRecNode 可以通过 peekOrder 获取当前阶数,并结合自身偏移量计算出需要求值的递归阶数值得注意的,递推函数除ParaNode以外还依赖于DynamicRecNode,它仅出现在递推表达式functionN中,用来表示f{n-1}和f{n-2}递推项,主要功能实现方法是:以内部属性offset表示阶数的偏移量(-1或-2),在calculate方法中获取当前order值,返回RecFunctionEvaluator.evaluate(order+offset, arg.calculate())的值。注意由于递推表达式存在嵌套递归结构,所以order不能像paraValue一样仅使用静态属性存储,而需要使用栈
这两个工具类将函数调用的上下文管理与 AST 节点的求值逻辑解耦,使得 FuncNode 和 RecFuncNode 等节点只需专注于调用对应的求值器,而无需关心参数传递、递归深度管理、缓存等复杂细节。
我利用Metrics Reloaded插件对于代码做了度量分析,主要分析了以下复杂度指标:
方法复杂度:
类复杂度:
数据层包含 MathValue 和 TermKey 两个核心类。MathValue 的 OCavg 达到 3.73,WMC 高达 41,是整个项目中复杂度最高的类之一。其复杂度主要集中在对多项式的核心运算上:derivative() 方法的圈复杂度为 11,需要同时处理 dx 和 dy 两个方向的偏导,还要通过链式法则处理指数函数的求导,逻辑分支较多;multiply() 方法的圈复杂度为 6,需要遍历两个多项式所有项进行两两相乘并合并同类项;compareTo() 方法的圈复杂度为 7,需要比较项数、每个键和每个系数三个维度。这种高复杂度是合理的,因为 MathValue 作为数据表示的核心,承担了所有多项式运算的底层实现,WMC 较高体现了其职责的集中性
相比之下,TermKey 的 OCavg 为 1.43,WMC 为 10,复杂度较低
调度层包含词法分析器、多个解析器和打印管理器,整体复杂度分布不均。
词法分析器 Lexer的 OCavg 为 1.88,WMC 为 15,复杂度主要集中在 next() 方法(圈复杂度 5)上,该方法需要根据当前字符类型(数字、字母、符号)选择不同的分词策略,分支结构清晰合理
解析器系列中,基类 Parser 的 OCavg 为 3.93,WMC 为 55,是最为复杂的一个。其中 parseFactor() 方法的圈复杂度高达 13,需要根据当前 token 类型(变量、数字、括号、条件、导数等)分支到不同的因子解析方法,这是递归下降解析器的特征决定的,即分支数量由文法结构决定。parseFuncVariable() 方法圈复杂度为 8,需要处理普通函数调用和递归函数调用两种语法;parseCondFactor() 圈复杂度为 8,需要顺序解析条件表达式的各个组成部分。通过继承扩展的三个特殊 Parser——FuncParser(OCavg 2.33,WMC 14)、RecBaseParser(OCavg 3.00,WMC 18)和 RecFuncParser(OCavg 4.17,WMC 25)——各自重写了部分解析方法,复杂度分布相对均衡。其中 RecFuncParser.parse() 圈复杂度为 9,RecFuncParser.parseFuncVariable() 圈复杂度为 9,主要因为需要解析 f{n-1}(x) 这类特殊结构并验证语法正确性
打印管理器 PrintManager是项目中复杂度最高的类,OCavg 达到 7.50,WMC 高达 60。printFirst() 和 printOther() 方法的圈复杂度分别为 23 和 28,是代码中最复杂的两个方法。这种高复杂度来源于输出格式的精细控制:需要判断系数是否为 1、是否需要输出乘号、x 和 y 的幂次是否为 1 或 0、指数表达式是否需要括号保护、公因子提取后的格式调整等多种情况组合。needParen() 方法的圈复杂度为 11,用于判断表达式是否需要括号包裹;getCommonDivisor() 方法的圈复杂度为 10,用于遍历系数的所有公因子;getSuggestion() 方法的圈复杂度为 8,用于选择使输出最短的公因子。总的来说,打印管理器的复杂度源于功能上对于精细控制的要求,是可以理解的,但是其较高的复杂度也的确导致它是全项目最脆弱的类
运算层包含各类 AST 节点和函数求值器,整体复杂度较低,体现较好的架构设计
各类 Node 子类的 OCavg 集中在 1.0~1.8 之间,WMC 普遍较低。CondNode.calculate() 的圈复杂度为 6,需要处理条件判断和分支选择,复杂度合理。DerivNode.calculate() 的圈复杂度为 5,需要区分 dx、dy、grad 三种求导类型。PowNode.calculate() 的圈复杂度为 6,使用快速幂算法实现幂运算。ExpNode.calculate() 和 FuncNode.calculate() 的圈复杂度均为 2,逻辑简单清晰
RecFunctionEvaluator 的 OCavg 为 1.73,WMC 为 19,其 evaluate() 方法的圈复杂度为 11,主要因为需要处理缓存逻辑(先查缓存是否命中)、递归深度管理(通过 orders 栈)和边界条件判断(order 为 0、1 或更大),复杂度虽然较高但职责集中,符合工具类的定位
整体来看,项目的复杂度分布与各层的职责基本匹配:数据层的 MathValue 承担多项式运算的核心逻辑,WMC 41 体现了其职责的集中性;调度层的 Parser 和 PrintManager 分别负责语法解析和输出优化,复杂度较高;运算层的各类 Node 节点 OCavg 普遍低于 2,符合单一职责原则,每个节点只负责一种运算的求值逻辑
主要改进空间在于 PrintManager 的输出格式化逻辑,可以通过抽取公共方法降低圈复杂度(例如,将打印一个项的逻辑抽取出来)。其他部分的复杂度控制在可接受范围内,整体代码质量较好
第一单元中,我的架构迭代经历很简单,这主要是得益于我在第一次作业中就对项目需求做了详细的架构分析,并极力保持高内聚低耦合的良好代码风格。我在第一次迭代中就已经采用了递归下降解析+AST树构建的思路,并使用运行时多态让每个Node实现类负责自己的运算逻辑。这样的架构选择离不开我对于迭代需求的理解:这个表达式解析项目迭代开发的主要扩展点应该是集中于增加新的运算,尽可能将运算层做得原子化,可以保证良好的扩展性能。事实证明,我在两次迭代开发中基本只需要增加新的运算节点,并对应地微调文法解析器即可,确实比较轻松
我在互测中被hack了两个bug,第一个是由于我对数字0的处理失误,当且仅当输入完全为"0"时,输出空串(因为我输出0的检测方法是Terms == null,而没有经过任何处理的0值则会错误地含有一个数值为0的term);第二个是由于我打印逻辑中提取公因子时存在函数的递归调用,即,对于每一个可能的因子,获取提取后的字符串长度的方法中对于e指数递归调用了这个获取表达式最短打印长度的方法,这导致指数级复杂度,被同学构造了用例hack出了TLE,修复方案是增加了一个记忆化的cache,对于已经计算过最短打印方案的MathValue,不再重复计算
在hack别人时,我没有仔细阅读别的同学的代码,而是采用了利用LLM搭建评测机的方案,对于自动生成的用例,验证几个学生项目输出的等价性,并标记不全部等价的用例,但是事实证明,评测机的用例生成规则划定了其边界情况的测试能力的边界,即当我们在编写数据生成脚本的时候没有考虑到一种边界情况,我们生成的测试用例再多都不能hack出这种边界情况带来的bug,这导致了我的评测机虽然生成并测试了百万数量级的测试用例,但是并没有成功hack。反而是考虑边界情况并人工构造的特殊用例成功完成了hack
在本项目的开发过程中,我从时间复杂度、空间复杂度和输出长度三个维度进行了多方面的优化,下面分别介绍
在 PowNode.calculate() 方法中,我采用了快速幂算法进行幂运算,将 O(n) 的幂运算降低为 O(log n)。例如计算 base^8 时,传统方法需要 7 次乘法,而快速幂只需要 3 次乘法(先计算 base^2,再计算 base^4,最后计算 base^8)。虽然项目中的指数都是整数常数,但在递归函数求值场景下,base 本身可能是复杂的 MathValue 对象,此时乘法次数减少带来的性能提升非常显著
RecFunctionEvaluator 中实现了缓存机制:使用 TreeMap<MathValue, HashMap<Integer, MathValue>> 缓存已计算过的 (参数, 阶数) 组合。在递归函数求值过程中,同一个参数值可能会被多次传入不同阶数,甚至同一阶数也可能被多次求值(例如递推表达式中多次引用同一个 f{n-1}(x))。通过缓存,可以避免重复计算,将指数级递归复杂度降为多项式级
在 PrintManager 中,我实现了 cache 成员变量,用于缓存每个 MathValue 的最优打印方案。在 printFirst() 和 printOther() 方法中,打印指数表达式时会递归调用 getSuggestion() 获取其最优格式,而 getSuggestion() 内部又会遍历所有公因子并递归计算提取后的格式。如果没有缓存,这种递归计算在打印复杂表达式时会产生指数级的时间开销。事实上,我在互测中就被同学构造的用例打出了 TLE,正是因为在没有缓存的情况下,一个包含多层嵌套 exp 的表达式导致了计算爆炸。添加缓存后,每个 MathValue 只计算一次最优格式,后续直接复用,问题得到解决
由于我对于MathValue类存在不可变的契约(getter只返回不可变视图unmodifiableMap,所有运算方法都返回新对象作为结果),所以我得以在 MathValue 的各项运算(加、减、乘、求导)中可以直接复用输入对象的引用,而非深拷贝整个多项式结构。例如在 multiply() 中,遍历左操作数的每一项时,直接使用 leftEntry.getKey() 和 leftEntry.getValue() 的引用,而不是复制新的对象。这减少了大量不必要的对象创建
在 add()、subtract()、multiply() 等方法中,当合并系数后结果为零时,我直接将该项从 Map 中移除(map.remove(exp)),而不是保留零系数项。这保证了多项式始终以最简形式存储,节省了存储空间,也减少了后续运算的遍历开销
MathValue 中定义了 ZERO、ONE、X、Y 四个静态常量(享元模式),所有需要这些基础值的场景都复用同一对象,而非每次新建。例如 PowNode 中指数为 0 时直接返回 MathValue.ONE,SubNode 中 NumNode.ZERO 也作为静态常量存在
输出长度优化是 PrintManager 的核心功能,目标是让最终的表达式字符串尽可能短,最求更好的性能分数
在 formate() 方法中,我会优先寻找一个系数为正的项作为输出首项。例如表达式 -1 + 2*x,如果按解析顺序输出会以负号开头,不够美观;优化后输出 2*x-1,避免表达式以负号开头。实现方式是在遍历 terms 时先找第一个正系数项,将其作为首项输出,其他项按顺序追加,正系数项前加 +,负系数项前加 -
对于 exp(E) 形式的表达式,如果 E 内部多项式的所有系数存在公因子,可以提取出来形成 exp(E/g)^g 的形式,有时能缩短输出长度。例如 exp((48+32*x+32*x^2)),系数公因子有 1、2、4、8、16。提取因子 8 得到 exp((6+4*x+4*x^2))^8,长度从 28 缩减到 26;提取因子 16 得到 exp((3+2*x+2*x^2))^16,长度反而增加到 28。因此我实现了遍历所有公因子的策略,分别计算每种提取方案的总输出长度,选择最短的
在实现中,getSuggestion() 方法遍历 getCommonDivisor() 返回的所有公因子,对每个因子调用 divide() 得到提取后的 MathValue,递归获取其最优格式,并计算 该格式长度 + 1 + 因子位数 的总长度,最后选择最短的方案
needParen() 方法用于判断表达式是否需要括号包裹。规则是:常数项、单个幂函数(如 x、y、x^2、exp(E))、无系数无幂次的纯指数函数不需要括号;其他情况(多项相加、带系数的项、幂次大于1的指数函数等)需要加括号。例如 exp(x+y) 中内部表达式 x+y 需要括号,最终输出 exp((x+y));而 exp(x) 不需要括号,输出 exp(x)
在输出格式中,我实现了多种省略规则:系数为 1 时省略系数(常数项除外);系数为 -1 时只输出负号;幂次为 1 时不输出 ^1;幂次为 0 时不输出该变量;乘号只在必要时输出(如系数与变量之间、变量与变量之间)。这些细节规则共同保证了输出尽可能简洁
我在分析中发现,对于某些表达式,将 exp(E) 拆分为多个 exp 相乘可以进一步缩短长度。例如 exp((23+23*x+29*x^2+29*x^3)),可以提取出公因子 23 和 29 的公共部分,拆分为 exp((1+x))^23 * exp((x^2+x^3))^29。这种优化需要将内部多项式按系数分组,每组提取出对应的公因子,形成多个 exp 相乘。由于实现复杂度较高,这一优化暂未实现
在本单元作业中,我对于大模型生成代码的使用仅在搭建评测机时,作业中的所有代码都是人工完成,这样可以保证我对于架构的完整掌握,但是在每次作业开始完成之前,我都会与大模型进行关于架构设计和优化方案的讨论,在每次作业完成后,我都会向大模型询问我代码中是否存在问题/是否存在脆弱的环节
使用大模型生成评测机时,大模型能够在提示词比较详尽的前提下较高质量完成评测机搭建,但是数据生成部分大模型对于数据约束和边界情况的注意力很差,导致实际用于hack的成功率不理想(事实上,我在a房没有利用大模型的评测机成功hack一次)
递归下降法是一种自顶向下的语法分析方法,它最大的特点是每个方法都专注于一个 特定层级 的结构与任务,这使得我们在构造每个方法是只需要专注于它自己的行为,对于其涉及到的其他子层级的方法,它信任并委托其他方法来完成,所以无需关心其他层级或者整体的实现方法,这有助于我们对于任务进行高效清晰地解耦,也有利于我们的代码适应以语法分析为代表的结构上具有很高灵活度的任务
进行递归下降解析器的编写时,我们其实只要理解下面几点内容就能完全把握了:
递归下降需要依赖特定的文法规则,一般由巴科斯范式(BNF)或扩展巴科斯范式(EBNF)给出,我们需要手动分析并消除左递归
对于给定的文法规则,只会包含这两种不同元素: 结构 和 语法糖
结构 是 非终结符 和 终结符 ,即被<尖括号>括起来的,被定义且可以被替换的结构和被"引号"引起来的,不可以再被替换的结构
对于一个 结构 ,我们只需要知道它的 文法规则 和 起始判断 即可,文法规范决定了它能调用谁,起始判断决定了如何调用它
对于 语法糖 ,我们分情况处理即可。例如:
对于“零个或多个”,即“*”或“{}”,采用:
while (起始判断) {
采用文法规则;
}
对于“零个或一个”,采用:
if (起始判断) {
采用文法规则;
}
封装、继承、多态是面向对象的三大基本特征,但是在思考时我认识到,以这三个作为“基本特征”,而非简单描述为面向对象的特征,一定证明了它们代表了面向对象的某个本质属性,解决了面向过程(c)语言中的某类重要问题。故而我们需要追问一个更根本的问题:为什么是这三个特征?它们要解决什么问题?
封装的核心不是“把数据和方法放一起”,而是划定模块边界,限制引起数据变化的原因。
在 C 语言中,全局变量随处可见,任何函数都可以修改它。当一个全局变量被意外修改时,软件工程师往往需要在成千上万个函数中排查罪魁祸首。封装通过将数据私有化,使得数据只能被类内的方法修改——当 bug 出现时,排查范围瞬间缩小到类内部
C++ 引入了 namespace 来解决命名冲突,但命名空间只是解决了“名字”的问题,没有解决“访问权限”的问题,而且存在命名污染的问题。封装更进一步:它不仅解决了命名污染,更解决了数据安全性的问题
为什么偏偏把属性和方法耦合在一起?因为方法(函数)是数据唯一合法的消费者。将数据与所有能够合法消费它的方法绑定在一起,就完成了对数据的所有权约束
很多人把继承理解为“复用代码”,但这个理解是有偏差的。如果只是为了复用代码,复制粘贴也能做到,而且过度强调“复用代码”更是不易于学生理解接口的作用,以及进一步的,“组合优于继承”的概念
那么继承的本质是什么?继承复用的是契约,而非代码
在 C 语言中,要实现“不同类型的对象可以统一处理”,往往需要 void* 加强制类型转换。这种写法极不安全——编译器无法帮你检查类型是否正确,一切都要靠程序员的记忆力
继承提供了类型系统层面的抽象能力。当子类继承父类时,它不仅仅是拿到了父类的代码,更重要的是承诺了遵循父类的行为契约。编译器可以在编译期检查这个承诺是否被遵守,这比人工记忆要可靠得多
从这个角度理解,继承的意义在于:
如果没有多态,我们写代码时经常需要这样的结构:
if (type == A) {
// 处理 A
} else if (type == B) {
// 处理 B
}
这种 if-else 链条有两大问题:
多态通过引入中间层(接口或抽象类)来解决这个问题。调用方只依赖抽象接口,具体实现由运行时动态绑定。新增类型时,只需新增一个实现类,调用方代码无需修改
这与函数指针、回调函数有本质区别:
希望OO取消每两周一次的上机,改为完全采用研讨课形式,研讨课有充分的思考交流机会,比上机高效
希望OO给出一些关于hack的建议,或者a房多给点分,a房的互测实在太困难了
希望北航OO课程越来越好