☰
编译原理:正则式转NFA与DFA最小化Python实现全解析
2026/10/2 8:43:22 网站建设 项目流程

简介:面向编译原理与形式语言课程的Python实现资料,完整覆盖正则表达式转NFA、NFA确定化为DFA、DFA最小化三个核心环节,涉及子集构造与状态等价类划分等经典算法,适合正在完成课程设计、备战考试或复习自动机理论的学生。压缩包共9个文件,包含3个Python源码、2个Markdown说明文档、3张流程示意图及1份License,整体约243KB,代码与文档分层存放,配有目录说明,便于快速定位源码、图片与报告。目前已有355人学习。资料不仅提供NFA.py、DFA.py、MINI.py等可直接运行的脚本,还通过图文README和作业报告详细说明了类设计、变量选择、幂集构造与Hopcroft最小化的实现细节,以及测试输出和中间结果展示,能够帮助读者快速理解NFA与DFA的转换逻辑和状态化简原理。对于需要提交编译原理课程报告的学生,这份资源既能作为算法参考,也能作为项目结构与文档撰写的范例,实用价值较高。

1. 这份正则式转 NFA 与 DFA 最小化的 Python 实现,解决的不是算法题而是作业闭环

如果你正在刷编译原理的课程设计,大概率会撞上这个经典组合:正则式转 NFA、NFA 确定化、DFA 最小化。理论书上的定义,到了动手写代码时,第一个卡点往往不是算法本身,而是不知道用什么数据结构去表达“不确定的状态转移”和“epsilon 闭包”。我拆这份资源时最直观的感受是:代码不是为了炫技写的,而是对着作业需求一步步铺开的,NFA.py、DFA.py、MINI.py 三个文件正好对应三个阶段,还带一份 Markdown 报告,适合当课程设计的骨架。适合两类人:一类是正在写作业但不想整个推倒重来的学生,另一类是想把形式语言理论落到 Python 代码上的从业者。

2. 正则式转 NFA:Thompson 构造法背后的状态组织方式

2.1 为什么必须用 epsilon 边来拼接子自动机

在做 NFA 构造之前,得先纠正一个常见的理解偏差。我们不是先解析出完整的正则表达式树,再去一次性生成 NFA,而是采用 Thompson 构造法:把正则式拆成最小的语法单元,每个单元对应一个子 NFA 片段,然后用 epsilon 转移把片段粘起来。epsilon 边的作用就是“免费跳转”,它让子自动机之间的连接不需要消费输入字符,这也是 NFA 不确定性的来源之一。

这份资源的第一层价值就在于把 NFA 的数据结构定义得足够朴素。它用字典来表示状态转移,键是源状态编号,值是一个列表,列表里存的是(字符, 目标状态)或('ε', 目标状态)。这样的设计在后续做确定化时,遍历转移关系非常方便,不需要额外维护邻接矩阵。我在拆代码时看到 NFA.py 里核心的add_transition函数,就是往字典里追加条目,没有什么高深的技巧,但胜在简洁。

2.2 用栈结构模拟正则表达式的优先级

在实际构造 NFA 之前,必须先解决“如何把正则式拆成片段”的问题。很多初学者在这里翻车,是因为直接用 Python 的re模块去解析用户输入的正则式,然后试图从匹配结果反推自动机结构。这个路子走不通,因为re模块是基于回溯的匹配引擎,不是基于自动机构建的,它不会给你暴露 NFA 的状态转移表。

正确的做法是自己写一个小的解析器,用两个栈来模拟运算优先级:一个操作数栈存 NFA 片段,一个运算符栈存|、*、连接符和括号。这里的“连接符”不需要用户显式输入,而是在解析过程中检测到两个相邻的字符或子表达式时,隐式压入的。比如正则式ab,解析器会在a和b之间插入一个连接操作,等价于a·b。核心处理逻辑我简化整理如下:

def regex_to_postfix(pattern): # 把中缀正则式转成后缀形式,方便后续用栈构造 NFA # 其中 '.' 表示连接操作,'|' 表示并操作,'*' 表示闭包 output = [] op_stack = [] precedence = {'|': 1, '.': 2, '*': 3} for ch in pattern: if ch.isalnum(): # 普通字符,直接输出 output.append(ch) elif ch == '(': # 左括号压栈 op_stack.append(ch) elif ch == ')': # 右括号弹栈直到左括号 while op_stack and op_stack[-1] != '(': output.append(op_stack.pop()) op_stack.pop() # 弹出左括号 else: # 运算符 while (op_stack and op_stack[-1] != '(' and precedence.get(op_stack[-1], 0) >= precedence.get(ch, 0)): output.append(op_stack.pop()) op_stack.append(ch) while op_stack: output.append(op_stack.pop()) return ''.join(output)

这段代码把a(b|c)*d这样的中缀正则式转成后缀形式abc|*·d·。核心逻辑是借用运算符优先级,把隐式的连接运算符.显式化,括号内的内容先处理。参数说明:pattern是用户输入的正则式,output是后缀表达式列表,后续遍历它就能顺序构造 NFA。值得注意的是,这段代码假设输入正则式已经合法,没有做括号匹配的校验,实际使用时可以在入口处先做一次完整的合法性检查。

拿到后缀表达式之后,NFA 的构造就变成机械的入栈出栈操作。遇到字符就创建一个两状态子自动机,遇到运算符就弹出对应的子自动机并进行合并。这类实现思路和计算器求值很像,但每个元素从“数字”换成了“NFA 片段”。合并时的关键点是:必须新建一个唯一的起始状态和接受状态,然后用 epsilon 边连接原片段的起止状态,这样每个运算符产生的子自动机仍然保持“单入单出”的结构,为下一步继续拼接做好准备。

3. NFA 确定化:epsilon 闭包与子集构造法的完整实现逻辑

3.1 epsilon 闭包计算的迭代细节

NFA 确定化的第一步是计算每个状态集合的 epsilon 闭包。这个操作在资源里对应的核心函数是epsilon_closure,它在 DFA.py 里被反复调用。闭包的定义不难理解:从一个状态集合出发,沿 epsilon 边能到达的所有状态都要包含进来,而且这个过程是递归的,因为 epsilon 边可以串联形成一条跳转链。

很多实现会用递归去写闭包函数,但 Python 的递归深度默认只有 1000,如果正则式嵌套很深,很容易触发RecursionError。这份资源里用的是显式的栈迭代,避免了这个问题。我把它对应的核心逻辑整理如下:

def epsilon_closure(state_set, nfa): # state_set 是当前关注的状态集合,nfa 是已经构造好的 NFA 对象 # 返回 state_set 的 epsilon 闭包,即所有通过 epsilon 边可达的状态 closure = set(state_set) stack = list(state_set) while stack: state = stack.pop() # 查找从当前状态出发的所有 epsilon 转移 for target in nfa.transitions.get(state, {}).get('ε', []): if target not in closure: closure.add(target) stack.append(target) return closure

这个函数值得细看两处。第一处是nfa.transitions.get(state, {}).get('ε', []),这里用了两层.get()来避免KeyError,因为并不是每个状态都有 epsilon 转移。第二处是stack的使用,每发现一个新的可达状态就把它压栈,继续探索它的 epsilon 边,直到栈为空时闭包计算才结束。参数说明:state_set是一个 set,建议传入时保证元素是整数状态编号,nfa.transitions是形如{状态编号: {字符: [目标状态列表]}}的字典。从工程角度看,这个函数是整个确定化过程里调用最频繁的基础设施,它的正确性直接决定后面 DFA 状态是否完整。

3.2 子集构造法如何避免状态爆炸

NFA 确定化的核心循环是:从起始状态的闭包开始,对输入字母表中的每个字符,计算所有可达状态的闭包,形成一个新的 DFA 状态。如果这个新状态之前没出现过,就加入待处理队列。这个过程叫子集构造法,也叫 powerset construction。

在实现时,最常见的性能问题是 DFA 状态数量理论上是指数级的。虽然实际正则式产生的 NFA 状态数不多,但如果不加控制地盲目扩展,仍然可能出现“状态爆炸”。解决思路在资源里体现得很直接:用字典dfa_states记录已生成的 DFA 状态,key 是对应的 NFA 状态集合的冻结集合(frozenset),value 是 DFA 状态编号。这样在循环中只需要检查当前集合是否已经在字典里,就能避免重复计算。

