ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

课程设计实战:ε-closure的Java实现与NFA确定化避坑

课程设计实战:ε-closure的Java实现与NFA确定化避坑

简介:这份课程设计报告面向计算机科学与技术等专业学习编译原理的学生,围绕有限自动机NFA的空闭包ε-closure(I)计算,给出可运行的Java实现思路与完整课程设计文档。内容从需求分析、概要设计延伸到详细设计与测试,涵盖NFA输入与文件保存、全部状态子集空闭包输出、指定状态子集空闭包求解,以及状态转换图的布局与绘制;并拆解读空函数、读状态子集、读字母表、状态子集扩展等模块,说明新增初态X与终态Y的连接处理,配有测试用例、用户使用说明、总结和程序源代码附录,便于对照任务复现实验、理解递归求空闭包与读字母表后再读空的组合逻辑。压缩包共1个docx文件,约177KB,以报告文档形式呈现,章节完整、便于检索。已有1287人学习,适合需要完成编译原理课程设计、参考Java实现与报告写法的读者。

1. ε-closure 到底难在哪:一份课程设计里真正卡人的三处

课本上讲 ε-closure(I) 就两行字:I 里每个状态自身,加上从它们出发经过任意条 ε 弧能到达的所有状态。我当时看完觉得这有什么难的,结果动手写 Java 实现,第一个函数就卡了两个晚上。后来把这份《编译原理 ε-closure(I)的程序实现(Java 实现)课程设计报告》翻来覆去啃了几遍,又把里面的源码拆开重跑,才明白难点根本不在定义上。真正的坑集中在三处:递归读空时返回值怎么累加、为什么"读字母"和"读空"必须拆成两个函数而不能混在一起、以及状态子集去重键怎么设计才不至于让程序转不完。这篇把踩过的坑、拆出来能直接复用的三段代码,还有三个测试用例的完整执行轨迹都摊开讲,给正在做同类课程设计、或者刚学 NFA 确定化的同学一份能照着跑的版本。

2. 需求拆解:把"输入任意 NFA"翻译成四个可编码的目标

这份课程设计报告的需求描述只有四条,看起来很清楚,但真落到代码上,每一条都会牵出一堆没说出口的隐含约束。比如"输入任意给定的有限自动机"这七个字,就至少要求程序支持:状态数量不固定、字母表不固定、ε 弧的条数不固定、空弧可以和普通弧交叉出现。再比如"输出所有的状态子集的空闭包",前提是你得先把所有状态子集求出来,而求子集本身又依赖空闭包,这是个互相咬合的循环,第一次写的人很容易在这里绕晕。

我一般的做法是先把需求拆成"数据怎么进、数据长什么样、数据怎么算、结果怎么呈现"四个格子,每个格子里只放一件事,然后再去看它们之间的依赖顺序。这个习惯后来做别的项目也一样用得上,因为凡是需求里出现"任意"两个字的地方,后面基本都藏着一个泛化设计。

2.1 产生式字符串:一个能用但很脆弱的存储方案

这份报告里选的是用 String 数组存状态转移函数,每个元素写成(1,a)→3这种形式,整体长这样:

// 每一条产生式就是一个字符串,下标位置被当作字段用 String[] rules = { "(X,ε)→5", "(5,a)→5", "(5,b)→5", "(5,ε)→1", "(1,a)→3", "(1,b)→4", "(3,a)→2", "(4,b)→2", "(2,ε)→6", "(6,a)→6", "(6,b)→6", "(6,ε)→Y" };

配套的取字段方式是用charAt卡位置,读代码的人第一眼会觉得莫名其妙,所以我把下标含义列一下:

下标位置含义例子(5,ε)→1里的取值
charAt(0)左括号,纯占位(
charAt(1)弧的起点状态5
charAt(2)逗号,纯占位,
charAt(3)弧上的标记,或 εε
charAt(4)右括号,纯占位)
charAt(5)箭头符号→
charAt(6)弧的终点状态1

