电路作业的三次迭代——继承、索引与作用域
一、前言
写完航空器配载管理系统的时候,我以为自己对面向对象已经"够用了"。直到题目从"货物装载"换成"数字逻辑电路仿真",我才发现之前不过是在舒适区里扑腾。货物是死的,电路是活的;货物之间最多比个重量,电路元件之间却有拓扑依赖、有信号传播路径。问题域一换,之前那套建模思路基本归零,只能从头来。
三次作业走的是同一条路:先搭骨架,再往上堆东西,最后推倒重来。
作业集4(V4 基础门电路):新起点。把 A(与门)、O(或门)、N(非门)、X(异或门)、Y(同或门)五种基本门抽象成类,拿队列驱动信号传播,模拟组合逻辑电路。这次的关键词是"传播"——信号怎么从输入引脚出发,经过导线和门,最后跑到输出端。
作业集5(V5 扩展门电路与多引脚输出):复杂度开始飙升。新加了 S(选择器)、M(译码器)、Z(多路选择器)、F(多路分配器)四种带控制信号的门。数据结构从 ArrayList 全面换成了 Map——因为引脚不再按顺序往里填,而是靠引脚编号精确索引。输出也从单个 "-0" 扩展到了多引脚。这次的关键词是"索引"——控制信号和数据信号一分家,门电路的行为就从线性的"填满就算"变成了多维的"按位寻址"。
作业集6(V6 子电路与层次化设计):终极重构。引入了 Scope(作用域)和 C...endc 子电路定义语法。更关键的是,整个仿真引擎从 BFS 队列传播一把推倒,重写成了 DFS 递归求值——用记忆化搜索同时解决了计算效率和组合环检测两个问题。还加了信号冲突检测、输入输出方向分类等静态校验。这次与其说是在"加功能",不如说是在"建框架"。
1.1 知识点覆盖
| 作业 | 核心知识点 |
| V4 | 抽象类与继承、队列、BFS信号传播、HashMap索引、比较器排序 |
| V5 | Map引脚索引、多输出门电路、控制/数据信号分离、LinkedHashSet保序、多态输出格式化 |
| V6 | DFS递归、记忆化搜索、组合环检测、Scope作业域、子电路层次化、信号冲突检测、静态校验 |
1.2 题量与难度
| 度量指标 | V4 | V5 | V6 |
| 总行数 | 360 | 537 | 300 |
| 类数量 | 7 | 11 | 8 |
| 核心算法 | BFS | BFS+引脚索引 | DFS+记忆化+冲突检测 |
| 架构变化幅度 | 基准 | 中度重构 | 彻底重写 |
二、设计与分析
2.1 V4:基础门电路与队列传播

2.1.1 整体思路
V4 的目标是搭一个最小可用的电路仿真引擎。我搞了个抽象基类 `dianlu`(电路元件),里面放 `name`、`inputCount`、`inputValue`(已收到的输入值列表)和 `outValue`(计算结果)。五个具体子类 A、O、N、X、Y 各自重写 `calc()`,塞进对应的逻辑。
信号传播用的是 BFS 队列模型:从 `INPUT:` 行解析出初始信号源,推进 `inputQueue`;每次弹一个信号名,找到它所有的下游门电路,把值喂进去;等下游门的输入引脚全齐了(`isFull()` 返回 true),立刻算输出,再把输出信号推进队列——一直循环到队列空为止。
// V4 核心驱动逻辑(简化)
|
while (!inputQueue.isEmpty()) {
String signal = inputQueue.poll();
for (String dest : wireMap.get(signal)) {
Gate g = gateMap.get(dest);
g.addInput(value);
if (g.isFull()) {
int out = g.calc();
inputQueue.add(dest + "-0"); // 输出变成新的信号源
}
}
}
|
这套模型的好处是直观——它跟"信号沿导线流动"的物理直觉完全对应,写起来很顺手。但隐患也埋下了:它默认电路是纯前馈的,一旦出现反馈环路(哪怕只是中间信号被两条路径引用),就会死循环。
2.1.2 排序设计
输出顺序要求按门类型排(A→O→N→X→Y),同类型再按编号排。我用 `getRank()` 把门类型映射成整数优先级,配一个 Comparator 搞定。这个排序策略后面一直沿用,是少数从头到尾没动过的设计。
2.1.3 分析图表

