前面介绍了flex这样的工具就是需要将输入程序中的正则表达式转换为状态转换图,并基于状态转换图生成代码。之前通过手动画出转换图进行了示例,现在讨论如何自动完成这个转变。
转变的核心是称为有穷(有限)自动机(finite automata)的表示方法。这些自动机在本质上是与状态转化图类似的图。有穷(有限)自动机判别输入串的类型;分为NFA(Nondeterministic Finite Automata)和DFA(Deterministic Finite Automata),是有限个状态以及在这些状态之间的转移和动作等行为的数学模型。
因为这两个缩写也好,中文名字也好,都有点生人勿近的感觉,所以这里举个例子让它接地气一点。
现实中的有限自动机的例子,验票闸门:
用于控制地铁和游乐园游乐设施的旋转门是一个门,在腰高处有三个旋转臂,一个横跨入口通道。最初,旋转臂被锁定,阻挡了入口,阻止了顾客通过。将硬币存放在旋转门上的槽中可解锁手臂,允许单个客户穿过。在顾客通过之后,再次锁定臂直到插入另一枚硬币。
旋转门被视为状态机,有两种可能的状态:锁定和解锁。有两种可能影响其状态的输入:将硬币放入槽(硬币)并推动手臂(推动)。在锁定状态下,推动手臂无效; 无论输入推送次数多少,它都处于锁定状态。投入硬币 - 即给机器输入硬币 - 将状态从锁定转换为解锁。在解锁状态下,放入额外的硬币无效; 也就是说,给予额外的硬币输入不会改变状态。然而,顾客推动手臂,进行推动输入,将状态转回Locked。
旋转门状态机可由状态转换表表示,显示每个可能状态,它们之间的转换(基于给予机器的输入)和每个输入产生的输出:
旋转栅状态机也可以由称为状态图的有向图表示 (上面)。每个状态由节点(圆圈)表示。边(箭头)显示从一个状态到另一个状态的转换。每个箭头都标有触发该转换的输入。不引起状态改变的输入(例如处于未锁定状态的硬币输入)由返回到原始状态的圆形箭头表示。从黑点进入Locked节点的箭头表示它是初始状态。
NFA对其边上的标号没有任何限制,一个符号可以标记离开同一状态的多条边,并且空串也可以作为标号。DFA对于每个状态和自动机输入字母表中的每个符号,有且只有一条离开该状态,以该符号为标号的边。
NFA和DFA能识别的语言是相同的。由一个NFA或DFA定义或接受的语言是从开始状态到某个接收状态的所有路径上的标号串的集合。事实上,这些语言的集合正好是能够用正则表达式描述的语言的集合。这个集合中的语言称为正则语言(regular language)。
上图是一个NFA,表示的是正则式(a|b)*abb表达的语言。
下图是表达正则式
NFA的正式定义,由以下几个部分组成:
- 有穷的状态集合S。
- 输入集合符号
,也即输入字母表,假设代表空串的不是中的元素。
- 转换函数(transition function),为每个状态和
中的每个符号都给出了相应的后继状态(next state)的集合。
-
中的一个状态被指定为开始状态,或者是初始状态。
-
中的一个子集被指定为接受状态(或者说终止状态)的集合。
不管是NFA还是DFA,都可以表示为一张转换图(transition graph)。上面的图和状态转换图十分相似,但是不同之处在于:
- 同一个符号可以标记从同一状态出发到达多个目标状态的多条边;
- 一条边的标号不仅可以是输入字母表中的符号,也可以空符号串
我们也可以将NFA表示为转换表(transition table),表的各行对应于状态,各列对应于输入符号和
可以使用下面的转换表表示:
使用转换表的好处是很容易确定给定状态和输入符号相对应的转换。
自动机如何判断是否接受输入字符串呢?
NFA接受(accept)输入字符串
值得注意的是,可能存在多条具有相同标号序列但是到达不同状态的路径。只要存在某条其标号序列为符号串的路径,是从开始状态到接受状态,NFA就接受这个字符串。
NFA定义(接受)的语言是从开始状态到某个接受状态的所有路径上的标号串的集合。可以用L(A)表示自动机A接受的语言。
确定的有穷自动机DFA是NFA的一个特例,其中:
- 没有输入
上的转换动作
- 对每个状态
和每个输入符号,有且只有一条标号为的边离开。
可以这么理解,NFA抽象地表示了用来识别某个语言中的串的算法,而相应的DFA则是具体的识别串的算法。在构造词法分析器的时候,真正实现的是DFA。而幸运的是,每个正则表达式和每个NFA都可以被转变成为一个接受相同语言的DFA。
如何理解DFA的工作呢?譬如:
输入:一个以文件结束符eof结尾的字符串
输出:如果
下面看一个例子,判断给定输入串ababb,下面的DFA是否接受该串。
这个
对于给定的输入串ababb,DFA顺序进入状态序列0、1、2、1、2、3,此时状态3是结束状态,因此,返回yes。
这里我们得做一些练习。加深对NFA和DFA的理解,看这些图需要看熟。
找出来所有标号为aabb的路径;请问这个NFA接收aabb吗?
2. 下面的NFA接受aabb吗?它表示的语言是什么?
3. 大家可以自己画一下 a*|b*、(a|b)*以及a*b*的自动机。
正则表达式非常适合描述词法分析器和其他模式处理软件,这些软件的实现需要模拟DFA或者NFA的运行,因为NFA可以执行输入
上图是接受(a|b)*abb的NFA和DFA。可以看到,相比于NFA,DFA看起来不太好理解,但是因为每个状态的动作都是确定的,所以更利于代码实现。
正则表达式和NFA(DFA)的转换
将正则表达式转换为NFA主要需要对正则表达式进行语法分析,分解出组成它的子表达式。构造NFA主要分为基本规则和归纳规则。基本规则处理不包含运算符的子表达式,而归纳规则根据给定表达式的直接子表达式的NFA构造出这个表达式的NFA,McMaughton-Yamada-Thompson算法。
基本规则:对于表达式
对于表达式
在以上的NFA中,状态
归纳规则:假设正则表达式
则对于
对于
对于闭包
使用这样方法构造出来的NFA有一些性质:
- NFA的状态数最多为正则表达式中出现的运算符和运算分量的总数的2倍,因为每一步的构造最多只引入两个新状态。
- NFA有且只有一个开始状态和一个接受状态,接收状态没有出边,开始状态没有入边。
- NFA中除接受状态的每个状态要么有一条标号为
中符号的出边,要么有两条标号为的出边。
使用这种构造方法构造出来
和之前直接构造的差别还是挺大的。
这种方法构造出来的转换图确实比较麻烦,所以有的书中就进行了简化,譬如:
再一次地,请大家画一下 a*|b*、(a|b)*以及a*b*的自动机。
接下来我们讨论NFA和DFA的转换。从NFA到DFA的转换主要使用子集构造(subset construction)算法。
该算法的输入是:NFA
具体的方法是,为
注意
子集构造法的伪代码如下:
上面的算法看起来是比较让人郁闷;大家要形成的思路是,整个编译课程都是这样,理论和算法上看起来难以理解,实际上还是很容易懂的,拿个例子来看就相当简单了。
拿这个NFA来做例子:
这个NFA在进行子集构造法是形成的转换表Dtran是这样的:
获得的DFA如下:
另外,注意下,所有包含了接受状态的DFA的状态,在DFA中都是接受状态。
通过视频来查看这个过程。
为了熟练掌握,我们再来做几个例子。
首先讨论下,这个NFA所对应的正则表达式是什么?
相应的DFA:
再来一个例子:
这个NFA所对应的正则表达式是什么呢?
相应的DFA:
我们对比来看一下。接受同样语言的另一个NFA,使用子集构造法获得的DFA是什么样呢?
这个NFA所对应的的DFA如下所示:
通过上面的练习,可以看出,通过两个不同的NFA得到的两个DFA是不同的,其中一个有5个状态,另一个有4个状态。如果我们使用DFA来实现词法分析器,自然希望DFA的状态尽可能地少,也即最小化DFA。任何正则语言都有一个唯一的状态数目最小的DFA,而且通过组合以及等价分析,总是可以构建得到这个状态数最小的DFA。
DFA状态最小化算法的工作原理是将DFA的状态集合分化成多个组,每个组中的各个状态之间相互不可划分。然后,将每个组中的状态合并为状态最少DFA的一个状态。算法执行过程中维护状态集合的划分,划分中的每个组内的状态还不能区分,但是不同组的任意两个状态是可区分的。当任意一个组都不能再被分解时,此时得到状态最少的DFA。
- A和B是可区别的状态
如果分别从状态A和B出发,沿着标号为
空串可以区分任何一个接受状态 和 非接受状态
- A和C是不可区别的状态(等价)
如果无任何串可用来像上面这样来区别它们,则是不可区分额
状态A和C等价的条件是:
(1)一致性条件:状态A和C必须同时为接受状态和不接受状态。(是否属于终止状态集)
(2)蔓延性条件:对于所有输入符号,状态A和C必须转换到等价状态里。
最初的时候,划分成两个组,接受状态组和非接受状态组。基本步骤是从当前分化中取出一个状态组,并选定某个输入符号
结合例子来讲,
首先将状态分成两个组,接受状态组和非接受状态组: {A, B, C}, {D}。
1. {A, B, C}, {D}
move({A, B, C}, a) = {B}
move({A, B, C}, b) = {C, D}
上面在接收b的时候,ABC三个状态到达的状态不同,因此可以进行划分,根据到达状态的不同,划分为{A,C}和{B}。
2. {A, C}, {B}, {D}
{B}和{D}都已经不能继续划分,测试A和C是否等价。
move({A, C}, a) = {B}
move({A, C}, b) = {C}
那么,最终可以发现状态A和状态C是等价的。从而DFA简化之后的结果是:
同样的,也请同学来做一下练习,看看能不能将上面
在这个例子中,首先划分成{A,B,C,D}和{E}。
接下来,继续划分{A,B,C,D}。
move({A, B, C,D}, a) = {B}
move({A, B, C,D}, b) = {C,D,E}
根据对b的输入,将{A,B,C,D}划分成{A,C},{B}和{D}。
{A,C}不能继续区分,所以将状态A和状态C合并。
以上是DFA最小化的过程。
我们现在讨论一个问题:
NFA和DFA哪个的状态更多?
前面说过因为NFA的不确定性,所以最好使用DFA来做;但支持使用NFA模拟的论据之一是子集构造法在最坏的情况下可能会使状态个数呈指数增长。举一个极端的例子:
对于语言
它所对应的NFA是:
可以证明,
证明思路如下:使用反证法。假设长度为
所以目前这两个串的情况是这样的,在长度为n的位置处它们到达了DFA的同一个位置;同时,它们在第n 个位置之后都相同;所以接下来,这两个字符串要么同时被DFA接受,要么同时被拒绝。
但另一方面,这两个字符串在倒数第n的位置处是不同的,也就意味着这两个字符串中应该有且只有一个应该被DFA接受。
因此,导致矛盾。也即意味着长度为
幸运的是,很少遇到这种类型的模式,也不用担心会遇到状态数量奇多的DFA。
词法生成器生成工具的设计
在构建自动机时,首先使用算法将Lex中的每个正则表达式转换为一个NFA(DFA),需要使用自动机来识别所有与Lex程序中的模式相匹配的词素,因此将这些NFA(DFA)合并为一个NFA。合并的方法是引入新的开始状态,从这个开始状态到各个NFA(DFA)各有一个
譬如在lex文件中有如下的规则
则三个规则分别对应的NFA是:
合并之后,整体的NFA如下:
在模拟NFA运行的过程中,最终会到达一个没有后续状态的输入点。那时,不可能有更长的输入前缀使得这个NFA到达某个接收状态,此后的状态集将一直为空。因此,我们可以判定最长前缀(与某个模式匹配的词素)是什么。
此时,我们沿着状态集的顺序回头寻找,直到找到一个包含一个或多个接受状态的状态集合为止。如果集合中有多个接受状态,那么就选择和在Lex程序中为止最靠前的模式相关联的那个接受状态。
举例来看,如果输入字符串是aaba,那么NFA从初始状态0的
到达状态8之后,没有进一步可以接收输入
写一个网页:
<script>
alert(/^1((10*1)|(01*0))*10*$/.test("111"))
</script>
用正则表达式判断数的整除性。例如,上面的代码可以匹配01串S当且仅当S是一个可以被3整除的二进制数。能被3整除的二进制数并没有什么明显的规律。之所以能够写出来这样的正则表达式,主要是判断一个数的整除性能轻易地用有限状态自动机实现,而有限状态自动机又可以翻译成正则表达式。
一个二进制数后面加一个“0”相当于该数乘以2,一个二进制数后面加一个“1”相当于该数乘2加1。设定三个状态,分别叫做0、1和2,它们表示当前的数除以3所得的余数。如果对于某个i和j,有i*2≡j (mod 3),就加一条路径i→j,路径上标一个字符“0”;如果i*2+1≡j (mod 3),则在路径i→j上标记“1”。状态0既是我们的初始状态,也是我们的最终状态。我们的自动机就做好了。
使用这个思路得到的DFA如下图所示:
有限状态自动机是可以转化为正则表达式的。上面的这个自动机转化起来非常容易。我们可以先试着用自然语言叙述一下。首先,每个二进制数第一位必然为“1”。到达状态1后,我们可以随意地、任意多次地在状态1周围绕圈圈,最终回到状态1。临近末尾,我们再读到一个“1”返回状态0,这之后随便读多少个“0”都可以了。现在问题分解为:我们又如何用正则表达式表述“从状态1出发随意地走最终回到状态1”呢?在本例中,这是很好描述的:它可以是字符串“1000..001”和“0111..110”的任意组合。把这些东西用正则表达式写出来,就是刚才那个神秘的式子:1((10*1)|(01*0))*10* 。
请同学们讨论下被5整除的情况。
<script>
var reg3 = /^1((10*1)|(01*0))*10*$/
var reg5 = /^(0|1(10)*(0|11)(01*01|01*00(10)*(0|11))*1)*$/
//alert(reg3.test("111"));
//alert(reg5.test('101'));
for(i=1;i<20;i++){
document.write(i+"/3 ",reg3.test(i.toString(2))+", ");
document.write(i+"/5 ",reg5.test(i.toString(2)),"<br/>");
}
</script>
正则式和NFA(DFA)的转换。
然后构造DFA:
最终如下:
讨论:
-
所对应的NFA和DFA分别是什么?
- (1|01)* 0*所描述的语言是什么?NFA和DFA分别是什么?
更多例子:
yet another:
yet another:
flex练习
请实现对注释的处理。
单行注释的情况比较好处理;多行注释的情况考虑下,应该怎么做?
START ("/*")
END ("*/")
COMMENT (.*)
%%
{START}{COMMENT}{END} printf("multiline comment");
# return 0;
. ECHO;
%%
这种写法对于下面的测试用例,返回结果是什么呢?
/* */
why?
/* hello world */
/* what's up?
hello world 2*/
I am sad.
/* "/* */" */
结果如下:
yan@ubuntu:~/compile/toy/flex$ cat comm.test | ./comment
multiline comment
why?
multiline comment
/* what's up?
hello world 2*/
I am sad.
multiline comment
应该如何改进呢?
START ("/*")
END ("*/")
COMMENT ((.|n)*)
%%
{START}{COMMENT}{END} printf("multiline comment");
# return 0;
. ECHO;
%%
结果如何呢?
yan@ubuntu:~/compile/toy/flex$ cat comm.test | ./comment
multiline comment
继续改进:
START ("/*")
END ("*/")
COMMENT (.*n*.*)
%%
{START}{COMMENT}{END} printf("multiline comment");
# return 0;
. ECHO;
%%
结果如下:
yan@ubuntu:~/compile/toy/flex$ cat comm.test | ./comment
multiline comment
why?
multiline comment
multiline comment
I am sad.
multiline comment
还有各种其他写法:
%x COMMENT
%{
%}
%%
"//".* { } /*跳过单行注释*/
"/*" {BEGIN COMMENT;}
<COMMENT>"*/" {BEGIN INITIAL; printf("Comment!");}
<COMMENT>. {} /*跳过多行注释*/
. {printf("%s", yytext);} /*用来测试,看是否已经跳过注释*/
%%
参考:
- http://blog.stevenlevithan.com/archives/algebra-with-regexes
- https://blog.csdn.net/qq_23100787/article/details/50402643
- http://apframework.com/2018/09/29/spring-statemachine-01/
- https://juejin.im/post/5c738dd5e51d457fcb40aaaa
- https://www.cnblogs.com/AndyEvans/category/1345751.html
- https://www.cse.cuhk.edu.hk/~siuon/csci3130-f17/slides/lec02.pdf