这个方案的好处是零依赖、写起来快、调试时打印出来人眼能看懂,对课程设计这种规模完全够用。代价也很明显:它默认状态名只能是单个字符,一旦出现q10这样的两字符状态名,charAt(6)就取不到终点了。报告里所有例子用的都是0 1 2 3 X Y这种单字符状态,所以这个问题一直没暴露出来。新手照着抄很容易在这里翻车,后面避坑章我会专门讲怎么改成健壮版本。

数据结构部分,报告里写的是"数组、图、哈希表"三种混用,对应的实际用途是:String 数组存产生式,HashSet 存状态子集,HashMap 存"子集 → 子集"的映射和"子集 → 空闭包"的映射。这三样东西的组合其实已经够用了,不需要再引入邻接矩阵或者链表,因为 NFA 的规模在课程设计里基本不会超过二三十个状态,性能完全不是瓶颈,可读性才是。

2.2 四条基本要求分别落到哪个函数头上

把需求摊平之后,四条要求对应的函数清单是这样的:

需求原文承担的函数输入输出
输入 NFA 并保存到文件解析模块文本文件初态集、终态集、字母表、产生式数组
输出所有状态子集的空闭包主循环 + 求闭包函数产生式数组子集 → 空闭包的映射表
输出给定状态子集的空闭包求闭包函数(单点版)一个状态集合串该集合的 ε 闭包
以状态转换图方式输出布局 + 绘图模块状态集 + 产生式带坐标的节点和连线

报告里"概要设计"部分列了六个模块:前端数据传入、后台数据处理与分析、对新引入初态节点 X 的读空函数、对状态子集读空的函数、对状态子集读字母表的函数、根据已有子集求剩余子集直到不出现新子集的函数。这个分法是对的,但名字起得有点绕,我按功能重新归一下类会更好记:

  • 录入层:文件解析,把文本变成产生式数组;
  • 基础动作层:epsilonFrom(沿 ε 弧展开)、moveOn(只沿某个字母的弧走);
  • 组合层:moveThenClosure(先走字母再求闭包)、closureOfSet(对整集求闭包);
  • 驱动层:主循环,负责不断产生新子集直到收敛;
  • 呈现层:把结果打印成表格,或者算坐标画图。

调用关系是一条直线:驱动层调组合层,组合层调基础动作层,呈现层只读结果不参与计算。这条链子一旦理清楚,后面写代码基本就是填空。

2.3 状态转换图:分层加圆周,够用且不会挤成一坨

第四条要求是"以状态转换图方式输出用户输入的有限自动机,要求状态结点大小位置布局合理,具有通用性"。这条最容易被糊弄过去,很多课程设计最后就是拿 Java Swing 随手在固定坐标上画几个圆圈交差,换个 NFA 立刻重叠。

我一般的做法是两级布局:先按"从新初态 X 出发的最短 ε 路径长度"给所有状态分层,同一层里的节点按顺序均匀铺在一个圆上。X 固定放在最左边或者最上边,Y 固定在对面。具体步骤:

  1. 从 X 开始做一次 BFS,只走 ε 弧,得到每个状态到 X 的最短空路径长度 d;
  2. 没被 BFS 覆盖到的状态(ε 不可达的)单独放到最后一层,避免漏画;
  3. 第 k 层的节点数量记作 n_k,就放在一条水平线上,横向间距取max(80, 600 / n_k)像素;
  4. 同层节点如果超过 6 个,改成沿半径120 + 20 * n_k的圆弧分布,避免横向撑爆画布;
  5. 连线用直线,自环画在节点正上方的小圆弧上,双向弧错开 4 像素防止完全重合。

这套规则不需要任何图布局库,纯手算坐标就行,而且对任意 NFA 都能保证节点不重叠、层次方向一致。报告里说"结点大小位置布局合理,具有通用性",其实就是这个意思——通用性不是靠调参调出来的,是靠一条确定的规则算出来的。

3. 三个读函数怎么配合:读空、只读字母、再读空

