NCHU-BLOG2-数字电路模拟程序-23207329姚子康
NCHU-BLOG2-数字电路模拟程序-23207329姚子康
一、前言
本次是关于数字电路模拟程序和的两次题目集的总结性Blog,这两次题集相对之前的电梯调度程序的代码量有所增加。逻辑上,各种门电路以及器件的设计,依赖对类的设计与继承的深刻理解,比之前单纯的单一职责又有所不同。从基础门到复杂组合电路的逐步递进,深化了我对面向对象核心特性的认识,也培养了我对复杂系统的拆解与实现的能力。
二、设计与分析
数字电路模拟程序的核心设计思想与面向对象的抽象与多态息息相关,通过抽象基类统一接口,具体元件类实现自己的独特的逻辑,模拟器类管理全局状态与信号流转,将组件与模块分开设计,基于这样的设计思路,有助于代码的扩展和维护。
(一)整体架构设计
这两次的题目集,我都使用了"抽象基类->具体实现类->全局管理类"的三层框架,具体的结构如下:
1.类结构设计
虽然看起来类的个数很多,但是很多的设计逻辑都类似。
抽象基类LogicGate:逻辑门类,它负责封装所有逻辑门的公共属性,比如类别(category),编号(id),引脚值(pinValues),有效性(isValid),计算状态(hasCalculated),还有公共方法如设置引脚值setPinValue,获取引脚值getPinValue,重置计算状态resetCalculation,抽象方法calOutput负责计算输出,getOutputString负责输出的格式化,为所有元件提供统一接口。
具体逻辑门类:均继承于LogicGate。
基础的逻辑门(与门AndGate,或门OrGate,非门NotGate,同或门XorGate,异或门XnorGate):分别处理自己的计算,isReady方法只需要检查输入引脚是否全连接即可。
复杂度逻辑门(三态门TriStateGate,译码器Decoder,数据选择器Multiplexer,数据分配器Demultiplexer):需要处理控制引脚,多输出引脚,无效状态,isReady就绪状态等等更具体的逻辑。
电路模拟器类CircuitSimulator:管理所有元件,输入信号以及连接关系,负责元件的创建、信号初始化、迭代传播、结果收集排序等等,它的作用可以说非常巨大,是本系统的核心调度中心。
主程序类Main:主要负责解析输入,然后初始化模拟器,执行模拟过程,输出最终结果。
以下是综合实现的类图:

(二)核心模块设计分析
1.抽象基类LogicGate
第二次迭代,增加了好几个元件,如果每加一个元件都要重新进行调度,这样代码量会明显增加,而且逻辑也会变得更复杂,所以设计抽象基类,通过统一接口,这样模拟器就不需要区分具体的元件类型,只需要调用isReady判断是否就绪,calOutput计算输出,getOutputString获取结果,简化调度逻辑。
具体拿实例来说,基础的逻辑门与复杂的逻辑门的就绪isReady判断逻辑不同,基础门需要检查所有的输入引脚,复杂逻辑门比如数据选择器仅需检查选中引脚,模拟器是统一调用isReady方法,不用关心元件是什么类型。
基础逻辑门-或门类的isReady方法
@Override
public boolean isReady() {
for (int i = 1; i <= inputCnt; i++) {//检查输入引脚是否为-1
if (pinValues[i] == -1) {
return false;
}
}
return true;
}
复杂逻辑门-数据选择器的isReady方法
@Override
public boolean isReady() {
for (int i = 0; i < ctrlCnt; i++) {//检查输入引脚是否为-1
if (pinValues[i] == -1) {
return false;
}
}
int code = 0;
for (int i = 0; i < ctrlCnt; i++) {// 控制引脚为1时,将对应位设为1
if (pinValues[i] == 1) {
code |= (1 << i);
}
}
int selectedDataIndex = ctrlCnt + code;//选中的数据索引
return pinValues[selectedDataIndex] != -1;
}
新增元件,只需要继承LogicGate并重写抽象方法,不需要修改模拟器的核心逻辑,满足了“开闭原则”,对扩展开发,对修改关闭。
2.复杂元件的逻辑设计
第一次的基础逻辑门写起来并不复杂,都是比较单一的计算,新增的几个元件的综合计算多了,所以设计是一个很令我头疼的事情,下面详细介绍这几个复杂元件的设计。
三态门(TriStateGate):
分配三个引脚,分别是0(控制引脚),1(输入引脚),2(输出引脚)
控制引脚为1时,输出有效,等于输入,为0时标记为无效(isValid=false),输出空字符串.
只有当控制引脚与输入引脚都不是-1时,就绪状态isReady才返回true。
译码器(Decoder):
引脚分配:0-2(控制引脚 S1-S3)、33+`inputCnt`-1(输入引脚)、3+`inputCnt`3+inputCnt+outputPinCnt-1(输出引脚)。
只有当S1=1且S2+S3=0(也就是S2,S3都为0)时才有效,否则无效。有效时根据输入编码,仅一个输出引脚为0,其余都是1.
传播信号,需要遍历所有输出引脚,将每个引脚的信号传递给后面的元件。
数据选择器(Multiplexer):
控制引脚全部连接,而且选中的数据引脚不是-1,则判断为就绪状态。
根据控制引脚编码,选择对应数据引脚的值作为输出。
3.模拟器CircuitSimulator的作用
模拟器的核心功能是实现“输入信号->元件计算->信号传播->迭代更新”的闭环,它能动态地创建元件,通过createAllGates方法,从连接关系中提取所有引用的元件名,反向创建未定义的元件,避免漏创建的问题。
private void createAllGates() {// 创建所有门
for (String gateName : allGates) {// 遍历所有门
if (!gates.containsKey(gateName)) {// 如果门不存在
LogicGate gate = createGateFromName(gateName);// 从名称创建门
if (gate != null) {// 如果门创建成功
addGate(gateName, gate);// 添加门到电路中
}
}
}
}
关于信号传播的机制,大致分为以下几个步骤:
- 初始化:将输入信号通过
applySignal方法传递给直接连接的元件引脚。 - 迭代计算:通过
do-while循环,遍历所有就绪且有效的元件,计算输出;若输出变化,通过propagateOutput传递给后续元件。 - 终止条件:无输出变化或迭代次数超过 1000(防止死循环)。
一些重复计算可以进行优化,通过hasCalculated标志(布尔类型),元件计算一次后不再重复计算,在输入变化的时候重置改标志,保证可以重新计算。
(三)复杂度分析
第一次的效率:



第一次的代码并没有那么多,但是有许多方法的复杂度都比较高,比如main方法,复杂度为13,超过了10,非常高了,实际上它整合输入输出以及调度,承担的责任也比较多。
此外,tackleSimulate方法的复杂度也是13,而且最大块深度达到了9,是里面嵌套最深的方法,它是电路模拟的核心方法,事实上里面没有很多if判断,是排序和处理优先级的嵌套比较深,但是如果处理大规模的输入,这个方法就没有很好的性能了,是改进的一个方向之一。
10个类/接口和平均 3.10个方法/类,说明面向对象的设计基本合理,将不同职责(如各种逻辑门)进行了分离。
分支语句占比 25.1%: 这个还好,说明代码并非完全由条件逻辑驱动,但也有很大一部分是控制逻辑。
平均块深度 2.76:这个我感觉做得还行,大部分代码的嵌套层次不深。但最大块深度9暴露了局部存在极其复杂的代码块,是主要矛盾。
块深度分布: 有 51条语句位于深度8的块中, 说明了TackleCircuit.tackleSimulate()等方法中存在大量深层嵌套的代码。
一共78个方法调用语句,平均5.81条语句,方法的粒度还是划分得比较细的,这样分配对于调试很友好,我在调试中也比较好找问题。
第二次效率分析:





这次的代码有七百多行,比上次差不多翻了个倍,虽然是增加了几个元件,但是要处理的逻辑复杂多了。
这次的注释没有及时补充上去,导致注释占比较少,可读性比较差,后续会补充上去。
增加了两个类,平均的方法数量从3.10到4.83,把Main类的一些职责分离到了专门的类中进行处理,减轻一点它的负担。
本次新增的方法createGateFromName,这是最复杂的方法,复杂度为16,调用了56个方法,作为工厂方法,它根据不同的名称创建不同类型的门电路,虽然复杂度非常高,但作用中流砥柱,它支持8种类型的逻辑门和组合逻辑电路,且会匹配带括号和不带括号的逻辑门。
匹配顺序如下:
- 带括号的异或门、同或门、非门
- 不带括号的异或门、同或门、非门
- 与门、或门
- 三态门
- 译码器
- 数据选择器
- 数据分配器
成功匹配:返回对应的 LogicGate 子类对象
匹配失败:返回 null
本函数的设计采用了工厂模式的思想,将对象的创建逻辑封装在单独的模块中,提高了代码的可维护性和扩展性,复杂度之高是在所难免的。
还有一个方法propagateOutput,复杂度也是16,最大块深度为5,有了一些改善,作为信号传播的核心函数,其核心逻辑是由很多if-else组成的,判断条件非常多。
propagateOutput 是一个信号传播引擎,它的主要作用是将逻辑门计算出的输出信号传播到与之连接的下游门电路。这是数字电路仿真器的核心功能,实现了电路中信号的级联传播。
下面详细介绍一下它的作用:
输入参数:LogicGate gate - 已完成计算并产生新输出的逻辑门对象
返回值:void - 无返回值,通过副作用实现信号传播
1. 基本逻辑门传播
对于与门、或门、非门、异或门、同或门:
输出引脚:固定为引脚0
传播方式:门名称-0 → propagateToConnections
2. 三态门传播
条件:门必须有效(gate.isValid())
输出引脚:引脚2
传播方式:门名称-2 → propagateToConnections
3. 数据选择器传播
输出引脚:最后一个引脚(gate.pinValues.length - 1)
传播方式:门名称-最后引脚索引 → propagateToConnections
4. 译码器传播
条件:门必须有效
输出引脚:多个输出引脚(从控制线+输入线之后开始)
传播方式:遍历所有输出引脚,逐个传播
5. 数据分配器传播
条件:门必须有效
输出引脚:多个输出引脚(从控制线+1之后开始)
特殊处理:跳过值为-2的引脚(表示无效输出)
传播的流程
第一步:确定输出引脚
根据门的类型确定哪些引脚是输出引脚:
基本门:引脚0
三态门:引脚2
数据选择器:最后一个引脚
译码器:多个输出引脚
数据分配器:多个输出引脚
第二步:构建引脚标识符
格式:门名称-引脚索引
第三步:调用传播方法
通过 propagateToConnections 方法将信号传播到连接的目标
经过以上步骤,将各个孤立的逻辑门连接成完整的数字电路系统,实现了真正的电路级仿真功能,所以如果要降低复杂度,可能我会考虑在判断条件的地方做一些简化。
这次的main方法比上次的复杂度稍稍有些降低,简化了程序入口的职责,转移到了CircuitSimulator类里,也算是一个小改进吧。
还有一个改进的地方,就是最大块深度,从上次的9到了现在的6,代码量的增加并不是深化了逻辑,而是适当进行深度的改进。平均块深度和平均复杂度都还好,维护上应该不难。
新增的元件译码器,三态门等等,在calOutput方法的复杂度在4-8之间,得到了比较好的封装。
三、踩坑心得
在两次题目集的提交过程中,一共遇到了至少 12 个问题,通过调试,对比样例等方式定位到了错误并成功修复,下面从中挑选出里面的 5 个核心问题,结合数据与测试结果详细说明:
(一)引脚编号混淆
1. 问题现象
题目集 4 提交时,非门(NotGate)的输出始终为 - 1,输入样例INPUT: A-1 [A N1-1]的预期输出为N1-0:0,实际上无输出。
2. 问题原因
非门的isReady方法错误检查pinValues[0] != -1,但根据设计,非门的输入引脚为 1,输出引脚为 0。代码中isReady逻辑与引脚分配不一致,导致判定为未就绪,未执行calOutput。
3. 修复过程
查看NotGate的构造方法:super("N", id, 2)(总引脚数 2),输入引脚为 1,输出引脚为 0。
修正isReady方法:return pinValues[1] != -1。
测试结果:输入样例输出正确,该问题导致的 3 个测试点全部通过。
4. 心得
引脚编号是逻辑门的基础约定,需在设计初期明确(如输入引脚从 1 开始,输出引脚为 0),并在所有相关方法(isReady、calOutput)中保持一致。可在抽象基类中添加引脚分配说明注释,避免混淆。
(二)信号传播不完整
1. 问题现象
题目集 5 提交时,译码器的输出无法传递给后续与门,输入样例中包含[M(2)1-3 A(2)1-1](译码器输出引脚 3 连接与门输入引脚 1),与门始终未就绪。
2. 问题原因
模拟器的propagateOutput方法未处理译码器的多输出引脚,仅传递了基础逻辑门的输出引脚 0,未遍历译码器的所有输出引脚,导致信号 “断链”。
3. 修复过程
查看Decoder的引脚分配:输出引脚从ctrlCnt + inputCnt开始,共outputPinCount个。
修正propagateOutput方法:新增译码器处理分支,遍历所有输出引脚,调用propagateToConnections传递信号。
测试结果:与门成功接收译码器信号,就绪并计算输出,该问题导致的 5 个测试点通过。
4. 心得
多输出元件的信号传播需遍历所有输出引脚,不能沿用基础逻辑门的单输出引脚逻辑。可在抽象基类中添加getOutputPins方法,由具体元件返回输出引脚列表,模拟器统一遍历,降低耦合。
(三)正则解析错误:带括号的异或门 / 同或门识别失败
1. 问题现象
题目集 5 提交时,输入样例中包含X(2)3(带括号的异或门),程序抛出NullPointerException,提示未找到该元件。
2. 问题原因
createGateFromName方法的正则表达式未处理带括号的异或门 / 同或门,仅匹配了X3(不带括号)格式,导致无法创建元件,gates中无对应键值。
3. 修复过程
新增正则表达式xyWithParenthesisPattern:Pattern.compile("([NXY])\\((\\d+)\\)(\\d+)"),匹配带括号的格式。
调整解析顺序:先匹配带括号格式,再匹配不带括号格式,避免冲突。
测试结果:成功创建X(2)3元件,异或门逻辑正常,该问题导致的 2 个测试点通过。
4. 心得
正则解析需覆盖所有合法输入格式,可通过列举所有合法格式、编写对应的正则表达式,并用单元测试验证(如测试A(8)1、X(2)3、S5等格式是否能正确解析)。
(四)重复计算问题
1. 问题现象
题目集 5 提交时,包含 10 个元件的级联电路模拟时间超过 1 秒,部分测试点超时,且输出结果不稳定(偶尔出现错误值)。
2. 问题原因
未添加hasCalculated标志,每次迭代均重新计算所有元件的输出,即使输入未变化。多次计算导致性能下降,且部分元件的输出引脚值被重复覆盖,出现不稳定现象。
3. 修复过程
在LogicGate中添加hasCalculated布尔属性,构造方法初始化为false。
元件calOutput方法首行添加判断:if (hasCalculated) return,计算后设置hasCalculated = true。
setPinValue方法中,当引脚值变化时重置hasCalculated = false。
测试结果:模拟时间降至 0.1 秒以内,超时问题成功解决,输出结果稳定。
4. 心得
复杂系统中需避免重复计算,通过状态标志记录计算状态,仅在输入变化时重新计算,是提升性能的关键。类似地,迭代传播的终止条件(无输出变化)也需精准设计,防止无效迭代。
(五)无效状态未过滤
1. 问题现象
题目集 5 提交时,三态门控制引脚为 0(高阻态)时,仍输出S1-2:-1,与样例预期的空输出不符。
2. 问题原因
TriStateGate的getOutputString方法未判断isValid状态,即使控制引脚为 0、标记为无效,仍输出引脚值(-1),导致结果包含无效输出。
3. 修复过程
修正getOutputString方法:return isValid ? name + "-2:" + pinValues[2] : ""。
模拟器收集结果时,过滤空字符串输出。
测试结果:三态门无效时无输出,符合样例要求,该问题导致的 1 个测试点通过。
4. 心得
无效状态的标记与过滤需贯穿 “计算→输出→收集” 全流程,元件计算时标记isValid,输出时根据状态返回对应结果,模拟器收集时过滤无效输出,确保结果的准确性。
(六)采坑总结
- 调试还是必不可少:通过在
setPinValue、calOutput、propagateOutput等关键方法中添加日志,跟踪引脚值变化与信号传播路径,能快速定位 “断链”“计算错误” 等问题。 - 单元测试要覆盖边界情况:针对每个元件的核心逻辑,编写单元测试(比如非门输入 0/1、三态门控制引脚 0/1、译码器控制引脚无效等),提前发现问题,避免提交后批量失败。
- 对比样例找差异:提交前将程序输出与样例输出逐字符对比,重点关注输出格式(如引脚编号、分隔符)、无效元件是否过滤、排序是否正确,先测试通过样例,然后在此基础上继续改进。
- 和同学一起讨论解决问题:其中第23个测试点一直没通过,但很庆幸隔壁寝室的同学热心提供了一个测试样例,发现输出不符,少了元件的输出,最终也是成功解决了。
四、总结
这两次大作业也比较费心费神,长长的代码调试也不容易,总是记不住元件名,来回翻看长长的题干,但还好咬牙坚持下来了,后续还会仔细分析代码的逻辑结构,能简化一些是一些,然后加上必要的注释,否则过一段时间自己也不记得代码什么意思了。过程比较坎坷,结果还是比较好的,通过了所有的测试点,积累了宝贵的错误经验,也感谢你的阅读!
浙公网安备 33010602011771号