
简介这份西南科技大学编译原理实验报告聚焦实验一“设计词法分析程序”面向计算机专业学生、编译原理初学者及需要完成课程实验的读者用于梳理词法分析从正则表达式到可运行程序的完整链路。压缩包内共1个doc文件约444KB内容涵盖实验目的、实验设计、实验过程与程序实现具体包括TEST语言各类词法规则的正则表达式、NFA构造与合并、确定化及最小DFA化简、单词分类与输出方案并给出基于Python状态转移表的词法分析程序框架。读者可据此对照理解标识符、保留字、无符号整数、分界符、运算符和注释符的识别方法参考状态机设计、字符分类函数与输出格式组织实验报告也能从错误处理、缓冲区管理和回溯机制等提示中延伸排错思路。目前已有479人学习适合作为课程实验参考与期末复习材料。1. 一份能直接跑的词法分析实验报告从正则推到 Token 流拿到一份编译原理实验报告最容易出现的情况是自动机图看着都对代码一跑就崩除号认不出来、分界符全被标成运算符、单字符 token 被后面的字符吞掉。这份《编译系统综合设计》实验一的文档主线是完整的正则表达式 → NFA → 合并 → 确定化 → 最小化 DFA → Python 扫描器 → 错误定位 → 最长匹配讨论还带一段真实的调试记录。它适合两类人正在赶词法分析实验、需要一套能交也能跑的东西以及想把状态机驱动扫描器真正写对的从业者。下面按「规则怎么写、自动机怎么推、代码怎么改、坑在哪」拆开讲。2. 把 TEST 语言规则写成正则六类词法的边界与合并顺序正则表达式是整个词法分析的前置工作写错一条后面构造出来的 NFA 再漂亮也没用。我的习惯是先把词法规则落成一张表把「类别、正则、边界情况」三列填满确认没有二义性再动自动机。大部分翻车都发生在这一步比如无符号整数的正则没排除前导零或者/同时出现在运算符和注释符规则里导致 DFA 里同一个字符有两个出口。2.1 六类词法规则的正则写法与优先级TEST 语言的规则数量和类别不算多但边界条件不少。按实验正文整理成表顺带把每条的坑标出来类别正则表达式边界与注意点标识符 ID(a|…|z|A|…|Z)(0|…|9|a|…|z|A|…|Z)*首字符必须是字母数字不能开头不含下划线保留字if|else|for|while|do|int|write|read词形与 ID 完全相同靠规则优先级区分无符号整数 NUM((1|…|9)(0|…|9)*)|0该写法明确排除了前导零012345属于非法分界符 DELIM(|)|;|{|}圆括号、花括号、分号不含冒号运算符 OP|-|*|/|||||||!|/与注释符冲突与冲突注释符 COMMENT//开头至行尾原文排版丢失成 /两条优先级必须钉死。第一条是关键字优先于标识符int既满足保留字正则也满足标识符正则按最长匹配两者长度相同此时只能靠规则顺序裁决先匹配保留字表命中就归 KEYWORD否则才归 ID。第二条是长运算符优先于短运算符读到不能立刻收尾要再看一个字符是就输出否则才输出。这两条如果不提前定好后面手写状态转移表时一定会在某个状态上犹豫。还有一个细节运算符集合里包含了/注释符也以/开头。这不是笔误而是语言设计上的真实二义必须靠在自动机里做两字符前瞻来解决——读到第一个/时先不落状态看第二个字符是不是/是就进注释分支不是就退回单字符除号。实验文档后面「调试记录」里提到的第一个问题就是这件事。2.2 从正则到 NFAThompson 构造的四个基本件正则到 NFA 用 Thompson 构造思路是把每条正则拆成「连接、选择、闭包」三种组合每种组合对应一小块固定形状的子图最后拼起来。落地时不要一上来就画图先写四个构造函数让代码生成图比手画准确得多。from dataclasses import dataclass, field dataclass class NFA: start: int accept: int trans: dict field(default_factorydict) # (state, char) - set(state) eps: dict field(default_factorydict) # state - set(state) _counter [0] def new_state(): _counter[0] 1 return _counter[0] def build_char(ch): 单个字符两个状态一条边 s, a new_state(), new_state() return NFA(s, a, {(s, ch): {a}}, {}) def build_concat(x, y): 连接x 的接受态用 ε 边接到 y 的起始态 return NFA(x.start, y.accept, {**x.trans, **y.trans}, {**x.eps, **y.eps, x.accept: {y.start}}) def build_union(x, y): 选择新起点 ε 分叉到两个子图两个接受态 ε 汇到新终点 s, a new_state(), new_state() eps {**x.eps, **y.eps, s: {x.start, y.start}, x.accept: {a}, y.accept: {a}} return NFA(s, a, {**x.trans, **y.trans}, eps) def build_star(x): 闭包新起点可跳过子图子图接受态可回跳起点 s, a new_state(), new_state() eps {**x.eps, s: {x.start, a}, x.accept: {x.start, a}} return NFA(s, a, dict(x.trans), eps)四个函数的共同点是每次构造都新建起止状态用 ε 边与子图粘连子图内部结构完全不动。这样写的好处是可组合——标识符的正则可以写成char(letter) star(char(letter|digit))代码里就是build_concat(build_char_in_set(LETTER), build_star(...))不需要手工算状态编号。参数上有两处容易写错。第一处是build_concat里eps字典的合并顺序x.accept: {y.start}必须放在最后否则如果 x 和 y 恰好复用同一批状态编号会被覆盖。第二处是build_star里x.accept的出边要指向{x.start, a}两个状态只指回起点会导致闭包结束后无法退出整个自动机永远接受不了任何串。2.3 合并 NFA一个新起点加 ε 边别改子图六条规则各自构造完 NFA 后要合并成一台机器做法是新建一个全局起点用 ε 边指向六个子图的起始状态。这样合并出来的 NFA 天然带有「六选一」的语义后面子集构造时第一步的 ε-闭包就会把这六个起点全部收进来。合并时有个顺序问题值得说清楚子图的排列顺序决定了子集构造后状态编号的稳定性。如果今天按「标识符、整数、运算符……」排明天按字母序排那最小化出来的 DFA 状态表每次都不一样调试时对不上号。我的做法是固定一个枚举顺序写进常量列表构机、确定化、最小化全部按这个顺序遍历产出的状态表可以稳定复现。这一点在实验报告里不会写但如果你要把状态表贴进文档或者交给别人复现顺序稳定能省掉大量「我这边跑出来怎么跟你不一样」的沟通。合并完先别急着确定化做一次自检统计除 ε 边以外的所有转移字符看是否和预期字符集一致。TEST 语言这边应该是 26 个大写、26 个小写、10 个数字加上 - * / ! ( ) { } ;共 15 个符号。少一个说明某条正则漏字符多一个说明哪个构造函数的字符集参数写串了。这一步五分钟能省掉后面半小时的排查。3. NFA 确定化与最小化子集构造手算一遍再落到表上从 NFA 到 DFA 是这套实验里最容易被跳过的一步。很多人直接抄一张现成的状态表跑通了就算完结果一改规则就全乱。真正值钱的是子集构造那套机械流程它不快但每一步都可以手算验证出问题一定能定位到具体哪个状态集合。3.1 ε-闭包与 move子集构造的两个原语子集构造只有两个原语。ε-闭包是「从一组状态出发只走 ε 边能到达的所有状态」move 是「从一组状态出发吃掉一个字符能到达的所有状态」。两个都是集合运算写起来很短。def eps_closure(nfa, states): 从 states 出发只走 ε 边能到达的闭包迭代代替递归避免深图爆栈 seen set(states) stack list(states) while stack: s stack.pop() for t in nfa.eps.get(s, ()): if t not in seen: seen.add(t) stack.append(t) return frozenset(seen) def move(nfa, states, ch): 吃掉字符 ch 之后到达的状态集合不含 ε 展开 out set() for s in states: out | nfa.trans.get((s, ch), set()) return frozenset(out) def determinize(nfa, alphabet): 子集构造DFA 的每个状态是一个 frozenset d0 eps_closure(nfa, {nfa.start}) index {d0: 0} order [d0] table [] # table[i] {char: j} queue [d0] while queue: cur queue.pop(0) row {} for ch in alphabet: nxt eps_closure(nfa, move(nfa, cur, ch)) if not nxt: continue # 空集不建状态等价于死状态 if nxt not in index: index[nxt] len(order) order.append(nxt) queue.append(nxt) row[ch] index[nxt] table.append(row) return order, table, index三个函数的分工很清楚eps_closure用显式栈而不是递归是因为合并后的 NFA 状态数虽然只有几十个但 ε 链可能很长递归写法在 Python 默认递归深度下不算安全。determinize里的alphabet必须传完整字符集不能只传出现过的字符——漏传会让某些转移缺失DFA 看起来能用实际上遇到那个字符就直接掉进死状态。determinize里有一行if not nxt: continue意思是空集不建状态。这是简化做法好处是状态表干净坏处是「转移缺失」和「转移到死状态」在语义上要靠约定来补查表查不到就按报错处理。如果你的实验要求画出完整 DFA包含死状态把这行删掉让空集也进index就行两种做法都对取决于是要交图还是要跑代码。3.2 最小化先删不可达再做等价类划分确定化之后的 DFA 一定有冗余状态最小化分两步走顺序不能反。第一步是删不可达状态从初始状态做一次可达性遍历凡是走不到的统统删掉第二步才是等价类划分。顺序反了会出现「划分过程中两个不可达状态被判定为等价并合并合并后又恰好可达」这种诡异情况虽然少见但排查起来极耗时间。def minimize(dfa_states, table, alphabet, accept_ids): # 第一步可达性剪枝 reachable {0} stack [0] while stack: i stack.pop() for ch in alphabet: j table[i].get(ch) if j is not None and j not in reachable: reachable.add(j) stack.append(j) # 第二步初始划分 接受态 / 非接受态 block {i: (1 if i in accept_ids else 0) for i in reachable} while True: sig {} for i in reachable: key (block[i], tuple(block.get(table[i].get(ch), -1) for ch in alphabet)) sig.setdefault(key, []).append(i) if len(sig) len(set(block.values())): break # 划分不再变化收敛 block {} for new_id, group in enumerate(sig.values()): for i in group: block[i] new_id return block这段代码的核心是key的构造一个状态的「签名」由它自身所属的划分块加上它在每个字符上转移到哪个划分块组成一个元组。签名相同就归为一类一轮一轮迭代到划分稳定。参数accept_ids是确定化阶段记下来的接受状态集合如果这步传错整个划分从第一轮就跑偏最后得到的最小 DFA 会接受错误的串而且很难从状态表上看出来。还有一个常见误解最小化不一定让状态数变少很多。TEST 语言这种规则集确定化后可能有二三十个状态最小化后剩十几个收益不算夸张。真正有价值的是合并那些「词形不同但后续行为完全一致」的状态——比如各条规则的收尾状态合并后状态表可读性会好很多手写实现时才有底气照着抄。3.3 把最小 DFA 落成 Python 字典两类写法的取舍理论推完最后要落到代码。这里有一个容易被忽略的选择是逐状态翻译整张表还是按词法类别拆开写。实验文档里给的代码是前者一张大字典dfa把 START、ID、NUM、OPERATOR、DELIMITER、COMMENT、ERROR 七个状态和它们的转移全列出来。这种写法忠实于自动机画图和代码能一一对应交实验报告很合适。但在真实工程里我一般选后者按类别拆成几个小循环标识符一个 while、数字一个 while、运算符两字符前瞻一次主循环只负责分派。原因很实际——整表翻译的状态里接受态和中间态混在一起回退逻辑要另外写拆开写之后每类词素的扫描边界天然清晰加一类新词法只加一个分支不动其他分支。两种写法并不冲突。实验要求「根据最小 DFA 编写程序」那就按表写把图、表、代码三者对齐如果要拿去接语法分析器、要长期维护那就在表的基础上重构成分类扫描器。文档里的代码骨架属于前者第 4 章给的实现属于后者两者的状态语义是一致的对照着看能看清「DFA 是模型扫描器是工程实现」这层关系。4. Python 词法分析器实现DFA 驱动、最长匹配与 Token 输出到这一步规则、自动机、状态表都有了剩下的问题全在工程细节上怎么保证最长匹配、怎么处理空白和注释、怎么在报错时给出能定位的行列号。实验文档里的代码骨架方向是对的但有几处会直接导致跑不对后面第 5 章逐条拆。这里先给一版能直接跑的完整实现再解释每处为什么这么写。4.1 状态机骨架字符分类、主循环与分派整份实现围绕一个主循环展开每次拿当前字符判断属于哪一类词素的起点然后按该类规则一路吃到不能吃为止产出 token 后把游标推到下一个位置。行号和列号随着游标推进同步维护。KEYWORDS {int, if, else, for, while, do, write, read} TWO_CHAR_OPS {, , , !} DELIMS set((){};) SINGLE_OPS set(-*/) def tokenize(src): tokens, errors [], [] i, line, col, n 0, 1, 1, len(src) def advance(ch): nonlocal line, col if ch \n: line, col line 1, 1 else: col 1 while i n: ch src[i] # 1) 空白只推进游标不产出 token if ch in \t\r\n: advance(ch); i 1 continue sl, sc line, col # 记录词素起点报错和输出都要用 # 2) 注释// 到行尾全部丢弃注释内容不参与词法 if src.startswith(//, i): while i n and src[i] ! \n: advance(src[i]); i 1 continue # 3) 标识符 / 关键字字母开头吃字母数字 if ch.isalpha(): j i while j n and src[j].isalnum(): j 1 word src[i:j] kind KEYWORD if word in KEYWORDS else ID tokens.append((kind, word, sl, sc)) for k in range(i, j): advance(src[k]) i j continue # 4) 无符号整数禁止前导零禁止数字后紧跟字母 if ch.isdigit(): j i while j n and src[j].isdigit(): j 1 if j n and (src[j].isalpha() or src[j] _): k j while k n and (src[k].isalnum() or src[k] _): k 1 errors.append((sl, sc, f非法词素 {src[i:k]}不能以数字开头)) for t in range(i, k): advance(src[t]) i k continue word src[i:j] if len(word) 1 and word[0] 0: errors.append((sl, sc, f非法整数 {word}存在前导零)) else: tokens.append((NUM, word, sl, sc)) for t in range(i, j): advance(src[t]) i j continue # 5) 双字符运算符先看两位命中就直接吃两位 if src[i:i 2] in TWO_CHAR_OPS: tokens.append((OP, src[i:i 2], sl, sc)) advance(src[i]); advance(src[i 1]) i 2 continue # 6) 孤立的 ! 不合法必须成对出现 if ch !: errors.append((sl, sc, 孤立字符 !只允许 !)) advance(ch); i 1 continue # 7) 分界符 if ch in DELIMS: tokens.append((DELIM, ch, sl, sc)) advance(ch); i 1 continue # 8) 单字符运算符包含 /注释已在第 2 步截走 if ch in SINGLE_OPS: tokens.append((OP, ch, sl, sc)) advance(ch); i 1 continue # 9) 其余一律非法 errors.append((sl, sc, f非法字符 {ch!r})) advance(ch); i 1 return tokens, errors主循环的九条分支就是九个「词素起点判定」顺序本身就是优先级注释判断排在单字符运算符之前所以/先被注释截走剩下的才是除号双字符运算符判断排在单字符之前所以不会被拆成和。这两个顺序如果对调整个扫描器行为就变了而且不一定报错只是 token 流悄悄不对。advance里用nonlocal line, col修改外层变量是为了让行号列号在任何分支下都只更新一次。有个坑要提前说字符串切片src[i:j]不会推进游标必须显式对range(i, j)逐个advance否则行号列号会和实际位置脱节。这个错误很难在短输入上发现一旦输入多了几百行报错位置就完全对不上。4.2 最长匹配与回退、!、//怎么一次读对最长匹配这件事文档在讨论部分说代码「满足最长匹配原则」理由是按状态逐个匹配读到时再看下一位是不是。这个说法方向正确但要落到实现上有两种做法取舍不同。第一种是状态表 回退DFA 进入「已识别但尚未确定」的中间态再看下一个字符如果是就进接受态输出如果不是就回退一个字符输出。回退意味着输入流要支持seek或者游标要能减一同时行号列号也要回滚实现成本明显更高。第二种是两字符前瞻直接看src[i:i2]在不在双字符集合里命中就吃两位否则吃一位。上面代码第 5、6、7、8 条分支就是这种写法。它不需要回退游标只前进不后退行号列号只增不减实现简单很多。代价是前瞻长度固定为 2如果哪天语言加了、这类三字符运算符就得改成先查三位再查两位。我一般选第二种因为词法规则里运算符长度通常很短写死一个最大长度反而更清晰。但有个例外注释和除号的区分不能靠前瞻长度解决必须靠判断顺序。/和//都是一到两位正好卡在前瞻的边界上所以代码里把注释判断放在最前面用startswith(//, i)单独截走。如果只写src[i:i2] in TWO_CHAR_OPS然后指望它处理注释就得把//塞进集合里再在命中后另外写一段「跳过到行尾」的逻辑——能做但分支会比现在散。验证最长匹配是否真的生效别只看正常输入。拿这几个串各跑一遍ab、ab、a!b、a!b、a/b、a//b。期望输出分别是、、!、报错、/、注释丢弃。六个都对了说明前瞻和判断顺序都没问题。4.3 输出 lex.txt 与错误位置行列号怎么维护实验要求把合法单词写进lex.txt并报告错误位置。输出格式没有硬性规定但有两个要素建议一定有token 类型和行列号。类型是为了给语法分析用行列号是为了报错能定位。src { //This a test program. int abc123$; int 123; int 2a; abc 012345; for (i 1; i n; i I 1) { if(i ! n) abc abc * i else abc abc / i; } } tokens, errors tokenize(src) with open(lex.txt, w, encodingutf-8) as f: f.write(# kind\ttext\tline:col\n) for kind, text, ln, cl in tokens: f.write(f{kind}\t{text}\t{ln}:{cl}\n) with open(lex_err.txt, w, encodingutf-8) as f: for ln, cl, msg in errors: f.write(f[{ln}:{cl}] {msg}\n) print(ftokens{len(tokens)}, errors{len(errors)})用制表符分隔而不是空格是为了让lex.txt能直接被后续脚本按列切开。列名行用#开头读取时跳过即可。行列号放在第三列格式行:列比单独两列更紧凑人眼看也方便。跑完这段lex_err.txt里应该出现四条记录abc123$后面的$非法字符、123后面的非法字符、2a整体非法词素、012345前导零。注意第一条和第二条实验文档的描述是「标识符 123 中包含非法字符」但按最长匹配的正确行为123是合法整数才是非法字符两者应该拆开。这个差异不是谁对谁错而是文档在手工分析时把123当成一个整体看了代码要按自动机的真实行为走。行号从 1 开始、列号从 1 开始这是编辑器和编译器的通用约定。如果列号从 0 开始报错信息贴到编辑器里会整体偏移一格用户点过去定位会差一位。这类约定不写清楚后面接语法分析器时两边对不上又要回来查。5. 避坑这份报告代码里五处会当场翻车的地方文档里的代码骨架思路没问题但直接复制粘贴运行结果和预期差得很远。下面五处是我照着敲一遍之后逐条改出来的每一条都按「现象、原因、解决」写方便对照自己的版本查。5.1 状态表层面的三处硬伤第一处除号永远识别不出来。现象是输入a/b输出的 token 流里没有/反而整段被当成注释或错误。原因在状态转移表里START状态对/的转移直接写成了COMMENT也就是说读到第一个斜杠就无条件进注释态根本没机会判断第二个字符。而注释态的转移表里只有SLASH - COMMENT没有回 START 的路所以后续字符全被吞。解决是把「斜杠」拆成两个阶段先进入一个中间态看下一个字符是不是/是则进注释并跳到行尾不是则退回来输出单字符/。用前瞻写法就是 4.2 里那句src.startswith(//, i)优先判断判断不中再落到单字符运算符分支。第二处DELIMITER是个死状态。现象是{、}、(、)、;全部被标成OPERATOR类型统计里分界符数量为 0。原因在START状态的转移OPERATOR_DELIMITER: OPERATOR把运算符和分界符合并到了一个状态而DELIMITER这个状态在整张表里没有任何一条边指向它从头到尾不可达。解决有两个方向。要么在字符分类函数里把分界符单独拆成一类DELIM_CHARSTART对它转移到DELIMITER要么在输出阶段按词素内容二次分类字符属于(){};就改标成DELIM。前者更符合自动机语义后者改动小。我选前者因为类型判断发生在扫描阶段比事后修补更不容易漏。第三处单字符 token 被下一个字符一起吞掉。现象是x1里的消失输出里出现一个ERROR类型的1。原因是状态机在OPERATOR态读到下一个字符1类别DIGIT时转移表给的是ERROR于是两个字符一起进了错误态也没单独输出。根因是接受态没有及时收尾。单字符运算符读到就该立刻产出 token 并回到START而不是继续留在OPERATOR态等下一个字符。修复方式是在每次状态转移前判断当前状态是否为接受态是就先 flush 一次 token再处理新字符。分类扫描器写法天然没这个问题因为每类词素的边界是显式的。5.2 词素与位置层面的两处第四处标识符会把后面的运算符吃进去。现象是abc* i被识别成一个 IDabc*或者abc123$后面的内容整段异常。原因在ID状态的转移表OPERATOR_DELIMITER: ID意思是标识符态遇到运算符还能继续留在标识符态。这条转移违反了标识符的正则定义——标识符只允许字母和数字遇到任何运算符都必须收尾。解决是把ID态对OPERATOR_DELIMITER的转移改成「触发收尾回到START再用当前字符重新分派」。这也是所有手写状态机都要遵守的一条规则接受态的出口必须回到初始态重新判断不能原地续命。文档里NUM态的处理是对的遇到非数字一律ERROR但ID态写反了两处对照着看就能发现不一致。第五处错误只报内容不报位置且错误分支会丢词素。现象是跑完只看到「有非法字符」不知道在第几行第几列输入长一点就没法定位。原因有两层扫描函数只返回 token 列表没有维护行列号ERROR态收尾时被显式排除在输出之外if current_state not in {...ERROR}错误词素既不进 token 也不进错误列表直接蒸发。解决是给扫描器加两个游标变量随每次字符推进同步更新产 token 和记错误时都把当前位置带上。错误词素不进lex.txt但要单独写一份lex_err.txt格式[行:列] 描述。另外文档的单词分类表把int同时列在「关键字」和「保留字」两栏这种内部矛盾在交给别人复现前最好统一掉否则读的人会以为两者有区别实际在词法阶段是一回事。6. 进阶把状态表当数据做自动验证与后续扩展词法分析器写完之后真正拉开差距的是怎么证明它对。靠人眼看几个例子不够我的习惯是把测试用例写成表跑一遍断言输入输出全部落成数据。下面这张表覆盖了六类词法的主要边界可以直接拿去当回归用例输入片段期望 token 序列考察点int x;KEYWORD(int) ID(x) DELIM(;)关键字优先于标识符x1ID OP() NUM双字符运算符最长匹配x1ID OP() NUM单字符运算符不吃多a//bID(a)注释直接丢弃不产出 tokena/bID OP(/) ID除号不被注释截走012345错误前导零整数正则的零边界2a错误数字开头标识符首字符约束abc$ID(abc) 错误非法字符$错误不吞合法词素把这张表写成列表每个用例断言tokens的类型序列和errors的条数跑一次几毫秒。每次改状态表、改字符分类、改判断顺序先跑一遍全绿再往下接语法分析。这套东西看起来笨但能挡住绝大部分「改 A 崩 B」的回归问题。再往前一步可以把状态表从代码里抽出来变成外部数据。转移表写成 JSON 或者 Python 字典常量扫描器只负责查表这样加一条新词法规则不需要动主循环改数据就行。抽表的时候有个细节要注意接受态要单独标出来不能靠状态名里有没有ACCEPT字样判断否则重命名一次状态就全乱。我的做法是表里加一列accept: true/false扫描器只认这一列。还有一个衔接点值得提前想好词法分析器的输出要喂给语法分析器token 的类型名最好和语法分析里的终结符一一对应。上面代码用的是KEYWORD、ID、NUM、OP、DELIM五类关键字的具体词形放在 token 文本里。如果语法分析那边需要把int、if当成不同终结符就在词法阶段把KEYWORD再细分成KW_INT、KW_IF别等到语法分析阶段再按文本二次判断——那样等于把词法的工作推迟了后面递归下降写起来会很别扭。最后说个血泪经验。我第一次做这个实验时状态图画得挺漂亮代码跑起来也能出结果交之前随手换了个更长的测试程序输出直接乱套。查了两小时才发现是行号没跟着切片推进报错位置全错位而我一直在看 token 类型对不对压根没注意位置列。从那以后我每次写完扫描器都强制先用一个二十行以上的多行输入跑一遍专门核对错误位置的行列号是否和编辑器里一致再去看 token 流。这份报告里的正则表、NFA 构造、最小化状态表和测试用例都整理在一起了照着第 4 章的骨架敲一遍比盯着图看半小时管用。希望帮到你。本文还有配套的精品资源点击获取