词法分析器
什么是词法分析器
当我们在写一个程序的时候,我们通常要打开一个编辑器并新建一个文件,如notepad、notepad++,并通过敲打键盘往此文件中输入具有一定规则(符合语言的语法)的字符。
这个或这些文件最终要被翻译成机器能够识别的语言,翻译的工作由编译器来做,而我们所编写的这些文件理所当然的就是编译器的输入啦。
我们编写文件所按照的“一定规则”就是一门语言的“语法”。我们写程序必须完全按照这门语言的语法去写,这样写完之后才能被这门语言的编译器来进行翻译。
但是通常文件很长的时候,我们通常不能一眼看出来这个文件有没有错误,所以编译器要进行分析,来分析我们所写的程序到底有没有错误。
这些分析步骤有很多个,其中第一个就是词法分析,它的作用就是将文件的一个个单词进行识别,并检查这些字符串是哪些类别,有的时候还要计算这个字符串的字面值。
假如一串字符符合规范,我们称这个字串为记号。
比如拿 C 语言为例,大概有4个类别的记号。关键字如 if,标识符 如 i,特殊符号如 >, 多字符符号如 >=.
下面这段程序:
int i = 0; i++;
经过词法分析器扫描,int会被识别为关键字且字面值为 “int”, i 会被识别为标识符且字面值为 “i”, = 会被识别为赋值运算符,0会被识别为数字值且值为0,;被识别为语句结束符,++ 被识别为自增运算符。
所以,词法分析的输入是文件,输出是一个一个被识别好类别的记号,中间如果有错误则当然也要报告错误。
怎么表示规则
一门语言,最基本的是它的语法,语法可以理解成一种规则,一种写什么东西才能被编译器理解的规则。
比如,C 语言中一个合格的标识符不能以数字打头,且只能含右字母,数字和_(下划线) 组成。这不是一个特定的值,而是一种模式,凡是符合这种模式的,都是合格的C语言标识符。
那么,用什么来表示这种模式呢?
答案是正则表达式,正则表达式特别适合描述文本的某种模式。
正则表达式
基本正则表达式
有关正则表达式(regular expression)的基本定义可以看这里。
正则表达式的基本运算
- 选择, 格式为r|s
- 连结, 格式为rs
- 重复, 格式为r*
这三个优先级为 重复, 连结, 选择。
正则表达式的扩展
- 一个或多个重复, r+
- 任意字符, .
- 字符范围, [a-zA-Z]
- 不在给定集合中的任意字符, ~(a|b|c)或[^abc]
- 可选的子表达式, r?
程序语言语法中常用的正则表达式
- 
数字 nat = [0-9]+ signedNat = (+|-)?net number = signedNat ("." nat) ? (E signedNat)?
- 
保留字 reversed = if | while | do | ...
- 
标识符(此处为追求简单和C语言中略有不同,标识符中不包含下划线), 不以数字开头,只包含字母,数字的单词 letter = [a-zA-z] digit = [0-9] identifier = letter (letter | digit)*
有穷自动机
我们找到了定义模式的方法(通过正则表达式),可是我们的输入是一个接一个的字符, 如何判断这一个接一个的字符是否与我们所定义的模式匹配呢?
有穷自动机就可以做这样的事情,有穷自动机接收输入而使得系统自己的状态发生变化,病着随着状态的变化,其可以接受的输入也会随之变化.有关有穷自动机的定义可以看这里.
确定性的有穷自动机(DFA)
正则表达式到有穷自动机
确定性的有穷自动机(DFA)是有穷自动机的一种,对于DFA,给出一个状态和字符,通常只会有一个指向单个的新状态的唯一转换。
我们可以通过构造有穷自动机从而达到根据模式接受字符串. 以下是数字, 标识符的自动机:
1. 为数字构造有穷自动机

2. 为标识符构造有穷自动机

有穷自动机中的错误表示
其实每个状态机图中转换出现的错误状态一般都不将其写出,原因是DFA一般只给出运算的要点。
非确定性的有穷自动机(NFA )
有穷自动机存在的问题
DFA没有什么问题,但是当我们将所有的小的DFA合并到一个大的DFA的时候,我们通常会得到一个巨大,易出错的DFA。
我们需要一种能够让我们写起来方便,又不会出错的机制。
NFA就是这么出来的,它引入了一个 ε 代表空串。引入的结果就是,无需输入字符就可以往自身转换。
对于DFA,给出一个状态和字符,通常只会有一个指向单个的新状态的唯一转换。
对于NFA,给出一个状态,同一个输入可以有多个可能的下一个状态。
用图说的话:
1. 用DFA的时候,你只能这么写:

2. 但是用NFA,你可以写的随便些。