def subset_construction(nfa, alphabet): # nfa 是 NFA 对象,alphabet 是输入字母表集合 # 返回 DFA 的转移表、起始状态和接受状态集合 dfa_transitions = [] # 每个元素是 {字符: 目标DFA状态} dfa_state_map = {} # frozenset -> DFA 状态编号 start_state = frozenset(epsilon_closure({nfa.start}, nfa)) dfa_state_map[start_state] = 0 dfa_transitions.append({}) queue = [start_state] accepting_states = set() while queue: current = queue.pop(0) current_id = dfa_state_map[current] for char in alphabet: next_set = set() # 遍历当前集合中的所有 NFA 状态 for state in current: targets = nfa.transitions.get(state, {}).get(char, []) next_set.update(targets) if next_set: next_closure = frozenset(epsilon_closure(next_set, nfa)) if next_closure not in dfa_state_map: new_id = len(dfa_transitions) dfa_state_map[next_closure] = new_id dfa_transitions.append({}) queue.append(next_closure) next_id = dfa_state_map[next_closure] dfa_transitions[current_id][char] = next_id # 如果当前 DFA 状态对应的 NFA 集合包含原接受状态 if current & nfa.accepting: accepting_states.add(current_id) return dfa_transitions, 0, accepting_states

这段代码里的queue.pop(0)是典型的 BFS 写法,在状态量不大时完全够用。参数说明:alphabet必须预先从正则式中提取出来,一般是所有出现过的普通字符集合,不包括 epsilon;dfa_transitions的索引就是 DFA 状态编号,每个元素是一个字符到目标状态的映射;accepting_states是在构造过程中顺带计算的,只要 NFA 状态集合里包含原接受状态,对应的 DFA 状态就是接受状态。

这里有一个值得注意的细节:current & nfa.accepting判断的是集合是否有交集。如果你的 NFA 有多个接受状态,这个条件可以正确处理,不要改写为current == nfa.accepting,那是常见误用,在存在多余 epsilon 跳转时会漏掉接受状态。

4. DFA 最小化:Hopcroft 划分如何正确合并不可区分状态

4.1 划分的初始化为什么不能只看终态

DFA 最小化最常用的方法是基于等价类划分。核心思路是:把 DFA 的所有状态划分成若干组,组内状态在任意输入串下都不可区分,然后每组合并成一个状态。初始化划分时,第一刀切在“终态”和“非终态”之间,因为终态和非终态在接受性上的表现不同,不可能等价。这一点初学者都能理解,但真正的难点在于后续迭代划分时,怎么判断组内的状态是否需要进一步分裂。

判断规则是:如果在某个输入字符下,组内两个状态转移到的目标状态属于不同的组,那这两个状态就不等价,需要把原组拆开。这份资源里的 MINI.py 用的是迭代细化法,不断检查每个组在字母表下的转移目标是否落在同一组内,直到所有组都稳定。实现时有个细节容易忽略:分组时不仅要把转移目标分组编号记下来,还要记录是从哪个输入字符触发的,否则下一次迭代分组的依据不完整。

4.2 分裂过程的实现与状态重编号

在实现分裂逻辑时,我见过不少代码直接修改正在遍历的分组列表,导致漏掉某些状态。正确的做法是维护一个“待检查的工作列表”,每次从里面取出一个组进行分裂测试,如果分裂出新的组,再把新组加入工作列表。这本质上是 BFS 式的传播,因为一个组分裂可能会影响其他组的等价关系。

def minimize_dfa(dfa_transitions, accepting_states): # dfa_transitions 是子集构造得到的 DFA 转移表 # accepting_states 是接受状态集合 # 返回最小化后的 DFA 转移表、起始状态、接受状态集合 n = len(dfa_transitions) # 初始化划分:终态一组,非终态一组 groups = [set(accepting_states), set(range(n)) - set(accepting_states)] # 去掉空组 groups = [g for g in groups if g] alphabet = set() for t in dfa_transitions: alphabet.update(t.keys()) changed = True while changed: changed = False new_groups = [] for group in groups: # 用转移目标所在组的编号作为签名 signature_map = {} for state in group: sig = [] for char in sorted(alphabet): target = dfa_transitions[state].get(char, -1) # 找到 target 属于哪个组,用组索引作为签名的一部分 group_idx = -1 for idx, g in enumerate(groups): if target in g: group_idx = idx break sig.append(f"{char}:{group_idx}") sig_str = '|'.join(sig) signature_map.setdefault(sig_str, set()).add(state) if len(signature_map) > 1: changed = True new_groups.extend(signature_map.values()) groups = new_groups # 重新编号:每个组映射到新的 DFA 状态 state_mapping = {} new_transitions = [] new_accepting = set() for new_id, group in enumerate(groups): for old_state in group: state_mapping[old_state] = new_id if old_state in accepting_states: new_accepting.add(new_id) for group in groups: old_rep = next(iter(group)) new_trans = {} for char in alphabet: target = dfa_transitions[old_rep].get(char, -1) if target != -1: new_trans[char] = state_mapping[target] new_transitions.append(new_trans) new_start = state_mapping[0] return new_transitions, new_start, new_accepting

