首先说明这是网易云课堂中国科学技术大学华保健老师的课程《编译原理》听课笔记,大部分内容是ppt资料,为了方便记忆,写了如下笔记。
词法分析器的任务
首先看编译器结构:
前端又可以分为:
语义分析器也叫类型检查器。
词法分析器结构又为
词法分析器将代码切分为单词,下面是例子:
x,y这些就是记号,EOF也是记号,记号是个大集合。
记号的数据结构定义
字符流变记号流,首先实现记号的数据结构,用C语言实现如下
enum kind {IF, LPAREN, ID, INTLIT, …}; //枚举类型,对词法的分类
struct token{
enum kind k; //第一个域,类型
char *lexeme; //具体值
};
例子:
if(x>5)-->
token{k=if,lexeme=0};
token{k=LPAREN,lexeme=0};
token{k=ID,lexeme="x"};
token{k=GT,lexeme=0};
......
词法分析器的任务:字符流到记号流
- 字符流:
- 和被编译的语言密切相关(ASCII, Unicode, or …)
- 记号流:
- 编译器内部定义的数据结构,编码所识别出的词法单元
手工构造法
至少两种实现方案:
- 手工编码实现法
- 相对复杂、且容易出错
- 但是目前非常流行的实现方法
- GCC, LLVM, …
- 词法分析器的生成器
- 可快速原型、代码量较少
- 但较难控制细
- 我们先讨论第一种实现方案
- 后面几讲会讨论第二种方案
手工构造主要问题是转移图的概念
如下是字符转记号的转移图:
优化之后:
下面是转移图的算法
token nextToken ()
c = getChar ();
switch (c)
case ‘<’: c = getChar ();
switch (c)
case ‘=’: return LE;
case ‘>’: return NE;
default: rollback(); return LT;
case ‘=’: return EQ;
case ‘>’: c = nextChar ();
switch (c): // similar
如何实现rollback
加入你正在实现基于图转移算法一个词法分析器,所用的语言是C语言。分别在以下情况下,请问你该如何实现上面的rollback()函数:
-
你的词法分析器所分析的程序是直接从文件中读取的;(FILE *)
-
词法分析器所分析的程序已经被从文件中读到了一个数组中,然后从数组中读取字符。
两种做法有什么优劣。
void rollback()
{
fseek(fp, -1, SEEK_CUR); // 这里文件指针假定是全局的了。
}
void roolback()
{
if (nFileIndex >0)
--g_nFileIndex; // 假定索引为全局变量
}
从文件:效率比数组要慢。且异常情况可能会对原文件有误操作,但节省内存
从数组:随着文件的增大,消耗的内存也会提高。但,效率高容易操作。
其他符号词法识别
标识符和关键字关系
很多语言中的标识符和关键字有交集
- 从词法分析的角度看,关键字是标识符的一 部分
以C语言为例:
- 标识符:以字母或下划线开头,后跟零个或 多个字母、下划线、或数字
- 关键字:if, while, else, …
识别关键字(以if为例)
第一种方案:
就是把i单独抠出来,当识别到i,从0走到3方向f同理。
第二种方案:
对给定语言中所有的关键字,构造关键 字构成的哈希表 H
对所有的标识符和关键字,先统一按标 识符的转移图进行识别
识别完成后,进一步查表H看是否是关键 字
通过合理的构造哈希表H(完美哈希), 可以 O(1)时间完成
课程中提到了完美哈希(perfect hashing),请结合查询wiki等资料,回答:
什么是(关键字的)完美哈希?
如何构造完美哈希?试列举一到两种算法。
为什么需要构造完美哈希?用关键字单链表是否可以?
-
完美哈希函数是没有冲突的的哈希函数,也就是,函数 H 将 N 个 KEY 值映射到 M 个整数上,这里 M>=N ,而且,对于任意的 KEY1 ,KEY2 ,H( KEY1 ) != H( KEY2 ) ,并且,如果 M = = N ,则 H 是最小完美哈希函数(Minimal Perfect Hash Function,简称MPHF)
-
打造一个完美的Hash函数,需要针对相应关键字的有限集合来实现,在查阅资料以后发现,先构造两个普通的哈希函数h1(x)和h2(x),还有一个用数组实现的函数g(x)。使得 h(x)=g(h1(x))+g(h2(x))modn ,其中n是参数的总个数,H(x)就是最终的有序最小完美哈希函数了
-
编译器需要有更快的速度来完成关键字辨别的速度,构造完美哈希可以提高效率,链式结构的链表不支持随机访问的,而且还要操作指针,效率更低
正则表达式
背景:写一个声明式的规范,通过如lex,flex,jlex,变成词法分析器。
对给定的字符集∑={c1, c2, …, cn}
归纳定义:
- 空串是正则表达式
- 对于任意c∑,c是正则表达式
- 如果M和N是正则表达式, 则以下也是正则表 达式
- 选择 M | N = {M, N}
- 连接 MN = {mn| mM, nN}
- 闭包 M* = {, M, MM, MMM, …}//也叫kleene闭包
正则表达式的形式表示
e ->
| c
| e | e
| e e
| e*
问题:对于给定字符集={a, b},可以写出哪些正则表达式?
1.
2.a,b
3.|,a|b,|a,|b,//选择
4,a,b,ab,,....a(|a),//连接
5.*,(a(|a)*
引入正则表达式是为了表达程序语言规则,生成词法分析器。下面是如何用正则表达式生成词法分析器的例子。
关键字
C语言中的关键字,例如if,while等
如何用正则表达式表示?
if:i∑,f∑,所以连接还是正则表达式。
C语言中的标识符:以字母或下划线开 头,后跟零个或多个字母、数字或下划 线。
如何用正则表达式表示?
这个可以分为两个部分,前半部分是字母加下划线,53种情况,后半部分是再加上数字63种情况。
然后两部分连接再有个闭包,也是正则表达式。
C语言中的整型数和浮点数的正则表达式
如何用正则表达式表示?
整数(+|-)?[1-9][0-9]*
浮点数(+|-)? (0|[1-9][0-9]*).[0-9]*
语法糖
图灵机只需要两种运算就能完整所有操作,赋值和跳转,C或者JAVA是为了更方便,所有所有的语句都是对下层
这两种运算的封装。也就是说语法糖不是必须的,只是为了更方便。
可以引入更多的语法糖,来简化构造
[c1-cn] == c1|c2|…|cn//顺序,表示c1到cn的任意一个
e+ == 一个或多个e
e? == 零个或一个e
“a*”== a* 自身, 不是a的Kleen闭包
e{i, j} == i到j个e的连接
. == 除‘\n’外的任意字符
有限状态自动机
如果要自动生成一个词法分析器,需要写一个声明式的规范,通过一个词法分析器的自动生成工具(flex),生成一个词法分析器,描述输出是什么,这里就是有限状态自动机(FA)。
就是自动机可以告诉你能不能接收或者识别提供的字符串。可以写成一个元组M,如上图。S是指的有限自动机的状态。q0是自动机一开始的状态,F是结束状态集,转移函数描述怎么动作。
自动机例子
下图黄色是状态
这里∑={a,b},S={0,1,2},q0=0(一般用一个单项箭头代表起始状态),F={2}(一般用双圈代表),如上图,接收就是通过转移函数到最后状态。
自动机第二个例子
这是个非确定的状态机(NFA),就是转移是多元素集合,每次转移是个状态集,多个转移状态。这种情况就比较复杂了,因为接受需要回溯状态。
确定状态有限自动机DFA
- 对任意的字符,最多有一个状态可以转移
- :SX∑->S
非确定的有限状态自动机NFA
- 对任意的字符,有多于一个状态可以转移
- :SX(∑ )->S(幂集,子集集合)
DFA的实现
是个有向图,边和节点都是有信息的,就是有向图。
正则表达式到非确 定有限状态自动机
RE -> NFA:Thompson算法
基于对RE的结构做归纳
- 对基本的RE直接构造
- 对复合的RE递归构造
递归算法,容易实现
- 在我们的实现里,不到100行的C代码
空串和单字符,直接构造,e1e2这种连接的如下构造
选择自动机形式如下,先构造e1,e2在用4个空,构造。,闭包也如下图
示例
a(b|c)*
证明题
这个论断是否成立:由Thompson算法构造出来的任何一个NFA,均只可能包括唯一的起始状态和唯一的接受状态。
若成立,请给出证明;若不成立,请给出一个反例。
成立,证明;
对于根据epsilon和单个字符的正则表达式构造的NFA只有一个起始状态和一个接受状态。
对于两个正则表达式e1,e2,假设它们构造出的NFA只有一个起始状态和一个接受状态。那么,对于e1|e2, e1 e2, e1*这三种情况,其结果也都是只有一个起始状态和接受状态的。
由归纳法可以得出结论成立
NFA转换到DFA
思想就是由起始状态出发,先读入任何一个字符,看能走到哪个节点,然后考虑根据看能够扩展到哪个节点,然后这些节点做一个集合,构成一个边界,然后从这个边界再找一个点,看能够通过扩展到哪个点。构成一个新的集合。
注意点
也就是有两步,先状态转换,就是先在NFA上能转换到哪个点,再看新集合上能转换成哪个点,然后对集合上每个点求闭包。
如下图
子集构造算法
(* 子集构造算法: 工作表算法*)
q0 <- eps_closure (n0) //随便取一个节点,看通过epsiono能走到的点,所以q0={0}
Q <- {q0} //这个点作为初始节点集,加入大Q,Q就是DFA的所有状态机
workList <- q0 //q0加入工作集
while (workList != [])
remove q from workList
foreach (character c) //对每个点都要循环
t <- e-closure (delta (q, c)) //看通过每个路径能走到哪个节点,发现这个例子只能走道n1,再看这节点能走epsiono闭包到哪个节点,就是先算一个状态,再算这个状态delta的闭包
D[q, c] <- t 把t(之前的所有能走到的闭包)加到DFA里
if (t\not\in Q)
add t to Q and workList,如果不在大Q,将新qx加入
代码操作过程就是下面如图:
叫子集构造算法是因为在不停构造一个个集合,每个节点都是状态机的子集。工作表算法就是有个worklist存放有待计算的节点。
对算法的讨论
不动点算法
- 算法为什么能够运行终止(循环到worklist为空为止,但是worklist是Q是NFA状态集合,这个n各元素幂集有限,是2^n,所以有限)
时间复杂度
- 最坏情况O(2N)
- 但在实际中不常发生
- 因为并不是每个子集都会出现
-闭包的计算:深度优先
复杂度O(n)
-闭包的计算:宽度优先
就是把一个点的所有能通过走到的点加入一个队列,然后向外扩散
epsilon闭包的计算
有两种方式可以计算epsilon闭包:
-
在线方式:在算法计算的过程中,遇到需要计算某个节点的epsilon闭包的时候,临时计算;
-
离线方式:在算法执行之前,把NFA中所有节点的epsilon闭包都计算完;这样算法开始执行后可随时使用。
你认为这两种方式哪种比较好?原因是什么?
因为在构造子集的过程中,获取某些节点的Epsilon闭包多次,如果每次需要的时候都计算,这样大大降低了程序效率。所以为了避免重复计算,离线方式才是最好的。但同时也可能一些闭包从未被使用过,有利有弊吧。
DFA的最小化
Hopcroft算法
// 基于等价类的思想
split(S) //切分
foreach (character c)
if (c can split S) //c能够切分开一个子集S
split S into T1, …, Tk
hopcroft ()
split all nodes into N, A //先将上面切分的状态分为接收状态和非接收状态,2个集合
while (set is still changes) //如果两个状态能切分再切
split(S)
注意hopcroft通过字符看能不能划分成子集是接受个字符会不会从这个集合出去。
下面两个示例,第一个示例,划分为N和A,之后通过字符不可能把A切分成开(比如通过b,c)
DFA的代码表示
概念上讲, DFA是一个有向图
实际上,有不同的DFA的代码表示
- 转移表(类似于邻接矩阵)
- 哈希表(表示关系)
- 跳转表(跳转代码实现)
- 。。。
取决于在实际实现中,对时间空间的权衡
转移表
构造个二维数组,x就是DFA的集合数量,y为字符数(如256个的ASCII表),这个就是转移表,还需要词法分析的驱动代码,根据表示表项做控制。代码如下:
nextToken() //每调用一次,返回识别的串
state = 0 //目前自动机走到的那个地方
stack = [] //为了实现最长匹配
while (state!=ERROR)
c = getChar()
if (state is ACCEPT)
clear(stack)
push(state)
state = table[state][c]
while(state is not ACCEPT)
state = pop();
rollback();
跳转表
nextToken()
state = 0
stack = []
goto q0
q0:
c = getChar()
if (state is ACCEPT)
clear (stack)
push (state)
if (c==‘a’)
goto q1:
q1:
c = getChar()
if (state is ACCEPT)
clear (stack)
push (state)
if (c==‘b’||c==‘c’)
goto q1
从拓扑结构来说,跳转表跟自动机结构一样如下图
跳转表基本实现是把每个状态变成一段代码,把边的转移变成显式的跳转,每个代码负责识别每个字符的跳转。
所以跳转表不用维护数组。性能高一些。
参考资料
网易云课堂课程:编译原理(中国科学技术大学 华保健)