pta作业集 4~6 的总结性 Blog
pta作业集 4~6 的总结性 Blog
一、前言
PTA 第二阶段作业结束后再回头看,我感觉这三次作业和第一次 Blog 里写的作业 1~3 很不一样。第一次那一组主要是航空器配载,更多是在练类的划分、组合聚合、输入校验和计算流程;这一次作业 4~6 虽然题目数量看起来少,但都是围绕“数字电路模拟程序”不断迭代。也就是说,不是每次重新写一个完全不同的小程序,而是在前一次代码基础上继续加功能。这样一来,前一次偷懒或者设计不合理的地方,下一次就会直接变成麻烦。
这三次作业的知识点主要有以下几个:
- 面向对象建模:把逻辑门、引脚、连接、电路、外部输入这些概念拆成类。
- 继承和多态:用
LogicGate作为父类,AGate、OGate、NGate、XGate、YGate等作为子类实现不同逻辑。 - 接口设计:用
SignalSource表示信号来源,后面又增加SignalTarget表示信号接收端。 - 集合的使用:大量使用
ArrayList保存输入引脚、输出引脚、连接关系、子电路等。 - 字符串解析:题目的输入格式基本都靠字符串拆分和判断,像
A(2)1-1、C1-A这种格式很容易写错。 - 迭代设计:作业 4 是基础逻辑门,作业 5 增加多输入多输出组合元件,作业 6 又加入子电路和异常输入检测。
从题量来说,三次作业都不像普通题目那样有很多小题,基本就是一个大题不断扩展。但是代码量增长很明显。根据 SourceMonitor 的统计,作业 4 有 481 行、369 条语句、16 个类;作业 5 增加到 745 行、505 条语句、20 个类;作业 6 达到 1055 行、402 条语句、18 个类。作业 6 的语句数反而比作业 5 少,是因为我没有继续保留作业 5 的全部复杂元件,而是把重点放在子电路、输入输出端适配和异常检测上。
难度上,我觉得作业 4 是“从无到有”的难,刚开始要想清楚电路里到底有哪些对象;作业 5 是“原来设计不够用”的难,因为一开始我默认所有元件只有一个输出,结果后面出现译码器、数据分配器这种多输出元件,就必须改父类结构;作业 6 是“输入关系太绕”的难,子电路的输入输出在不同场景下身份会变化,再加上异常优先级,写的时候很容易把方向搞反。
作业 6 给我的印象最深,因为它不是一次就顺利通过的。提交时 8、24、29 测试点总是不过,我反复改了很多次才定位到问题:有的是输出顺序不符合要求,有的是 INPUT: 行里直接给子电路输入或元件输入引脚赋值时解析错了,还有的是子电路输入名和输出名相同时判断身份不对。这些问题单看代码不一定明显,但一放到隐藏测试点里就会暴露出来。
这三次作业整体给我的感受是:写代码本身并不是最难,最难的是前期设计没有想清楚时,后面改起来会越来越乱。第一次 Blog 里我也写过,最好先画类图和时序图再动手。其实这次我还是有点急着写代码,很多关系是写着写着才意识到需要拆出来的,所以这篇总结也主要是复盘这些问题。
二、设计与分析
本部分结合 SourceMonitor 报表、UML 类图和时序图,对作业 4~6 的源码结构进行分析。这里不贴源码,只分析类的作用、类之间的关系以及当时这样设计的原因。
(一)作业4:基础数字电路模拟程序
1. 代码质量分析(SourceMonitor)

作业 4 是本阶段的第一版数字电路模拟程序。SourceMonitor 显示这一版有 481 行代码、369 条语句、16 个类,最大复杂度为 12,平均复杂度为 2.16。这个复杂度在三次作业中不算低,主要原因是第一次写这种输入解析题,我把不少判断都放在 CircuitParser 和 CircuitOperator 里,特别是创建元件、查找元件、传递信号这些逻辑,刚开始写得比较集中。
这一版的类数量已经不少了,但并不是为了凑类数,而是题目本身就适合拆成几个对象:逻辑门、输入引脚、输出引脚、外部输入、连接、电路、解析器、运行器。相比直接在 Main 里用字符串数组硬写,我觉得这样至少能把题目里的真实概念对应到代码里。
2. UML 类图分析