2.1.4 优缺点
优点:
- BFS 模型直观,跟物理信号流一一对应,好理解也好调试;
- 抽象基类 + 多子类的继承体系,新增门类型只需要写一个子类;
- HashMap<String, ArrayList<String>> 的导线表设计简洁,查找 O(1)。
缺点:
- `ArrayList<Integer>` 按插入顺序存输入值,隐式依赖信号到达顺序——复杂电路里可能搞出很难复现的 bug;
- 遇到反馈环就无限循环,毫无还手之力;
- `createGate()` 里字符串解析和对象创建搅在一起,职责不清。
---
2.2 V5:扩展门电路与多引脚输出

2.2.1 整体思路
V5 新加了四种带控制信号的门,输入模型必须从"线性填槽"升级成"按索引寻址"。核心变了三处:
第一,`inputValue` 从 `ArrayList<Integer>` 换成 `Map<Integer, Integer>`。每个输入值现在带着明确的引脚编号,不再靠插入顺序。这意味着 `isFull()` 的判断逻辑也得重写——不能比一下 `size() == inputCount` 就完事,得逐个检查指定范围内的引脚是不是都到了:
|
public boolean isFull() {
if (inputValue.size() < inputCount) return false;
int startPin = ("AONXY".indexOf(model) != -1) ? 1 : 0; // 控制门从 0 开始
for (int i = startPin; i < startPin + inputCount; i++) {
if (!inputValue.containsKey(i)) return false;
}
return true;
}
|
第二,`calc()` 返回值从 `int` 变成了 `Map<Integer, Integer>`。因为 M、F 这些门有多个输出引脚,一个门算完可能同时往好几个目标发不同信号。V4 里"一个门 = 一个输出"的假设被打破了。
第三,加了 `isValid` 标志位和 `controlCount` 字段。S(选择器)和 M(译码器)需要控制信号满足特定值才能正常工作——S 的选择端为 0 时整个门无效,M 的使能端不满足条件时所有输出都无效。这些门控制条件不满足,就不参与最终输出。
2.2.2 新门电路细节
| 门类型 | 控制引脚数 | 功能 |
| S | 1(引脚0) | 选择器:控制=1时,把引脚1的值传给引脚2 |
| M | 3(引脚0-2) | 译码器: n位二进制输入->2^n位独热输出 |
| Z | n(引脚0-n-1) | 多路选择器:从2^n路输入中选一路输出 |
| F | n(引脚0-n-1) | 多路分配器:一路输入分配到2^n路输出之一 |
M 的输出格式最特殊——它不是列出每个引脚的值,而是输出一个"索引值"表示哪一路有效,其余都无效。这要求 `getOutString()` 在每个子类里有不同实现。
2.2.3 分析图表

2.2.4 优缺点
优点:
- 引脚索引化之后输入顺序不再重要,信号到达的先后不影响结果;
- 多输出支持让 M、F 这类复杂门有了精确的表达力;
- `LinkedHashSet` 替代了 V4 的 `ArrayList` 当终止门集合,去掉了重复输出。
缺点:
- `isFull()` 里硬编码 `"AONXY".indexOf(model)` 来判断起始引脚号——脆弱,每加一种门类型都可能要改这里;
- BFS 队列模型的环路问题还在;
- `extraVal` 字段只有 F 门在用,却定义在基类 `dianlu` 里,污染了公共接口。
---
2.3 V6:子电路、DFS 递归求值与信号冲突检测

