☰
NFA转DFA中ε-closure的坑与实现:从课程设计到工程实践
2026/10/31 14:58:45 网站建设 项目流程

简介:这份课程设计报告面向计算机科学与技术等专业学生,聚焦编译原理中有限自动机空闭包ε-closure(I)的程序实现,帮助读者理解并动手完成从NFA输入到状态子集空闭包计算、再到状态转换图绘制的完整流程。报告以Java为实现语言,内容涵盖需求分析、概要设计、详细设计、测试与分析、用户使用说明与总结,并附程序源代码,涉及数组、图与哈希表等数据结构,以及读空、读字母表、状态子集扩展等核心函数的设计思路,还给出多组测试用例的运行结果分析。资源包共1个docx文件,压缩包约177KB,结构完整、便于直接参考与改写。目前已有1287人学习下载,适合正在做编译原理课程设计、需要参考实现方案与报告写法的读者使用。

1. 从一份课程设计报告说起:ε-closure 到底卡在哪

很多人第一次写 NFA 转 DFA 的代码,卡住的地方不是子集构造法本身,而是 ε-closure 这个看起来最简单的函数。道理都懂:从状态集合出发,沿空弧能走到的所有状态都算进去。真写起来,递归怎么收敛、结果怎么累加、读字母前后要不要各读一次空,全是坑。这份课程设计报告给了一个 Java 版本的完整实现思路,核心就三个函数:对新增初态 X 读空、对状态子集读字母表、对读字母结果再读空。它不追求工程化,但把算法骨架拆得很清楚,适合拿来对照自己的实现,或者作为编译原理课程设计的参考底稿。下面按「数据怎么存、函数怎么写、坑在哪、怎么验证」的顺序拆开讲。

2. 数据结构与输入约定:先把 NFA 存成程序能读的样子

2.1 为什么用字符串数组存产生式

报告里最反直觉的一个选择:状态转移函数没有用邻接表或二维矩阵,而是存成String[],每个元素形如(1,a)→3。这个做法在工程上肯定不优雅,但它有一个实际好处——课程设计阶段输入规模小,字符串数组可以直接从文件按行读入,不需要额外写解析器,调试时System.out.println一眼就能看到每条产生式长什么样。

代价是后续所有函数都得靠charAt取固定位置的字符:charAt(1)是源状态,charAt(3)是输入符号,charAt(6)是目标状态。这个约定必须全程序统一,一旦某条产生式格式多一个空格,整个解析就错位。我一般会在读入阶段加一道格式校验,把不满足(x,a)→y模式的行走直接抛异常,而不是等到读空函数里静默返回空串。

// 读入阶段:按行解析并校验产生式格式 public static String[] loadTransitions(String filePath) throws IOException { List<String> lines = Files.readAllLines(Paths.get(filePath)); List<String> valid = new ArrayList<>(); for (String line : lines) { line = line.trim(); if (line.isEmpty()) continue; // 约定格式:(源状态,符号)→目标状态,长度固定为 7 if (line.length() != 7 || line.charAt(0) != '(' || line.charAt(2) != ',' || line.charAt(4) != ')' || line.charAt(5) != '→') { throw new IllegalArgumentException("产生式格式错误: " + line); } valid.add(line); } return valid.toArray(new String[0]); }

这段代码的关键不是解析本身,而是把「格式约定」显式化。参数说明:charAt(0)和charAt(4)卡括号,charAt(2)卡逗号,charAt(5)卡箭头,长度必须为 7。这样后面所有charAt(1)/charAt(3)/charAt(6)的取值才有保障。常见做法是把源状态、符号、目标状态抽成一个小类Transition,但报告选择保持字符串形式,好处是和文件内容一一对应,坏处是每加一个功能都要重复写charAt,容易下标写错。

2.2 引入 X 和 Y 两个哨兵状态