这段代码的核心是签名机制:对每个状态,计算它在所有输入字符下转移目标所在组的编号列表,作为它的“签名”。如果组内状态的签名不同,说明它们可以被区分,需要分裂成多个组。changed标志确保循环直到没有组再分裂才终止。参数说明:dfa_transitions里每个状态的转移表默认缺失的字符表示死状态,代码里用-1来统一表示不存在的转移,但在后续重编号时没有为死状态预留新编号,这是一个容易忽略的点,如果你的 DFA 中包含显式死状态,需要在重编号之前为死状态单独分配一个编号,否则状态映射会错位。

这里有一个新手常犯的错误:直接用old_rep的转移来代表整个组,这在组内状态等价时是正确的,但如果你的分组逻辑有 bug,导致状态并未真正等价,那么转移信息就会失真。所以最小化算法的验证必须放在最后,用随机字符串在最小化前后的 DFA 上做一致性测试,这一步不能省。

5. 避坑记录:从空白输出到状态爆炸的五个常见问题

5.1 输出结果总是空白,问题出在解析阶段

现象:程序没有任何报错,但生成的 NFA 和 DFA 都是空结构,或者只包含起始状态和接受状态,中间的转移边一条都没有。

原因:正则式解析时,隐式连接符没有正确处理。比如ab被解析成两个独立的字符,没有生成连接操作,导致 NFA 片段之间没有 epsilon 桥接。更隐蔽的情况是,字符类[a-z]没有被展开,直接被当成单个符号。解决:在解析入口处打印后缀表达式,检查是否出现了预期的.操作符。如果[a-z]这类字符类出现,需要在预处理阶段把它展开为a|b|c|...|z的形式再送入解析器。我拆这份资源时注意到它的代码没有做字符类展开,但作业里如果要求支持,需要自己补这一段。

5.2 epsilon 闭包计算少了间接可达的状态

现象:确定化后,某些 DFA 状态对某个输入字符没有任何转移,但手工推演 NFA 时这个字符明明可以走到某个可接受状态。

原因:闭包计算的循环写成了单层遍历,只考虑了直接 epsilon 转移,没有用栈或队列去展开间接转移。比如状态 A 有 epsilon 边到 B,B 有 epsilon 边到 C,单层遍历只能发现 B,无法发现 C。

解决:改用显式栈,每加入一个新状态就压栈继续探索。验证方法很简单:构造一个包含两个连续 epsilon 边的 NFA,跑一下闭包函数看看是否包含三个状态。我一般会加一个断言函数,专门检查“闭包结果必须对 epsilon 转移封闭”,不满足就抛异常。

5.3 确定化后出现无效状态,输入任意字符都回到自身

现象:生成的 DFA 里有一个状态,对字母表中所有字符的转移都指向它自己,而且它不是接受状态。

原因:没有显式定义死状态。在子集构造中,如果某个字符下没有可达的 NFA 状态集合,很多实现就跳过这条转移,导致 DFA 状态转移表缺少该字符的处理。后续做最小化时,这个空缺会被解释成“无转移”,而其他状态可能会指向一个隐含的死状态,造成不一致。

解决:在子集构造时,为缺失的转移统一填入一个显式死状态编号(比如-1或n+1),并把这个死状态也加入状态集合。最小化时对死状态单独处理,不要让它参与分组。常见做法是给死状态单独编号,并让它的所有转移指向自己。

5.4 最小化后合并掉了接受状态

现象:最小化之后,原本的接受状态集合发生了变化,部分非接受状态变成了接受状态,或者反过来。