可以看出,比起DFA,NFA包含的冗余信息很多,我们可能会想,NFA写的这么随便,那么最后写起代码来,代码岂不是很长很乱?
其实,我们最后写代码还是用的DFA。DFA更为精确,给出一个状态和一个输入,只有一个新状态对应,DFA比起NFA更为短小。
所以我们最后写代码肯定还是按照DFA来写。
说了半天,又绕回到DFA了。是的,我们还需要将我们写的NFA转换到DFA,注意,在这里,不管你的NFA写的多么的千奇百怪,只要转换是
正确的,经过
- NFA转DFA
- DFA最小化
这两个步骤,你得到的这个DFA一定是唯一的且最小的。
NFA
有关NFA的定义可以看这里。现在我们要做的已经不是从正则表达式到DFA了,而是从正则表达式到NFA。
正则表达式到 NFA
从正则表达式到 NFA 用的是 Thompson 结构,Thompson 结构利用 ε- 转换将正则表达式的机器片段“粘在一起”以
构成与整个表达式相对应的机器。
1. 基本正则表达式
单个字符的匹配,拿字母a为例,对应的NFA是:

ε 表示空串匹配,对应的NFA是:

2. 并置
正则表达式:rs
对应的NFA是:

3. 在各选项中选择
正则表达式:r|s
对应的NFA是:

4. 重复
正则表达式:r*
对应的NFA是:

下面两个例子展示了 数字和标识符(以字母开头,且只能包含数字和字母,不含下划线)如何用 Thompson 结构表示:
标识符:

数字:

NFA 到 DFA
现在我们可以用正则表达式来写模式,可以正则表示构造NFA,但是我们代码仍然用 DFA,所以现在剩下的是如何将 NFA 转化为 最精简的DFA。用的算法有两个:
- 利用子集构造的方法将NFA 转化 DFA
- DFA 最小化
子集构造法
NFA相较于DFA主要是多了ε- 转换和在单个输入字符上的多重转换,所以子集构造法的用途就是消除这两种转换。
其中消除 ε- 转换涉及到了 ε- 闭包的构造;而消除单个输入字符上的多重转换涉及到跟踪可由匹配单个字符而达到的状态的集合。 这两个步骤都需要与状态的集合而不是单个状态打交道。
所以这个算法叫做 “子集构造”。
什么是状态结合的 ε- 闭包 呢?
ε- 闭包指的是 单个状态s 经过零个或者多个ε- 转换能达到的状态集合s’。需要注意的是,定义中规定零个ε-转换到达的状态一定包含s状态本身,即s’一定包含s。
我们讲过ε- 闭包 是针对集合的,那么一个集合的ε- 闭包指的是,有一个状态集S,其中每个状态s 经过零个或者多个ε- 转换能达到的状态集合s’的并集S’,其中S’也是必然包含S本身。
例如以下NFA (a*):

单个状态上的ε- 闭包 :1’= {1, 2 ,4}, 2’= {2}, 3’= {3, 4}, 4’= {4}.
而对于状态集 {1,3}’= 1’∪ 3’={1, 2, 4} ∪ {3, 4} = {1, 2, 3, 4}
子集构造过程是在单个输入字符上的多重转换,中间构建状态集的ε- 闭包也是关键的一部分。
给定了一个NFA,它的子集构造过程是怎样的呢?
1. 找出这个 NFA的初始状态 Start,并构造这个Start集合的ε- 闭包 Start’。把Start’放入集合 S 中。
2. 计算Start’上的由输入产生的转换,如Start’中有字母a上的转换,对于Start’中的每一个状态s,若有字母a 上的转换,则将s经a转换到的状态t添加到集合 {Start->a} 中。
3. 计算{Start->a}的ε- 闭包 {Start->a}’。将{Start->a}’放入S中。
4. 若{Start->a}’上由输入产生的转换,如{Start->a}’有字母b上的转换,对于{Start->a}’中的每一个状态p,若有字母b上的转换,则将p经b转换到的状态q添加到集合 {{Start->a}’->b}中。
5. 计算{{Start->a}’->b}的ε- 闭包{{Start->a}’->b}’,将{{Start->a}’->b}’放入到S中。
6. 重复4,5 直到 S 中的状态集个数不再增多。
注意:子集构造过程中,若某一状态有结束状态标志,则此状态所在的集合也有结束状态标志。
以a*的NFA状态图为例:

1. 初始状态为 1,计算1’={1, 2, 4}。 添加1’到S中。
2. 计算 1’中字母a 上的转换,只有{3}
3. 计算 {3}的ε- 闭包 {3}’= {2, 3, 4}。将{3}’添加到S中。
4. 计算{2,3,4} 上字母a的转换,有 {3}
5. 计算 {3}的ε- 闭包 {3}’= {2, 3, 4}。由于{3}’已经在S中,所以{3}’不往S中添加。
6. 之后发生的转换总是{3}’到其自身状态的转换,所以不会再有新的转换发生,子集构造结束。
得出的a*的DFA图为:

由于 状态4为结束状态,所以1’和3’都有结束状态标志。
DFA中状态数最小化
自动机理论有一个很重要的结论:对于任何给定的DFA, 都有一个含有最少量状态的等价的DFA。而一个NFA经过子集构造法之后,生成的DFA可能比需要的要复杂,我们还需要一种算法,使得DFA中的状态最小化。
状态最小化后的标志是什么呢?
1.没有多余状态(死状态)
2. 没有两个状态是互相等价(不可区别)
 
两个状态s和t等价的条件:
兼容性(一致性)条件——同是终态或同是非终态
  
传播性(蔓延性)条件——从s出发读入某个a和从t出发经过某个a并且经过某个b到达的状态等价。就是相同。
下面就介绍如何用分割法来最小化DFA。
最小化的步骤是:
1. 首先将 DFA 中的状态分为两个集合,状态集合P 存放所有终结(接收)状态,状态集合Q 存放所有的非终结状态。
2.
- 对于集合 P 和字母表中任一字母 a
- 若 P 中所有状态在 a 上到达的状态都在 P 中,那么结状态集合 P 就有到其自身的 a- 转换。
- 若 P 中所有状态在 a 上到达的状态都在 Q 中,那么终结状态集合 P 就有到 非终结状态集合 Q 的 a- 转换。
- 若接收状态 s 和 t 在 a 上有转换且到达的状态位于不同的状态集合(比如一个在 P 一个在Q),那么 a 区分了状态s 和 状态t。此时状态集 P 应该分割为PaP和PaQ,其中PaP包含P中在a上到达状态集P的所有状态,PaQ包含P中在a上到达状态Q的所有状态。
- 若接收状态 s 在 a 上由转换,但是 t 却没有,那么 a 区分了状态s 和 状态t。此时状态集 P 也应该被分割。
- 对集合 Q 做同样的划分
- 若 Q 中所有状态在 a 上到达的状态都在 Q 中,那么非结状态集合 Q 就有到其自身的 a- 转换。
- 若 Q 中所有状态在 a 上到达的状态都在 Q 中,那么非终结状态集合 Q 就有到 终结状态集合 P 的 a- 转换。
- 若接收状态 s 和 t 在 a 上有转换且到达的状态位于不同的状态集合(比如一个在 P 一个在Q),那么 a 区分了状态s 和 状态t。此时状态集 Q 应该分割为QaP和QaQ,其中QaP包含P中在a上到达状态集P的所有状态,QaQ包含P中在a上到达状态Q的所有状态。
- 若接收状态 s 在 a 上由转换,但是 t 却没有,那么 a 区分了状态s 和 状态t。此时状态集 Q 也应该被分割。
3. 重复步骤2直到没有新的状态集被分割出来
举个例子,最小化下图这个DFA:
步骤1. 所有的终结状态放到一个集合 P = { F, G},非终结状态放到一个集合 Q = { A, B, C, D, E, H}
步骤2. 先看P 中的状态:
- 状态 F 在输入 0,1后达到 P 中的F
- 状态 G 在输入0 上到达 P 中的 G, 在输入1 上到达 P 中的 F,所以 P不能被 0,1 区分。
步骤3. 再看Q中的状态:
- 先看在输入0上的转换:
- 状态A,B,C,D,H,在输入 0 上都到达了Q,而状态E在输入0上到达了 P,所以此时有了输入0区分了 Q,Q被分割为 Q0P={E},Q0Q={A,B,C,D,H}
- 由于分割出新的状态集,所以要从步骤2重新开始,P不会变。再看Q:
- 状态A,B,H在输入0 上都到了Q0Q,而状态C,D在输入0上到达了Q0P,所以又要将Q0Q分割为Q0Q={A,B,H},Q0E={C,D}
- 由于分割出新的状态集,所以要从步骤2重新开始,P还是不会变,再看Q:
- 状态A,B在输入0上都到了Q0Q,而状态H在0上到达了状态Q0E,所以又要将Q0Q又要分割为Q0C={H},Q0H={A,B}
- 由于分割出新的状态集,所以要从步骤2重新开始,P还是不会变,对于Q:
- 转台A,B在输入0上都到了Q0C,所以无需再分割。
- 此时有集合,Q0H={A,B}, Q0P={E},Q0E={C,D},Q0C={H}
- 再看在输入1上的转换:
- 状态A,B在输入1上都到了Q0H,是一个1-自身的转换。
- 状态C,D在输入1上都到了P。
- 状态E在输入1上P。
- 状态H在输入1都到达了Q0C。
- 所以在输入1上并没有分割出新的状态集,转换结束
最后的转换图应该是这样的:
DFA的代码描述
1. 读行
2. 表驱动
效率相关:
关键字的查找
标识符的长短是否限制
记号缓冲区的拷贝次数
 
                    
                     
                    
                 
                    
                


 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号