报告在求子集之前做了一件事:给 NFA 加一个新的初态 X 和新的终态 Y,X 通过 ε 弧连到原初态,原终态通过 ε 弧连到 Y。这是子集构造法的标准预处理,目的是让「初态集合」和「终态集合」在算法里变成单点,后续判断接受状态时只需要看子集里有没有 Y。

报告里有一句很实在的坦白:X 到「任意一个」非空初态、以及「任意一个」终态到 Y 的通用连接没做出来,最后固定连到某一个节点,并把这条连接直接写进产生式数组。这个妥协在单初态单终态的 NFA 上完全没问题,但遇到多初态或多终态的自动机就会漏状态。如果你要复现并扩展,建议把这一步做通用:

// 通用预处理:X 连所有初态,所有终态连 Y public static List<String> addSentinels(List<String> transitions, Set<String> initials, Set<String> finals) { List<String> result = new ArrayList<>(transitions); for (String s : initials) { result.add("(X,ε)→" + s); // 新初态 X 空弧到每个原初态 } for (String s : finals) { result.add("(" + s + ",ε)→Y"); // 每个原终态空弧到新终态 Y } return result; }

逻辑说明:initials和finals从输入文件里单独读一行声明,而不是靠扫描产生式猜。参数上要注意,如果原 NFA 本身就有 ε 弧,这里追加的哨兵弧和原有空弧在后续读空函数里是同等对待的,不需要特殊分支。这一步做完,第一个状态子集就是ε-closure({X}),后面所有子集都从它出发扩展。

3. 三个核心函数:读空、读字母、再读空

3.1 对单状态读空:递归加静态变量累加

报告的第一个函数RestltList负责从一个状态出发,沿任意条 ε 弧收集所有可达状态。实现方式是递归:每找到一条源状态匹配且符号为 ε 的产生式,就把目标状态拼到结果字符串末尾,再用新结果递归调用自己,直到某一轮没有任何新产生式匹配为止。

// 从 string 的最后一个字符出发,递归收集所有 ε 可达状态 public static String RestltList(String string, String[] strings) { for (int i = 0; i < strings.length; i++) { // 取当前结果串的最后一个状态作为本次扩展的起点 if (String.valueOf(string.charAt(string.length() - 1)) .equals(String.valueOf(strings[i].charAt(1)))) { if ("ε".equals(String.valueOf(strings[i].charAt(3)))) { string += String.valueOf(strings[i].charAt(6)); // 追加目标状态 RestltList(string, strings); // 继续从新状态扩展 } } } return string; }

这段代码有两个必须说清楚的点。第一,它只从「结果串的最后一个字符」扩展,依赖每次追加后最后一个字符恰好是新状态,所以调用前必须保证传入的string末尾是当前要扩展的状态。第二,递归调用RestltList(string, strings)的返回值被丢弃了,真正生效的是string +=这行对传入引用的重新赋值——在 Java 里字符串不可变,这里其实是靠每次递归传入新的字符串对象,而外层拿到的仍是第一次调用的返回值。这种写法能跑通,但可读性很差,换个语言或者换个编译器优化就可能翻车。

更稳的写法是用一个Set<String>做访问标记,避免重复状态导致无限递归:

// 推荐写法:显式 visited 集合,防止 ε 环导致栈溢出 public static void epsilonClosure(String state, String[] transitions, Set<String> visited) { if (!visited.add(state)) return; // 已访问过,直接返回 for (String t : transitions) { if (String.valueOf(t.charAt(1)).equals(state) && "ε".equals(String.valueOf(t.charAt(3)))) { epsilonClosure(String.valueOf(t.charAt(6)), transitions, visited); } } }

参数说明:visited既是访问标记也是结果集合,调用方传入一个空HashSet,函数返回后它就是完整的 ε-closure。这个版本对 ε 环天然安全,因为visited.add返回 false 时直接剪枝。报告原版在存在 ε 环的 NFA 上会无限递归,这是复现时第一个要改的地方。

