PTAOOP最后三次作业分析与总结
PTAOOP最后三次作业分析与总结——数字电路仿真系统
一、前言
这学期的前三次PTA作业是"数字电路模拟程序"的迭代开发,三次题目分别是:程序1(基础逻辑门)、程序2(增加S/M/Z/F四种元件)、程序4(增加子电路与异常检测)。从最基础的与或非门电路开始,到后面加入译码器、数据选择器这些复杂元件,最后还要支持子电路嵌套和异常输入检测——三次作业一步一步加功能,难度也跟着涨。
这几次作业里面用到了不少面向对象的东西:类与对象、封装、继承、抽象类、集合(HashMap、ArrayList),还有工厂模式。之前大一全是在写C语言,现在换成Java写面向对象,感觉确实很不一样,尤其是继承和多态,写顺了之后是真的方便。下面我就对这三次作业做个简单的分析和总结。
二、设计与分析
(一)第一次作业:数字电路模拟程序1——基础逻辑门
1. 类图设计
用PowerDesigner对第一次作业的代码生成了类图:

从图里看结构比较简单:
gate是抽象基类,把所有门都有的东西(名称、引脚数、输入输出信号列表)放在里面;aGate(与门)、oGate(或门)、nGate(非门)、xGate(异或门)、yGate(同或门)这五个类继承gate,每个只要实现fn()方法就行;GateFactory是工厂类,根据元件名字字符串来创建对应的门对象;Circuit管整个电路的运行,负责读输入、传信号、输出结果;Main创建Circuit然后调用run(),没啥可说的。
2. 设计分析
(1)抽象基类与多态
我把所有门都有的东西抽到一个 gate 抽象类里:
abstract class gate
{
private char name;
private int inputNum;
private int outputNum;
private ArrayList<Integer> inputPower;
private ArrayList<Integer> outputPower;
public gate(char name, int inputNum) { ... }
public char getName() { return name; }
public void setInputPower(int index, int power) { ... }
public int getOutputPower() { return outputPower.get(0); }
public boolean checkOutputCalculated() { ... }
public abstract int fn();
}
然后每个具体的门只需要写个 fn() 说清楚自己的逻辑就行。比如与门——所有输入都是1才输出1,有一个0就输出0:
class aGate extends gate {
public int fn() {
int power = 1;
for (int i = 0; i < getInputNum(); i++) {
if (getInputPower().get(i) == 0) {
power = 0;
break;
}
}
return power;
}
}
这样后面想加新的门类型直接继承就行,不用动原来的代码,挺方便的。
(2)工厂模式
GateFactory 用了一个简单工厂,看元件名的第一个字母就知道要创建什么门(A→与门、O→或门、N→非门、X→异或门、Y→同或门),括号里的数字就是输入引脚数:
class GateFactory {
public static void createGate(String gateName, HashMap<String, gate> gates) {
char type = gateName.charAt(0);
if (type == 'A' || type == 'O') {
int inputNum = Integer.parseInt(gateName.substring(
gateName.indexOf('(') + 1, gateName.indexOf(')')));
if (type == 'A') gates.put(gateName, new aGate(type, inputNum));
else gates.put(gateName, new oGate(type, inputNum));
}
// ... 其他类型
}
}
把创建对象的活儿交给工厂,Circuit 就不用管具体创建哪种门了。
(3)信号传播
第一次作业的传播方法比较粗暴——就是不停循环:先把所有连接线上的信号从输出端传到输入端,然后检查哪些门的输入齐了可以算了,算完再检查有没有新的门可以算,直到没有变化为止:
private void propagateSignals() {
boolean changed = true;
while (changed) {
changed = false;
for (int i = 0; i < connections.size(); i++) {
String outputPinName = connections.get(i)[0];
int outputPin = getOutputPinValue(outputPinName);
if (outputPin == -1) continue;
setInputPinsValue(connections.get(i), outputPin);
}
for (gate g : gates.values()) {
if (g.getOutputPower() == -1 && g.checkOutputCalculated()) {
int result = g.fn();
g.setOutputPower(result);
changed = true;
}
}
}
}
3. 复杂度分析

