【梳理】编译原理与实践 第二章 扫描(docx)

编译原理

知 识 梳 理

(第一版)

建议先修课程:离散数学、C / C++、数据结构、汇编语言、计算机组成原理。
配套教材:
Kenneth C. Louden Compiler Construction: Principles and Practice


链接:https://pan.baidu.com/s/1sBfYGtpjNzh8YbnLGCiYnw
提取码:0000


二 扫描
2.1 扫描过程
标记是通常用枚举类型定义的逻辑实体。例如:
typedef enum { IF, THEN, ELSE, PLUS, MINUS, NUM, ID, … } TokenType;
标记被分为几类。包括保留字(reserved word),又称关键字(keyword),例如IF和THEN,代表字符串“if”和“then”。第二类是特殊符号,例如算术运算符PLUS和MINUS,代表“+”和“-”。最后一类是可以代表多个字符串的标记,例如NUM和ID,分别代表数字和标识符。
如果编程语言不支持枚举,则需要按照比较老的方式直接定义标记了。例如:
#define IF 256
#define THEN 257
#define ELSE 258

(数值从256开始,以免与ASCII字符的值冲突)

标记是本身必须与它们代表的字符串区分开来的逻辑实体。在上例中,保留字标记IF必须与它代表的“if”区别。标记代表的字符串也被叫做字符串值(string value)或词义(lexeme)。每个保留字只有1个词义。标识符虽然都用单一的标记ID表示,但具有许多字符串值代表它们各自的名称。这些名称必须由编译器使用符号表追踪。因此,扫描器也必须至少构造部分标记的字符串值。与标记关联的值叫做属性(attributes)。标记-属性属于key-value结构,这种结构在计算机科学与工程中是极其常见的。字符串值就是一种属性。标记也可以具有其它属性。例如,NUM标记可以同时具有一个字符串值“32767”,也可以具有一个从字符串计算得来的数值32767。特殊符号标记PLUS也同时与字符串“+”和算术运算符+关联。实际上,可以把标记符号自己看成一个属性,而全体标记看作属性的集合。记号主要在词法分析和语法分析中使用,而属性主要在语义分析中使用。

扫描器至少需要计算后续步骤需要的每个标记的属性数量。举例:NUM的字符串值需要先行得到,但字符串值代表的数值倒不用马上计算。另一方面,如果数值计算出来了,字符串值就可以丢弃。扫描器可以为编译的后续阶段执行必要的运算,也可以直接把属性传递给后续阶段。比如,可以将标识符的字符串值输入符号表,或干脆丢给编译器的其它部件。

由于扫描器可能需要计算标记的数个属性值,因此将属性值都用单一的结构类型表示是很有用的。这样的结构可以称为标记记录(token record)。例如:
typedef struct { TokenType TokenVal; char* StringVal; int NumVal; } TokenRecord;
typedef struct { TokenType tokenval; union { char* stringval; int numval; } attribute; } TokenRecord;
后一种形式假定标识符只需要字符串值,而数字只需要数值。一个更常用的方法是:扫描器仅返回标记值,其它属性都放在编译器的其它部分可以访问的变量中。

扫描器的任务是转换整个源程序为标记序列,但是扫描器几乎不会只扫描一次。相反,扫描器在语法分析器的控制下进行词法分析,通过类似这样的函数按需返回单个标记:
TokenType GetToken();
此函数被调用是,从输入返回下一个标记,并计算额外的属性,比如标记的字符串值。输入的字符串一般不作为函数的参数,而是被保留在缓冲区,或由系统输入设备提供。
在读取并返回标记时,需要跳过空白字符。在完整读取完一个标记后,停止读取。
2.2 正则表达式
正则表达式(regular expression,regex,RE)代表字符串的模式。一个正则表达式r完全是由它匹配的字符串集定义的。这个字符串集合称为正则表达式生成的语言(language generated by the regular expression),记为L®。“语言”一词在这里仅仅是用于描述“一组字符串”,与编程语言没有关系。这个语言首先依赖于可用的字符集。一般地,可用字符集是ASCII字符集或其子集,也可以是ASCII字符集的超集,这时字符集的元素也称符号(symbols)。这一组合法符号称为字母表(alphabet),常记作Σ。
正则表达式r也会包含字母表中的字符,但这些字符的意义不同:在正则表达式中,所有符号都代表模式(pattern)。此外,正则表达式r中的一部分字符可以具有特殊意义,称为元字符(metacharacter)或元符号(metasymbol)。这些通常不是字母表中的合法字符,但有时也会存在于字母表。为了将元字符与字母表的字符区别,一般在它们之前添加转义字符(escape character)。转义字符通常是反斜杠“\”或引号“’”。转义字符也是元字符。