作业 4 中我设计的核心类是 LogicGate。它保存元件编号、输入引脚列表、输出引脚和是否已经计算的状态。五种门 AGate、OGate、NGate、XGate、YGate 都继承它,各自重写 calculate() 和 getGateName()。这样做的好处是计算规则比较清楚,比如与门只管判断所有输入是否为 1,或门只管判断是否存在 1,外部流程不用关心具体是哪一种门。
我认为这一版比较重要的设计是 SignalSource 接口。刚开始我其实想过直接把连接信息里的第一个字符串保存下来,等计算时再用字符串去找信号值。但后来发现信号来源有两种:一种是外部输入,比如 A;另一种是元件输出,比如 A(2)1-0。如果都靠字符串判断,后面会写很多重复的 if。所以我把外部输入 ExternalInput 和输出引脚 OutputPin 都设计成 SignalSource,这样 Connection 只需要保存一个信号源,不用管它具体来自哪里。
不过这一版也有一个设计上的不足:Connection 只保存一个目标输入引脚。题目中的一条连接信息其实可以写成 [A A(2)1-1 O(2)1-1 X1-1],也就是一个信号源连接多个目标。我在作业 4 中是通过创建多条连接来实现效果,功能上能跑,但从类设计来说,它没有完全表达“一条连接线分到多个目标”的含义。这个问题在作业 5 中才改成了一个 Connection 里保存多个目标。
3. 时序图分析

作业 4 的整体流程是:Main 读取所有输入行,交给 CircuitParser 解析;解析器先处理 INPUT: 行,把外部输入存入电路,再处理连接行,创建元件和连接;最后 CircuitOperator 先传递外部输入,再循环扫描所有元件,遇到输入完整的元件就计算,并把输出继续传给后续元件。
这里我当时踩过一个思路上的坑:一开始想按输入顺序直接算,但后来发现电路中的连接顺序和计算顺序不一定完全一致。比如某个门的输入来自另一个门,就必须等前一个门算完才能算。最后我采用了循环扫描的方式:只要本轮有新元件算出来,就继续下一轮;如果一轮下来没有新的元件可以计算,就停止。这个方法虽然不算高级,但对本题的组合电路是可行的。
(二)作业5:组合电路元件扩展
1. 代码质量分析(SourceMonitor)

作业 5 的代码增加到 745 行、505 条语句、20 个类,调用次数从作业 4 的 92 增加到 232。这个变化很明显,因为新增了 SGate、MGate、ZGate、FGate 等组合元件,类之间的调用也更多了。
比较意外的是,作业 5 的最大复杂度从作业 4 的 12 降到了 5,平均复杂度也从 2.16 降到 1.54。这个数据说明虽然功能多了,但把每种元件拆成单独类后,单个方法反而没有那么复杂。比如译码器的逻辑放在 MGate 里,数据选择器的逻辑放在 ZGate 里,输出时再统一由 CircuitOperator 按类型排序。
2. UML 类图分析

作业 5 最大的结构变化是把 LogicGate 中原来的单个 OutputPin 改成了 ArrayList<OutputPin>。这是我当时改得比较痛苦的一点。作业 4 写完后,我默认一个逻辑门就是一个输出,因为基础门确实都是一个输出。结果作业 5 出现多输出元件,原来的父类就不够用了。
改完以后,LogicGate 只保存输入引脚列表和输出引脚列表,具体有几个输入、几个输出,由子类构造方法自己决定。例如基础门只添加一个输出引脚;三态门有控制端和数据端;译码器根据输入位数生成多个输出;数据分配器也要根据控制端数量生成多个输出。这样父类就不再限制元件形态了。
这一版中 Connection 也比作业 4 更合理,它保存一个 SignalSource 和一个目标引脚列表。这样一条连接信息可以真正对应一个信号源和多个目标,而不是拆成很多条小连接。这个改动和题目描述更一致,也让后面的信号传递更自然。
但是作业 5 的问题也很明显:CircuitParser 还是太重。新增一种元件时,我必须在 createGate() 里继续加 if,判断 A、O、N、X、Y、S、M、Z、F。这说明虽然计算逻辑拆开了,但“创建对象”的职责还集中在解析器里。以后如果继续加元件,解析器会越来越长。
3. 时序图分析