这是整份课程设计报告里最有价值的部分,也是报告作者在总结里明确说"当时在这个问题上纠结了好一阵"的地方。把这三个函数的关系想通了,NFA 确定化的代码基本就写完了。

核心矛盾在于:一个状态子集读字母 a 的时候,实际发生的事情不是简单的一条弧。它可能是「先走若干条 ε 到某个状态 → 再走 a → 再走若干条 ε 到别的状态」,也可能中间根本没有 a 弧,只是被 ε 带到了一个有 a 弧的状态上。如果你试图用一个函数把这两件事一起处理掉,代码里立刻会出现三层嵌套的条件判断,写到一半必然失控。

正确的拆法是让每个函数只做一件最笨的事。

3.1 给新初态 X 读空:递归能干,但游标更稳

引入新初态 X 是为了让子集构造有一个统一的起点,X 通过一条 ε 弧指向原 NFA 的初态。求第一个状态子集其实就是求 X 的空闭包。报告里给的实现是这样的:

// 从一个状态出发沿 ε 弧递归展开,返回累积后的状态串 // string:已累积的状态串,末位字符是本次要展开的状态 // strings:全部产生式 public static String RestltList(String string, String[] strings) { for (int i = 0; i < strings.length; i++) { // 产生式第 1 位是弧起点,和当前串末位状态比较 if (String.valueOf(string.charAt(string.length() - 1)) .equals(String.valueOf(strings[i].charAt(1)))) { // 第 3 位是弧标记,只处理 ε 弧 if ("ε".equals(String.valueOf(strings[i].charAt(3)))) { string += String.valueOf(strings[i].charAt(6)); // 第 6 位是终点 RestltList(string, strings); // 继续往下展开 } } } return string; }

这段能跑,因为报告里用了一个"静态变量字符串来接收、每次递归结果都往上累加"的办法。但严格讲它是有隐患的:递归调用RestltList(string, strings)的返回值没有被接住,靠的是每层递归自己把新字符拼进自己的局部变量,再把更深层的结果吞掉。在链式结构(A→B→C)上这会丢失信息,只是因为报告里的测试用例 ε 链都不长,所以没暴露。

我一般会把它改成游标推进的写法,逻辑等价但不需要递归,也不怕 ε 环:

// 沿 ε 弧展开,返回状态下标集合 // state:起点状态名;rules:产生式数组 public static String epsilonFrom(String state, String[] rules) { StringBuilder sb = new StringBuilder(state); int cursor = 0; // 游标:指向下一个待展开的位置 while (cursor < sb.length()) { char from = sb.charAt(cursor); // 取一个还没展开过的状态 cursor++; for (String rule : rules) { char src = rule.charAt(1); // 弧起点 char lab = rule.charAt(3); // 弧标记 char dst = rule.charAt(6); // 弧终点 if (src != from) continue; // 起点不匹配,跳过 if (lab != 'ε') continue; // 不是 ε 弧,跳过 // 已经收进结果的状态不再重复追加,防止 ε 环把程序拖死 if (sb.indexOf(String.valueOf(dst)) < 0) { sb.append(dst); } } } return sb.toString(); }

两个改动值得说清楚。一是把递归换成游标,sb一边增长一边被游标扫描,天然就是广度优先的展开顺序,不需要额外维护队列。二是加了indexOf去重,因为一旦 NFA 里存在1 -ε→ 2、2 -ε→ 1这样的 ε 环,递归版会一直往下钻,而游标版最多扫到串里没有新状态为止,撑死跑状态数 × 产生式数次。

参数说明:state传单个状态名(比如"X"),rules就是完整的产生式数组。返回值是一串拼接起来的状态字符,比如"X51"表示{X, 5, 1}。用字符串而不是 Set 存,是为了让后面所有函数的签名保持一致,方便互相传参——课程设计这个量级,字符串足够快,而且打印出来就是人眼可读的结果,调试成本极低。

3.2 只读字母的函数:它必须彻底不管 ε

第二个函数是报告里说的"对状态子集读字母表"。它的职责边界必须守死:只走标记为某个具体字母的弧,一条 ε 都不许碰。