2.3.1 整体思路——架构彻底重写
V6 是三次作业里最剧烈的一次重构。题目引入了子电路(`C1:...endc` 语法),要求支持层次化电路定义:一个子电路内部可以引用别的门和子电路,最终在 MAIN 作用域里被实例化。同时,系统要检测并报出五种错误:
1. 一条导线连了多个输入源
2. 一条导线没有输入源
3. 一条导线没有输出目标
4. 输入和输出顺序不对(输入不在第一位)
5. 同一个目标被多个信号源驱动(信号冲突)
到这一步,V4/V5 的队列模型已经完全不够用了。我做了一个关键决定:把算法从 BFS 队列改成 DFS 递归求值。
思路很简单:要算某个门的输出,先递归算出它所有输入引脚的值,再调 `gate.calc()`。配一个全局 `memo`(HashMap)缓存已经算过的信号值,再加一个 `visiting`(HashSet)检测正在递归栈里的节点(用来发现组合环),整个求值过程又高效又安全:
|
static int dfs(String node, Set<String> visiting) {
if (memo.containsKey(node)) return memo.get(node); // 算过了,直接拿
if (visiting.contains(node)) return -1; // 发现环路
visiting.add(node);
int result = -1;
if (inMap.containsKey(node)) {
result = dfs(inMap.get(node), visiting); // 递归追溯信号源
} else if (node.endsWith("-0")) {
String gateName = node.substring(0, node.length() - 2);
Gate gate = gateMap.get(gateName);
// 递归求所有输入引脚的值
for (int i = 1; i <= gate.inputCount; i++) {
int val = dfs(gateName + "-" + i, visiting);
if (val == -1) break; // 输入不可求,放弃
inputVals[i] = val;
}
result = gate.calc(inputVals);
}
if (result != -1) memo.put(node, result);
visiting.remove(node);
return result;
}
|
这个设计一口气解决了三个问题:
- 环路检测:`visiting` 集合天然能捕获反向边,不需要额外数据结构;
- 重复计算:`memo` 保证每个信号只算一次,哪怕被多个下游引用;
- 按需求值:只算最终输出端需要的那部分电路,没被引用的门不会触发计算。
2.3.2 Scope 作用域与信号方向分类
子电路一加进来,新的麻烦出现了:同一个引脚名(比如 "A1-0")在不同作用域里可能代表完全不同的东西。我设计了 `Scope` 类来管每个作用域的输入/输出引脚声明,用 `classify()` 判断一个引脚在当前作用域中的角色:
|
static int classify(String pin, String scopeName, Map<String, Scope> scopes) {
// SOURCE (1): 该引脚在作用域中声明为 INPUT,或是子电路的输出端口
// DEST (2): 该引脚在作用域中声明为 OUTPUT,或是子电路的输入端口
// UNKNOWN (0): 无法确定
}
|
`classify()` 最核心的点在于"视角翻转":在 MAIN 作用域里,子电路的输出在导线上表现为信号源(SOURCE),但在子电路内部,它其实是 OUTPUT。站在不同视角看同一个引脚得出不同结论——这个设计是实现层次化电路的关键。
2.3.3 信号冲突检测
冲突检测的思路是:遍历所有导线,提取每条导线的源端和目标端(加上全局作用域前缀避免不同 Scope 里命名冲突),然后检查有没有两个不同的源端驱动了同一个目标端:
|
for (RawLine rl : rawLines) {
String globalDest = rl.scopeName + "::" + dest;
if (conflictMap.containsKey(globalDest)) {
if (!conflictMap.get(globalDest).equals(theSource)) {
// 冲突!同一个目标被两个不同的源驱动
System.out.println("ERROR: " + dest + " input signal conflict");
}
} else {
conflictMap.put(globalDest, theSource);
}
}
|
2.3.4 分析图表

