本文的主要好玩是对于这道题目 力扣https://leetcode-cn.com/problems/regular-expression-matching/作为 OJ,方便学习 NFA 自动机的代码:
#define EPSILON '0'
#define START 0
class Solution {
public:
auto AddPossibleNext(
unordered_map<long, unordered_map<char, set<long>>>& StateTable,
set<long>& NextStates,
long OneOfCurrents,
char Input) -> void {
if (StateTable[OneOfCurrents].count(Input)) {
auto possible_states = StateTable[OneOfCurrents][Input];
for (auto possible_state : possible_states) {
NextStates.insert(possible_state);
// DFS + backtrack (insert into set)
AddPossibleNext(StateTable, NextStates, possible_state, EPSILON);
}
}
}
bool isMatch(string s, string p) {
unordered_map<long, unordered_map<char, set<long>>> StateTable;
// create the first state, as a begining
StateTable.emplace(START, unordered_map<char, set<long>>{});
long lastState = 0;
for (long i = 0; i < p.size(); ++i) {
if (i + 1 < p.size() && p[i + 1] == '*') {
// create a new state
// add a cycle edge to the kleene-star state
StateTable[i + 1].insert({p[i], {i + 1}});
// update the last state with a epsilon-edge,
// if there are chained kleene-stars,
// just think as chained epsilon-edges...
if (StateTable[lastState].count(EPSILON) == 0) {
StateTable[lastState].insert({EPSILON, {i + 1}});
} else {
StateTable[lastState][EPSILON].insert(i + 1);
}
lastState = i + 1;
} else if (p[i] != '*') { // we just skip the star...
// nornal matching request, we just add a state, and update last state.
StateTable.emplace(i + 1, unordered_map<char, set<long>>{});
// add an edge to this state
if (StateTable[lastState].count(p[i]) == 0) {
StateTable[lastState].insert({p[i], {i + 1}});
} else {
StateTable[lastState][p[i]].insert(i + 1);
}
lastState = i + 1;
}
}
StateTable[lastState].insert({EPSILON, {static_cast<long>(p.size()) + 1}});
// Only
long AcceptableState = p.size() + 1;
set<long> CurrentPossibleStates, NextPossibleStates;
// Start by the START state
CurrentPossibleStates.insert(START);
// explore possible epsilon chains
AddPossibleNext(StateTable, CurrentPossibleStates, START, EPSILON);
// foreach char in str as an input to move
for (long i = 0; i < s.size(); i++) {
NextPossibleStates.clear();
// foreach possible states, do the move
for (auto one_of_currents : CurrentPossibleStates) {
AddPossibleNext(StateTable, NextPossibleStates, one_of_currents, s[i]);
AddPossibleNext(StateTable, NextPossibleStates, one_of_currents, '.');
}
CurrentPossibleStates = NextPossibleStates;
}
NextPossibleStates.clear();
// try to skip any epsilon chain
for (auto one_of_currents : CurrentPossibleStates) {
AddPossibleNext(StateTable, NextPossibleStates, one_of_currents, EPSILON);
}
CurrentPossibleStates = NextPossibleStates;
for (auto possible_end : CurrentPossibleStates) {
if (possible_end == AcceptableState) {
return true;
}
}
return false;
}
};
首先吐槽一下校编教材,实际龙书里面讲解 DFA 这个技术,是在 lexical-Analysis -> Lexical-Analyzer Generator Lex 之后讲 Design of a Lexical-Analyzer Generator 之前时候讲的。也就是,reg -> NFA-> DFA 这个本身是做 Lexical-Analyzer Generator 的技术,校编教材里最后才说有 lex 这种 generator。不用 RE 到 DFA 也可以做词法分析的,可以直接写 DFA。另外一个点就是,龙书首先第二章完整讲了一个 Java 的语法制导编译的代码,之后才讲各种通用技术,这样学生才能有一个很好的系统观点去学习后面的东西。不过我对大专安排水平本来也不抱希望了。
本文假定具备数字电路与逻辑分析基础,没有的话本文下面也讲了,所以也可以当成是自洽的。对于正则表达式本身,我不打算涉及 chomsky 的语言形式化描述,我认为形式化描述确实是必要的但是我学习和理解的过程本身,我不喜欢形式化描述,多轮的否定之否定之后再熟练掌握形式化描述才有效率和意义。
先理解 NFA 和 DFA 的具体区别,对于同一个记号,可能会有多个不同的转移状态。要理解这个,就想起字节3面做的算法题了(leetcode.10)。。考虑 ab*cd 能匹配 abeefcd 不能匹配 abeeee 也就是他要不要转移到下一个序列的判断的时候,* 匹配 e 还是 ee 还是 eef ?这些都是可能性,因此涉及到一个递归尝试然后选择其中一个的问题。然后由于各种情况太多(k的n次方,k还是可变的),递归+回溯可能会 overflow,也有动态规划的做法。对于这个 NFA 转 DFA 的方法很多公开的编译原理课程都有,后续也可以去看 slides。
具体来说,正则表达式本身得到状态机很复杂(就算让人来),但是如果用 NFA 并且支持 epsilon (空输入转移),就能够简单的由正则表达式(编译原理中叫正规式,或者正则表达式是一种正规式,中文术语不纠结)得到 epsilon-NFA(通用的,适用性广的确定性规范算法,考虑正则表达式中的并和或,用 eNFA 表达是非常简单的,Thompson 算法),然后可以有 epsilon-NFA 得到一个 无 epsilon NFA(通用的,适用性广的确定性规范算法),然后我们就能够由无 epsilon NFA 得到 DFA(通用的,适用性广的确定性规范算法),整个 eNFA 到 DFA 的算法是 Subset Construction 算法。最大的问题其实是 NFA 到 DFA 的过程,NFA 的问题是有试探,但是试探中,正如我们可以通过 DP 优化递归回溯一样,一定有重复的部分,那么就可以收缩他们,具体我不说了(因为我现在也根本不会,编译原理考试大题手算 15 分)。分两步,一个是 NFA->DFA, 一个是最简 DFA。
最后一步是 DFA 的通用算法,简单到爆了(NFA 也不是很复杂,就是执行效率太低,下面也讲解了 NFA 怎么做,或者还有一个点是由于 NFA 的简化不容易,多了不确定转移以及 epsilon 边,但是 DFA 进行简化是很简单的因为所有边都是确定的)。
while not EOF:
state := move(state, new_char) // use a hashmap...
new_char := get_next_char();
if state is valid:
return true
return false
整体这个思路就是典中典之高中数学转化与划归思想,因为一步到位可能需要非常复杂的取巧思路,但是规范的方法(which 契合计算机的运行,就像图形学这么多神奇多边形算法放到 3D GPU 里面就没意义了,因为 GPU 做超级简单的统一的三角形做法就能赢)通用有效。
总之词法分析就是:源语言的词法规则 ->正规式 -> epsilon-NFA -> 无 epsilon NFA -> DFA 然后就能用这个 DFA 去做 lexical analysis 了。
所以为什么字节3面我写的没软用呢?因为那个是 NFA (因为有 * 存在,这个东西又叫 kleene star),我写的是 DFA 驱动,我也没有程序或人工把 NFA 转 DFA,所以我写的代码垃圾到爆,最后根本用不了。挂麻了,而且新手现场写 NFA 版也得花上小时计,leetcode hard 还得上 DP...回去狂刷 DP 去了。
下面进入本节正文部分。
只生成 tokens,不管单词如何排列(语法的问题和上下文有关)。
几种方法 | 操作 | 缺点 |
一次遍历 | 依次遍历输出所有的 token 直到错误(一般没办法这里识别错误)或者结束 | 语法错误后浪费了 |
增量同步迭代器模式 | 作为语法分析的子程序,需要一个 token 就调用一次,直到语法分析出错 | 赢麻了 |
并行异步生产消费队列 | token 生产队列,单消费者单生产者无锁队列作为 token stream | 并行编译(然...) |
步骤 | 技术点 |
输入 | 双缓冲区,由于源文件太大(太多)的情况,分批读入源码,创建一个扫描缓冲区,避免内存爆炸(感觉操作系统已经做了这部分,就是不要锁住文件而已?)。 |
预处理 | 无关字符丢掉,所以实际到 token 扫描 char string 的时候,迭代器遇到无关字符要跳出。 |
然后进入到正则表达式和状态机的部分,基本的词法分析器的思路就是纯纯 NFA 思路,因为比如 keywords 和 user defined id 本身就涉及 NFA (不确定的匹配),下面给出简单语言的,主要内容就是区分 id 和 num,至于 literal(字符串常量等)、var 等 keywords,则是通过和 id 一起进行的 NFA 完成的匹配。
下面进入 FA 的部分,首先通过一个记号的状态机来熟悉一下一般的状态机的标识方法吧。
首先复习一下 Pascal 里面的关系比较运算符,!= 是用 <> 表示的,顺便复习一下 SQL 和 C++20 的飞行船运算符 <=> 。MySQL 里面,由于 null 的特殊存在,所以使用 <>/!=/= 比较的时候,null 会得到结果为 null 而不是有效结果,所以 <=> 用了得到确定的结果,如果一个是 null,就会 return 0,如果都是 null 就 return 1. 额,就是标准里面的 IS NULL 的一个语法糖。C++20 里面 <=> 是为了直接得到 <0, =0,>0 三个结果,也就是针对 ordinal atrributes 的。下图我忘记标了,箭头进入意味着起点,至于状态的编号,是随便编号的,这个不重要。
重要的三个功能,一个是接受输入后状态跳转,第二个是结束返回,第三个功能是输入指针回退。(下面这幅图是校编教材里面的,不过本质上也是偷的龙书的图(e2,figure3.13)
有必要顺便讲一下 NFA 的驱动算法,对于 DFA 来说确实很简单,然后当然 NFA 能表达的 DFA 都能表达,但是 NFA 转 DFA 这个过程太复杂了,人手算了算不过来。algs4 里面讲解了怎么写 NFA。
构建 NFA 的过程不说了,就是给有向图加边而已,可以用矩阵或者邻接表表示图。匹配(驱动执行)基本思路是,对于所有出现分叉的情况,比如 s 0 或者 多次,对于输入来说,就要测试读入一个为 s 的情况,如果没有,pass,然后测试下一个 0 的情况,跳转到 i 的匹配中。
然后再讲 NFA 和 DFA 的区别,对于 DFA 来说,每次一个输入,其下一个状态都是确定的一个,所以可以一步就完成了:
loop: state := move(s, new_char) // use a hashmap...
但是对于 NFA,每次的状态转移,下一步是一个集合,即需要 loop 套 loop。我们可以用一个 set 来代表当前的 states,而 DFA 是用一个 int 来代表当前的 state。也就是说 DFA 是一层循环(for chars,每次 move O(1)),NFA 是3层循环(for chars,每轮遍历所有子状态,每个转移遍历可能的转移)。
基本思路讲明白了,下面就讲 a.*b 为例子说明,如何写一个通用的 NFA(通用的)。
我先给出类似于 DFA 的伪代码那样的 NFA 驱动的伪代码(确实很伪),但是实际 NFA 由于 ε 边的存在,采用 move(state, input) 做转移函数接口的话会很麻烦,下面我们就能看到为什么了,之后给出一个效率更高的思路,但是现在已经熟悉 DFA 了,就先按这种思路继续吧!
while not EOF:
for s in cur_states: // for every cases in a single 'states'
next_states.add(move(s, new_char));
new_char := get_next_char();
if state is valid:
return true
return false
龙书的写法是这样的:
其中 move 返回的是一个集合,s 是一个集合,new_char 是一个新的输入字符。对于一开始的 move 函数怎么确定,下面就根据例子更好的理解这一点。这里的 new_char 可以被吃掉也可以不被吃掉,但是不被吃掉的那个转移实际是可以在上一轮就确定了(见下面的表格会证明为什么正确),这样编写程序会更加方便。
得到的是这样的,主要的多状态转移在于 .* 可以吃一个 b 到自己,也可以吃一个 b 到结局。
下面尝试匹配 abcdb:
步 | 当前状态 s (读到 char 收入之后的状态) | 输入 abcdb 读到哪里了 |
1 | {0} | x (输入) |
2 | {a, .*} 注意 .* 存在是指,输入读到 a 时,此时可能处于的状态,由于读到 a 的时候,既可以处于 a 状态,也可以是读入 a 后再读入 ε 进入 .* | a |
3 | {.*, Final} 由于此时 a 无法合法转移到下一个状态,丢弃他 | b |
4 | {.*} 由于 final 无法转移,丢弃他 | c |
5 | {.*} | d |
6 | {.*, Final} | b |
7 | Final is valid,return true。 | EOF |
到这里,理解 NFA 的模拟思路和编写通用 NFA 驱动已经是很简单的事情了,state 是整个状态机的圆圈状态,states 是圆圈状态的一个集合。对于 epsilon-NFA 的构造来说,ε 的一个好处是方便构造 | 的表达式也就是考虑下图的这种情况,以及因为 NFA 本身也要处理这种情况,所以我们必须在前面 DFA 的基础之上,用好几个 set 数据结构:
之后我们还会回来研究 NFA 怎么构造(Thompson 算法),现在主要是为了理解 NFA 的求解驱动,对于这个 NFA 本身的构造来说,他一般是专家代码来的,或者说,编译正则表达式本身就是一门词法分析+句法(语法)分析+... 的过程。现在先看这个特定问题的解决办法:
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。
'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。
链接:https://leetcode-cn.com/problems/regular-expression-matching
相比用 DP 嗯做,我们还可以比较方便地实现 + 的逻辑,也就是在构造部分加一个 if branch \滑稽。对于 ε 边的处理有点麻烦。首先是定义状态表,NFA 的状态表应当是一个矩阵
unordered_map<long, unordered_map<char, set<long>>> StateTable;
然后是求解的过程:
long AcceptableState = p.size() + 1;
set<long> CurrentPossibleStates, NextPossibleStates;
// Start by the START state
CurrentPossibleStates.insert(START);
// explore possible epsilon chains
AddPossibleNext(StateTable, CurrentPossibleStates, START, EPSILON);
// foreach char in str as an input to move
for (long i = 0; i < s.size(); i++) {
NextPossibleStates.clear();
// foreach possible states, do the move
for (auto one_of_currents : CurrentPossibleStates) {
AddPossibleNext(StateTable, NextPossibleStates, one_of_currents, s[i]);
AddPossibleNext(StateTable, NextPossibleStates, one_of_currents, '.');
}
CurrentPossibleStates = NextPossibleStates;
}
NextPossibleStates.clear();
// try to skip any epsilon chain
for (auto one_of_currents : CurrentPossibleStates) {
AddPossibleNext(StateTable, NextPossibleStates, one_of_currents, EPSILON);
}
CurrentPossibleStates = NextPossibleStates;
for (auto possible_end : CurrentPossibleStates) {
if (possible_end == AcceptableState) {
return true;
}
}
return false;
注意的要点就是,由于 epsilon 边的存在,导致 move 的时候可能要引入一个回退指针的功能(上面讲 DFA 的时候提到)。但是我们并不想做回退指针。因此退而求其次,我们规定了对于 ε 边的处理是在上一个状态的时候就要进行,也就必须引入了一个 DFS 回溯的东西在 AddPossibleNext 函数中。这个函数等同于上面的为代码中:
next_states.add(move(s, new_char));
的作用。因此 move 函数实际是这样的:
auto AddPossibleNext(
unordered_map<long, unordered_map<char, set<long>>>& StateTable,
set<long>& NextStates,
long OneOfCurrents,
char Input) -> void {
if (StateTable[OneOfCurrents].count(Input)) {
auto possible_states = StateTable[OneOfCurrents][Input];
for (auto possible_state : possible_states) {
NextStates.insert(possible_state);
// DFS + backtrack (insert into set)
AddPossibleNext(StateTable, NextStates, possible_state, EPSILON);
}
}
}
不过这里其实还有一个办法减少 DFS 的开销,最简单的思路是用一个 memo 因为 NFA 的 ε 路径是已知的。但是这样并不简洁。我们可以改变 move 接受一个 input 的思路,而是把 input 的处理和 move 的求解放到遍历 inputs 的循环里面去,这里我就不写了。一个不是很通用 NFA 的思路在这里有大佬写的很简洁代码(针对本题)可以窃: 建议学习:形式语言与自动机(雾) - 正则表达式匹配 - 力扣(LeetCode) (leetcode-cn.com),而且他这里的 hashmap 本身也不需要了,因为可以通过 state 直接在 pattern 串中找到对应的模式 char 进行匹配。我懒得继续优化了,读者(我)感兴趣的时候再自己写一遍吧。
最后我再补充完建立 NFA (即那个 unordered_map<long, unordered_map<char, set<long>>> )的过程,本文就结束了。一般来说,采用的应该是 Thompson 算法,但是我不打算在这里直接上他,因为针对本个 leetcode 题目的规则比正则表达式简单,我们遵循从特殊到一般,从简单到复杂的学习方法,讲完这个代码再讲解 Tompson 算法。主要关键点是要处理好这个开头和结尾的边界,一种简单的做法是结尾的时候添加一个 ε 边到结束状态。
unordered_map<long, unordered_map<char, set<long>>> StateTable;
// create the first state, as a begining
StateTable.emplace(START, unordered_map<char, set<long>>{});
long lastState = 0;
for (long i = 0; i < p.size(); ++i) {
if (i + 1 < p.size() && p[i + 1] == '*') {
// create a new state
// add a cycle edge to the kleene-star state
StateTable[i + 1].insert({p[i], {i + 1}});
// update the last state with an epsilon-edge,
// if there are chained kleene-stars,
// just think as chained epsilon-edges...
// (codes here can be simplified)
if (StateTable[lastState].count(EPSILON) == 0) {
StateTable[lastState].insert({EPSILON, {i + 1}});
} else {
StateTable[lastState][EPSILON].insert(i + 1);
}
lastState = i + 1;
} else if (p[i] != '*') { // we just skip the stars...
// nornal matching request, we just add a state, and update last state.
StateTable.emplace(i + 1, unordered_map<char, set<long>>{});
// add an edge to this state
if (StateTable[lastState].count(p[i]) == 0) {
StateTable[lastState].insert({p[i], {i + 1}});
} else {
StateTable[lastState][p[i]].insert(i + 1);
}
lastState = i + 1;
}
}
StateTable[lastState].insert({EPSILON, {static_cast<long>(p.size()) + 1}});
技术总结,上面的代码写得又臭又长,但是我觉得这是十分契合简单直接(愚蠢的我)的直觉思路,对 NFA 的优化更有兴趣的,一个是 leetcode 上面针对这一题的有很多简洁的写法,然后 algs 4 上面有专门的章节讲这个,但是他还真的有一个 Graph (邻接表或者矩阵,忘记了)的数据结构,同时支持了括号、|,* 等,纯纯 DFS 思路,我是不想研究了。
下一篇,讲解 Thompson 算法,即完整的正则表达式如何生成 NFA,前面对这道题的 NFA 生成,不直接讲解 Thompson 算法的原因是,我们在这道题不用考虑的东西属实是太多太多了。就算是前面那个 NFA,如果是完全没有经验的人(比如我这种采集)来写,可能一张纸,一道力扣几个小时就过去了。还是按龙书的来吧。
上面的代码可能写得有点复杂离谱了。实际龙书的伪代码图片是写完代码才贴上来的。一开始的代码因为校编教材没讲NFA 驱动,我乱写的。后来我发现龙书的 NFA 伪代码直接考虑 epsilon-edge,好写多了,后悔了。
重新复习一下正则表达式比较常用的一些功能
. | 匹配任意 |
^[] | [^abc] except a|b|c |
* | 0=+ 次 |
+ | 1=+次 |
^ | 匹配开始 |
$ | 匹配结束 |
() | 递归子正则表达式 |
我前面说了,使用 NFA 并且有了 epsilon 之后就会很简单,因为我们马上就有直觉的思路了。
首先我们构建一个思路,就是所有的状态机,都可以由 ε 边和一系列的子状态机构成。由于我们之后可以优化,所以这个过程引入一大堆没有用的 ε 边并没有什么关系!最简单的情况是入边和结束边。鉴于上一篇文章我们写简化正则 NFA 的时候,用到了一个 epsilon 边来做结尾的跳转(即所有能合法结束的状态,都添加一个真结束状态,然后给他们添加 epsilon 边到达终态),这个就是一个很简单的点了,这样使得状态机的终结变成了自然而然的事情。
StateTable[lastState].insert({EPSILON, {static_cast<long>(p.size()) + 1}});
以下阐述一个直觉的串联情况,并联我们也马上可以做类似的思想实验:
然后这里的 F1 和 s2 实际是可以合并为同一个状态的。到这里我们对子状态机做的思想实验就结束了。
有了上面的思路,下面给出比较正式的方法,首先给出两个最基本的组成成分的子状态机的构成方案:
然后是串并联,其中 M 是一个子状态机(也是 ε-NFA),s 是状态,p,q 是不同的子状态机编号,Mp 就是编号为 p 的子状态机如此类推。
然后到了一个比较关键的点,就是 kleene star,前面做的 leecode 10 的时候我们没有括号,现在有括号的情况下,我们明白一个*可以是重复一个子状态机,而不仅仅是一个字符 0=+ 次。这里就会有点不一样,我们首先用我拙略的画图工具画一幅图,讲解这个转移到自己的情况,然后就能理解下面的 epsilon 边回环了。
首先上面已经明白了一个 a 的匹配是这样的:
因此,我们要基于这个为基础来实现 a*,实际 a* 有三种做法:
我们依照递归的思路,自然而然选择下面这种。因此就能得到 kleene star 的 thompson 算法表述了:
有了这三个,稍加扩展和补充,正则表达式的所有匹配都可以完成了。算法的本身也很容易编写。
好了,有关生的 NFA 和 DFA 就绷到这里,接下来的章节笔记将会来到 NFA 转换到 DFA 的算法(Subset Construction)。
总之一切就是守恒定律了,NFA 在构造的时候很简单,但是求解的代码就很麻烦,然后效率也低下; DFA 很难构造,构造时需要编写很难的算法,但是后续的优化和求解的时候就很简单了,而且效率也高。既然编译中我们需要的语法规则一般是给定的,也就是说 DFA 是可以相对稳定的,那么为了提高编译效率,我们花时间精力一次做好复杂的 DFA,这样编译的时候求解 DFA (运行 DFA 驱动) 就得到高效率了。所以也就理解,在 Thompson 算法以及后面的 Subset Construction 算法中,用到的复杂度可能会不低,但是我们的最终目标是减少最终运行词法分析器(即给定的 DFA 执行 DFA 驱动程序进行模拟)的时候效率最高。
此处应当有一系列手算正则得到 NFA 的练习题,但是我觉得可以进行编程练习,读者到这里可以尝试编写简单的 NFA 正则表达式引擎,只需要支持上面表格支持的匹配即可,不需要支持括号参数替换等功能,而 NFA 的驱动前面已经练习过,应当很容易写出来(几个小时)。
实际词法分析的故事到此就可以完结了,因为本质上我们讲解是如何生成一个状态机来匹配某个单词,而直接写一个状态机来得到 tokens 其实是很简单的事情,因为故事只不过是等于遇到空格的时候判断一下当前的 state,如果是 id ,就打个 id 的 tag,如果是 keywords,就打个 keywords 的 tag,然后丢给某个数据结构存起来,就可以进入句法/语法分析了。。。
接下来的词法分析章节,可以认为是大神超人们为了编译的性能和效率,进行的通用的词法分析器的生成器最终优化。
因为我们直到这个东西可以做出来了,反正之后也是用 lex 做词法分析器,不做编译器的话谁会手写词法分析器啊啊,而且就算到了要手写词法分析器的时候,也不会走解析 reg 然后 NFA -> DFA 一般。教科书里面这样走是因为因为我们需要一个 lex (flex)这样的东西,也就是用正则来描述语言,然后生成一个状态机,这种方法叫做表驱动。
如果我们需要手写词法分析器,也就是可能有性能和效率的考量,不会采用 lex 这种通用的 generator,那么就自己根据语法规则写状态机就行了,当然是自己根据要实现的语言,直接写 DFA,怎么写呢?当然是嗯造,这时候回退指针这种东西也能派上用场,然后用上 switch case 语句,基本就像 CS61A 里面 scheme lab 的那样的做法。比如公共前缀的语法里,分别用 switch case 和定义一大堆 state 去实现就行了,纯纯类似 DFA,比如需要匹配用户定义 id,那就是用 [a-zA-z]+([a-zA-z]|(0-9))*, 如果出现了 keywords,就查字典balabala,对于状态太多,还可以写一写小工具来处理,这些在知乎用户蓝色的博客里面都有讲解,也有示例代码,不过是对于 Pascal 的。所以词法分析暂时到这里我就不写了。不过 NFA 转 DFA 的算法还是要掌握的,毕竟期末 15分大题,我这里就懒得做笔记了,因为做法基本是机械套路(一个速成PPT),也不知道为什么这样做是正确的(懒惰要不得)。而且,实际我们有直接的从 RE(regular expression)生成 DFA 的算法(龙书 2e 3.9),就是算法和过程比这个还麻烦一点。
不过算了,为了帮助自己期末速成,我还是开一篇记录一下怎么做,以及潜在的为什么能 work。。。。