// 从状态集出发只走标记为 letter 的弧,不处理任何 ε // states:状态集合串,如 "X51";letter:单个字母,如 "a" public static String moveOn(String states, String letter, String[] rules) { StringBuilder sb = new StringBuilder(); for (String rule : rules) { // 标记不匹配,这条弧不参与本次移动 if (rule.charAt(3) != letter.charAt(0)) continue; // 起点不在当前集合里,这条弧也走不到 if (states.indexOf(rule.charAt(1)) < 0) continue; char dst = rule.charAt(6); if (sb.indexOf(String.valueOf(dst)) < 0) { sb.append(dst); // 目标状态去重后追加 } } return sb.toString(); }

为什么非要拆出来?因为move(T, a)和ε-closure(T)在语义上是两个不同层级的操作:前者是"经过一条标记为 a 的边",后者是"经过零条或多条 ε 边"。如果你把两者写进同一个循环,遇到1 -ε→ 2 -a→ 3这种情况,代码就没法回答一个问题——这个 3 到底是被 a 带出来的,还是被 ε 带出来的?答案会影响下一步:如果已经处理完了 a,那 3 还要继续做闭包;如果只是 ε 展开的一半,那后面还得补一次完整的 move。混在一起写,边界就丢了。

拆开之后,每个函数只有两三个判断条件,出 bug 的时候一眼就能看出是"弧标记读错了"还是"起点匹配错了",不用在十几行的嵌套里找。

3.3 求 Ia 的三段式:ε 到 a 再到 ε

有了前两个函数,Ia = ε-closure(move(T, a))就只是一次组合调用:

// 求 Ia:先对集合做一次 ε 展开确保闭包性,再走 a,再对结果求闭包 public static String moveThenClosure(String subset, String letter, String[] rules) { // 第一段:把集合里的每个状态各自 ε 展开,合并去重 StringBuilder closed = new StringBuilder(); for (char c : subset.toCharArray()) { String one = epsilonFrom(String.valueOf(c), rules); for (char d : one.toCharArray()) { if (closed.indexOf(String.valueOf(d)) < 0) closed.append(d); } } // 第二段:只走 letter 弧 String moved = moveOn(closed.toString(), letter, rules); if (moved.isEmpty()) return ""; // 一步都走不动,Ia 就是空集 // 第三段:再对 move 的结果做一次 ε 展开 StringBuilder result = new StringBuilder(); for (char c : moved.toCharArray()) { String one = epsilonFrom(String.valueOf(c), rules); for (char d : one.toCharArray()) { if (result.indexOf(String.valueOf(d)) < 0) result.append(d); } } return result.toString(); }

三段的顺序是有讲究的。第一段看似冗余(因为subset本来就应该已经是闭包的),但在主循环里,如果你传进来的是手工指定的{3}这种非闭包集合,少了这一段就会漏掉3 -ε→ …带出来的状态。第二段只走 a,第三段补闭包,这是标准定义的直接翻译。报告总结里说"在读 a 的同时还需要分别在读 a 的前后分别调用读空的函数",说的就是这件事,只是当时没写成三段,而是靠一个函数来回调,逻辑上绕了一圈。

参数说明:subset是状态集合串,letter是单个字母字符,rules是产生式数组。返回空串表示这个子集在该字母下无路可走,这个空串后面要当成一个合法的"死状态"处理,不能当异常丢掉——这是报告里用例二专门要展示的情况。

3.4 子集构造主循环:去重键不稳定,程序就转不完

最后是把上面这些组装起来。主循环要做的事很简单:从一个子集出发,对字母表里每个字母算出 Ia,如果 Ia 是新子集就入队,直到队列空。