设字母表Σ中有字符a。正则表达式a匹配字符a:
L(“a” )={a}
我们用符号ε代表空串,元符号"ε" 的定义为
L(“ε” )={ε}
如果一个正则表达式无法匹配任何字符串,则该式生成的语言是空集:
L(“ϕ” )={}
注意{}和{ε}是不同的:后者包含一个空串。

正则表达式有三种基本运算:
【1】从替代项中选择。用元字符“|”表示。
设有正则表达式r,s。则r|s匹配任何匹配r或s的字符串。即
L(r│s)=L®∪L(s)
此外,我们有
L(“a” │"ε" )={a,ε}
【2】连接(concatenation)。连接运算不使用任何元字符,直接并列列写。
设有正则表达式r,s。则rs匹配任何匹配r或s的字符串连接后的字符串。即
L(rs)=L®L(s)
【3】重复或闭包(closure)。用元字符“”表示。
正则表达式r的重复运算,有时称为Kleene闭包或闭包,用r*表示。r匹配任何非负数次连接的、匹配r字符串。即
L(r^
)=L^
®=L®^*
对于字符串集S,记
S^*={ε}∪S∪SS∪SSS∪…

S*=∪_(n=0)(+∞) S^n
这三种运算的优先级依次上升。重复运算的优先级比连接要高,正如复数域的乘法运算比加法运算的优先级要高。需要强制改变优先权时,需要使用括号。

我们可以给正则表达式命名,称为名称的正则定义(regular definition)。但是,正则定义不能是递归的,即:名称的正则定义中不能包含名称自己。

综上,我们把正则表达式的定义归纳为:
正则表达式是如下的一种或多种表达式:
1、一个基本的正则表达式,包括单个字符a,a来自一张字母表Σ;元字符"ε" ;元字符"ϕ" 。且满足
L(“a” )={a}
L(“ε” )={ε}
L(“ϕ” )={}
2、符合形如r|s的表达式,其中r,s都是正则表达式。且满足
L(r│s)=L®∪L(s)
3、符合形如rs的表达式,其中r,s都是正则表达式。且满足
L(rs)=L®L(s)
4、符合形如r^的表达式,其中r是正则表达式。且满足
L(r^
)=L®^*
5、符合形如®的表达式,其中r是正则表达式。即
L(®)=L®
也就是说,在此种情况中,最外层括号不影响其语义。
从上述定义中,我们可以得出元字符集
{Φ, “ε” , ┤|, *, (, )}

不同的正则表达式可以是等效的。虽然我们尽可能希望正则表达式简单(例如,尽可能短)。但我们不会去尝试寻找某种“最简单的”正则表达式。原因有二:首先,在现实中确实很难找到这样的表达式。其次,在研究识别正则表达式的过程中,一些算法对正则表达式已经具有一定的化简作用。

考虑在字母表Σ={a,b}上的一个字符串集S:
S={b,aba,aabaa,aaabaaa,…}={a^n ba^n, n≠0}
使用正则表达式无法匹配这里面的任何一个字符串。因为正则表达式并不允许匹配特定次数的重复(意即,正则表达式不能计数)。也就是说,并非任何能用简单的语句描述的字符串都能由正则表达式产生或匹配。能使用正则表达式表示出来的字符串集合称作正则集合(regular set)。

不同的正则表达式可以表示一组完全相同的字符串。这时我们称这些正则表达式是等价(equivalent)的。
如果正则表达式r和s等价,记作r=s,则r和s表示同一个集合,即L®=L(s)。

