刚结束的OO课程U1让我印象很深。题目从“输入一个只含x、常数、括号和乘方的表达式,输出展开后的最简多项式”开始,到后期却变成了“对任意合法表达式做等价化简,并按规范格式输出”。这个过程看起来只是需求逐渐变复杂,但真正经历过三次迭代设计后我才意识到,U1考察的核心从来不是某个算法,而是你有没有能力用面向对象思想把一条从解析、建模到化简的流水线组织得可维护、可扩展。这篇总结会围绕多项式展开、表达式化简、AST设计、递归下降解析和迭代重构展开,记录我踩过的坑和最终沉淀下来的方案,给正在做类似课程设计的同学一个参考。
1. U1 的需求全貌:从“会展开”到“会化简”到底变了什么
1.1 三次作业的递进关系
第一次作业拿到题面的时候,我心里其实是有点轻视的:把多项式展开、合并同类项、去掉冗余符号输出,听起来不就是一个正则表达式能搞定的事吗?但真正动手才发现,一旦表达式里出现括号嵌套、幂函数和优先级组合,正则写到最后就是在维护一座纸牌屋。第一次作业的输入还比较友好,只包含常数、变量x、加减乘和幂符号,输出要求是展开并合并同类项后的多项式。
第二次作业开始变味了。题面加入了更多运算结构,比如自定义函数、表达式内部的嵌套幂,以及需要识别“函数调用”这类语义单元。最关键的转变是:输出不再要求“展开到没有括号”,而是要求“化简到最简形式”。这意味着你不能把所有情况都暴力展开,因为某些表达式展开后可能比原式更长,比如带三角函数或者指数函数的组合。到第三次作业,则进一步要求输入合法性的全量校验和输出格式的严格规范化,隐藏测试里开始出现各种边界条件。
三次作业之间表面上是加功能,实际上每一次都在逼你调整整个架构。第一版如果设计得太“贴地”,第二周就会被迫重建;第二版如果不符合开闭原则,第三周就是要新增规则时改得头疼。这也是为什么我强烈建议把“迭代设计”当成U1的第一主题:不要试图第一次就把所有代码写完,但要确保每个阶段的改动都能平滑嵌入。
1.2 展开与化简的本质差异:分配律不等于等价变换
先说一个容易混淆的概念:展开和化简不是一回事。展开的核心是分配律作用的“正规化”,目标是把括号消掉,把表达式转成项的和。化简是更广义的代数等价变换,目标是根据需求让表达式在某种度量下更“短”或更“规范”。举个例子,(x+1)^3展开成x^3+3x^2+3x+1属于展开;但如果某个表达式包含sin^2(x)+cos^2(x),你能化简成1,靠的却是三角恒等式。
U1的现实问题是:前面阶段要求多项式展开,后面阶段却要求表达式化简,这两者不是简单的“多写几个规则”的关系。如果你一开始就把所有逻辑建立在“展开成多项式”的假设上,遇到指数是表达式、底数是复杂结构时,盲目展开反而会让结果更丑。比如(e^x+1)(e^x-1),展开得到e^(2x)-1,这很漂亮;但换一个带sin的表达式,强行展开可能得到一长串无法合并的项,而原式本身才是更简的形态。
所以我说,理解“展开”和“化简”的差异,决定了你的架构上限。真正可扩展的化简器,应该是一个能持续增加代数规则的框架,而不是一个只会做多项式展开的专用程序。这也是面向对象思想在这里的实际价值:把规则封装成对象,把表达式结构封装成节点,再通过组合和派让系统在迭代中自然生长。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从第一版代码到 AST:架构演进的关键转折
2.1 字符串直接操作的局限:为什么一周后必须重写
我的第一版代码非常直白:整个表达式就是一个String,展开时用字符串替换把所有括号内部的乘法一层层扒开,再用正则提取每一项的系数和指数。第一次作业规模不大,这样写确实能跑,通过率也还可以。但代码量堆到600多行时,我明显感觉到几个致命问题。
第一个问题是信息丢失。括号嵌套和运算优先级在字符串里是没有结构的,每次展开都得重新做语法分析,并且一旦中间状态出错,肉眼根本看不出哪层括号算错了。第二个问题是性能。遇到(x+1)(x+2)(x+3)这种连续乘法,每次展开都会产生巨大的中间字符串,内存和耗时都很难看。第三点也是最致命的:字符串方案里,一切信息都铺平在字符序列里,你根本没法区分“这是一个乘法节点”和“两个项正在合并”。
第二个星期我花了一整天重构成AST。这次重写几乎是我整个U1里最值得的一笔投入。AST把表达式变成一棵树,每个节点保存自己的子节点对象,所有信息只存一份,处理逻辑也可以按节点类型分派。后来加入自定义函数,本质上只是把函数调用节点替换成一颗新的表达式子树,用AST做这件事非常自然;用字符串做这种替换,基本就是灾难。
2.2 AST 节点体系设计:接口、抽象类和具体节点
重构后的接口设计得非常小,这是我有意为之的。接口越小,新增节点类型时越容易实现。
java复制public interface Expr {
Expr simplify();
String toPolyString();
boolean isZero();
boolean isOne();
}
具体节点我按语义划分成Constant、Var、Add、Sub、Mul、Div、Pow、Neg、Sin、Cos、FunctionCall这些类。每个类的内部字段不一样:Constant内部存的是BigInteger,Var内部存变量名字符串,Pow内部存base和exp两个Expr引用。字段对外不可见,只能通过方法操作。
我的另一个习惯是加一个AbstractExpr抽象基类,把常用方法的默认实现放到里面。isZero()默认返回false,isOne()默认返回false,子类按需覆盖。这样新加一个节点类型时,最少只需要写simplify()和toPolyString()两个方法,学习成本很低。
下面是节点类型的核心设计对比:
| 节点类型 | 内部核心字段 | 主要化简要点 |
|---|---|---|
| Constant | BigInteger value | 常数折叠 |
| Var | String name | 无法化简 |
| Add | Expr left, right | 合并同类项、吸收零元 |
| Mul | Expr left, right | 因子合并、零元吸收、分配展开 |
| Pow | Expr base, exp | 指数归并、特殊指数处理 |
| Sin / Cos | Expr arg | 基本恒等式、参数化简 |
| FunctionCall | 函数名 + 参数列表 | 函数体替换、实参绑定 |
在U1中期,Sin和Cos是可以用FunctionCall类凑合的,但我后来还是把它们单独建成了类。原因是Sin和Cos的化简规则明显不同,把它们塞进同一个函数调用类里会导致simplify方法里到处是if-else判断函数类型。这里我总结出一条经验:如果两种节点“行为模式”差异很大,不要贪图省类数量硬塞进同一个基类,后期一定需要重构,早拆早省事。
2.3 Visitor 模式在化简与输出中的实际价值
当节点种类增长到10个以上以后,我开始意识到一个问题:如果化简、输出、求值、统计长度这些操作全都写在节点类内部,每个节点类会变得非常臃肿,而且每次新增一个操作,就要在10个类里各加一个方法,代码四处开花。
后来我引入了Visitor模式。核心思想是“操作与结构分离”:把每个操作封装成一个访问器,节点类只保留一个accept方法。
java复制public interface ExprVisitor<R> {
R visitConstant(Constant c);
R visitVar(Var v);
R visitAdd(Add add);
R visitMul(Mul mul);
R visitPow(Pow pow);
// 其他节点
}
节点内部实现accept方法,剩下的逻辑交给具体的Visitor:
java复制class Add implements Expr {
public <R> R accept(ExprVisitor<R> visitor) {
return visitor.visitAdd(this);
}
}
这样我的输出逻辑、化简逻辑、甚至求导逻辑,都各自独立成一个Visitor类。要增加一个操作,不需要改任何节点类,只需要新增一个Visitor实现类。对U1这种每周都会加新需求的场景来说,这个设计让我的迭代节奏舒服了非常多。
但Visitor也有代价:新增一种节点类型时,你必须去改Visitor接口以及所有实现类。所以我的建议是,不要在节点类型还不够稳定的时候就急着引入Visitor。更合理的时间点是在第二次作业中期,节点列表基本确定了再上手。引入太早,改接口的痛苦会盖过收益;引入太晚,迁移成本又太高。
3. 递归下降解析器的实现细节:语法设计决定了你的“容错上限”
3.1 语法分层与优先级处理
表达式解析我采用经典递归下降。核心思路是把表达式拆成几个层级,每个层级对应一个优先级:
text复制Expr := Term (('+' | '-') Term)*
Term := Factor (('*') Factor)*
Factor := Primary ('^' Factor)?
Primary := number | variable | '(' Expr ')' | function '(' Expr ')'
这里要注意的是幂运算的右结合性。2^3^2应该解析成2^(3^2),而不是(2^3)^2。所以Factor这一层不能像加减乘那样写左递归循环,而应该使用右递归:
java复制Expr parseFactor() {
Expr base = parsePrimary();
if (peek() == '^') {
next();
Expr exponent = parseFactor(); // 右递归,保证右结合
return new Pow(base, exponent);
}
return base;
}
我第一次就把幂写成了左结合循环,导致x^2^3在化简时顺序颠倒,直到自己构造测试数据才暴露。递归下降的好处是语法规则和AST结构一一对应,出问题能快速定位到具体是哪一层。有人问我为什么不用ANTLR之类的生成器,我的看法是课程作业场景下手写更可控,也更方便自己控制错误信息和异常行为。ANTLR确实能少写代码,但为了接它的运行时,还要处理一堆配置和兼容问题,反而转移了重点。
3.2 一元负号、省略乘号与右结合的坑
U1里的第一坑是一元负号。表达式“-x^2”到底应该理解为“-(x^2)”还是“(-x)^2”?按数学惯例,负号优先级低于幂运算,所以结果应该是“-(x^2)”。因此不能把负号简单地在Expr层当成减法处理,那会导致“-x^2”被解析成“减法:0 - (x^2)”,看似一样,但在生成语法树时结构不同,可能影响后续化简。我的做法是在Primary解析里先尝试读取正负号,如果读到了,再解析一个Primary,外面包上Neg节点:
java复制Expr parsePrimary() {
if (peek() == '+' || peek() == '-') {
char sign = next();
Expr inner = parsePrimary();
return (sign == '-') ? new Neg(inner) : inner;
}
// 其余逻辑:数字、变量、括号、函数调用
}
第二坑是省略乘号。很多题面允许写“2x”或“2(x+1)”,这意味着TokenScanner遇到数字后如果紧跟字母或左括号,中间没有运算符,也需要自动补一个乘法。我当时在扫描器里维护一个“上一个Token类型”的状态,当判断到当前Token与前一个Token之间缺少运算符且满足乘法省略条件时,就自动插入一个MultiplyToken。这个功能看起来小,但对解析器的整体设计影响很大,因为Token不再只是“显式读到的符号”,还需要考虑相邻关系。
第三坑是右结合。幂运算符和加减乘不同,它右侧递归。如果你把它当作普通左结合运算符处理,大部分测试不会报错,但一定会挂在连续幂的隐藏测试上。我提醒自己每次新增运算符,先确认它的结合性,再决定解析函数怎么写。
3.3 异常处理与输入合法性校验
第一次作业输入保证合法,我没写任何异常分支。第二次要求非法输入输出指定错误信息,我才发现正规解析器必须把“词法错误”“语法错误”“语义错误”区分开。
我建了三个异常类:LexException表示非法字符,ParseException表示表达式结构不正确,EvalException表示化简/求值过程中出现的错误。每个解析函数只要遇到不符合语法的情况就抛对应异常,main入口统一捕获并输出。这样测试时每个错误案例都能快速定位到是哪一环节,而不是面对一段莫名其妙的栈输出。
这里有一点经验:不要把所有错误都归为“Invalid Expression”。分类以后,对拍时错误类型能帮你快速判断是扫描器的锅、解析器的锅、还是化简器的锅。后面如果需要给用户做报错提示,这个设计也能直接复用。
4. 多项式展开与表达式化简:核心算法对比
4.1 多项式展开:HashMap 合并同类项的完整思路
第一次作业需要输出展开并合并后的多项式。我采用的方案是:把表达式语义化为“多项式项”的集合。每一项用系数和指数向量描述,指数向量用Map存储变量名到指数的映射:
java复制public class Term {
BigInteger coeff;
Map<String, Integer> exponents;
}
SimplifyVisitor遇到Mul节点时,不再递归生成字符串,而是先分别拿到左右两侧化简后的Term集合,再做笛卡尔积:每一对Term的指数向量对应相加、系数对应相乘,生成一组新的Term。遇到Add节点时,合并两侧集合,并用exponents的哈希值当Map的key,系数直接相加。
java复制Map<Map<String, Integer>, BigInteger> map = new HashMap<>();
for (Term t : leftTerms) {
map.merge(t.exponents, t.coeff, BigInteger::add);
}
这个数据结构选对以后,展开和合并就是同一件事,完全不需要写正则去提取系数。这也是我强烈推荐用AST而不是字符串的根本原因:在AST上,展开和合并都是对Term集合的代数操作,算法清晰,测试也容易做。
这里重点提醒:系数类型别用int或long,要用BigInteger。U1第二阶段的题面一旦出现(x+1)^10展开,系数很容易超过int范围。我第一版用long,自己构造了一个展开到12次的测试用例,答案直接溢出成负数,排查了很久才发现是数字类型的问题。别在数字类型上省事。
4.2 通用化简:规则式重写的设计方式
到第二阶段,如果还坚持“一切转成多项式项集合”,就会撞墙。因为你可能遇到sin(x)、e^x、cos(x)这类结构,它们没法用“系数+指数向量”表示。这个阶段我把化简器的设计从“Term集合的代数运算”切换成了“规则式重写”。
规则式重写的想法很直接:每个规则是一个函数,接收一个Expr节点,如果能匹配某个模式,就返回一个等价的Expr;如果匹配不上,就返回原始节点。默认规则包括:
| 规则 | 模式 | 化简结果 |
|---|---|---|
| 常数折叠 | Constant op Constant | 直接计算结果 |
| 零元吸收 | 0 + x / 0 * x | x / 0 |
| 一元恒等 | x * 1 / x^1 | x |
| 幂的底数恒等 | x^0 | 1 |
| 双重负号 | -(-x) | x |
| 同底数幂相乘 | x^a * x^b | x^(a+b) |
我实现了一个SimplifyRule接口,SimplifyVisitor维护一个规则列表,然后反复遍历节点、应用规则,直到某一轮没有任何节点发生变化。这样自然就形成了“不动点化简器”。
相比把规则硬编码进每个节点的simplify方法,这种可插拔设计最大的优势是新规则只需要往容器里加一个对象,不影响已经调通的代码。缺点是规则应用顺序会影响性能和中间结果,某些规则甚至可能互相触发导致死循环。我的经验是:先自底向上化简子树,再应用当前节点的规则,重复到不再变化;对U1规模的数据,这个复杂度足够。
4.3 幂次处理:二项式展开与快速幂
第三个让我花了不少时间的是幂次展开。如果遇到(x+1)^10,直接递归展开意味着执行10次乘法,每次都要走一遍Term集合合并,总时间还好。但遇到(x+1)^20,时间就开始肉眼可见地卡顿。后来我实现了一个优化:当幂的底是加法表达式时,不递归展开乘法,而是用二项式定理直接生成展开项。
二项式展开的核心是:对(a+b)^n,通项是C(n,k)*a^(n-k)*b^k。如果a和b本身也是复杂表达式,可以递归地展开较小的部分。更通用的优化是配合快速幂:
java复制public Expr expandPower(Expr base, BigInteger exp) {
if (exp.equals(BigInteger.ZERO)) return new Constant(BigInteger.ONE);
if (exp.equals(BigInteger.ONE)) return base;
if (exp.testBit(0)) {
return new Mul(base, expandPower(base, exp.subtract(BigInteger.ONE))).simplify();
} else {
Expr half = expandPower(base, exp.divide(BigInteger.TWO));
return new Mul(half, half).simplify();
}
}
不过快速幂有个坑:如果base是Add节点,快速幂产生的中间子树形态可能不如用二项式系数直接生成来得规整,合并效率反而低。所以我会判断:base是Add节点且指数大于某个阈值时,走二项式展开;其他情况走快速幂。这个阈值我取的是10,因为指数小于10时,两者性能差异几乎可以忽略,而二项式展开代码更复杂,没必要为小指数引入额外bug。
5. 踩坑排查实录:三次作业里最典型的三类问题
5.1 边界条件不会报错,但答案错得莫名其妙
第一次作业我通过了所有公开样例,但用自己构造的边界数据一测,还是发现了一堆问题。
第一个是0^0。我一开始输出1,后来发现不同课程规范对0^0的要求不同,必须严格按题面说明处理。这种“数学争议”在程序里没有争议空间,规则说什么就是什么。
第二个是系数边界。乘数为0时,0*(x+1)必须直接输出0,不能输出“0x+0”;系数为-1时,输出“-x”而不是“-1x”;系数为1时,输出“x”而不是“1*x”。这些规则看起来琐碎,但隐藏测试里几乎必考。
第三个是“-0”问题。某个节点化简后可能得到负零系数,如果不去掉前面的负号,输出就会变成“-0”。我在Constant节点的化简逻辑里加了一个判断:value.equals(BigInteger.ZERO)时,强制把符号位清为正。
我给自己建了一个“边界词表”:0、1、-1、空括号、连续负号、超大指数、深嵌套括号,每次改完代码都逐项过一遍。这个方法虽然原始,但已经帮我抓住了至少五个只在特殊数据下出现的错误。
5.2 性能瓶颈:嵌套括号和递归深度的真实影响
第三个阶段的某个作业,我构造了一个很深的表达式,形如((((x+1)(x+2))+1)((x+3)*(x+4))+...)。原本以为几百层括号不至于有问题,但程序跑起来直接栈溢出。
排查过程是这样的:我先加日志看是在解析阶段崩溃还是化简阶段崩溃,结果发现是解析阶段就崩了。原因是parsePrimary里不断递归调用parseExpr,调用栈被撑爆。我试过几种方案:
- 增加JVM栈大小:-Xss512m。这个治标不治本,而且依赖运行环境,不适合提交到评测系统。
- 把递归改显式栈:改动量大,但可控,适合追求极致性能的场合。
- 限制输入嵌套深度:超过直接抛ParseException。这个最实用,因为课程作业的合法输入通常不会深到需要几千层递归。
我最终选择的是给递归解析设置深度上限。同时,化简阶段也做了优化:不再对一个巨大表达式做多层递归化简,而是先做一遍轻量的子树缓存,避免同一个子树被重复访问。
这里我想说的是,不要迷信“别人能跑到很深的递归,我也一定要能”。先确认题目输入范围,再决定实现复杂度。课程作业终究是工程训练,不是无限性能挑战。
5.3 输出格式的“隐形炸弹”:省略乘号和规范化顺序
输出格式是U1里最容易反复扣分的地方。我一开始觉得多项式输出不就是“系数*x^指数”拼接吗?实际坑特别多。
- 系数为1时省略,系数为-1时省略1但保留负号。
- 指数为0时省略x^0,指数为1时省略^1。
- 所有项按指数降序排列,指数相同按变量名字典序升序。
- 第一项为正时不输出“+”号,第一项为负时直接以“-”开头。
- 不同项之间用“+”或“-”连接,减号前不加多余的符号。
这些规则我全部拆到了一个独立的OutputFormatter类里,每个小方法负责一种格式化决策。后来我发现,光靠手工测试不够,于是写了一个“对拍工具”:用随机表达式生成器生成输入,让化简器输出结果,再用另一个独立的求值器在多个采样点上比较值与原表达式是否一致。这个工具帮我发现了很多只在特定输出组合下才会出现的格式错误。
对拍思路在算法竞赛里很常见,但面向对象课程里很多人会忽略,总觉得“样例过了就行”。我统计过,自动化对拍帮我发现的bug数量,占整个U1所有bug的七成以上。
6. 重读代码后的个人设计总结:如果让我重做一次 U1
6.1 第一步就建 AST,别急着写化简
如果时间倒流,我会在第一天就把AST节点体系搭起来,即使第一次作业只有加法和乘法。因为“表达式”在数学上本质是递归结构,AST是递归结构最自然的建模方式。用字符串硬撑,只会让第一次功能扩展时付出双倍重构成本。
同时,我一开始就要把“化简”和“输出”分开。第一版代码里我用toPolyString方法承担了化简和打印两个职责,后来重构时拆成了simplify()和toPolyString()两个阶段。职责分离以后,每个方法的测试都变得简单,修改输出规则也不会影响化简逻辑。
6.2 化简规则要可插拔
第二次作业的教训是:把大量if-else堆在simplify方法里,虽然能过,但每加一个规则就要改simplify方法本身,风险很大。后来改成SimplifyRule列表,每个规则实现一个统一接口,通过遍历应用来工作。新需求只需要新增一个Rule类并注册到列表里,不会动已经调通的代码。
这个设计在化简规则超过十种以后价值尤其明显。U1后期我加了十几个规则,几乎每次新增都是“加类、注册、测试”三步,很少出现改一处坏一处的情况。
6.3 测试用例的自动化生成
最后强烈建议:从第一次作业开始就写一个随机表达式生成器。用递归方式随机生成表达式树,再转换成字符串,同时保留表达式树结构,用于与化简结果做对比。配合一个高精度暴力求值器做对拍,每天跑几百个随机用例,比手写几十个测试用例效率高得多。
如果担心随机生成器本身有bug,可以先让它只生成加法、乘法、常数和变量组成的基本表达式,跑通以后再加幂和函数。这样每一层都有验证,不容易出现“测试工具错误导致误报”的情况。
最后再分享一个小技巧:每次改完化简或输出逻辑,先用git保存快照,再跑对拍。当对拍报告某个表达式结果不一致时,用二分法缩小到最近一次提交,基本能快速定位是哪条规则引入的错误。这种“版本化调试”的习惯,离开课程之后做工程项目同样受用。