作业 5 的运行流程和作业 4 基本一致,还是先解析再运行。但因为有些元件输出可能是无效状态,所以 Connection.transfer() 中对负数信号做了处理:如果信号小于 0,就不继续传递。
这个处理当时主要是为了应对三态门和一些组合元件的无效输出。比如三态门控制端没打开时,输出就不能当成正常的 0 或 1 继续传下去。我一开始没把这个状态处理好,容易出现后面的元件拿到错误信号,然后输出看起来像是逻辑门算错了,实际上是前面的无效状态被传过去了。
不过现在看,这里也只是比较粗糙地用 -1 代表无效。它能解决当前题目,但不是特别严谨,因为“没连接”“还没计算”“高阻态”都可能被我用 -1 表示。短期能过题,长期肯定不太好维护。
(三)作业6:子电路与异常输入检测
1. 代码质量分析(SourceMonitor)

作业 6 是三次里代码行数最多的一次,有 1055 行,类数为 18,最大复杂度为 14。虽然语句数只有 402,但复杂度反而最高,主要是因为 CircuitParser 中承担了太多解析和异常判断工作。
这一版没有继续保留作业 5 中所有复杂组合元件,而是回到基础逻辑门,然后增加子电路和异常输入检测。也就是说,难点不是门电路计算,而是“连接对象到底是谁”“它现在是输入还是输出”“这个连接有没有异常”。这些问题都集中在解析阶段,所以 CircuitParser 变得很长。
2. UML 类图分析

作业 6 中我觉得最重要的变化是增加了 SignalTarget 接口。作业 4 和作业 5 只抽象了信号来源 SignalSource,当时觉得已经够了。到了作业 6 才发现,光有“来源”还不够,“目标端”也必须抽象。
原因是子电路的输入输出身份很容易变。比如 C1-A 在主电路中表示子电路 C1 的输入端,主电路要把信号传给它,所以它是目标端;但到了子电路内部,A 又是外部输入,它要作为信号源传给内部元件。再比如子电路的 OUT,在子电路内部是接收内部元件输出的目标端,在主电路中又是可以被读取的信号源。
为了解决这个问题,我让 ExternalInput 和 ExternalOutput 同时实现 SignalSource 和 SignalTarget,又增加了 SubInputTarget 和 SubOutputSource 这两个适配类。这样 Connection 只需要面对 SignalSource 和 SignalTarget,不用在传递信号时判断“这是普通引脚还是子电路端口”。
另外,作业 6 后期为了通过测试点,我对输入赋值方式也做了补充。原来我的程序只支持普通外部输入,比如 INPUT: X-1,然后通过连接行 [X C1-A] 把信号传给子电路输入。后来发现隐藏测试点可能直接写 INPUT: C1-A-1,也就是直接给子电路输入端赋值。还有一种情况是直接给元件输入引脚赋值,例如 INPUT: A(2)1-1-1 A(2)1-2-0,这种字符串里前面的 -1、-2 是引脚号,最后一个 -1 或 -0 才是信号值。所以 INPUT: 的拆分不能用 indexOf("-"),必须改成 lastIndexOf("-"),否则会把元件名或子电路端口名截错。
但是这个设计也还有问题:子电路在我的代码里更像是一个已经创建好的对象,而不是一个模板。如果同一个子电路被主电路多次引用,可能会出现状态共享的问题。比如第一次引用给子电路输入了 1,第二次引用又给它输入 0,如果没有重新创建实例,前一次的状态可能影响后一次。题目测试不一定卡这个点,但从设计上看是不够稳的。
3. 时序图分析

