电路作业的三次迭代——继承、索引与作用域

一、前言

写完航空器配载管理系统的时候,我以为自己对面向对象已经"够用了"。直到题目从"货物装载"换成"数字逻辑电路仿真",我才发现之前不过是在舒适区里扑腾。货物是死的,电路是活的;货物之间最多比个重量,电路元件之间却有拓扑依赖、有信号传播路径。问题域一换,之前那套建模思路基本归零,只能从头来。

三次作业走的是同一条路:先搭骨架,再往上堆东西,最后推倒重来。

作业集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:基础门电路与队列传播

image

 


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 分析图表
 

image

 


2.1.4 优缺点

优点:
- BFS 模型直观,跟物理信号流一一对应,好理解也好调试;
- 抽象基类 + 多子类的继承体系,新增门类型只需要写一个子类;
- HashMap<String, ArrayList<String>> 的导线表设计简洁,查找 O(1)。

缺点:
- `ArrayList<Integer>` 按插入顺序存输入值,隐式依赖信号到达顺序——复杂电路里可能搞出很难复现的 bug;
- 遇到反馈环就无限循环,毫无还手之力;
- `createGate()` 里字符串解析和对象创建搅在一起,职责不清。

---
2.2 V5:扩展门电路与多引脚输出

image

 


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 分析图表

image

 

2.2.4 优缺点

优点:
- 引脚索引化之后输入顺序不再重要,信号到达的先后不影响结果;
- 多输出支持让 M、F 这类复杂门有了精确的表达力;
- `LinkedHashSet` 替代了 V4 的 `ArrayList` 当终止门集合,去掉了重复输出。

缺点:
- `isFull()` 里硬编码 `"AONXY".indexOf(model)` 来判断起始引脚号——脆弱,每加一种门类型都可能要改这里;
- BFS 队列模型的环路问题还在;
- `extraVal` 字段只有 F 门在用,却定义在基类 `dianlu` 里,污染了公共接口。

---
2.3 V6:子电路、DFS 递归求值与信号冲突检测

image

 


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 分析图表
 

image

 

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 重写整个引擎,发现代码不仅更短、逻辑还更清晰时,那种"原来可以更好"的顿悟感,比拿满分更让我兴奋。

编程学习的路上,每一次"推倒重来"都不是浪费,而是在为下一次"一步到位"攒判断力。
posted @ 2026-06-24 22:18  ry7oll  阅读(1)  评论(0)    收藏  举报