// 主循环:从 ε-closure({X}) 出发,不断产生新子集直到收敛 Map<String, String> subsets = new LinkedHashMap<>(); // 去重键 → 原始状态串 Deque<String> queue = new ArrayDeque<>(); String start = epsilonFrom("X", rules); subsets.put(sortKey(start), start); queue.add(start); while (!queue.isEmpty()) { String cur = queue.poll(); for (char letter : alphabet) { // 遍历字母表 Σ String next = moveThenClosure(cur, String.valueOf(letter), rules); String key = sortKey(next); // 排序后做去重键 if (!subsets.containsKey(key)) { // 只处理没见过的子集 subsets.put(key, next); queue.add(next); } } } // 把状态串排序,保证同一个集合永远生成同一个键 static String sortKey(String s) { char[] arr = s.toCharArray(); Arrays.sort(arr); return new String(arr); }

这里的sortKey是关键。如果你直接用HashSet.toString()当去重键,同一个集合{1,3,5}在不同轮次可能被打印成[1, 3, 5]、[3, 1, 5]或者别的顺序,containsKey就会认为它们是不同的子集,于是无限入队、程序永远停不下来。我第一次跑的时候就是这样,控制台刷屏十几秒还在跑,最后靠手动 kill 才停下来。排序之后,键就稳定了,时间复杂度也可以接受——课程设计量级下,排序开销完全可以忽略。

4. 代码复现:三个测试用例从头跑到尾

光看代码还是虚的,得跑数据。报告里给了三个用例,覆盖了 ε 链、自环、空集子集、有终态但分支少这几种情况,我按上面的写法重新推了一遍,把每一步的结果都记下来,方便你对照自己的程序输出。

4.1 用例一:七个子集,ε 链嵌在自环里

状态集{X,1,2,3,4,5,6,Y},字母表{a,b},产生式是:

(X,ε)→5 (5,a)→5 (5,b)→5 (5,ε)→1 (1,a)→3 (1,b)→4 (3,a)→2 (4,b)→2 (2,ε)→6 (6,a)→6 (6,b)→6 (6,ε)→Y

第一步先求ε-closure(X):X 走 ε 到 5,5 走 ε 到 1,1 没有 ε 出边,所以S0 = {X,1,5}。

接下来对 S0 分别读 a 和 b,按ε → 字母 → ε三段走:

起始子集读 a 后读 b 后
S0 = {X,1,5}{1,3,5} → S1{1,4,5} → S2
S1 = {1,3,5}{1,2,3,5,6,Y} → S3{1,4,5} → S2
S2 = {1,4,5}{1,3,5} → S1{1,2,4,5,6,Y} → S4
S3 = {1,2,3,5,6,Y}{1,2,3,5,6,Y} → S3{1,4,5,6,Y} → S5
S4 = {1,2,4,5,6,Y}{1,3,5,6,Y} → S6{1,2,4,5,6,Y} → S4
S5 = {1,4,5,6,Y}{1,3,5,6,Y} → S6{1,2,4,5,6,Y} → S4
S6 = {1,3,5,6,Y}{1,2,3,5,6,Y} → S3{1,4,5,6,Y} → S5

七个子集全部闭环,没有一个引向新子集,收敛。注意 S3 读 a 之后还是 S3,这不是写错了——{1,2,3,5,6,Y}里 1 走 a 到 3、3 走 a 到 2、5 走 a 回到自己、6 走 a 回到自己,而 2 和 Y 本身又各自被 ε 链带着回到了这个集合里,所以结果就是它自己。这种"自己指向自己"的情况在实际 NFA 里很常见,程序里必须能正确识别成同一个子集,否则就会无限产生新子集。

4.2 用例二:空集子集是怎么冒出来的

第二个用例的状态集是{0,1,X,Y},产生式只有五条:

(X,ε)→0 (0,ε)→Y (0,a)→1 (0,b)→1 (1,a)→0

ε-closure(X) = {X,0,Y},这是 S0。S0 读 a 得到{1},读 b 也得到{1},所以 S1 是{1}。S1 读 a 走1 -a→ 0,再做闭包得到{0,Y},也就是 S2。关键在于 S1 读 b:状态 1 只有一条 a 弧,没有任何 b 弧,所以 move 的结果是空的,ε-closure(∅)按定义还是空集。

子集读 a读 b
{X,0,Y}{1}{1}
{1}{0,Y}∅
{0,Y}{1}{1}
∅∅∅