下面介绍正则表达式的一些扩展。注意:不是所有应用程序都支持这样的运算。即使支持,也未必使用相同的符号。
【1】一次或多次重复。设r为正则表达式,若要求匹配r的字符串至少重复一次,则记为r+。
【2】任意字符。一般用元字符“.”表示匹配字母表中的任意一个字符。
【3】字符范围。用中括号[]和连字符-表示。例如,[0"-" 9],[“A-Z”]分别表示匹配0到9、A到Z;[“A-Za-z”]表示匹配全部大小写英文字母。这种记法也称作字符类(character class)。但需要注意,这种记法依赖字符集中的字符顺序。例如,若字符集为ASCII字符集,则[“A-z”]不能做到仅匹配全部的英文字母。
【4】不在指定字符集中的任意字符。常在字符集前加“”表示。如"(a|b|c)" 表示匹配除a、b、c以外的单个字符。在Lex中,使用[^“abc”]表示。
【5】可选的子表达式。在表达式后添加“?”代表该表达式是可选的。例如
Natural="[0-9]+"
SignedNatural=("+|-" )?Natural

下面我们举几组能够匹配常见标记的正则表达式。再次强调,不同的应用程序或编程语言可能使用不同的记法。
·数字。数字既有整数,也有浮点数。数的前面可以有符号(正号或负号),也可以写成科学计数法。因此我们有
UnsignedNum="[0-9]+"
SignedNum="("+"|"-")? " UnsignedNum
Num=SignedNum(""." " Num)?((“E"│"e”) SignedNum)
·保留字。一般而言,编程语言中的保留字都不多(大约几十个)。因此直接把它们列举出来即可:
reserved=“if” | ““while” " | “do”” |…
·标识符。通常,编程语言要求标识符必须以字母或下划线开头。所以:
identifier="(|"[A-Za-z])(|[0-9A-Za-z])"
·注释。一般来说,注释在扫描过程都是被忽略的(有时候,注释可以包括编译器指令)。扫描器需要匹配注释,并丢弃它们。即使扫描器可以没有显式常量标记(伪标记,pseudotoken),我们也需要为注释编写正则表达式。注释的常见形式可能有:
{this Is a Pascal comment}
// this is a C comment
/
this is a C comment /
; this is a Scheme comment
– this is an Ada comment
匹配Pascal和Ada的注释的正则表达式分别是:
{(~})
}
–(~"\n" )^*
这里,我们假设“\n”会被解析成换行符。
对于C / C++的形如“//”的注释,编写正则表达式就比较困难。首先,注释是形如ba…ab的字符串,其中a,b代表单个字符。我们不能直接写成
"ba(~(ab))ab"
因为元符号“”通常只能作用于单个字符,否则将被视为非法。所以"ab" 要写成"~(a|b)" (写法不唯一)。另外,“/”和“
”两个字符也大概率是元字符,需要额外的特殊处理。我们在之后再讨论这个问题。
另外,有的编程语言允许注释嵌套。例如,Modula-2允许如下形式的注释:
(* this is (* a Modula-2 *) comment )
注释分隔符必须正确配对。这就要求扫描器为分隔符计数。但正则表达式是不允许计数操作的。具体的解决方案留作练习。
·歧义、空白和先行。在使用正则表达式描述编程语言的标记时,一些字符串可以被不同的正则表达式匹配。例如,if和while可以识别为关键字或标识符;而<>既可以被识别为不等号,又可以被识别为两个运算符:小于和大于。正则表达式不能处理这种歧义(ambiguity)。编程语言必须指出在这些情况下应当取哪一种解释,也就是在语言定义中添加歧义消除规则(disambiguating rule)。
一些规则是常用的。例如,当字符串既可以被解析为标识符又可以被解析为关键字时,解析为关键字。其实“保留字”这个术语一定程度上就体现了这一点。换句话说,关键字不可以作为标识符。又例如,如果一个字符串可能是单个标记或多个标记,则解析为单个标记。这常称为最长子串原则(principle of longest substring)。
在应用最长子串原则时,会带来一个问题:标记分隔符(token delimiter)。例如,对于字符串“xtemp=ytemp”,“=”不能作为标识符的一部分,因此分割了标识符xtemp。空白、换行和制表符一般也被认为是分隔符,因此“while x”是两个标记,分别为保留字while和标识符x。与注释伪标记一样,将空白定义为伪标记是有用的,可以令扫描器识别其它标记。实际上,注释本身也起到分隔符的作用。因此C代码
do/**/if
将被识别为2个保留字do和if,而不是单个标识符doif。
空白伪标记的一种典型定义是:
whitespace=(""\n""|""\t"" |" "|comment)+
通常,空白是被忽略的,而不作为标记分隔符。遵循这个行为的语言叫做自由格式(free format)语言。C / C++是自由格式的语言;Fortran和Python则是固定格式(fixed format)的语言,通过缩进而不是关键字或括号来对代码分组。自由格式语言的扫描器在检查标记分隔符的效果后,必须丢弃空白。
分隔符标志着标记的末尾,但不是标记的一部分。扫描器必须解决先行(lookahead)问题:当面对分隔符时,不允许将分隔符从输入中移除,而是要将它返还给输入,或者干脆在字符从输入中被删除以前就先把之后的一些字符提前读取并保存下来。在许多情况下,最多只需先行读取1个字符。例如,对字符串“xtemp=ytemp”,当读取到“=”的时候,判断当前的标识符“xtemp”已经终结,就要把“=”推回给输入,以防影响后续的标记识别。不过,这种方式有时也不是必须的。在这个例子中,读取到“=”的时候,因为已知没有其它标记是以“=”开头的,所以也可以不用将它返回给输入。另一个类似的例子是:使用C语言的scanf和C++的std::cin读取数字时,如果读到了空白,就要将空白“推回”给输入流。这样,如果后续的代码在继续读取并处理输入时需要考虑空白字符的影响,这种推回的操作就使得接下来的代码能正常处理输入。
有时候,一门编程语言需要先行读取多于1个的字符,这时扫描器就需要留出缓冲区,以临时存储大量的字符。
2.3 有限自动机
有限自动机(finite automaton)又称有限状态机(finite-state machine),是描述特定类型算法(或“机器”)的数学方法。有限自动机尤其适用于描述字符串匹配的过程,因此能被用于构建扫描器。
先来看一个例子。为了方便,假设在某编程语言中,标识符仅支持字母和数字,且必须以字母开头。即
identifier=letter(letter│digit)^

