编译原理
持续更新…
一、概述
一个编译器的结构
- 词法分析:词法分析(lexical analysis)是编译器的第一个步骤,它负责读入组成源程序的字符流,并将它们组织成为更有意义的词素(lexeme)序列,对于每个词素,词法分析器产生一个二元组形式的词法单元(token)作为输出
- 语法分析:语法分析(syntax analysis)使用由词法分析生成的词法单元来构建树形的中间表示,通常为一个语法树(syntax tree)
- 语义分析:语义分析器(semantic analyzer)使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。 它可时也收集类型信息,并提这些信息存放在语法树或符号表中,以便在随后的中间代码生成过程中使用
- 中间代码生成器
- 代码优化器
- 代码生成器
二、词法分析
1 词法分析介绍
1.1 词法分析的主要任务
词法分析是编译的第一个阶段。它的主要任务是读取源程序的输入字符,识别出各个单词,将它们组成词素,生成并输出一个词法单元(token)。词法分析器通常要和符号表进行交互,当词法分析器发现了一个标识符的词素时,它将这个词素添加到符号表中。在某些情况下,词法分析器也会从符号表中读取有关标识符种类的信息,以确定向语法分析器传送哪个词法单元。
另外,在读取源程序字符的过程中,词法分析器还会做一些额外工作,比如:
- 过滤掉源程序中的注释和空白(如换行符、空格、制表符等用于分割词法单元的字符)
- 将编译器生成的错误消息与源程序的位置关联(如记录下遇到的换行符个数,以便给予每个错误消息一个行号)
因此,词法分析器可以分为两个原子模块:
- 扫描阶段
- 词法分析阶段
1.2 词法单元、模式、词素
词法分析中首先要了解三个相关但有区别的术语:
-
词法单元:是一个由词法单元名(种别码)和一个可选的属性值组成:
1
token<词法单元名,属性值>
其中:
- 词法单元名是一个表示某种词法单位的抽象符号,类比于英文的名词、动词、形容词一样,常见的词法单元名如:关键字
if
、else
,常量int
、float
等。 - 属性值是可选的,比如,关键字
if
是程序设计中的唯一标识,事先已经确定,则不需要属性值;而常量0
、1
都能和词法单元名number
匹配,但是对于代码生成器而言,需要具体知道number
是什么,因此,还需要附加一个描述该词法单元的属性值。
- 词法单元名是一个表示某种词法单位的抽象符号,类比于英文的名词、动词、形容词一样,常见的词法单元名如:关键字
-
模式:描述了一个词法单元的词素可能具有的形式。
-
词素:是源程序中的一个字符序列,它和某个词法单元的模式相匹配,并被词法分析器识别为词法单元的一个实例。
在很多程序设计语言中,词法单元可以总结为如下形式:
词法单元类型 | 词法单元名 | 举例 |
---|---|---|
关键字 | keyword | if 、else 、while 、for 、… |
运算符 | operator | + 、- 、* 、/ 、= 、… |
标识符 | identifier | 变量名、数组名、… |
常量 | constant | 10 、88.88 、… |
标点符号(分隔符) | separator | { 、} 、( 、) 、… |
2 串和语言
首先介绍几个基本概念:
2.1 字母表(alphabet)
字母表是一个有限的符号集合,以下例子都是字母表:
- 一个字母表包括英文字母、数字和标点符号
- 一个二进制字母表包括0、1两种数字
- ASCII字符集
- Unicode字符集
2.2 串(string)
串是某个字母表中符号的一个有穷序列。串s
的长度通常记作|s|
,是指s
中符号出现的次数,例如apple
是一个长度为5的串;空串的长度为0,用ε
(Epsilon)表示。
如果x
和y
是串,那么x
和y
的连接xy
是指把y
附加到x
后面形成的串,例如x=dog
,y=banana
,那么xy=dogbanana
;空串ε
是连接运算的单元,也就是说,对于任何串s
都有sε=εs=s
。
如果把两个串的连接看作是“乘积”,则串的指数运算如下:定义,并且对于整数,有,由于εs=s
,则有
2.3 语言(language)
语言是某个给定字母表上一个任意的可数的串的集合。根据这个定义:
- 空集
∅
是语言 - 空串
ε
是语言 - 所有语法正确的C程序是语言
- 所有语法正确的英语句子是语言
2.4 语言上的运算
语言上的运算实际上等价于字母表上的运算,在词法分析中,最重要的运算是并、连接和闭包的运算。其中:
- 并运算是集合中的并 运算
- 连接运算是指:以各种可能的方式,从第一个语言中任取一个串,再从第二个语言中任取一个串,然后将它们连接起来得到的所有串的集合
- 对于一个语言的克林闭包(Kleene closure)记作,是指将连接0次或多次后得到的串的集合。对于,即将连接0次得到的串的集合,被定义为
|ε|
,对于被归纳地定义为 - 语言的正闭包是指但不包含
闭包closure是什么?todo
举个具体的例子:
令,,前面说过,我们可以用两种方式来考虑和,这两种方式是等价的。一种方法是将看成大小写组成的字母表,将看成是10个数位组成的字母表;另一种方式是将和看作语言,它们中所有串的长度都为一。我们可以通过运算来得到新的语言:
- 是字母和数位的集合,令,则是一个包含62个长度为1的串,每个串是一个字母或者一个数位。(或者说是一个大小写和数位组成的字母表)
- 是包含520个长度为2的串的集合,令,则的数量为一个全排列,即。
- 是所有由四个字母构成的串的集合,令,的数量为
- 是所有由字母构成的串的集合(即克林闭包),包括空串
ε
,一个语言的克林闭包也可以表示为 - 是所有以字母开头的,由字母和数位组成的串的集合
- 是由一个或多个数位构成的串的集合(即正闭包),不包括空串
ε
,即
3 文法
这部分在后续的语法分析中会更加具体地介绍。
3.1 文法定义
这里的定义是指上下文无关文法。
文法描述了一个程序设计语言的层次化语法结构。文法由一个四元组构成:
其中:
-
是
grammar
的缩写 -
是
terminal symbol
的缩写,即终结符号集合,也被称作上文所述的词法单元(token)。终结符号是指该文法所定义语言的基本符号的集合。 -
是
nonterminal symbol
的缩写,即非终结符号集合,也被称作语法变量。它是用来表示一个终结符号串的集合。 -
是
production
的缩写,即产生式集合。它描述了某个构造的某种书写形式,换句话说就是描述了如何将终结符和非终结符组合的方法。产生式的形式如下:
其中,是产生式头(或左部)的非终结符号,是产生式体(或右部)的由终结符和非终结符组成的序列。读作“可以具有如下形式”或“定义为”。因此,上述形式整体读作:“定义为”。
其中,中至少包含非终结符中的一个元素。
-
是
start symbol
的缩写,即开始符号。它用来描述文法中“最大的”语法成分。
根据上述定义,我们可以来描述一个if-else
语句:
1 | if (expression) statement else statement |
即一个条件语句由关键字if
、左括号(
、表达式expression
、语句statement
、关键字else
、右括号)
、语句statement
构成。它的产生式如下:
其中,关键字if
和括号这样的元素就是终结符号,而statement
和expression
这样的变量为非终结符。
下面用一个简单的例子来看看一个文法的完整书写格式。
我们在小学一年级学过加减法,一个加减法表达式由数位、加号、减号构成。比如3+6
、7-1
或者6
,如果是两个数位,则它们之间必须用+
或-
相连。该文法的完整定义如下:
其中,终结符号为
1 | + - 0 1 2 3 4 5 6 7 8 9 |
非终结符号为
1 | expr digit |
由于expr是整个文法中最大的语法成分,因此开始符号
产生式的定义用白话描述:
1 | 表达式定义为表达式+数位 |
这里的0,1,2...,9
也被称为digit
的候选式。
另外,在实际使用上,如果不会引起歧义,我们可以省略文法的其它部分,只保留产生式作为文法的定义。
3.2 文法推导
我们可以通过一个文法从开始符号出发,不断地将某个非终结符号替换为该非终结符号的某个产生式的体。比如,在上文中的文法:
由于digit
可以表示从0到9中的任意数位,再根据第三条产生式,单个数位本身就是表达式,而第一条和第二条说明:任何表达式后面跟一个+
或-
再跟一个数位可以构成一个新的表达式。因此,我们可以推导出9-5+2
是一个表达式:
- 因为9是数位,根据第三条可知,9是表达式
- 因为5是数位,9是表达式,根据第二条可知,9-5也是表达式
- 因为2是数位,9-5是表达式,根据第一条可知,9-5+2也是表达式
3.3 文法分类
todo
4 正则表达式
4.1 基本概念
如果要描述C语言所有合法标识符的合集,它差不多就是在2.4中的例子:
- 是所有以字母开头的,由字母和数位组成的串的集合
唯一不同的是C语言中标识符可以有下划线。
在这个例子中,我们可以首先给出字母和数字集合,然后通过并、连接、闭包运算来描述标识符。正则表达式是一种更紧凑的表示方法,它可以描述所有通过某个字母表上的符号通过运算而得到的语言。
正则表达式可以由较小的正则表达式递归构建,下面两个规则构成了归纳基础:
- 是一个正则表达式,,即该语言只包含空串
- 如果是字母表上的一个符号,那么是一个正则表达式,并且。也就是说这个语言仅包含一个长度为1的符号串。
假设和都是正则表达式,分别表示语言和,将小的正则表达式构造为大的正则表达式的归纳步骤有如下四步:
- 是一个正则表达式,表示语言
- 是一个正则表达式,表示语言
- 是一个正则表达式,表示语言
- 是一个正则表达式,表示语言,这一条的意思是在表达式两边加括号并不影响表达式所表示的语言。
另外,为了简化,我们可以将不必要的括号去掉,在去掉括号之前,需要先进行如下约定:
- 一元运算符具有最高优先级,并且是左结合的
- 连接具有次高优先级,并且是左结合的
- 运算的优先级最低,并且是左结合的
左结合和右结合的概念是指在相同运算优先级下,操作符对操作数的执行顺序:
如果一个操作符对其操作数是自左向右执行的,则称该操作符是左结合的;
如果一个操作符对其操作数是自右向左执行的,则称该操作符是右结合的;
比如,赋值运算符
=
是右结合的,a=b=c
的运算顺序就是先将c
赋值b
再将b
赋值a
。加法
+
是左结合的,a+b+c
的运算顺序就是先计算a+b
再计算(a+b)+c
4.2 使用正则表达式描述语言
现在使用正则表达式来描述语言,令字母表,则:
- 正则表达式表示语言
- 正则表达式表示语言,即字母表上长度为2的所有串的集合,也可以用另一个等价正则表达式来描述:
- 正则表达式表示语言,即所有由零个或多个组成的串的集合
- 正则表达式表示语言,即所有由零个或多个或组成的串的集合
- 正则表达式表示语言,即和以结尾的零个或多个组成的串的集合
可以用正则表达式定义的语言叫做正则集合(regular set)。如果两个正则表达式可以表示同样的语言,则称它们等价,例如,正则表达式遵循一些代数定律,每个定律中两个不同形式的表达式等价。如下图:
4.3 正则定义
为了方便,我们可以给正则表达式命名,之后将这些名字像使用符号一样使用这些名字。如果是基本符号的集合,那么一个正则定义是具有如下形式的定义序列:
其中:
- 每个都是一个新符号,它们都不在中,并且各不相同
- 每个是字母表上的正则表达式
并且,为了防止递归定义,我们限制每个中只含有中的符号和它之前定义的各个,并且,我们可以为每个构造出只包含中符号的正则表达式。比如我们可以首先将(它只能使用)中的替换为,然后再将中的和替换为和替换之后的,以此类推,最终,每个中都只包含中的符号。
前面提到C语言中标识符应该是数字、字母、下划线组成的串,我们用表示字母或下划线,用表示数位,它的正则定义如下:
4.4 正则表达式的扩展
下面介绍一种当今仍在使用的扩展正则表达式表示法:
- 单目后缀运算符表示一个或多个,即一个正则表达式及其语言的正闭包。和具有同样的优先级和结合性。
- 单目后缀运算符表示零个或一个,和具有同样的优先级和结合性。
- 字符类。一个正则表达式(是字母表中的各个符号)可以缩写为,如果它们构成一个逻辑上连续的序列,比如连续的字母,可以表示为,因此是的缩写,是的缩写。
根据这个扩展表示法,我们可以将上面C语言中标识符的正则定义改为:
5 词法单元的识别
词法单元识别即根据输入的字符串中找出某个模式匹配的词素。
5.1 状态转换图
作为构造词法分析器的中间步骤,首先将模式转换成具有特定风格的流图,即状态转换图(transition diagram),它有一组被称为状态(state)的结点或圆圈,词法分析器在扫描输入串的过程中寻找和某个模式匹配的词素,而转换图中的每个状态代表一个可能在这个过程中出现的情况。状态图中的边(edge)从图的一个状态指向另一个状态,每条边的标号包含了一个或多个符号。如下图所示:
图中,每个结点都表示一个状态,并且:
- 有一个开始状态,它由一条没有出发结点的、标号为
start
的边指明,在读入任何符号之前,状态转换图总是位于开始状态 - 用双圈表示的叫做接受状态(最终状态),如图中标号为
3
的圆圈,它表示已经找到了一个词素,最终状态可以有多个 - 如果需要回退一个位置,那么我们会在接受状态的附近标上,如果需要回退多个位置,则对应地会附加多个
上图中的状态转换图能够识别正则表达式,即由和组成的,以字符串结尾的语言。我们将结合下一节进行具体介绍。
5.2 有穷自动机
- 有穷自动机(finite automata,FA)由两位神经物理学家MeCuloch和Pitts于1948年首先提出,是对一类处理系统建立的数学模型
- 这类系统具有一系列离散的输入输出信息和有穷数目的内部状态(状态:概括了对过去输入信息处理的状况)
- 系统只需要根据当前所处的状态和当前面临的输入信息就可以决定系统的后继行为。每当系统处理了当前的输入后,系统的内部状态也将发生改变
有穷自动机在本质上与状态转换图类似,但前者只能对每个可能的输入回答“是”或者“否”。
对于一个输入串x
,如果存在一个对应于串x
的从初始状态到某个终止状态的转换序列,则称串x
被该自动机FA接收(识别、定义)。比如对于上图中的自动机,我们的输入串如果为aabbabb
,则前四个字母aabb
都可以被状态0接收,之后,对于后三个字母abb
,则依次由状态1,2,3接收,由于这个状态转换是从初始状态0到终止状态3,因此输入串aabbabb
能够被上图中的自动机接收。另外,在自动机的匹配过程中,当输入串的多个前缀与一个或多个模式匹配时,总是会选择最长的前缀进行匹配,即遵循最长子串匹配原则。
5.3 有穷自动机的分类
有穷自动机FA分为两类
- 不确定有穷自动机(nondeterministic finite automata,NFA)对其边上的标号没有任何限制。一个符号标记离开同一状态的多条边,并且空串也可以作为标号
- 确定有穷自动机(deterministic finite automata,DFA)对于每个状态及自动机输入字母表中的每个符号,有且只有一条离开该状态、以该符号为标号的边
确定和不确定的有穷自动机能识别(接收)的语言的集合是相同的。事实上,这些语言的集合正好就是能够用正则表达式描述的语言的集合,这个集合中的语言称为正则语言(regular language)。
在实践中,我们不会想用正则表达去描述空的语言,但是有穷自动机可以定义空语言,因此,在理论研究中,被视为一个额外的正则表达式,这个表达式被用于定义空语言。
DFA的完整定义为一个五元组:
- 为一个有穷的状态集合
- 为一个输入符号集合,即输入字母表(input alphabet)。我们假设空串不是该集合中的元素
- 为一个转换函数(transition function),它为每个状态和输入符号中的每个符号都给出了相应的后继状态的集合。即对于中任意的状态,给定任意一个字母表中的符号,表示从状态出发,沿着标记为的边所能到达的状态
- 为开始状态,或者称为初始状态,它是中的一个状态
- 为所有接收状态(接收状态可能不止一个)的集合
对于NFA的定义,与DFA的区别在于:
- 转换函数中,对于中任意的状态,给定任意一个字母表中的符号,表示从状态出发,沿着标记为的边所能到达的状态集合(与DFA相比,NFA所能到达的状态集合不唯一),且字符集中可以包括空串
ε
在5.1图中的转换图就描述了这样的一个NFA。因为当读入字符a
时,自动机所能进入的状态并不是唯一确定的(可以是状态0,也可以是状态1)。我们可以用一张转换表来描述,如下:
状态\输入 | a | b | |
---|---|---|---|
0 | {0,1} | {0} | |
1 | {2} | ||
2 | {3} | ||
3 |
表的各行对应于状态,各列对应于输入符号和空串。对于一个给定状态和给定输入的条目就是将NFA的转换函数应用到这些参数得到的值。如果转换函数没有给出结果,就把放入对应的表项中。比如,上表中,在状态0输入字符a
,自动机所能进入的状态为{0,1}
集合,可以是状态0,也可以是状态1,因此也确定了它是一个NFA。
转换表的优点在于我们可以很清楚直观地看到一个给定状态和输入符号所对应的转换,但是缺点在于,如果字母表很大,且大多数状态\输入字符没有对应的转换时,需要填充很多,这占用了大量无用空间。
对于DFA的转换图,和NFA有一点点区别。DFA没有了ε
上的转换动作,且表中的表项没有花括号集合,而是一个确定的状态。如下图的DFA:
它接收的语言与5.1图中的NFA所能表示的语言相同,都是,它的转换表如下:
状态\输入 | a | b |
---|---|---|
0 | 1 | 0 |
1 | 1 | 2 |
2 | 1 | 3 |
3* | 1 | 0 |
如上所述,在DFA的转换表中,没有对于空串的转换动作,且所有的表项都对应确定的状态。
对于一个给定输入串ababb
,这个DFA的状态进入序列为0、1、2、1、2、3,对于最后的输入,自动机处于最终状态,因此它回答yes
。
我们可以用如下的伪代码来模拟这个DFA:
1 | // s:当前状态 |
5.4 从正则表达式到有穷自动机
实际上,给定一个正则表达式,则一定能够构造一个DFA,但是,直接构造比较困难。由于一个NFA也能够转化为等价的DFA。且在语法分析实现的层面,我们更倾向于DFA。
我们将首先介绍如何构造一个NFA,然后再将NFA转化为DFA。一般的构造步骤如下:
1 | 正则表达式--->NFA--->DFA |
5.4.1 从正则表达式构造NFA
现在我们介绍一个从正则表达式构造NFA的Mcmaughton-Yamada-Thompson算法,它可以将任何正则表达式转变为接收相同语言的NFA。
-
算法输入:字母表上的一个正则表达式
-
算法输出:一个接收的NFA
-
方法:首先对进行语法分析,分解除组成它的子表达式。构造一个NFA的规则,分为基本规则和归纳规则两组。基本规则处理不包含运算符的子表达式,而归纳规则根据一个给定表达式的直接子表达式的NFA构造出这个表达式的NFA。
-
基本规则:对于表达式,构造出对应的NFA:
这里
i
是一个新状态,也是这个NFA的开始状态;f
是另一个新状态,是这个NFA的终止状态。由i
到f
之间的有向边ε
表示从状态i
只需要接收空串就可以到达状态f
。对于字母表中的符号对应的NFA:
同样,这里
i
是一个新状态,也是这个NFA的开始状态;f
是另一个新状态,是这个NFA的终止状态。从i
到f
需要接收一个符号a
。在这两个基本构造规则中,对于
ε
和某个a
作为r
的子表达式的每次出现,我们都是用新状态分别构造出一个独立的NFA。 -
归纳规则:假设正则表达式和的NFA分别为和:
- 对于的NFA,即,可以按照下图方式构造得到。
这里的
i
和f
是新状态,分别是的开始和接收状态。从i
到和的开始状态各有一个转换,从和到接收状态f
也各有一个转换。-
对于的NFA,可以按照下图方式构造:
的开始状态变成了的开始状态,的接收状态变成的唯一接收状态。的接收状态和的开始状态合并为一个状态,合并后的状态拥有原来进入和离合并前的两个状态的全部转换。一个路径不可能在离开后再次进入,因此,恰好接收,它是的一个正确的NFA。
-
对于的NFA,可以按照下图方式构造:
这里,
i
和f
是两个新状态,分别是的开始和唯一接收状态,要从i
到达f
,我们可以沿着标号为ε
的路径前进,这个路径对应于中的一个串。我们也可以到达的开始状态,然后经过该NFA,再零次或多次从它的接收状态回到它的开始状态并重复上述过程。因此,它可以接收、等集合中的所有串,因此识别的所有串的集合就是。 -
最后,对于,那么,我们可以直接把当做。
经过该算法构造的NFA具有如下性质:
- 的状态数最多为中出现的运算符和运算分量的总数的2倍,这是因为算法的每一个构造步骤最多只引入两个新状态。
- 有且仅有一个开始状态和一个接收状态,接收状态没有出边,开始状态没有入边。
- 中除接收状态之外的每个状态要么有一条标号为符号表中符号的出边,要么有两条标号为的出边。
下面尝试使用该算法为正则表达式构造一个NFA。下图展示了的一颗语法分析树:
对于子表达式,即第一个,构造如下的NFA:
同理,对于:
现在我们可以使用Mcmaughton-Yamada-Thompson算法,将和合并,得到的NFA:
的NFA和相同。的NFA构造如下图:
现在考虑,它是表达式中的另一个,因此,我们的状态必须使用新的而不能重复使用为构造的那个NFA:
要得到的NFA,我们应用上述算法,得到如下图所示的NFA:
最终我们得到下图所示的的NFA:
5.4.2 从NFA到DFA的转换
我们利用子集构造法的技术,可以将NFA转化为DFA,其基本思想是:让构造的得到的DFA的每个状态对应于NFA的一个状态集合。
-
算法输入:一个NFA
-
算法输出:一个接收同样语言的DFA
-
方法:我们的算法为构造一个转换表Dtran。的每个状态是一个NFA状态集合,我们构造Dtran,使得D"并行地"模拟在遇到一个给定输入串时可能执行的所有动作。我们面对的第一个问题是正确处理的空串转换。在下表中我们可以看到一些函数的定义,这些函数描述了一些需要在这个算法中执行的的状态集上的基本操作。标识的单个状态,标识的一个状态集合。
操作 描述 能够从NFA的状态开始只通过转换到达的NFA状态集合 能够从中某个NFA状态开始只通过转换到达的NFA状态集合 能够从中某个状态出发通过标号为的转换到达的NFA状态集合 我们必须找出当读入了某个输入串之后可能位于的所有状态集合。首先,在读入第一个输入符号之前,可以位于集合中的任何状态上,其中是开始状态。下面进行归纳。
- 假设在读入输入串之后可以位于集合中的状态上,如果下一个输入符号是,那么可以立即移动到集合中的任何状态。
- 可以在读入后再执行几个转换,因此在读入后可以位于中的任何状态上。
根据这些思想,我们可以得到的状态集合Dstates和的转换函数Dtran,算法如下:
1
2
3
4
5
6
7
8
9一开始,ε-closure(s0)是Dstates中的唯一状态,且它未加标记;
while(在Dstates中有一个未标记状态T){
给T加上标记;
for(每个输入符号a){
U=ε-closure(move(T,a));
if(U不在Dstates中) 将U加入到Dstates中,且不加标记;
Dtran[T,a]=U;
}
}的开始状态是,接收状态是所有至少包含了的一个接收状态的状态集合。我们只需要说明对NFA的任何状态集合计算,就可以完整地描述子集构造法。这个计算过程是从一个状态集合开始的一次简单的图搜索过程,不过此时假设这个图中只存在标号为的边,算法如下:
1
2
3
4
5
6
7
8
9
10将T的所有状态压入stack中;
将ε-closure(T)初始化为T;
while(stack非空){
将栈顶元素t弹出栈;
for(每个满足如下条件的u:从t出发有一个标号为ε的转换到达状态u)
if(u不在ε-closure(T)中){
将u加入到ε-closure(T)中;
将u压入栈stack中;
}
}
我们拿5.4.1中的NFA来应用上述算法:
1 | 一开始,ε-closure(s0)是Dstates中的唯一状态,且它未加标记; |
等价NFA的开始状态是,即能够从状态0出发,只经过标号为的路径到达的所有状态:。由于路径可以不包含边,因此0状态也属于集合A。此时,A是Dstates状态中的唯一状态,且它未加标记,
1 | while(在Dstates中有一个未标记状态T){ |
进入循环,为加上标记。
1 | for(每个输入符号a){ |
NFA中的字母表是,因此我们需要分别计算和。
在状态中,只有状态2和状态7具有的转换,分别到达状态3和状态8,因此,则,因此我们有:
我们称这个集合为,得到。
在状态中,只有状态4具有的转换,到达状态5,因此,则,因此我们有:
我们称这个集合为,得到。
经过本轮循环,我们的栈中得到了两个未加标记的集合B和C,继续进行这个算法,
对于状态,需要计算和。
在状态中,仍然只有状态2和状态7具有的转换,因此仍然得到;状态4和状态8具有的转换,分别到达状态5和状态9,因此得,则,因此我们有:
我们称这个新状态集合为,得到。
继续进行这个算法,对于状态,按照上述方法可得:
对于状态:
对于新状态:
至此,没有新状态出现,且这个DFA的所有状态都被加上标记。在这个例子中我们实际构造出5个不同的DFA状态,这些状态、它们对应的NFA状态集以及的转换表如下:
NFA状态 | DFA状态 | a | b |
---|---|---|---|
{0,1,2,4,7} | A | B | C |
{1,2,3,4,6,7,8} | B | B | D |
{1,2,4,5,6,7} | C | B | C |
{1,2,4,5,6,7,9} | D | B | E |
{1,2,4,5,6,7,10} | E | B | C |
根据转换表,可以得到的转换图如下:
5.4.3 DFA状态数最小化
在上一节我们得到了经由NFA转换的DFA,对于同一个语言,可以有多个可识别该语言的DFA,且这些DFA的状态名称、状态个数都可以不同。在使用上它们都是正确的,但我们总是希望使用DFA的状态数尽可能地少,而状态名称是次要的,它只是一个标识状态的符号而已。如果我们只需改变状态名字就可以将一个自动机转换为另一个自动机,我们就称这两个自动机是同构的。
可以证明,任何正则语言都有一个唯一的(不计同构)状态数目最少的DFA,且从任意一个接受相同语言的DFA出发,通过分组合并等价的状态,我们总是可以构建得到这个状态数最少的DFA。例如,上一节最后构造的DFA就可以将状态划分为{A,C},{B},{D},{E}
然后合并等价状态就可以得到这个最小DFA。
下面我们介绍这个将任意DFA转化为等价的状态最少的DFA的算法。该算法首先创建出输入DFA的状态集合的划分。首先介绍一个概念:如果分别从状态s
和t
出发,沿着标号为x
的路径到达的两个状态中只有一个是接受状态,我们说串x
区分状态s
和t
。如果存在某个能够区分状态s
和t
的串,那么它们就是可区分的。空串ε
可以区分任何一个接受状态和非接受状态。
对于上一节中的DFA,如图:
串bb
区分状态A和B,因为从A出发经过bb
的路径会到达非接受状态C,而从B出发则会到达接受状态E。
DFA状态最小化的的工作原理是将一个DFA状态集合划分为多个组,每个组中的各个状态之间相互不可区分。然后,将每个组中的状态合并成状态最少的DFA的一个状态。算法在执行过程中维护一个状态集合的划分,划分中的每个组内的各个状态尚不能区分,但是来自不同组的任意两个状态是可区分的。当任意一个组都不能再被分解为更小的组时,这个划分就不能再进一步简化,此时我们就得到了状态最少的DFA。
最开始,我们将划分出两个组:接受状态组和非接受状态组。算法的基本步骤是从当前划分中取一个状态组,比如接受状态组A,并选定一个输入符号x
,检查x
是否可以用于区分A中的某些状态。我们检查A中这些状态在x
上的转换,如果这些转换到达的状态落入当前维护的状态集合的不同(两个或以上)划分中,我们就将A分割成多个组,使得这些组中的任意状态在x
上的转换都到达同一个划分组。对于所有的输入符号重复这个分隔过程,直到无法根据某个输入符号对任意个组进行分割为止。下面给出这个算法:
-
算法输入:一个DFA ,它的状态集合为,输入字母表为,开始状态为,接受状态集为。
-
算法输出:一个DFA ,它和接受相同的语言,且状态数最少。
-
方法:
-
首先构造包含两个组和的初始划分,这两个组分别是的接受状态和非接受状态组。
-
应用下面的过程构造新的划分:
-
如果,令并接着执行步骤4;否则,用替换并重复步骤2。
-
在划分的每个组中选取一个状态作为该组的代表。这些代表构成了状态最少的DFA得状态。的其他部分按如下步骤构建:
- 的开始状态是包含了的开始状态的组的代表。
- 的接受状态是那些包含了的接受状态的组的代表。请注意,每个组中要么只包含接受状态,要么只包含非接受状态,因为我们一开始就将这两类状态分开了,而步骤2中的过程总是通过分解已经构造得到的组来得到新的组。
- 令是中某个组G的代表,并令DFA中在输入上离开的转换到达状态。令为所在组的代表。那么在中存在一个从到在输入上的转换。注意,在中,组中的每一个状态必然在输入上进入组中的某个状态,否则组应该已经被步骤2中的过程分割成更小的组了。
-
让我们重新考虑上节构建的DFA:
按照算法,我们首先将状态划分为两个组{A,B,C,D},{E}
,分别为非接受状态组和接受状态组。下面构造,我们考虑输入符号a
和b
,对于接受状态组,由于它只包含一个状态,不可再被分割,因此{E}
原封不动地保留在中。
另一个组{A,B,C,D}
是可以被分割的,依照步骤2的算法:对于输入a
,这些状态都跳转到状态B,状态B属于中的组{A,B,C,D}
,因此对于输入a
来说,无法区分这些状态。下面考虑输入b
,状态A,B,C经过b
分别到达状态C,D,C,这些状态都属于组{A,B,C,D}
,因此b
也无法区分状态A,B,C;但是对于状态D,经过b
到达状态E,状态E属于组{E}
,而{E}
与{A,B,C,D}
并不是上的同一组,因此我们可以将状态D拆分出来,{A,B,C,D}
被替换为{A,B,C}
和{D}
。这一轮得到的是{A,B,C},{D},{E}
,由于出现了新状态,我们需要用替换并重复步骤2,进入下一轮。
在下一轮中,我们可以把{A,B,C}
分割为{A,C}
和{B}
,因为状态A,C经过b
都到达状态C,属于组{A,C}
,而状态B经过b
到达状态D,属于组{D}
。这一轮中得到的是{A,C},{B},{D},{E}
,由于出现了新状态,我们需要用替换并重复步骤2,进入下一轮。
在下一轮中,无论经过a
还是b
,我们都无法分隔{A,C}
,这一轮中的状态与上一轮相同:,令并接着执行步骤4;
在划分的每个组中选取一个状态作为该组的代表。这些代表构成了状态最少的DFA的状态。我们分别挑选A,B,D,E作为这四个组的代表,其中A为开始状态,E为唯一的接受状态。它的转换表如下:
DFA状态 | a | b |
---|---|---|
A | B | A |
B | B | D |
D | B | E |
E | B | A |
由于删掉了状态C,原来DFA中到达C的状态改为状态C所处组的代表的状态,即替换为A。对应的修改为A经过b
的转换和E经过b
的转换;其他的转换和之前的DFA转换相同。
5.5 练习
下面我们通过编程实现来练习一下这部分的算法。至此为止,我们已经可以实现:
- 正则转换NFA的算法
- NFA转换DFA的算法
- DFA状态数最小化的算法
- 验证某个串是否被NFA/DFA所接受的算法
这里给出我的代码,点击查看