3.2 对状态子集读字母表:只读 a,不读空

第二个函数RestltListA的职责很单一:给定一个状态子集和一个字母a,找出子集里每个状态经过a弧能直接到达的状态,先不管这些目标状态还能不能走空弧。报告特意把它和读空拆开,是为了让主流程能按「读空 → 读 a → 再读空」的顺序组合,逻辑清晰。

// 状态子集读字母 s,返回直接目标状态集合(不含后续空弧) public static String RestltListA(String string, String s, String[] strings) { String resultA = ""; for (int i = 0; i < strings.length; i++) { if (string.equals(String.valueOf(strings[i].charAt(1)))) { if (s.equals(String.valueOf(strings[i].charAt(3)))) { resultA += String.valueOf(strings[i].charAt(6)); } } } return resultA; }

注意这里的string.equals(...)是整串比较,意味着传入的string必须是单个状态字符,而不是多个状态拼起来的子集。报告在主流程里是对子集逐个状态调用这个函数再合并结果,所以函数本身不需要处理多状态。参数s是当前扫描的字母表符号,strings是全部产生式。返回值是字符串拼接,重复目标状态不会去重,调用方需要自己转成Set去重,否则后续读空会重复扩展、结果里出现重复状态。

3.3 对读字母结果再读空:把 a 弧和 ε 弧串起来

第三个函数DuKong是整份报告里最容易写错的地方。它的输入是「读 a 之后得到的目标状态串」,输出是这些目标状态再走任意条 ε 弧能到达的全部状态。换句话说,它补上了「经过 a 又经过 ε」和「经过 ε 再经过 a」这两种混合路径里属于后半段的部分。

// 对读 a 的结果逐个状态再读空,合并进原结果 public static String DuKong(String result, String[] strings) { for (int i = 0; i < result.length(); i++) { for (int j = 0; j < strings.length; j++) { if (String.valueOf(result.charAt(i)) .equals(String.valueOf(strings[j].charAt(1)))) { if ("ε".equals(String.valueOf(strings[j].charAt(3)))) { result += String.valueOf(strings[j].charAt(6)); // 追加空弧目标 RestltList(result, strings); // 继续递归读空 } } } } return result; }

这段代码的问题和 3.1 一样:result在循环中被不断追加,for循环的边界result.length()每轮都在变,虽然 Java 的for条件每次重新求值,能覆盖新追加的字符,但配合内层RestltList的递归,实际执行路径很难追踪。更关键的是RestltList(result, strings)的返回值又被丢弃了,真正起作用的是result +=和递归内部的副作用。这种「靠字符串拼接 + 递归副作用」的写法,在状态数少的时候能出结果,状态一多就容易出现重复状态和顺序依赖。

我一般会把这三个函数统一成集合操作,主流程写成一行组合:

// 子集 T 读字母 a 的完整移动:ε-closure(move(T, a)) public static Set<String> moveWithClosure(Set<String> subset, String symbol, String[] transitions) { Set<String> moved = new HashSet<>(); for (String state : subset) { for (String t : transitions) { if (String.valueOf(t.charAt(1)).equals(state) && symbol.equals(String.valueOf(t.charAt(3)))) { moved.add(String.valueOf(t.charAt(6))); // 先收集直接目标 } } } Set<String> closure = new HashSet<>(); for (String s : moved) { epsilonClosure(s, transitions, closure); // 再对每个目标读空 } return closure; }

逻辑说明:moved对应报告里的RestltListA结果,closure对应DuKong的结果,epsilonClosure对应RestltList。参数上,subset是当前已求得的状态子集,symbol是字母表里的一个符号。这个组合版本把「读空 → 读 a → 再读空」压成一个函数,主循环只需要对每个未处理子集、每个字母调用一次,结果直接进Set去重,不会出现报告原版里重复状态越滚越多的情况。