2.3.5 优缺点
优点:
- DFS + 记忆化搜索的架构很优雅——核心递归函数不到 50 行,替换了 V4/V5 上百行的队列循环,代码更短但能力更强;
- 环路检测从被动的"死循环 → 超时"变成了主动的"发现环路 → 返回 -1 → 安全跳过";
- 错误检测体系完整——五种错误各有明确的检查逻辑和报错信息,不再是黑盒崩溃;
缺点:
- DFS 递归深度受 JVM 调用栈限制,虽然实际测试用例碰不到,但对极深链路的电路有理论风险;
- 子电路的 INPUT/OUTPUT 声明靠用户严格按语法写,解析逻辑对格式错误(多余空格、大小写)的容忍度不高。
三、踩坑心得
3.1 ArrayList 隐式顺序 vs Map 显式索引
问题表现:
V4 的 `ArrayList` 迁移到 V5 的 `Map<Integer, Integer>` 时,我一开始只是把 `size()` 简单替换成了 `inputValue.size()`,结果门电路在输入还没全到齐的时候就提前计算了,输出错误值。
问题定位:
V4 里 `inputValue` 是 ArrayList——加三次就是三个元素,`size() == inputCount` 意味着全部到齐。换 Map 后,引脚的键可能不连续(比如只收到了引脚 0 和引脚 2,引脚 1 还没到),此时 `size()` 也是 2,但实际上缺了引脚 1 的输入。
解决方案:
逐个检查所需引脚范围:
|
for (int i = startPin; i < startPin + inputCount; i++) {
if (!inputValue.containsKey(i)) return false;
}
|
这个 bug 教会我一件事:从无序集合切到有序索引时,不能只换数据结构,还要重新审视所有依赖"顺序隐含语义"的判断逻辑。
3.2 基类字段污染
问题表现:
V5 里 `dianlu` 基类定义了一个 `extraVal` 字段,实际上只有 F(多路分配器)用它——存的是待分配到某一路输出的数据值。其他 8 个子类根本用不上。
问题定位:
写 F 类的时候图省事,直接把 `extraVal` 塞进了基类,心想"反正就一个 int"。但这破坏了基类的纯粹性。
解决方案:
正确做法是把 `extraVal` 下沉到 F 类内部当私有字段。V6 重构时,我彻底扔掉了这种"基类大杂烩"的做法,每个门的特殊数据都留在自己的类里。这验证了单一职责原则的一个推论:基类不应该为子类的特殊需求买单。
3.3 子电路作用域的"命名空间"陷阱
问题表现:
V6 加入子电路后,我一开始直接用引脚原始名称(如 "A1-0")当 `inMap` 的键。结果子电路 C1 内部有个 "A1-0",MAIN 作用域里也有个 "A1-0",后者的定义覆盖了前者,子电路内部连线全乱了。
问题定位:
不同作用域里的同名引脚是不同的物理实体,但在我的扁平化存储里被当成了同一个。这是典型的"命名空间缺失"问题。
解决方案:
引入全局前缀——每个作用域内的引脚都带上前缀(如 "C1::A1-0"),确保跨作用域的引脚在 HashMap 里有唯一的键。这个教训让我深刻体会到:层次化设计不只是语法糖,它要求底层数据结构也有"作用域"的概念。
四、改进建议
4.1 引入拓扑排序替代全量 DFS
V6 的 DFS 虽然解决了环路检测,但对每个输出门都独立发起一次递归,可能存在重复遍历。更高效的做法:先对整个电路图做一次拓扑排序,然后按拓扑序一次性计算所有门。每个门只算一次,且天然保证依赖项在它之前已经算完。DFS 适合"只算少数几个输出"的场景,拓扑排序适合"所有门都要输出"的场景——当前题目显然属于后者。
4.2 把 classify() 的硬编码规则换成策略模式
当前 `classify()` 内部是一串 if-else,规则全硬编码在方法体里:
|
if (scopeName.equals("MAIN") && scopes.containsKey(prefix)) { ... }
else { if (suffix.equals("0")) return SOURCE; else return DEST; }
|
随着电路语法扩展(比如引入总线、多级层次),这种硬编码会迅速膨胀。建议把每条分类规则抽象成独立的 `ClassificationRule` 接口,由 `Scope` 对象持有自己的规则链。降低耦合,也方便测试验证每条规则。
4.3 拆掉 Main 类里的"上帝逻辑"
三次迭代下来,Main 方法始终扛着输入解析、对象创建、电路仿真、结果输出四件事。V6 里 Main 的行数虽然比 V5 收敛了一些,但不同层次的逻辑还是搅在一起。建议拆成:
| 模块 | 职责 |
| CircuitParser | 解析输入文本,构建Scope和RawLine列表 |
| CircuitValidator | 执行信号冲突检测和导线合法性校验 |
| CircuitEvaluator | 执行DFS递归求值,管memo/visiting状态 |
| ResultFormatter | 排序并格式化输出 |
每个类只为一个原因变化:Parser 因为输入格式变而改,Evaluator 因为仿真算法优化而改,互不干扰。
4.4 isFull() 的起始引脚判断应该交给子类
V5 里 `isFull()` 需要知道引脚起始编号(基础门从 1 开始,控制门从 0 开始),但它用 `"AONXY".indexOf(model)` 硬编码判断——这让基类不得不了解所有子类的分类。更合理的做法是把判断下放:
|
abstract class dianlu {
protected abstract int getStartPin(); // 每个子类自己声明
public boolean isFull() {
for (int i = getStartPin(); i < getStartPin() + inputCount; i++) {
if (!inputValue.containsKey(i)) return false;
}
return true;
}
}
|
新增门类型时不用再改基类的判断逻辑,符合开闭原则。
五、总结
5.1 成长与体会
从 V4 到 V6,我经历了三次完全不同层级的设计挑战。
V4 是"从零建模仿真"——学会用抽象类和继承表达不同类型的门电路,用队列驱动信号传播。这个阶段的我对"仿真"的理解还停留在"让数据流起来"的层面,既没考虑环路的可能性,也没注意数据结构选择的长期影响。
V5 是"在旧架构上做加法"——引脚索引化、多输出支持、控制信号分离,每一项改动都在试探 V4 基类的弹性极限。ArrayList 到 Map 的迁移让我第一次亲身体会到"初始数据结构的选择,决定了后续扩展的成本"。如果一开始就用 Map,V4→V5 的迁移会轻松很多。
V6 是"推翻重来"——当子电路和作用域的需求摆在面前,我终于下定决心放弃了队列模型,全面转向 DFS 递归求值。最终 DFS 版本用更少的代码实现了更强的功能,印证了那句话:好的抽象胜过更多的代码。
三次作业下来,最大的收获不是 PTA 上那几百分,而是一种对架构演进的直觉:什么时候该在现有框架里修修补补,什么时候该承认当前设计的根本局限、然后推倒重来。这种判断力不来自书本,只能在一次次"写完 → 审视 → 重构 → 再审视"中慢慢攒出来。
5.2 亟待补足的短板
- 单元测试的意识完全缺失。三次作业我全是"写好代码 → 跑样例 → 对了就提交",从没写过一行 JUnit。这导致每次重构(尤其 V5→V6 那次大重构)都是一场豪赌——改完之后根本不知道哪些功能被破坏了,只能靠肉眼比对输出。后续必须养成"先写测试、再改代码"的习惯。
- 命名规范有待统一。`dianlu`(电路)、`startMap`(信号源映射)、`inputQuene`(Queue 还拼错了)、`endset`——中英文混着来,缩写随心所欲,拼错了也不改。对比 V6 换成英文后的 `Gate`、`Scope`、`visiting`、`memo`,可读性的差距一目了然。命名是代码可读性的第一道门槛。
- 递归深度的工程意识不够。V6 的 DFS 把递归深度完全交给 JVM 调用栈,虽然作业测试用例没问题,但我完全没想过栈溢出的边界。工程上,对于深度不可控的递归(比如用户输入的电路链路可能几千级),应该预留显式栈(`Stack` 迭代)的备选方案,而不是默认递归一定安全。
5.3 感悟
写代码容易,写好代码很难。而能坦然面对"自己写的代码不够好"并愿意推倒重来,是最难的。V5 写完的时候,我看着那 400 行已经跑通的代码,心里是有抵触的——"跑都跑了,为什么要重写?"但 V6 的需求逼着我直面了 V4/V5 架构里的根本缺陷。当我硬着头皮用 DFS 重写整个引擎,发现代码不仅更短、逻辑还更清晰时,那种"原来可以更好"的顿悟感,比拿满分更让我兴奋。
编程学习的路上,每一次"推倒重来"都不是浪费,而是在为下一次"一步到位"攒判断力。
浙公网安备 33010602011771号