其中letter和digit分别是匹配英文字母和数字的正则表达式。那么,匹配这种字符串的过程可以描述为:

在该有限自动机中,1和2所在的圈代表状态(state),即记录了已经识别模式的多少个字符的过程。箭头代表从一个状态到另一个状态的转换。在本例中,成功匹配一个字符时,就转换了状态。状态1是起始状态(start state),通常有一个起点未知的箭头指向起始态。状态2代表已经匹配第一个字母的状态。匹配第一个字母时,状态机沿着标有letter的箭头从状态1转换到状态2。一旦到达状态2,无论成功匹配多少个字母或数字,都将继续保持在状态2。识别过程结束后,可以宣布成功,本例中这样的状态称为接受状态(accepted state)。接受状态的边界是双圆周,且一个状态机可以具有多个接受态。在此例中,状态2同时也是接受态,代表一旦识别出一个字母,接下来无论匹配多少个(可以是零)字母或数字,都代表一个合法的标识符。
于是,识别一个字符串的过程也可以画成这样:

确定性有限自动机(deterministic finite automaton,DFA)M包括一张字母表Σ,状态集S,转换函数T:S×Σ→S,起始状态s_0∈S,接受状态集A⊂S。确定性有限自动机M接受的语言写作L(M),定义为:字符串C=c_1 c_2…c_n, c_i∈Σ, i=1,2,…,n,存在一系列状态s_i=T(s_(i-1),c_i )使得s_n∈A。
状态转换函数实现将有序对(s,c)映射到另一状态s^’,即

到达接受态需要经历的过程是:

每个状态的命名不仅仅可以是数字,也可以是字母和下划线。
此外,状态集里应当具有错误状态,不过这些状态以及到达这些状态的错误转换(error transition)被省略了。如果我们把识别标识符的例子中的这些省略的部分画出来,那么自动机应当是这样的:

在本例中,当读取到一个既非字母又非数字的字符,或者读取到的第一个字符不是字母时,就进入错误状态,意味着读取到不合法的标识符,或者读取到了标记分隔符。图中的两个other分别表示letter和(letter│digit)。