4. 子集扩展主循环:从 X 的空闭包滚到不再出现新子集

4.1 主循环的终止条件与工作队列

报告的主函数逻辑是:先求ε-closure({X})作为第一个子集放进map,然后不断从map里取子集、对每个字母求Ia、把新子集加回map,直到某一轮没有新子集产生。这个「直到不再出现新状态子集」就是子集构造法的终止条件,因为 NFA 状态数有限,子集总数最多 2^n,循环一定会停。

用Map<Set<String>, Set<String>>同时存子集和它的空闭包,是报告里比较聪明的一个设计:key是状态子集,value是该子集的 ε-closure。这样最后输出「所有状态子集的空闭包」时直接遍历entrySet就行,不需要再算一遍。

// 子集构造主循环:从 {X} 出发,扩展所有可达子集 public static Map<Set<String>, Set<String>> buildSubsets(String[] transitions, Set<String> alphabet) { Map<Set<String>, Set<String>> subsets = new LinkedHashMap<>(); Set<String> start = new HashSet<>(); epsilonClosure("X", transitions, start); // 第一个子集:ε-closure({X}) subsets.put(start, new HashSet<>(start)); boolean changed = true; while (changed) { changed = false; List<Set<String>> current = new ArrayList<>(subsets.keySet()); for (Set<String> subset : current) { for (String symbol : alphabet) { Set<String> next = moveWithClosure(subset, symbol, transitions); if (next.isEmpty()) continue; // 空集不加入,避免无意义扩展 if (!subsets.containsKey(next)) { subsets.put(next, new HashSet<>(next)); changed = true; // 出现新子集,需要再扫一轮 } } } } return subsets; }

参数说明:alphabet是去掉 ε 之后的输入符号集合,从产生式里扫描charAt(3)收集。LinkedHashMap保证输出顺序和发现顺序一致,方便对照测试用例。next.isEmpty()跳过空集,是因为空集读任何字母还是空集,加进去只会让循环多跑一轮。这里用changed标志而不是队列,是因为子集数量小,全量重扫的代价可以接受;如果状态数上百,建议换成显式工作队列,只处理新加入的子集。

4.2 输出所有子集的空闭包与指定子集查询

报告要求输出两类结果:所有状态子集的空闭包,以及用户指定子集的空闭包。前者在主循环结束后遍历map即可,后者需要单独写一个查询入口,把用户输入的状态串解析成Set,再调用epsilonClosure逐个状态合并。

// 查询指定状态子集的空闭包 public static Set<String> queryClosure(String input, String[] transitions) { Set<String> result = new HashSet<>(); for (char c : input.toCharArray()) { if (c == ',' || c == ' ') continue; // 跳过分隔符 epsilonClosure(String.valueOf(c), transitions, result); } return result; }

逻辑说明:输入形如1,3或1 3,逐字符取状态,对每个状态调用epsilonClosure,result在多次调用间累积,天然完成并集。参数上要注意,如果状态编号超过一位(比如10),逐字符解析就会把10拆成1和0,这是报告原版按单字符状态设计的局限。扩展时应该按分隔符split而不是逐字符遍历。

5. 避坑与排查:五个复现时最容易翻车的地方

5.1 现象:程序在含 ε 环的 NFA 上栈溢出

原因:RestltList和DuKong都靠「结果串变长」隐式判断是否继续,遇到(1,ε)→2 (2,ε)→1这种环,状态会无限追加,递归永远不收敛。报告里的测试用例恰好都没有 ε 环,所以这个问题没暴露。

解决:所有读空函数改成带visited集合的版本,visited.add返回 false 时立即返回。这是复现这份报告时第一优先要改的地方,没有之一。

5.2 现象:输出里同一个状态出现多次,子集数量比预期多

原因:RestltListA和DuKong都用字符串拼接,不去重。两个不同路径到达同一状态时,结果串里会有重复字符,后续按字符遍历就会重复扩展,map的 key 也会因为顺序不同被当成不同子集。