作业 6 的流程比前两次多了一步:先解析所有子电路,再解析主电路。解析子电路时需要记录它的输入端和输出端;解析主电路时,如果遇到 C1-A 这样的端点,就要去已解析的子电路列表中查找 C1,再判断 A 是输入还是输出。
输出流程也不能简单地把所有元件混在一起输出。我一开始把子电路和主电路中的元件统一收集后按 A/O/N/X/Y 排序输出,结果测试点错得更多。后来恢复成先输出子电路内部元件,再输出主电路元件,才和题目要求更一致。如果子电路里又引用了另一个子电路,还要先递归输出被依赖的子电路,并且同一个子电路只输出一次,避免重复打印。
异常检测部分也在解析连接时完成。我的处理顺序基本对应题目要求:先判断一条连接信息里是否有多个信号源,再判断是否没有信号源,再判断是否没有目标端,再判断第一个端点是不是信号源,最后判断某个输入端是否已经被其他来源连接。这个顺序不能随便改,因为题目要求同一条输入如果有多个异常,只输出优先级最高的一个。
这一块是我写得最容易绕晕的地方。特别是“include more than one input”和“include none output”里的 input/output,并不是普通意义上元件的输入输出,而是连接系统里的信号源和信号目标。我刚开始就是按元件引脚的输入输出去理解,结果会把一些异常判断反。
三、踩坑心得
这次因为做题时没有每一步都截图,所以不能像测试报告那样把每个错误截图都贴出来,但大致踩过的坑我还记得比较清楚。总体来说,这三次作业最折磨我的不是与门或或门怎么算,而是输入格式、连接方向、状态传递和后续迭代时原来的设计不够用。
(一)作业4踩坑心得
作业 4 第一个坑是连接方向。我一开始看到 [A A(2)1-1],脑子里很容易把 A(2)1-1 当成“输出”,因为题目里有时候会说输入输出引脚,读起来很绕。后来真正写程序时才理清楚:连接信息中第一个位置是信号源,后面的才是目标端。对程序来说,A 是 SignalSource,A(2)1-1 是 InputPin。如果这个方向搞反,后面所有信号传递都会错。
第二个坑是计算顺序。刚开始我以为按照输入行顺序解析完连接后就可以顺着算,但样例里很快就会出现一个元件的输出接到另一个元件输入的情况。这个时候不是谁先出现就谁先算,而是谁的输入完整谁先算。所以我后来用了循环扫描:每一轮找还没算、但 canCal() 为真的元件,算完后把输出传下去。这个写法比较笨,但至少不会被连接顺序卡住。
第三个坑是输出不完整元件的问题。题目说如果某个元件输入不全,输出时忽略它。我一开始只判断引脚是否被连接,后来又发现信号值也可能还是 -1,所以 canCal() 里必须同时判断 connect 和 signal。不然有些没有真正得到有效信号的门也会被输出。
(二)作业5踩坑心得
作业 5 最大的坑就是我作业 4 的设计想简单了。作业 4 中所有基础门都只有一个输出,所以我在 LogicGate 里直接放了一个 OutputPin。到了作业 5,译码器、数据分配器这些元件出来后,一个输出完全不够用。于是我必须把父类改成 ArrayList<OutputPin>,然后所有查找输出、传递信号、打印结果的地方都要跟着改。
这个问题让我印象比较深,因为它不是某个小 bug,而是设计假设错了。假设一错,后面就不是改一两行,而是整个结构都要调整。也就是从这一题开始,我才真正感觉到老师说的“迭代作业要考虑扩展性”不是随便说说。
第二个坑是 createGate() 里解析元件名。像 A(2)1-1 这种字符串,最后一个 -1 是引脚号,不是元件编号。真正的元件名是 A(2)1。如果这里截取错了,就会创建出错误元件,或者同一个元件被重复创建。我当时调试时就遇到过类似情况:看起来连接都写进去了,但某个门就是算不出来,后来才发现是查找元件时名称没有统一。
第三个坑是无效信号。三态门、译码器这些元件不是永远输出 0 或 1,有时会输出无效状态。我用 -1 表示无效,但一开始没有在连接传递时拦住它,导致后面元件拿到 -1 后还参与计算,结果当然不对。后来我在 Connection.transfer() 里判断信号小于 0 就不传递,才避免了这种错误继续扩散。
(三)作业6踩坑心得
作业 6 是三次里最绕的一次。第一个坑是子电路输入输出的身份变化。比如 C1-A 在主电路里是目标端,在子电路内部又是信号源;子电路的输出端在内部是目标端,在外部又是信号源。我一开始只想着用 ExternalInput 表示输入、ExternalOutput 表示输出,但真正写连接时发现它们在不同语境下都可能既要读又要写,所以才加了 SignalTarget 接口。
第二个坑是异常优先级。题目要求同一条连接里有多个异常时,只输出优先级最高的一个;多条连接都有异常时,只输出最前面的那条。我一开始想把所有异常都检测完再输出,后来发现这样不符合题目。最后做法是在解析连接时一旦发现错误,就把错误信息存到 error 里,主流程看到有错误就直接输出并结束。
第三个坑是“input/output”这两个词在异常检测里很容易误解。比如 [A(2)1-1] 这种情况,A(2)1-1 从元件角度看是输入引脚,但从连接信息角度看,它不是信号源,所以属于 include none input。如果不区分“元件输入引脚”和“连接系统输入端”,就会判断错。
第四个坑是子电路多次引用的问题。我的代码目前把子电路对象保存起来直接使用,这在简单样例下可以运行,但如果一个子电路被主电路多次使用,理论上应该为每次使用创建独立实例。这个问题我写完后才意识到,算是当前版本留下的隐患。
第五个坑是作业 6 的 8、24、29 测试点。我当时这几个点一直不过,开始以为是门电路计算错了,但实际问题主要在输入解析和输出顺序上。第一,子电路内部元件和主电路元件不能混在一起按类型排序输出,必须先输出子电路内部元件,再输出主电路元件。第二,隐藏测试点可能直接在 INPUT: 中给子电路输入赋值,比如 INPUT: C1-A-1,旧代码只支持 INPUT: X-1 加 [X C1-A],所以会拆错。第三,隐藏测试点也可能直接给元件输入引脚赋值,比如 INPUT: A(2)1-1-1 A(2)1-2-0,这里最后一个短横线后面才是信号值,所以必须使用 lastIndexOf("-") 拆分。第四,如果子电路里 INPUT: A 和 OUT: A 名字相同,那么 C1-A 在连接行第一个位置时应该优先当输出源,在后面位置时应该优先当输入目标。这个规则不处理好,就会出现同一个名字在不同位置含义不同而判断错误。
第六个坑是子电路依赖的递归输出。如果一个子电路内部又引用了另一个子电路,只输出当前子电路是不够的,被依赖的子电路也要先输出。我后来在输出时增加了递归处理,并用一个列表记录已经输出过的子电路编号,保证同一个子电路只输出一次。这个问题也说明输出格式不是最后随便打印一下,而是程序设计的一部分。
四、改进建议
-
解析器需要继续拆分。现在
CircuitParser既负责读输入,又负责创建元件,还负责判断异常。作业 6 的最大复杂度达到 14,主要就是它太重。后续可以拆成InputParser、ConnectionParser、ErrorChecker、GateFactory几个类,每个类只负责一件事。 -
元件创建应该使用工厂类。现在每增加一种元件,就要在
createGate()里加一个if。作业 5 已经能看出这个方法会越来越长。更好的做法是单独写GateFactory,以后新增元件时只改工厂,不让解析器继续膨胀。 -
信号状态不应该一直用整数表示。现在用
1表示高电平,0表示低电平,-1表示无效或未知。短期很方便,但语义不清楚。以后可以改成枚举,比如HIGH、LOW、UNKNOWN、HIGH_Z,这样一眼就能看出状态含义。 -
子电路应该区分定义和实例。现在我的子电路对象可能会被主电路直接复用,存在状态共享的风险。后续应该把子电路定义保存为模板,每次在主电路中使用时创建一个独立实例,这样输入、输出、计算状态都不会互相影响。
-
输出排序代码可以优化。作业 4、5、6 中都有按类型和编号排序输出的逻辑,现在基本还是手写循环排序。后续可以用
Comparator按类型优先级和编号统一排序,减少重复代码。 -
注释必须补上。SourceMonitor 显示三次作业注释率都是 0.0,这一点和我第一次 Blog 里反思的一样,还是没有改好。代码量少时不写注释问题不大,但作业 6 已经一千多行了,像
findSubInput()、findSubOutput()、parseConn()这种方法,如果过一段时间再看,不写注释真的很难马上想起来。 -
测试用例要自己补。只靠样例不够,尤其是这类迭代题。以后至少要自己测:一个输出连接多个目标、输入引脚缺失、同一个输入引脚被两个来源连接、子电路输出接主电路、子电路再接子电路、无效信号不传递等情况。很多测试点过不了,其实就是自己没有提前造边界数据。
-
对
INPUT:行的解析要统一用“最后一个短横线”切分。因为输入名本身可能包含短横线,例如C1-A-1和A(2)1-1-1。如果使用第一个短横线切分,只适合X-1这种最简单格式,一遇到子电路端口或元件引脚就会错。 -
输出模块要单独设计,不要把计算结果随手打印。作业 6 中子电路输出顺序、主电路输出顺序、递归依赖输出、同一子电路只输出一次,这些都属于输出规则。如果继续把输出逻辑和计算逻辑混在一起,后面调测试点会很难定位问题。
五、总结与反思
这三次数字电路作业让我最大的感受是:面向对象设计不是把类写得多就行,而是要看这些类能不能承受下一次需求变化。作业 4 中我觉得一个输出引脚已经够了,作业 5 马上就被多输出元件推翻;作业 5 中我只抽象了信号来源,作业 6 又因为子电路目标端的问题,必须补上 SignalTarget。这些都说明一开始的抽象如果只看当前题目,很容易到下一次就不够用。
我这次收获比较大的地方有三个。
第一,我对接口的理解更具体了。以前写接口有点像为了形式好看,这次 SignalSource 和 SignalTarget 是真正解决问题的。外部输入、输出引脚、子电路输出都可以作为信号源;普通输入引脚、外部输出、子电路输入都可以作为目标端。用接口统一以后,Connection 才能写得比较干净。
第二,我更理解迭代式作业为什么要求先设计。作业 4 如果一开始就把输出引脚设计成列表,作业 5 会轻松很多;作业 6 如果一开始就把端点解析成对象,而不是一直拆字符串,异常检测也会清楚很多。很多时间其实不是花在写新功能上,而是花在弥补旧设计不够通用上。
第三,我的调试思路比之前更清楚了一点。以前测试点不过时,我经常只盯着最后输出看。这次我会去想信号是从哪个源传到哪个目标、哪个元件什么时候能 canCal()、某个引脚有没有真正接收到信号。虽然调试过程还是很费时间,但至少不是完全乱试。
作业 6 的 8、24、29 测试点也让我认识到,隐藏测试点往往不是考最表面的样例,而是在考程序对边界格式的适应能力。比如 INPUT: C1-A-1 和 INPUT: A(2)1-1-1 这种写法,如果只按样例思路写,就很容易漏掉。以后遇到类似题目时,我不能只看题目给出的输入样例,还要主动想“同一种语义有没有别的合法写法”。
当然问题也不少。最明显的是代码注释太少,命名也还有不规范的地方;解析器类太重,很多判断都堆在一起;子电路实例化还不够严谨;异常检测虽然能按当前题目做,但结构不够优雅。如果以后题目继续迭代,这些地方肯定还会成为新的麻烦。
下一阶段我需要继续提高的地方主要有三点:
① 先画图再写代码。类图和时序图不是写 Blog 时才补的,而应该在写代码前帮助自己理清对象关系。
② 多想边界情况。不要只拿样例测,特别是异常输入、缺失连接、重复连接、无效信号这些情况,要自己提前构造数据。
③ 改掉不写注释和把解析逻辑堆在一起的习惯。代码量一大后,真正折磨人的不是写不出来,而是改不动、看不懂、找不到问题。
总体来说,作业 4~6 比第一阶段更能体现“迭代”和“可扩展性”的重要性。虽然我的代码还有不少不足,但这三次作业确实让我从只关注能不能过测试点,慢慢开始关注类之间的关系、接口抽象、状态传递和后续维护。以后再遇到类似题目,我会尽量先把模型想清楚,再动手写代码,而不是写到一半才发现前面的设计不够用。
浙公网安备 33010602011771号