PTAOOP4一共5个Java文件,只有5种基本门,整体不复杂。题目要求按 A→O→N→X→Y 的顺序输出,同类按编号从小到大排,输入不全的门直接跳过不输出。
(二)第二次作业:数字电路模拟程序2——扩展元件与扇出传播
1. 类图设计
第二次的类图:


跟第一次比,变化挺大:
gate抽象基类的信号存储从ArrayList改成了HashMap<Integer, Integer>,因为这次新元件的引脚编号不是连续的,用 Map 更灵活;- 在原来5种门的基础上加了4种新的:
sGate(三态门):0号脚是控制端,1号脚是输入端,2号脚是输出端。控制端=1时导通(输出=输入),控制端=0时高阻态(输出无效)mGate(译码器):0/1/2号脚是使能端S1/S2/S3,从3号脚开始是地址输入。当S1=1且S2+S3=0时有效,根据地址值在对应输出脚输出0,其余输出1;无效时全部不传播zGate(数据选择器):控制脚选哪个数据脚,就输出那个数据脚的值fGate(数据分配器):控制脚选哪个输出脚,就把输入数据送到那个脚上,其余输出脚为无效状态
Circuit里加了fanout这个 HashMap,用来做扇出传播
2. 设计与分析
(1)多输出引脚
第二次作业有了译码器M和数据分配器F这种多个输出引脚的门。M的输出脚有 2^inputNum 个(从 3+inputNum 号开始),F有 2^controlNum 个(从 controlNum+1 号开始)。传播的时候得一个一个来。比如译码器:
if (g instanceof mGate) {
if (result == -1) continue;
mGate mg = (mGate) g;
int start = mg.getOutputStartPin();
int cnt = mg.getOutputCount();
int zeroIdx = result;
for (int k = 0; k < cnt; k++) {
int val = (k == zeroIdx) ? 0 : 1;
String outPinName = g.name + "-" + (start + k);
propagateToFanout(pending, outPinName, val);
}
continue;
}
译码器有效的时候,地址值对应的那个输出脚是0,其他全是1;如果S1≠1或S2+S3≠0那就是无效的,全部不传播。
另外M和F的输出格式也和普通门不一样——M输出的是"哪个引脚为0"(如 M(3)1:3 表示Y3输出0),F输出带 - 的字符串表示哪些脚无效(如 F(2)1:--0- 表示只有W2输出0,其余无效)。
(2)扇出传播
第一次那个循环迭代的方法,电路大一点就慢了。第二次换成扇出传播:预处理的时候把"哪个输出脚连到哪些目标"提前建好(就是 fanout 这个 HashMap),传播的时候用一个队列按拓扑顺序来:
private void propagateToFanout(ArrayList<gate> pending, String outPinName, int value) {
ArrayList<String[]> targets = fanout.get(outPinName);
if (targets == null) return;
if (value == -1) return; // 高阻态不传
for (int i = 0; i < targets.size(); i++) {
String[] target = targets.get(i);
for (int j = 1; j < target.length; j++) {
String pin = target[j];
if (pin.contains("-")) {
String[] t = pin.split("-");
gate next = gates.get(t[0]);
if (next != null) {
next.setInputPower(Integer.parseInt(t[1]), value);
if (next.getOutputPower() == -1 && next.checkOutputCalculated())
pending.add(next);
}
}
}
}
}
从O(N²)变成O(N),快了不少。
(3)高阻态
三态门S控制脚是0的时候输出高阻态(-1),译码器M使能不对的时候也是-1。传播的时候遇到-1直接跳过,相当于"这根线断了":
class sGate extends gate {
public int fn() {
int ctrl = inputPower.get(0);
if (ctrl == 1) {
valid = true;
return inputPower.get(1);
} else {
valid = false;
return -1;
}
}
}
这也是题目要求的:输出无效的元件直接忽略不输出,S门高阻态时不输出,M门无效时不输出。
3. 复杂度分析

PTAOOP5一共9种门,加了S/M/Z/F四个新家伙,传播算法也换了,代码量比第一次多了不少。
(三)第三次作业:数字电路模拟程序4——子电路与异常检测
1. 类图设计
第三次的类图:


这次改动最大,结构也最复杂:
Component成了最顶层的抽象类,门和子电路都继承它——这就是题目建议的"组合模式",把子电路当作一种组合型的电路元件;Gate继承Component,管所有门的公共逻辑(outputPower、resetOutput());SubCircuit也继承Component,但它自己内部有一整套东西:components(内部元件)、connections(内部连接)、fanout(内部扇出)、inputMapping/outputMapping(引脚映射)、externalInputs/outputValues(对外的接口);ComponentFactory替代了之前的GateFactory,统一创建门和子电路;Circuit多了subCircuits管理、errorMessages/hasError错误检测,还有信号冲突检测。
2. 设计与分析
(1)三层继承
第三次因为要支持子电路,按照题目建议用了组合模式——搞了个 Component 抽象类放最上面,Gate 和 SubCircuit 都继承它:
Component (abstract)
/ \
Gate SubCircuit
(abstract) (concrete)
/ | | \
aGate oGate ... fGate
SubCircuit 虽然实现了 Component 的那些抽象方法,但里面的逻辑跟门完全不一样——门就是算一个 fn(),子电路是靠自己内部的 propagateInternal() 来传播信号。不过因为都是 Component,Circuit 里可以统一处理,靠多态就行了。
(2)子电路格式
题目规定子电路的格式是:
C子电路编号:
INPUT:输入1 输入2 ... 输入n
OUT:输出1 输出2 ... 输出n
...(内部连接信息,跟主电路格式一样)...
endc
主电路中引用时,子电路的引脚用 C编号-引脚名 的格式,如 C2-A 表示子电路2的输入引脚A。子电路内部元件输出时带上子电路前缀,如 C2-X1-0:0。
外部输入通过 inputMapping 映射到内部元件的输入脚:
public boolean setExternalInput(String inputName, int value) {
Integer old = externalInputs.get(inputName);
if (old != null && old == value) return false; // 值没变,不用重算
externalInputs.put(inputName, value);
return true;
}
在 propagateInternal() 里,根据 inputMapping 把外部的值设到内部元件的引脚上。内部输出通过 outputMapping 和 fanout 最终传到子电路的输出接口。
(3)异常检测
第三次多了五类异常输入检测,按优先级从高到低:
- 一条连接里有多个信号源 →
ERROR: [连接信息] include more than one input - 一条连接里没有信号源 →
ERROR: [连接信息] include none input - 一条连接里没有目的地 →
ERROR: [连接信息] include none output - 连接第一个位置写的是目的地而不是信号源 →
ERROR: [连接信息] input and output sequence error - 一个输入引脚收到多个不同源的信号 →
ERROR: 引脚名 input signal conflict
注意这里的"信号源"和"目的地"是从连接系统的角度看的——元件的输入引脚在连接里属于"系统的输出"(信号从它出来),元件的输出引脚在连接里属于"系统的输入"(信号进去)。多条输入都异常时,只处理排在最前面的那条。
private void checkConnectionErrors(String line, String[] link) {
if (sourceCount >= 2) {
errorMessages.add("ERROR: " + line + " include more than one input");
hasError = true; return;
}
if (sourceCount == 0) {
errorMessages.add("ERROR: " + line + " include none input");
hasError = true; return;
}
if (destCount == 0) {
errorMessages.add("ERROR: " + line + " include none output");
hasError = true; return;
}
if (pos0IsDest) {
errorMessages.add("ERROR: " + line + " input and output sequence error");
hasError = true; return;
}
}
(4)信号冲突检测
传播之前先扫一遍所有连接,看看有没有两个不同的源往同一个目标引脚送信号(题目规定"一个输入引脚不能连接多个输出引脚"):
HashMap<String, String> destSources = new HashMap<>();
for (int i = 0; i < connections.size(); i++) {
String[] link = connections.get(i);
String src = link[0];
for (int j = 1; j < link.length; j++) {
String dest = link[j];
if (destSources.containsKey(dest)) {
if (!destSources.get(dest).equals(src)) {
errorMessages.add("ERROR: " + dest + " input signal conflict");
hasError = true; return;
}
} else {
destSources.put(dest, src);
}
}
}
3. 复杂度分析