报告里那句"空代表状态子集为空的情况",指的正是这里。空集子集在理论上对应 DFA 里的死状态,是合法且有意义的,不能因为它是空串就跳过不处理。很多实现就是在这里直接if (next.isEmpty()) continue;,结果 DFA 少一个状态,后面做最小化或者模拟匹配的时候行为就不对了。

4.3 用例三:有终态但只有四个子集

第三个用例状态集{X,1,2,3,Y}:

(X,ε)→1 (1,a)→1 (1,b)→1 (1,ε)→2 (2,b)→3 (3,a)→Y

ε-closure(X) = {X,1,2}是 S0。往下推:

子集读 a读 b
{X,1,2}{1,2} → S1{1,3} → S2
{1,2}{1,2} → S1{1,3} → S2
{1,3}{1,Y} → S3{1,2} → S1
{1,Y}{1,2} → S1{1,2} → S1

四个子集就收敛了,比用例一少一半。这个用例的价值在于它有 Y(新终态),可以验证"终态是否被正确带进子集"——S3 ={1,Y}里包含 Y,说明含终态的子集能被识别出来,后面判断接受性就靠这个。

4.4 三个能固化成断言的校验点

跑完之后我一般会留三个检查,下次改代码直接对:

  1. 子集总数对得上:用例一 7 个、用例二 4 个、用例三 4 个。数量不对,八成是去重键不稳定或者空集被跳过了。
  2. 每个子集读完字母表都有落点:哪怕落点是空集,也要有记录。如果某个子集读某字母后直接抛出异常或者返回 null,说明moveThenClosure里对空串没做处理。
  3. ε-closure 的幂等性:对任意子集求两次闭包,结果必须和求一次完全一样。这条能抓出"忘了把自己加进去"和"ε 环没去重"两类问题。

5. 避坑与常见问题:五条让我返工的记录

这一节写的是真出过问题的五件事,每条都按"看到什么现象、根子上是什么原因、怎么改"来写。你在自己实现的时候大概率会撞上其中两三条。

5.1 递归读空,链式结构上少状态

现象:测试用例一里ε-closure(X)出来是{X,5},少了 1,但单独对 5 求闭包又能正确得到{5,1}。

原因:递归版RestltList里,string += ...改的是当前栈帧的局部变量,然后调用RestltList(string, strings)却没接返回值。下一层递归里继续拼的字符只存在于下一层的局部变量中,往上一层返回时被丢弃了。链只有一层的时候碰巧能对,链长到两层就断。

解决:要么把递归改成return RestltList(state, rules);让结果一层层回传,要么干脆换成 3.1 节的游标版。游标版更好,因为它顺手解决了 ε 环的问题。

5.2 状态名一超过一个字符,charAt 全部错位

现象:把测试数据里的1改成q1,程序不报错,但求出来的子集莫名其妙,读 a 之后目标状态变成了一堆括号和逗号。

原因:产生式用(1,a)→3这种固定格式,字段全靠charAt(1)、charAt(3)、charAt(6)定位。状态名变成两个字符,整条串的每个位置都往后挪了一格,charAt(6)取到的就不再是终点。

解决:解析和取值分开。读取的时候用split或者正则一次性拆成源状态 / 标记 / 目标状态三个字段,存成对象,后面全部按字段访问,不再碰下标。课程设计报告里用 charAt 是因为例子都是单字符,能跑通就行;但如果你想把这份代码留下来复用,这一步值得花半小时改掉。

5.3 空闭包把自己漏了

现象:ε-closure(3)返回空串,但 3 明明有一条(3,a)→2出边,按定义 3 自己应该在闭包里。

原因:写循环的时候只遍历产生式找 ε 弧,忘了闭包定义的第一条是"状态自身"。当某个状态没有任何 ε 出边时,循环一次都不进,结果就是空。