解决:所有中间结果统一用Set<String>,map的 key 也用Set,HashSet的equals与顺序无关,天然去重。报告用字符串当 key,"12"和"21"会被当成两个子集,这是子集数量虚高的直接原因。

5.3 现象:指定子集查询结果比预期少状态

原因:queryClosure逐字符解析,遇到多位数状态编号会拆错;另外如果用户输入带了空格或中文逗号,charAt判断会漏掉分隔符后的状态。

解决:先按[,,\\s]+正则split成状态数组,再逐个调用读空。输入校验放在查询入口,格式不对直接提示,不要静默返回部分结果。

5.4 现象:状态转换图节点重叠、边穿过节点

原因:报告要求「结点大小位置布局合理、具有通用性」,但正文没有给出布局算法,只说了「根据状态转移函数逐步构造」。实际实现里如果按状态编号顺序等距排布,遇到自环和双向边就会重叠。

解决:常见做法是把状态按环形布局,节点均匀分布在圆周上,自环画在节点外侧,双向边用贝塞尔曲线错开控制点。布局参数(半径、节点直径、边偏移量)根据状态数动态计算,状态数超过 12 时半径按n * 节点直径 / π放大,避免边挤在一起。

5.5 现象:哨兵 X、Y 固定连一个节点,多初态 NFA 结果错误

原因:报告正文明确说了 X 和 Y 固定连到某一个节点,并把这条连接写死进产生式。单初态单终态时没问题,多初态时其他初态根本不在第一个子集里,后续所有子集都漏状态。

解决:按 2.2 的addSentinels通用版本,从输入文件单独读初态集和终态集,X 连所有初态、所有终态连 Y。这一步改完,测试用例要补一个双初态的 NFA 验证,否则改没改对看不出来。

6. 验证与扩展:用三个测试用例反推实现是否正确

报告给了三个测试用例,我一般会拿它们当回归基线,但会额外补两个边界用例。第一个是含 ε 环的 NFA,验证 5.1 的修复;第二个是多初态 NFA,验证 5.5 的修复。验证方法不是肉眼看输出,而是写一个断言函数,把预期子集集合和实际结果做集合比较。

// 回归断言:比较实际子集集合与预期子集集合 public static void assertSubsets(Map<Set<String>, Set<String>> actual, Set<Set<String>> expected) { Set<Set<String>> actualKeys = actual.keySet(); if (!actualKeys.equals(expected)) { System.out.println("缺失子集: " + diff(expected, actualKeys)); System.out.println("多余子集: " + diff(actualKeys, expected)); throw new AssertionError("子集构造结果不符"); } System.out.println("通过,共 " + actualKeys.size() + " 个子集"); }

参数说明:expected是手工推导的预期子集集合,每个元素是一个Set<String>。diff是自定义的差集函数,输出缺失和多余的具体子集,方便定位是哪个字母的移动算错了。这个断言比打印全部结果再人眼比对可靠得多,尤其是子集数量上到十几个以后。

扩展方向上,这份报告的实现离「能用的 NFA 工具」还差两步:一是把字符串产生式换成Transition对象列表,消除charAt下标依赖;二是把子集构造的输出直接喂给 DFA 最小化,形成 NFA → DFA → 最小 DFA 的完整链路。状态转换图那块,如果不想手写布局,常见做法是导出 DOT 格式交给 Graphviz 渲染,节点位置由布局引擎算,比自己在 Java 里算坐标省事得多。

血泪经验是:这类课程设计代码最怕「测试用例刚好绕开 bug」。报告的三个用例都没有 ε 环、没有多初态、状态编号都是单字符,所以字符串拼接和固定哨兵连接的问题全被掩盖了。从那以后我每次拿到这种小规模算法实现,都强制先补一个环用例和一个多入口用例再跑,不然改到最后都不知道自己改的是不是真问题。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询