原因:在重编号阶段,用old_state in accepting_states来判断新状态是否接受,这里没有问题。但如果你在分组时把终态和非终态分到了同一组,就会出现不可逆的错误。通常原因是初始化分组时用了set(range(n)) - set(accepting_states),但这里的accepting_states如果是从子集构造阶段继承下来的,可能包含了死状态的编号,而死状态不应该参与终态判断。

解决:在最小化之前,打印输出每一个 DFA 状态对应的 NFA 状态集合,人工核对终态的映射关系。这是一个黑匣子步骤,不打印中间结果很难发现问题。我习惯在子集构造和最小化之间增加一个断言:assert accepting_states <= set(range(n)),防止越界。

5.5 Python 递归深度影响闭包计算

现象:正则式里包含大量交替和嵌套闭包,比如(a|b|c|d|e)*,运行时报RecursionError: maximum recursion depth exceeded。

原因:闭包计算如果用递归写法,深层嵌套的 epsilon 链会耗尽递归栈。虽然递归写法看起来更清晰,实测中确实会遇到这个边界。

解决:把递归改为显式栈迭代,就是章节 3.1 里的写法。这是最稳妥的方案。如果坚持用递归,可以在文件头部设置sys.setrecursionlimit(10000),但这只是延后问题,不是根治。从那以后我每次写闭包计算都强制走显式栈迭代。

6. 验证 DFA 的正确性:用随机串测试和转移表可视化保住作业分数

完成三个阶段的代码之后,最容易被忽略的是验证环节。课程设计的验收不只看结果,还会看你对代码正确性的把握程度。一个很实用的技巧是:写一个随机字符串生成器,在 NFA 和最小化后的 DFA 上分别模拟匹配,比对结果是否一致。这个做法比分几个手写用例可靠得多,因为随机测试能覆盖到状态组合的边界条件。

模拟 NFA 匹配时要注意,存在多条路径可以同时推进,不能用简单的单路径模拟,而是维护一个“当前可达状态集合”,每个字符输入后先计算字符转移的并集,再做 epsilon 闭包。而 DFA 模拟就简单得多,每个字符输入后走唯一的转移边即可。把两者的结果对比,如果出现不一致,就缩小随机种子递归定位到导致不一致的最小字符串,这个最小反例往往能直接指出你代码里的逻辑 bug。

另外,把 DFA 转移表打印成矩阵形式也能帮助快速排查。我用一个简单的函数输出状态行,每行列出一组“字符->目标状态”,再加上是否接受的标记。以下是我在验证 DFA 最小化时常写的一段辅助代码:

def print_dfa_table(transitions, start, accepting): # 打印 DFA 转移表,方便人工核对 # transitions 是 {状态编号: {字符: 目标状态}},accepting 是接受状态集合 alphabet = set() for t in transitions: alphabet.update(t.keys()) alphabet = sorted(alphabet) print(f"起始状态: {start}") print("状态".ljust(6) + "".join(ch.ljust(6) for ch in alphabet) + "接受?") for state, trans in enumerate(transitions): row = str(state).ljust(6) for ch in alphabet: target = trans.get(ch, '-') row += str(target).ljust(6) row += "是" if state in accepting else "否" print(row)

在打印结果里,我最关注两列:一是是否存在某个状态对所有字符都转到自身且不是接受状态,这通常意味着死状态没处理干净;二是是否存在不可达的孤立状态,因为这会影响最小化后的状态数量,还可能让作业验收时讨论环节扣分。

验证时我一般会跑三个层次的测试:第一层是单字符测试,比如正则式a,NFA 和 DFA 都应该只接受a;第二层是空串测试,检查a*是否接受空串,这是最常见的边界;第三层是随机串批量测试,样本量至少 5000 条,字符串长度从 0 到 8 均匀分布。随机测试通过之后,代码的正确性就站得住了。这份资源虽然没有自带测试脚本,但按照这个思路补一段测试代码,整体作业的完成度能高出不少。

这个项目里最值得带走的东西不是三份代码文件本身,而是“每一阶段都能被验证”的工程习惯。做编译原理课程设计时,最恼人的是算法写完了却不知道对不对。从那以后我做这类自动化构造的任务都会强制走一遍“随机测试 + 最小反例定位”的流程,治好了不少因为玄学翻车带来的失眠。希望帮到你。

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

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

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

立即咨询