解决:函数入口先把起点本身塞进结果容器,再开始扫产生式。用字符串实现的时候就是StringBuilder sb = new StringBuilder(state);,用 Set 实现就是set.add(state);。这一行看着不起眼,但漏掉之后所有只经过一条 a 弧的状态都会丢。

5.4 子集去重键不稳定,主循环停不下来

现象:程序跑到第三个用例还在刷屏,控制台一直在输出看起来差不多的子集,等半分钟也没结束。

原因:用HashSet.toString()或者直接拿未排序的状态串当 Map 的 key。{1,3,5}和{3,1,5}在逻辑上是同一个集合,但字符串不相等,containsKey判成新子集,于是又入队,又展开,又产生更多顺序不同的等价串。

解决:入 Map 之前先把字符数组排序再拼回字符串,保证同一个集合只有一个键。这一步开销很小,但直接决定了程序能不能收敛。另一个办法是用TreeSet存状态,它的toString天然有序,效果一样。

5.5 新初态 X 只连一个原初态,多初态 NFA 直接算错

现象:用例一、二、三都能跑对,但换成一个有两个初态的 NFA,结果里有一半子集消失了。

原因:报告里作者自己也说了,"X 和 Y 都固定连接到那一个节点,并将其作为状态转移函数写在自动机里"。这是为了省事做的简化——只给 X 连一条 ε 弧到某一个原初态。原 NFA 只有单个初态时没问题,一旦有多个初态,X 就只覆盖了其中一个,另一个初态及其后续状态永远进不了子集。

解决:X 到原初态集的每一条 ε 弧都要显式写进产生式数组。比如原初态是{1,4},那就要同时有(X,ε)→1和(X,ε)→4。这一条改起来只要两行,但如果不改,程序的"支持任意 NFA"就是假的。

6. 把大函数拆碎的收益:一个可以复用到别处的套路

回过头看,这份课程设计报告里最有价值的不是那些具体代码,而是作者在总结里写下的那句"当一个功能比较难以实现的时候可以把它再分成几个模块来写"。他当时卡在"一个状态子集既要想 a 弧又要考虑前后 ε"上纠结了很久,最后是在资料里看到"可以先写两个函数,分别读字母表和读空,求子集的时候按读空、读 a、再读空来写",才一下通了。

这个套路的通用性比 NFA 本身大得多。任何"看起来要在一个循环里同时处理两件事"的场景,先问一句:这两件事能不能拆成两个各自只做一件笨事的函数?拆开之后,每个函数的输入输出都能单独测,出问题的时候定位范围就从"整个算法"缩小到"一个 if 判断"。我后来做词法分析、做表达式求值、甚至做配置文件合并,用的都是同一个思路:先把原子动作写死,再用组合函数把它们串成流程,最后才写驱动循环。

如果这份报告你只打算带走一样东西,我建议带走三行骨架:

// 原子动作一:沿 ε 展开,不做别的 String epsilonFrom(String state, String[] rules); // 原子动作二:只沿某个字母走一步,绝不碰 ε String moveOn(String states, String letter, String[] rules); // 组合:ε → 字母 → ε,Ia 就这么来 String moveThenClosure(String subset, String letter, String[] rules);

我自己有个习惯,凡是写这种"看起来要递归、要嵌套、要维护一堆状态"的算法,先在纸上把原子动作列成两三行函数签名,签名写不出来就说明还没想清楚,绝不开编辑器。这个习惯是被坑出来的:早年间写一个类似的闭包算法,直接上手堆了一个两百行的嵌套循环,跑出来的结果时对时错,改了两天最后推翻重写,重写只用了四十分钟。从那以后我每次动这种算法,都强制先写签名、先拿一个小用例手推一遍、确认每一步的中间结果能对上,才动手敲代码。

这份课程设计报告里那三个测试用例,其实就是干这个用的——用例一验 ε 链和自环,用例二验空集,用例三验终态传递,三个都过,算法基本就站住了。报告本身还带了完整的源码附录和需求、概要、详细设计的分层描述,对着里面的产生式格式和函数划分看一遍,再配合前面几张子集推导表,自己重写一遍大概一个晚上能通。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进