前面讲过,使用正则表达式来匹配C / C++的注释是困难的。但是使用DFA表示则容易得多:

然而,DFA的数学定义也不能描述DFA算法的行为的每个方面,也没有具体指出当程序即将到达接受状态时做出的动作。并且,如果在状态转换期间需要继续动作(在本例中,就是在状态转换的过程中还需要匹配字符),DFA的数学定义也无法指明。
在我们的例子中,转换状态期间,程序一般需要将字符从输入字符串移动到标记的字符串值的末尾。当到达接受状态时,程序一般需要返回读取到的标记,有时还需要一并返回一些关联的属性。当到达错误状态时,程序一般需要将已经读到的字符都推回给输入(而不能直接“吞掉”),或生成一个代表出错的标记。

我们把之前的图补上处理标识符的非法字符的部分:

如果在第一个字符以后读到了不可以作为标识符的一部分的字符,那么将进入接受状态,并返回读取到的标识符。在这个图中,错误状态变为了接受状态,一部分指向错误状态的箭头(未画出)也转而指向接受状态。注意:读到的非法字符要返回给输入。这个图也体现了最长子串原则:直到读到第一个非法字符,才认为一个标识符已经读取完毕。相比之下,之前的DFA图并没有体现这个原则,也就是说可以在标识符没有读完的时候就结束,这显然不是我们想要的。

接下来讨论如何用DFA表示处理其它标记的过程。例如,对于匹配赋值运算符“:=”、小于等于运算符“<=”和等于运算符“=”的自动机,可以将它们的起始态合并:

但是,如果把不等号“<>”也考虑进来,情况就不同了。这个图会变成:

然而,这并不是一个DFA。因为匹配了小于号以后,可以导向3个不同的状态。而DFA的数学定义中的状态转换函数要求给定状态和字符以后必须导向一个唯一的状态。

在我们的例子中,匹配每个标记的过程都对应一个单独的DFA。把它们合并为一个巨大的DFA是极难做到的。因此,需要引入非确定性有限自动机(nondeterministic finite automaton,NFA)。在给出NFA的定义之前,我们还需要引入ε转换的概念。
ε转换(ε-transition)是无需考虑输入字符串(也无需吞掉字符)就可能发生的一种转换。它可以看作匹配到了一个空串,即

ε转换是反直觉的,因为它有可能自发地发生,即:未先行读取、未改变输入字符串时,就可以发生。但它在两个方面很有用:第一,它能表示具有多种选择的情况:

这有一个优点:在保持这些原始的自动机完整性的情况下,只增加一个新的初始状态来连接它们。第二个优点是:能显式表示空串匹配:

虽然它与右侧的DFA等效,但有时候这种显式记法是很有用的。

现在给出NFA的定义。它与DFA的很相似,但我们需要向字母表Σ中增加ε。状态转换函数T的定义也需要扩充,使得允许导向多于1种状态:将T的返回值改成一组状态,而不是一个状态。也就是说,T的返回值的取值范围是状态集S的幂集P(S):
非确定性有限自动机(nondeterministic finite automaton,NFA)M包括一张字母表Σ,状态集S,转换函数T:S×(Σ∪ε}→P(S),起始状态s_0∈S,接受状态集A⊂S。确定性有限自动机M接受的语言写作L(M),定义为:字符串C=c_1 c_2…c_n, c_i∈Σ∪ε, i=1,2,…,n,存在一系列状态s_i=T(s_(i-1),c_i )使得s_n∈A。
虽然字符串C可以包含ε,但最终接受的字符串C是不包含ε的,因为任何一个字符串s与ε连接还是s。所以,字符串C的实际长度可以短于n。并且,状态序列s_1,…,s_n也是从状态T(s_0,c_1 ),…,T(s_(n-1),c_n )中选出的,这种选择并不总是唯一。这就是为什么这样的自动机叫做非确定性的:接受同一字符串的转换序列并不被当前状态和下一个输入的字符唯一决定下一个状态。事实上,任意数量的ε能在任何位置被插入字符串,对应这个NFA的ε转换数量。因此,NFA并不代表一个算法。然而,它可以被一个回溯每一步非确定性选择的算法模拟。
2.4 从正则表达式到DFA
因为正则表达式的简洁性,通常更倾向于使用DFA作为记号来描述它们,于是扫描程序的生成就通常是从正则表达式开始,并通过DFA的构造以得到最终的扫描程序。
将正则表达式转换为DFA的最简单算法是:借助NFA作为中介。也就是说,先将正则表达式转为NFA,再将NFA转换为DFA。当然,也有直接将正则表达式转换为DFA的算法,但它们更复杂。