PTAOOP6一共10个Java文件,加了子电路、错误检测、信号冲突检测,东西多了但各管各的还算清楚。
三、踩坑心得
第一个坑:译码器M的引脚编号
第二次作业的时候,M的输出怎么都不对。我一开始想当然地把M的地址输入从1号脚开始编号,使能放后面,结果全错了。后来仔细看题目才发现M的引脚编号规则是:0/1/2号脚固定为使能端S1/S2/S3,从3号开始才是地址输入A0/A1/A2,输出从 3+输入数 号开始。跟普通门的1~n规则完全不一样。改过来就好了:
public boolean checkOutputCalculated() {
for (int i = 0; i < 3 + inputNum; i++) { // 0,1,2使能 + inputNum个地址
if (!inputPower.containsKey(i) || inputPower.get(i) == -1)
return false;
}
return true;
}
这个教训就是:不要想当然,新元件的引脚规格一定得看题目里怎么写。
第二个坑:子电路输出映射漏了
第三次作业,子电路内部元件输出已经算出来了,但外面就是看不到,输出是空的。我检查了很久,最后发现是 propagateInternalFanout() 里面漏了——当内部的信号传到输出引脚的时候,我只处理了连到元件的线,忘了把值写到 outputValues 里:
// 之前的代码,漏了输出映射
for (int j = 1; j < target.length; j++) {
String pin = target[j];
if (pin.contains("-")) {
// 只处理了元件引脚...
}
}
// 改之后
for (int j = 1; j < target.length; j++) {
String pin = target[j];
if (pin.contains("-")) {
// ...处理元件引脚连接
} else if (outputs.contains(pin)) {
outputMapping.put(outPinName, pin);
outputValues.put(pin, value);
}
}
内部传得好好的,结果到门口没开门,信号出不去。加了两行就解决了。
第三个坑:信号冲突检测的时机
第三次作业,我开始把信号冲突检测放在传播过程中间。结果有冲突的时候,程序已经改了部分元件的输入值,先输出了一些错误结果才报错。后来我把冲突检测提前到传播之前,先预扫描一遍,没问题再开始传——这样有错就直接报,不会产生脏数据。而且按题目的优先级要求,如果一条连接有多种错误,只输出优先级最高的那个,预扫描也方便实现这个逻辑。
四、改进建议
第三次作业的组合模式设计,有一个地方让我纠结了很久——子电路的连接信息和主电路的连接信息格式完全一样,但子电路里的简单名称(如 A、B、C)到底是输入引脚还是输出引脚,只能去遍历 inputs 和 outputs 列表来判断。如果题目规定输入输出引脚用不同的前缀区分,解析起来会省事很多。
另外,异常检测那块的 if-else 链条太长了,虽然功能没问题,但如果以后再加新的错误类型就更乱了。题目建议用组合模式处理电路元件,我觉得错误检测也可以类似地抽一下——不过这个我也只是想想,作业交了就没动了。
五、总结
收获
-
对面向对象更有感觉了。三次迭代下来,从简单的继承(gate 基类→具体门)到两层继承(Component→Gate→具体门),再到组合模式(Component 统一管 Gate 和 SubCircuit),跟大一写 C 语言的感觉完全不一样。以前是面向过程一个个函数调,现在是先想好类之间的关系再动手写。
-
工厂模式真的有用。从
GateFactory到ComponentFactory,创建对象的事都不用关心了。之前学设计模式觉得是纸上谈兵,自己写一遍才知道确实好用。 -
搞懂了信号传播。从最开始的简单循环,到扇出传播,再到子电路里面的层次化传播,对信号怎么在一个电路里跑来跑去有了直观的理解。第三次主电路和子电路的信号要来回传好几次才能稳定下来,这个过程挺有意思的。
-
错误处理要提前想。第三次作业的对五种异常按优先级处理,让我意识到错误处理不能等写完了再加,得一开始就考虑到,不然后面改起来很痛苦。
不足
-
子电路之间如果有相互依赖形成环,我现在的代码可能会死循环。虽然设了最大迭代次数兜底,但没有主动检测循环依赖。
-
输出排序还在用冒泡,元件多了效率差。后面学了可以用
Collections.sort()配合Comparator来搞。

浙公网安备 33010602011771号