一、前情提要
本文是作者对之前编译原理课程项目中词法分析模块的正则表达式->NFA->DFA->最小化DFA功能的总结归纳和思路整理展示,并结合一个具体用例帮助读者更好地了解其中转化思路和算法设计。因为当时写这个的时候是用QT来写的,所以下列部分代码可能涉及QT的书写格式,主要还是看代码思路和算法设计、选择自己有需要的部分读取即可。下面也给出了作者当初针对这些有穷状态机设计的数据结构供读者参考,后面的设计思路也基于这个展开(注:整篇文章是针对TINY语言进行的词法分析,所以也展示了TINY所有单词的正则表达式的获取)。
-
数据结构说明
-
NFA:用类定义NFA的结点和边,并用邻接表存储NFA的信息,该邻接表包括结点链表以及当前结点的边链表。
-
DFA:DFA用二维数组Dtran[ ][ ]表示,把Dtran看成一个表;字符数组ch[ ]为横坐标,DFA状态集QVector<QVector<> >NFA_node_set为纵坐标,通过这两个数组的下标定位转换后的状态集合在Dtran中存储的下标,即Dtran[k][i]表示该状态集合NFA_node_set[Dtran[k][i]]是NFA_node_set[k]经过ch[i]的转换后得到的状态集合。
-
最小化DFA:最小化DFA用二维数组dtran[ ][ ]表示,用二维动态数组QVector<QVector<> >miniDFA_set存储分割完后的状态集合,最后根据DFA状态转换表中各状态集合在miniDFA_set中的归属更新Dtran表,并将更新结果存储到dtran表中,即为最小化DFA的状态转换表。
-
TINY所有单词的正则表达式
上面这张图相信大家并不陌生,这里我们也将基于这张TINY的最小化DFA图总结出TINY语言的正则表达式
( w | { o * } ) * ( d d * o | l l * o | : ( = | o ) | o ),其中:
w 代表 white space
o 代表 other
d 代表 digit
l 代表 letter
{ } : = 为本身对应的符号
因为正则表达式中只包含了括号、闭包(*)、或(|)、连接四种运算符,下面的对正则表达式的处理中也只针对这四种运算符。
二、正则表达式→NFA
- 输入或打开文件导入正则表达式;
- 对正则表达式添加连接运算符(默认输入的表达式已省略连接运算符),这里的连接运算符用 . 表示(添加方式比较简单粗暴,直接罗列情况插入连接运算符);
- 将处理后的正则表达式转换为逆波兰式;
- 将逆波兰式通过Thompson 构造法转换为NFA。
① 添加连接运算符
//当 左边字符是字母或)或* 且 右边字符是(或字母 时需要插入连接运算符
void RegExp::insert_join_operator(string r){
RE_CLEAN();
int i=0,j,l=r.length();
while(i<l-1)
{
if((((r[i]!='(')&&(r[i]!='|')&&(r[i]!='.')&&(r[i]!='*'))||(r[i]==')'||r[i]=='*'))
&&((r[i+1]!=')')&&(r[i+1]!='|')&&(r[i+1]!='.')&&(r[i+1]!='*')))
{
//字符串多拼接一位
r=r+'\0';
for(j=l;j>i+1;j--)
{
r[j]=r[j-1];
}
r[i+1]='.';
l++;
i++;
}
i++;
}
regexp_=r;
}
② 正则表达式→逆波兰表达式
在前面我们已经得到了用户输入并已添加了连接运算符的正则表达式,同时我们也知道正则表达式中涉及的运算符的优先级括号>闭包(*)>连接(.)>选择(|),为了后面能够用Thompson构造法构造出NFA,这里我们需要先通过调度场算法将正则表达式转换为逆波兰式,也就是后缀表达式,这里具体可以看我的另一篇文章:
③ Thompson (汤普森)构造法
Thompson结构利用ε-转换将正则表达式的机器片段“粘在一起”以构成与整个表达式相对应的机器。即先构建所有的基本单元,然后再根据转换规则对基本单元进行处理,这里包含了连接( . )、或( | )、闭包( * )三种转换规则。
这里提供一个有限状态机设计器链接,下面对FA图的绘制都是基于这个网站实现,可点击链接进入,缺点是不能使用希腊字母,所以下面的(epsilon)ε 都用 @ 表示。
Finite State Machine Designer - by Evan Wallace (madebyevan.com)
-
数据结构
-
使用栈存储每次单元或规则构建后的头、尾结点,并用邻接表存储NFA信息
1、基本单元
- 与正则表达式a等同的NFA
- 与ε等同的NFA(ε用@表示)
- 算法思想:新建两个结点存入邻接表并相连(即将新建的结点存入结点链表中,再根据该基本单元NFA的起点和终点关系,将终点的结点值和边上的权值存入起点的边链表中),最后将两个结点入栈。
2、连接运算
- ab
- 算法思想
3、或运算
- a|b
- 算法思想
4、闭包运算
- a*
- 算法思想
④ 用例展示
针对上述正则表达式→NFA的过程,下面以 a(a|b)* 为例展示转换过程:
1、 输入 a ( a | b ) *
2、添加连接运算符
- a ( a | b ) * 添加连接运算符后为 a . ( a | b ) *
3、转换为逆波兰式
- a ( a | b ) * 转换为逆波兰式后为 a a b | * .
4、转换为NFA
- NFA邻接表
- NFA状态转换表
- NFA图
三、NFA→DFA —— 子集构造法(Subset Construction)
① ε-closure(I) 和 move(I,a)
在了解子集法之前先定义状态集合 I 的两个运算:
- 状态集合 I 的 ε 闭包,表示为 ε-closure(I) ,定义为状态集J,J是状态集合 I 中的任何状态 S 经任意条 ε 弧而能到达的状态的集合;
- 状态集合 I 的 a 弧转换,表示为 move(I,a) ,定义为状态集K,K是所有状态集合 I 中的任何状态 S 经过一条 a 弧能到达的状态的集合。
② 算法介绍
子集构造法即根据NFA和DFA的不同(DFA不同于NFA主要在于DFA初始状态只有一个、DFA中存在的都是非 ε 转换且不存在多重转换),通过消除NFA的 ε 转换和多重转换得到DFA,ε 转换指NFA中的 ε 弧,多重转换指某一状态读入一输入符号后能够到达多个状态,分别通过 ε-closure(I) 和 move(I,a) 消除,因为这两个过程得到的结果均是状态集合,所以称这个算法为子集构造法。
③ 算法思路
//K表示NFA中的结点的集合,C为新建的DFA状态集
① 开始,令ε-closure(K0)为C中唯一成员,并且它是未被标记的
② While(C中存在尚未被标记的子集T)do
{标记T:
for 每个输入字母a do
{U:=ε-closure(move(T, a));
if U 不在C中 then
将U作为未被标记的子集加在C中
}
}
④ 用例展示
沿续上面NFA中 a(a|b)* 的例子,已知 ch[2]={a, b},DFA状态集C:
- 从状态结点0开始,T0=ε-closure(0)={0,1},C.append(T0);
- 对T0:ε-closure(move(T0, a))={2,9,10,7,3,5} 不在C中=T1,C.append(T1);ε-closure(move(T0, b))为空;
- 对T1:ε-closure(move(T1, a))={4,8,7,10,3,5} 不在C中=T2,C.append(T2);ε-closure(move(T1, b))={6,8,7,10,3,5} 不在C中=T3,C.append(T3);
- 对T2:ε-closure(move(T2, a))={4,8,7,10,3,5}=T2 在C中;ε-closure(move(T2, b))={6,8,7,10,3,5}=T3 在C中;
- 对T3:ε-closure(move(T3, a))={4,8,7,10,3,5}=T2 在C中;ε-closure(move(T3, b))={6,8,7,10,3,5}=T3 在C中;
即得到DFA状态集{T0,T1,T2,T3},QVector动态二维数组表示为QVector(QVector(0, 1), QVector(2, 9, 10, 7, 3, 5), QVector(4, 8, 7, 10, 3, 5), QVector(6, 8, 7, 10, 3, 5))。
Dtran二维数组表的存储情况以及最终展示出来的状态转换表如下:
DFA图:
四、最小化DFA
① 为什么要最小化?
上述生成的DFA存在缺点:生成的DFA可能比需要的复杂的多,状态数也多。因为在扫描程序中效率很重要,所以构造的DFA越小越好,故而需要我们对DFA进行最小化处理。
所谓有穷自动机是化简了的,就是说它没有多余状态并且它的状态中没有两个是互相等价的;一个有穷自动机可以通过消除无用状态和合并等价状态而转换成一个与之等价的最小状态的有穷自动机。
② 分割法
1、算法介绍
为了将前面生成的DFA最小化,我们采用分割法来实现。分割法可以将一个DFA(不含多余状态)的状态分成一些不相交的子集,使得任何不同的两个子集的状态都是可区别的,而同一个子集中的任何两个状态都是等价的。
注:如果两个状态不等价,则称这两个状态是可区别的。判断两个状态等价需要满足两个条件:①一致性条件——两个状态必须同时为可接受态(终态)或不可接受状态(非终态);②蔓延性条件——对于所有输入符号,两个状态必须转换到等价的状态。
2、算法思路
- 将之前得到的 DFA 分为非终止状态和终止状态两组,然后对每一组进行检查是否能够再做分割,若可则对分割后的组再分割,直到不可再分割为止。
- 判断是否为终止状态的依据:该DFA的状态集合中是否包含NFA中的终止结点,若包含则为终态,反之为非终态。
- 判断能否分割的依据:即判断当前的终止或非终止状态组中的状态是否等价,等价的保留,不等价的分割出来;最直观的判断等价与否方法就是看Dtran表中各DFA状态集NFA_node_set对于每个ch[k]是否都有同样的转换结果,若是则这些DFA状态集等价。
3、用例展示
- 沿续上面 a(a|b)* 的例子:
- 首先分割为非终止状态组{T0}和终止状态组{T1,T2,T3},判断依据为各T是否包含终止结点(node_num=10);
- 然后分别对两组进行检查,看组内包含的各状态集合是否转换结果相同
- 对于非终止状态组:只有T0,保留,不做分割;
- 对于终止状态组:T1、T2、T3的转换结果都相同,全都保留,不做分割;
- 最终得到miniDFA_set状态集合QVector(QVector(0), QVector(1, 2, 3))。
- 令A表示QVector(0),B表示QVector(1, 2, 3),根据Dtran表更新并存入dtran表中,更新过程如下(重复的去掉):
- 最小化DFA状态转换表:
- 最小化DFA图: