点赞再看,养成习惯!觉得不过瘾的童鞋,欢迎关注公众号《机器学习算法工程师》,有非常多大神的干货文章可供学习噢…
PS: 鉴于本文对很多童鞋有所帮助,小编于2020/6/7对其回炉重造,在CodeBlocks IDE(采用GCC套装 编译器)中成功编译运行,代码已上传至Github上,童鞋们可在参考文献中找到链接。
本篇是凭借Thompson方法,从而根据正规表达式(正规式)构造对应的NFA(不确定有穷自动机),其实现语言是C++。基本逻辑如下图所示:
本篇中,有效的正规式的字母表 ∑ = { a − z , A − Z } \sum=\{a-z,A-Z\} ∑={a−z,A−Z},辅助字母表 ∑ ′ = { ∗ , ∣ , ( , ) } \sum'=\{*,|,(,)\} ∑′={∗,∣,(,)}。对应的规则:
其实还有连接符号,不过我们平时写正规式时会省略它,但小编在源码中其实加上了就是’+’,参考文献的那个课本用的是’.’。这些算符的优先顺序为先’*’,再’+’,最后’|’。这个优先关系在中缀表达式转后缀表达式的时候会用到。
非法字符
评论中曾有童鞋问小编非法字符指的那些,当时文章写得并不清晰。这次,小编将从源码角度解释,非法字符指的便是除字母表与辅助字母表以外的字符,并且源码额外还对括号进行检查。
/**检测输入的正则表达式是否合法
*/
int check_legal(const string check_string)
{
if (check_character(check_string) && check_parenthesis(check_string))
{
return true;
}
return false;
}
/**
检查输入的字符是否合适 () * | a~z A~Z
合法返回true,非法返回false
*/
int check_character(const string check_string)
/**检查括号是否匹配
*合法返回true,非法返回false
*/
int check_parenthesis(const string check_string)
加入连接符
这一步主要是为了方便后面的工作(中缀转后缀),将省略的连接符显式加进去。要加的地方如ab 、 *b 、 a( 、 )b 等情况,一次遍历即可完成。
这一节的算法,小编特地写了一篇博文帮助理解,童鞋可在参考文献中找到。其实在源码中这块有比较详细的注释,这里要利用上前面说的算符优先关系。
一个NFA M是一个五元组, M = ( K , ∑ , f , S , Z ) M=(K,\sum,f,S,Z) M=(K,∑,f,S,Z)。我们目前知道的只有 ∑ \sum ∑有穷字母表,即是正规式的字母表,而其他的像 K K K状态集, f f f转移函数, S S S非空初态集, Z Z Z终态集,就在这一节能够得到。源码实现的方法便是Thompson方法,在参考文献《编译原理》书中第4章能够找到与下面内容一样的解释,不再赘述。
下面的理论光看文字描述可能难以快速理解,小编打算以一个demo并辅助源码来细致讲解,希望能达到过一遍就能上手源码的效果!
Demo
正规式为a(a|b)*,很明显这个简单的正规式包含了所有的辅助字母表的符号(即所有的规则),和字母表里的字母a和b,不过我们不要简单地把它们看成字母a和b,实际上它们可以抽象为两个不同的符号,他们所代表的可以是一类符号,比如是数字或者是英文字母,这取决于我们在源码中把这些符号识别成什么。
首先,介绍一下源码中用到的数据结构:
//NFA的节点,定义成结构体,便于以后扩展
struct state
{
string StateName;
};
//NFA的边,空转换符用'#'表示
struct edge
{
state StartState;
state EndState;
char TransSymbol; //转换符号
};
//NFA单元,一个大的NFA单元可以是由很多小单元通过规则拼接起来
struct cell
{
edge EdgeSet[MAX]; //这个NFA拥有的边
int EdgeCount; //边数
state StartState; //开始状态
state EndState; //结束状态
};
Thompson原理
方法:将r分解成最基本的子表达式,使用下面的规则1和2为r的每个基本符号( # 或Σ中的符号)构造NFA。用规则3逐步组合前面构造的NFA,直到获得整个正规表达式的NFA为止。
规则1. 对#, 构造NFA 。
这里 i 是新的开始状态,f是新的接受状态。很明显这个NFA识别{#}。
规则2. 对于Σ中的每个符号a,构造NFA。
同样,i 是新的开始状态, f 是新的接受状态。 这个NFA识别 {a}。
【小编讲解】
规则1和2是NFA构建的基本单元,含有两个状态以及状态转换的边。在源码中实现是如下面代码所示的,会新增两个状态与一条边,状态从new_StateNode函数生成,能够保证全局唯一性;而边则记录转换的符号以及起始状态信息。而最终返回的便是一个包含了新状态节点与边的NFA单元NewCell。
//处理 a
cell do_Cell(char element)
{
cell NewCell;
NewCell.EdgeCount = 0;
edge NewEdge;
//获得新的新状态节点
state StartState = new_StateNode();
state EndState = new_StateNode();
//构建边
NewEdge.StartState = StartState;
NewEdge.EndState = EndState;
NewEdge.TransSymbol = element;
//构建单元
NewCell.EdgeSet[NewCell.EdgeCount++] = NewEdge;
NewCell.StartState = NewCell.EdgeSet[0].StartState;
NewCell.EndState = NewCell.EdgeSet[0].EndState;//EdgeCount此时为1
return NewCell;
}
规则3 . 如果N(s) 和 N(t) 是正规表达式s和t的NFA,则:
①对于正规表达式 s | t, 可构造复合的NFA N(s|t):
【小编讲解】
在规则1和2构建好基本的NFA单元后,遇到规则①,我们只需要增添2个状态和4条边,并按照要求连接起来即可,实现如下:
//处理 a|b
cell do_Unite(cell Left, cell Right)
{
cell NewCell;
NewCell.EdgeCount = 0;
edge Edge1, Edge2, Edge3, Edge4;
//获得新的新状态节点
state StartState = new_StateNode(); //s
state EndState = new_StateNode(); //e
//构建边
//e1
Edge1.StartState = StartState;
Edge1.EndState = Left.EdgeSet[0].StartState;
Edge1.TransSymbol = '#'; //代表空串
//e2
Edge2.StartState = StartState;
Edge2.EndState = Right.EdgeSet[0].StartState;
Edge2.TransSymbol = '#';
//e3
Edge3.StartState = Left.EdgeSet[Left.EdgeCount - 1].EndState;
Edge3.EndState = EndState;
Edge3.TransSymbol = '#';
//e4
Edge4.StartState = Right.EdgeSet[Right.EdgeCount - 1].EndState;
Edge4.EndState = EndState;
Edge4.TransSymbol = '#';
//构建单元
//先将Left和Right的EdgeSet复制到NewCell
cell_EdgeSet_Copy(NewCell, Left);
cell_EdgeSet_Copy(NewCell, Right);
//将新构建的四条边加入EdgeSet
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge1;
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge2;
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge3;
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge4;
//构建NewCell的起始状态和结束状态
NewCell.StartState = StartState;
NewCell.EndState = EndState;
return NewCell;
}
②对于正规表达式 st, 可构造复合的NFA N(st):
【小编讲解】
规则②并不需要新增边和状态,而是在已有的NFA单元上修改,把N(s)的终止状态与N(t)的起始状态合并,源码实现中是保留了N(s)的终止节点而删掉N(t)的起始节点,然后把N(t)与起始节点有关的边都要修改,代码如下:
//处理 ab
cell do_Join(cell Left, cell Right)
{
//将Left的结束状态和Right的开始状态合并,将Right的边复制给Left,将Left返回
//将Right中所有以StartState开头的边全部修改
for (int i = 0; i<Right.EdgeCount; i++)
{
if (Right.EdgeSet[i].StartState.StateName.compare(Right.StartState.StateName) == 0)
{
Right.EdgeSet[i].StartState = Left.EndState; //该边e1的开始状态就是N(t)的起始状态
STATE_NUM--;
}
else if (Right.EdgeSet[i].EndState.StateName.compare(Right.StartState.StateName) == 0)
{
Right.EdgeSet[i].EndState = Left.EndState; //该边e2的结束状态就是N(t)的起始状态
STATE_NUM--;
}
}
Right.StartState = Left.EndState;
cell_EdgeSet_Copy(Left, Right);
//将Left的结束状态更新为Right的结束状态
Left.EndState = Right.EndState;
return Left;
}
③对于正规表达式 s, 构造复合的NFA N(s)*
【小编讲解】
规则③要新增4条边与两个状态,图示与代码可以一一对照。
//处理 a*
cell do_Star(cell Cell)
{
cell NewCell;
NewCell.EdgeCount = 0;
edge Edge1, Edge2, Edge3, Edge4;
//获得新的新状态节点
state StartState = new_StateNode(); //s
state EndState = new_StateNode(); //e
//构建边
//e1
Edge1.StartState = StartState;
Edge1.EndState = EndState;
Edge1.TransSymbol = '#'; //闭包取空串
//e2
Edge2.StartState = Cell.EndState;
Edge2.EndState = Cell.StartState;
Edge2.TransSymbol = '#'; //取字符,自连接
//e3
Edge3.StartState = StartState;
Edge3.EndState = Cell.StartState;
Edge3.TransSymbol = '#';
//e4
Edge4.StartState = Cell.EndState;
Edge4.EndState = EndState;
Edge4.TransSymbol = '#';
//构建单元
//先将Cell的EdgeSet复制到NewCell
cell_EdgeSet_Copy(NewCell, Cell);
//将新构建的四条边加入EdgeSet
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge1;
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge2;
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge3;
NewCell.EdgeSet[NewCell.EdgeCount++] = Edge4;
//构建NewCell的启示状态和结束状态
NewCell.StartState = StartState;
NewCell.EndState = EndState;
return NewCell;
}
④对于括起来的正规表达式 (s), 使用N (s) 本身作为它的NFA。
在构造过程中,每次构造的新状态都要赋予不同的名字。这样,任何NFA的两个状态都具有不同的名字。即使同一符号在r中出现多次,我们也要为该符号的每个实例创建一个独立的带有自己状态的NFA。
两个正规式:
编译原理的相关算法难在理论,这是小编再本科二年级的项目,距今过去了4年,其实没怎么用到过相关知识点,不过要深入一门编程语言的编译器源码看看,那这些知识还是必不可少的。另外,我们常用的正则表达式底层用到的便是这个算法,我们构造好NFA,又该如何使用它,这都是值得进一步探讨的问题。
童鞋们,让小编听见你们的声音,点赞评论,一起加油。