自己动手写正则表达式引擎
正则表达式的应用场景非常广阔,分布在计算机技术的方方面面,Linux下很多工具都围绕正则表达式进行工作的,grep、awk、sed都是文本处理的神器,语言方面perl,python, Js等各种脚本语音,都有功能强劲的正则表达式模块,前端编程中判断合法邮箱,判断合法ip地址都有正则表达式的身影。在方便使用正则表达式的同时,你是否也想知道,神奇的正则引擎是怎么生成出来的。 在自己动手写正则表达式引擎之前,需要回忆一下大学里那门懵懂的《形式语言与自动机》的课程,当我想找那本教材的时候,悲剧的发现已经找不到了。不过还好,网上这篇文章写的非常好,《Regular Expression Matching Can Be Simple And Fast》认真学习完这篇文章之后,顿时神清气爽,感觉思路清晰,迫不及待的去写代码了。 正则表达式简介介绍一下最常见的正则表达式,正则主要用来做文本匹配,就是判断一个字符串是否符合一定的规律,例如判断一个字符串是不是符合邮箱的格式等。 正则表达式的构成包括字母数字等常规ascii字符,还包括一些特殊字符,代表特殊的含义。 *代表一个模式出现0次或多次, +代表一个模式出现1次或多次, ?代表一个模式出现0次或一次, |代表或者,两边的模式,任何一个匹配了就好, .代表一个任意的字符, 因为*+?.这四个字符具有了特殊的含义,使用进行转义,例如?就代表?本身。 这里举一个例子正则表达式a(bb)+c,代表了以a开头,后面接一个或多个bb最后c结尾的字符串。 现代的正则表达式函数库对基本的正则进行了扩展,引入了很多特性,例如后向引用,反义等功能,这里介绍的正则引擎为了说明引擎的构建原理,保持简单,只支持上面介绍的基本功能。 正则表达式和自动机的关系自动机用来描述一些状态之间的关系,通过状态转移能够模拟一个字符串的匹配过程。 举个例子,自动机很像城市的地铁,地铁站代表状态,乘客从一站做到下一站就是状态之间的转移,对于换乘车站,下一个目标可能是1号线,也可能是2号线,会不止一个车站,也有的地铁会做成环线,绕了一圈又回到了起点。把输入的字符串看成旅行指南,乘客只要按照旅行指南的指令,一站一站的做地铁,如果读完了这个指南,发现是终点站,就说明这个自动机和字符串是匹配的。 对于正则表达式a(bb)+c,它的自动机如下图所示: 对于字符串abbbbc最后可以找到终点站状态4 . 这里状态3,有两条出边,一条是如果遇到指令c,就跳转到状态4,另一条叫做空跳转(ε跳转),就是不需要读入任何指令就能够跳转到状态1,正因为这个空跳转,才具备识别一个或多个bb的能力。 这个自动机有点特殊,在状态ε跳转的时候,因为有ε跳转,必须两条路都试一试才能知道等字符串结束的时候是不是到了结束状态4,这就是非确定性自动机了,nondeterministic finite automaton,简称NFA 介绍了NFA,就需要引出DFA了,对于同一个正则表达式a(bb)+c,它的DFA如下 相比较这里的状态转移没有了ε跳转,对于状态3,如果遇到是字符c,就到状态4, 如果遇到字符b,就跳到状态2,每一步都是确定的。这就是确定性自动机了,deterministic finite automaton。 正则表达式引擎有了前面的基础知识,就可以写出一个简单的正则表达式引擎。主要有两种方法, 一种是使用NFA进行匹配的: 1.解析正则表达式。 2.将正则表达式转换成为NFA。 3.对NFA进行搜索,看能否找到一条路径,当读完输入字符串的时候恰好停在了结束状态。 另一种是使用DFA进行匹配: 1.解析正则表达式。 2.将正则表达式转换成为NFA。 3.将NFA转换成为DFA。 4.模拟DFA的跳转,看读完输入字符串的时候,是不是停在了结束状态。 两种方法的区别是,NFA算法因为跳转的时候不确定,需要使用搜索算法(宽度优先或深度优先),来试探进行匹配,DFA算法因为每一步跳转的条件是确定的,直接模拟就好,速度上有明显的优势。但是正则表达式转换NFA方便理解,搜索算法的使用也很好理解,对于扩展正则的特殊语法,支持也很方便,很多成熟的正则表达式函数库还是使用了NFA算法。 解析正则表达式对于正则表达式的解析,可以参考四则运算表达式的解析方法,要区别操作数和运算符, 对于操作数就是普通字符,和特殊的通配符,转义字符,对于运算符要区别一下类型 双目运算符 | 单目运算符 * ? + 括号( ) 双目运算符就像算数+一样,单目就像阶乘!,括号影响优先级。 最后注意对于正则表达式a(bb)+c,连续两个bb其实是有一个隐藏的运算符的,连接运算符。 优先级排序为:双目运算符| <连接运算符<单目运算符 * ? + 正则表达式是一个中序遍历的表达式,使用一个符号栈,一个操作数栈,就能转换成后缀表达式,后缀表达式从左到右线性解析,不用考虑优先级的问题,方便后续的处理。 对于 a(bb)+c,转换成后缀表达式是abb_+_c_,使用_代表连接字符串 正则表达式转NFA只要区分几种情况先转换成子图,然后将子图进行连接起来就好。 单独的操作数
连接操作符 连接前后两个操作数 例如连接前: 连接后: 可以看出这里需要标记一个状态1和状态2是同一个状态就好。 或操作 对于a|b连接前 连接后 这里需要标记状态0和状态2属于等价状态,1,3属于等价状态 ?操作 ?操作符代表,0次或1次 需要添加一条ε跳转的正向边 +操作 +操作符代表,1次或多次 需要添加一条ε跳转的反向边 *操作 *操作符代表,0次或多次 需要添加一条正向一条反向ε跳转 将这些部分连接起来之后,就得到了一个完整的NFA 处理后缀表达式的时候,如果遇到操作数,就压栈,遇到操作符就根据是单目还是双目运算弹出需要的操作数,然后把结果压栈,最后在栈上的结果就是最终的NFA 这里在处理或操作和连接操作的时候,使用了等价状态,而没有使用多条ε跳转连接不同的状态,主要为了减少ε跳转和减少状态数,为了之后搜索和之后转DFA时更有效率。 等价状态可以使用并查集这个数据结构进行存储。 各种状态转移可以按照图的邻接表数据结构进行存储。 对NFA进行搜索通过上一节正则表达式转NFA,得到了一个具有开始节点S和终结节点T的NFA, 需要对NFA进行搜索,找到一条从S到T的路径,巧好能接收输入字符串。 这里推荐使用宽度优先搜索,遇到一个节点有ε跳转的时候,将ε跳转指向的节点入队,并且不要移动输入字符串,遇到普通节点的时候,按照当时指向的字符选择一条路径,将这条路径的下一个节点入队,同时移动输入字符串。 最后发现出队的状态是结束状态,并且字符串当好接收完整的时候,就说明这个NFA接受了输入字符串,代表的正则表达式和输入字符串也就匹配上了。 因为搜索算法比较简单,这里就不细描述了。 NFA转换成为DFA一般的正则表达式使用NFA搜索就可以了,如果我们做的更有追求一些,可以将NFA转换成为DFA,这样字符串匹配的过程,就变成了线性模拟的过程。 首先伟大的计算机理论学家们证明了NFA和DFA是等价的,而且提出了一套算法用来将NFA转换成为DFA,我们不必纠结如何证明的,只要能看懂算法,写出代码就好。 首先有两个概念:
e-closures(T) 定义为一个状态集合,是状态集合T中的任何状态经过任意条ε边到达的状态. 通俗点说就是从T集合中,任意状态通过0次或多次ε跳转达到的状态,自然包含T集合本身。 move(T,a) 是状态集合T中的任何状态经过a边到达的状态. 通俗点就是从T集合出发,走一步a跳转,能够到达的状态。 有了这两个定义,就能解决NFA转DFA的问题了 算法如下,想细细研究的同学,可以找到《编译原理》相关章节。
Dstate就是DFA的状态了,Dtran就是状态转移数组。 正则表达式a(bb)+c转出的DFA如下,已经没有了ε跳转 转出DFA之后,可以对DFA进行最小化的操作,提升效率,有兴趣的同学可以做一下。 对DFA进行模拟得到DFA之后,事情就好处理多了,安装输入字符串,从状态转移表里找到下一个状态,一步一步的跳转,最后如果跳到了终结状态而且字符串读完了,就代表匹配上了。 对于上一个DFA,程序模拟输出的结果如下
代码实现代码使用了cpp,大量的数据结构,如队列,栈等都使用了STL,重点在于对上面理论知识的实践,regex -> postfix -> nfa -> dfa总共760行。 文章中的自动机示意图,使用dot language生成,工具在 http://www.graphviz.org/下载。 总结程序员日常工作过程中,大多数是站在巨人肩上,熟练使用别人开发好的函数库在进行拼接,一个人的能力和素质跟他对整个语言,整个函数库,整个平台的熟悉程度有很大关系。等到一定阶段会有一种写小程序看不上,写大程序觉得太烦没时间写的感觉,这时候不妨静下心来,看看自己平时工作中最常用的程序的源代码,学一学其中的原理,自己动手实现一下,会有新的体会。 参考资料 1. Regular Expression Matching Can Be Simple And Fast http://swtch.com/~rsc/regexp/regexp1.html (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |