第四章:正则表达式与自动机——从文本到状态机的魔法
说实话,编译器前端最让我着迷的部分,就是词法分析这一块。你想想看,一堆乱七八糟的字符流,怎么就能被识别成有意义的单词?这背后靠的就是正则表达式和自动机理论。今天我就带你把这套东西彻底搞明白。
核心思想:正则表达式描述模式,NFA/DFA执行匹配。编译器里词法分析器干的就是这个活。
4.1 正则表达式到NFA:Thompson构造法
我记得刚学编译器那会儿,看到Thompson构造法觉得特别神奇。它能把任何正则表达式,转化成等价的NFA。说白了,就是给每个正则操作都配一套固定的NFA模板。
基本的构造规则其实就几条:
- 空串ε:直接一个开始状态到接受状态的ε边
- 单个字符a:开始状态通过a到接受状态
- 连接AB:把A的接受状态和B的开始状态合并
- 选择A|B:新开始状态通过ε边分别到A和B的开始状态
- 闭包A*:新开始状态既是接受状态,又通过ε边到A的开始状态
我的习惯:写Thompson构造法时,我总喜欢先画ε边,再画字符边。这样不容易漏掉状态转移。
举个例子,正则表达式 a(b|c)* 的NFA构造过程:
1. 构造 a 的NFA: [0] --a--> [1]
2. 构造 (b|c) 的NFA:
[2] --ε--> [3] --b--> [4] --ε--> [5]
[2] --ε--> [6] --c--> [7] --ε--> [5]
3. 构造 (b|c)* 的NFA:
[8] --ε--> [2] ... [5] --ε--> [8]
[8] 也是接受状态
4. 连接 a 和 (b|c)*:
[1] --ε--> [8]
嗯,这里要注意:Thompson构造法生成的NFA有个特点——每个状态最多有两个出边,而且ε边特别多。这为后续的NFA到DFA转换埋下了伏笔。
4.2 NFA到DFA:子集构造法
NFA虽然构造简单,但执行起来效率低。为啥?因为一个状态可能同时走多条路径。DFA就不一样了,每个输入字符只有一条确定的路径。
子集构造法的核心思路:把NFA的状态集合,映射成DFA的单个状态。
具体步骤我总结成三步:
- 计算ε闭包:从一个状态出发,所有能通过ε边到达的状态集合
- 计算转移:对每个输入字符,计算当前状态集经过该字符能到达的状态集
- 重复直到没有新状态集出现
我曾经踩过的坑:写子集构造法时,忘了处理ε闭包的传递性。比如状态A能ε到B,B能ε到C,那A的ε闭包必须包含C。这个递归关系很容易漏掉。
来看个具体例子。假设NFA有状态{0,1,2},其中0通过a到1,1通过ε到2。那么:
ε-closure(0) = {0, 1, 2} // 因为0→1→2都是ε可达
move({0,1,2}, a) = {3, 4} // 假设3和4是a转移的目标
ε-closure({3,4}) = {3, 4, 5} // 继续计算ε闭包
你想想看,这个过程其实就是在做状态集的幂集构造。最坏情况下,DFA的状态数可能达到2^n,但实际中远小于这个数。
4.3 DFA的最小化
DFA构造完了,但往往不是最简的。有些状态其实是等价的,可以合并。最小化的目的就是找出这些等价状态。
我常用的方法是填表算法:
- 先把所有状态对标记为「可能等价」
- 然后反复检查:如果两个状态对同一个输入字符转移到不同等价类的状态,那它们就不等价
- 直到没有新的不等价对出现
关键点:接受状态和非接受状态一开始就是不等价的。这是初始划分的基础。
举个例子,假设DFA有状态A、B、C,其中C是接受状态:
初始划分:{A, B} 和 {C}
检查A和B:对输入a,A→C,B→C(都到接受状态,暂时等价)
对输入b,A→B,B→A(都在非接受状态集内,暂时等价)
所以A和B等价,可以合并
我个人习惯在最小化之后,再手动检查一遍所有转移是否完整。有时候最小化会暴露一些死状态,需要补上默认的出错转移。
4.4 词法分析器生成工具:Lex/Flex
理论讲完了,来点实际的。Lex(及其GNU版本Flex)就是基于上述理论的词法分析器生成工具。你写正则表达式,它帮你生成C代码。
一个典型的Lex文件结构:
%{
// C代码头,比如头文件、全局变量
#include <stdio.h>
%}
%%
// 规则部分:正则表达式 + 动作
"if" { return IF; }
"then" { return THEN; }
[a-zA-Z]+ { return ID; }
[0-9]+ { return NUM; }
[ \t\n] { /* 跳过空白 */ }
. { printf("非法字符: %c\n", yytext[0]); }
%%
// C代码尾,比如main函数
int main() {
yylex();
return 0;
}
我建议:写Lex规则时,把关键字放在标识符前面。因为Lex是贪婪匹配,如果标识符规则在前,"if"会被识别成标识符而不是关键字。
Flex的工作原理,其实就是把我们前面讲的理论串起来了:
- 把每条正则表达式用Thompson构造法转成NFA
- 用子集构造法把NFA合并成DFA
- 对DFA做最小化
- 生成C代码,用表驱动或直接编码的方式执行匹配
我记得第一次用Flex生成词法分析器时,看到它自动处理了那么多细节,真心佩服前人的智慧。你只需要关注「匹配什么模式」和「匹配后做什么」,剩下的交给工具。
避坑指南:我曾经在Flex里写了一个超长的正则表达式,结果生成的DFA状态数爆炸,编译都过不去。后来拆成多个小规则,用Lex的起始状态来管理,问题就解决了。
本章小结
这一章我们走完了从正则表达式到词法分析器的完整路径:
| 步骤 | 输入 | 输出 | 核心算法 |
|---|---|---|---|
| 1 | 正则表达式 | NFA | Thompson构造法 |
| 2 | NFA | DFA | 子集构造法 |
| 3 | DFA | 最小化DFA | 填表算法 |
| 4 | 正则规则 | 词法分析器 | Lex/Flex |
说白了,这些理论不是空中楼阁。你写的每一行Lex规则,背后都在经历这些转换。理解了底层原理,遇到问题才能快速定位。
嗯,这一章的内容就到这。代码写多了,回头看看这些基础理论,反而觉得更有味道。