把正则表达式转换为NFA的构造方法称为Thompson构造(Thompson’s construction)。它使用ε转换将正则表达式的每一部分的机器都“粘在一起”,获得对应整条表达式的状态机。
我们知道,基本正则表达式是形如"a" 、“ε” 或"ϕ" 的。“a” 代表字母表中的任意一个字符,“ε” 代表空字符串,“ϕ” 代表无法匹配任何字符串。它们的NFA分别为:

其中,“ϕ” 对应的自动机只有初态和终态,无状态转换。
正则表达式r的NFA为(s的类似)

左右的圆圈分别代表起始态和接受态,中间经过的状态没有画出来。所有基本正则表达式的NFA都可以这样画(只有一个接受态)。
对于两条连接后的正则表达式rs,其NFA为

也就是说,将r的NFA的接受态和s的NFA的起始态通过ε变换连接起来。新的自动机的起始态和接受态分别是r的起始态和s的接受态。显然,它满足L(rs)=L®L(s)。
注意:你可能已经想到了更简单的画法,但那样的画法不符合Thompson构造的要求。严格遵循Thompson画法,是为了更方便地令计算机处理正则式。
对于形如r|s的正则表达式,其NFA为

起始态和接受态分别是最左侧和最右侧的圆圈。显然,它满足L(r│s)=L®∪L(s)。
对形如r^*的正则表达式,其NFA为

显然,该自动机允许非负数次重复次数。
当然,从正则表达式构造NFA的算法不唯一。例如,对于rs,它的NFA也可以是:

但是,这要求r的自动机的接受态不能具有转移到其它状态的途径。具体参见练习。
能够进行这样子的转换,是因为参与构造的正则表达式都是很简单的。首先,从每个状态最多只有两种转换。而且如果有两种转换,都是ε转换。其次,每个状态一旦建立就不会被删除;并且,转换不会改变,除非添加了从接受态的转换。这些性质使得刚才构建自动机的整个过程都是非常容易的。

为了构造出能将任意的NFA转换为等价的DFA的算法,我们需要一些方法去掉ε转换和从单个状态借由一个字符可以到达的多个状态。消除ε转换需要ε闭包。ε闭包(ε-closure)是所有能通过一个或多个状态和ε转换到达的状态集合。消除单个字符上的多重转换,涉及到追踪通过成功匹配单个字符可以到达的状态集。这些过程令我们考虑状态集而不是单个状态。因此,构造出来的DFA也具有原NFA的状态集。该算法称为子集构造(subset construction)。
单个状态s的ε闭包s ̅,是零个或非零个ε转换导向的状态集。更接近数学语言的定义留作练习。注意:一个状态s的ε闭包s ̅总是包含状态s自己。
一个状态集S的ε闭包定义为S的各个状态的ε闭包的并集:
S ̅=⋃_(s∈S)▒s ̅

下面给出对指定的NFA M构造出等价的DFA M ̅的算法。
首先,计算M的起始态S_0的ε闭包,它作为M ̅的起始态S。计算字符a上的转换的步骤是:
给定状态集S和字母表Σ中的一个字符a,计算集合
S_a^’={t ┤| ∃s∈S, ∃T:s□(→┴a ) t}
即:存在字符a上的从s到t的转换。
再计算S_a’的ε闭包(S_a’ ) ̅。这在子集构造中定义了一个新状态,以及一个新的转换
S□(→┴a ) (S_a^’ ) ̅
首先对M ̅的起始态S做上述运算得到(S_a^’ ) ̅(对字母表中的每个字符a,这样的过程都要做一次),再对(S_a^’ ) ̅做上述运算。
一直继续这个过程,直到再也不能建立新状态或转换。
将这些构造出来的状态集连同这些状态集上的转换一起记为M ̅,这就是所求的DFA。包含M的一个接受态的M ̅的状态集都是接受态。

简单讨论一下编程模拟NFA的可能性。模拟NFA的一种方式是:使用子集构造,但并不构造DFA的全部状态,而是在下一个输入字符指示的每个点的状态。因此,我们仅建立那些实际上DFA在给定的输入字符串上会选择的路径上会发生的状态的集合。这样做的优势是:我们可能无需建立整个DFA。但劣势是:当路径包含回路时,有的状态可能会被重复建立很多次,这可能会导致最终的效率甚至低于建立整个DFA。出于这样的理由,NFA模拟不在扫描器中完成,而可能在编辑器和搜索程序中被用于进行模式匹配。在这些场景中,用户可以动态给出正则表达式。

上述建立DFA的算法可能会建立比需要的更复杂的DFA。由于扫描器对效率的要求是极高的,因此我们希望能建立一个尽量小的DFA。自动机理论指出:对给定的任意DFA,总有唯一的包含状态数量最少的DFA与其等价。并且,获得这个最小状态DFA也是可能的。
这个算法通过创建状态集合并将其统一为单个状态来推进。
首先,从最乐观的假设开始:创建两个集合,一个包含全部接受态,另一个包含全部非接受态。
为原DFA这样划分状态后,考察字母表Σ中的每个字母a上的转换。
如果所有的接受态都具有在a上的到接受态的转换,那么这定义了一个从新的接受态(所有旧的接受态的集合)到它自己的a转换。
类似地,如果全部的接受态都具有a上的到非接受态的转换,那么这定义了一个从新的接受态到新的非接受态(所有旧的非接受态的集合)的a转换。
另一方面,如果存在两个接受态s,t,具有a上的导向不同集合的转换,则无法为这一组状态定义a转换。我们称a区分(distinguish)了状态s和t。在这种情况下,正在考虑的状态集(即全部接受态的集合)必须根据a变换导向的位置进行拆分。
类似地,对其它每个状态集,一旦我们考虑完字母表中的全部字母,就要处理这些集合了。
当然,如果还有更多的集合分裂出来,我们就必须返回并从头开始过程。我们继续这个过程,以便优化原DFA的状态划分,直到所有集合都只包含一个元素,或没有新的集合分裂,这时原DFA已经达到最小。
为了上述算法能正确工作,还必须考虑导向错误态的错误转换。即:
如果存在两个接受态s,t,s具有一个a转换到另一接受态,但t不具有任何a转换(或者,具有从a到错误态的转换),则a区分了s和t。
类似的,如果一个非接受态s具有一个a转换到另一接受态,而另一个非接受态t不具有a转换(或者,具有从a到错误态的转换),则a也区分了s和t。

状态最小化算法的实质是:将DFA的全部状态的集合S划分为若干个不相交的子集,其中不同子集的状态之间是可以区分(distinguish)的,相同子集内的状态之间等价。
初始划分:将全部状态划分为两个子集,一个包含全部接受态,另一个包含全部非接受态。
继续划分的过程:
设状态s_1,s_2∈I,其中I是划分中的一个子集;a∈Σ,Σ是字母表;状态转移函数为δ。
若δ(s_1,a)∈J, δ(s_2,a)∈K,J, K都是划分中的子集(可以是错误态的集合)。则划分I需要划分成I_1, I_2,使得s_1∈I_1, s_2∈I_2。
也就是说,当存在字母表中的一个字母能区分两个状态时,要求这两个状态最终要被放在不同的集合里。当字母表中的所有字母都不能区分两个状态时,这两个状态最终仍然需要放在同一个集合里。
需要注意的是,不但要对划分中的每个子集都尝试继续划分,而且尝试为每个子集继续划分的过程中,要将字母表Σ中的每个字母都考察一遍。
当对划分中的每个子集都无法再继续划分时,划分结束。在剩下的集合中,如果有集合含有超过1个状态,就取任意1个状态作为代表即可。化简后的DFA的状态数量,就是划分完毕以后的集合数量。

猜你喜欢

转载自blog.csdn.net/COFACTOR/article/details/115211089