编写一个正则表达式解析器
正则表达式是进行文本处理的强有力工具,比如,UNIX环境下的Sed命令,Perl脚本语言都支持基于正则表达式的文本匹配。很多人都知道怎么使用正则表达式,但正则表达式的工作原理,相信很少有人知道。前段时间看了一些有关编写正则表达式解析器的文章,总结了一下,算是读书笔记吧。首先,我们将简要介绍正则表达式,接着我们将以一个简单的正则表达式作为例子,讲解如何构建正则表达式的解析器。
1. 正则表达式简介
一个正则表达式就是由普通字符(例如字符 a 到 z)以及特殊字符(称为元字符)组成的文字模式。该模式描述在查找文字主体时待匹配的一个或多个字符串。正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。
比如:正则表达式(a|b)*c表示匹配字符a或者字符b重复0或多次,接着一个字符c的字符串。其中*表示重复0或者多次,|表示或者。与模式匹配的字符串可以是ac, bc, abc, aabc, abbc, c等。
这里只是举了一个正则表达式的例子,有关正则表达式的介绍见 正则表达式。
1. 正则表达式简介
一个正则表达式就是由普通字符(例如字符 a 到 z)以及特殊字符(称为元字符)组成的文字模式。该模式描述在查找文字主体时待匹配的一个或多个字符串。正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。
比如:正则表达式(a|b)*c表示匹配字符a或者字符b重复0或多次,接着一个字符c的字符串。其中*表示重复0或者多次,|表示或者。与模式匹配的字符串可以是ac, bc, abc, aabc, abbc, c等。
这里只是举了一个正则表达式的例子,有关正则表达式的介绍见 正则表达式。
2. 构建一个正则表达式的解析器
为了便于讲解,我们只讨论正则表达式中三个基本操作符:
a* 匹配字符a重复0次或者多次 (star)
a|b 匹配字符a或者b (union)
ab 匹配字符ab (concatenation)
其他一些操作符可以有这3个操作符构建而成:
A+ = AA* (At least one A)
[0-9] = (0|1|2|3|4|5|6|7|8|9)
[A-Z] = (A|B|...|Z), etc.
为了便于讲解,我们只讨论正则表达式中三个基本操作符:
a* 匹配字符a重复0次或者多次 (star)
a|b 匹配字符a或者b (union)
ab 匹配字符ab (concatenation)
其他一些操作符可以有这3个操作符构建而成:
A+ = AA* (At least one A)
[0-9] = (0|1|2|3|4|5|6|7|8|9)
[A-Z] = (A|B|...|Z), etc.
2.1 NFA和DFA
NFA (nondeterministic finite-state automata)是不确定性有限状态自动机的简写,NFA的定义为:
一个不确定性有限状态自动机由以下部分所组成:
一个有限的输入字符集I
一个有限的状态集S
状态转换函数f: S x I -> P(S),P(S)为s的幂集
一个结束状态集Q,Q是S的子集
一个初始状态s0 (属于S)
表示为A(I, S, f, Q, s0)
例如,下图就是一个不确定性有限状态自动机:
图中,有一种特殊的转换Epsilon转换,表示输入一个空字符串,由一个状态转换为另一个状态。例如,状态s3到s4,s3到s5之间的转换就是Eplison转换。Epsilon转换是NFA的一种特有的转换。另外,状态s0输入字符a,可以转换为s1或者s3,是不确定的,这也就是NFA成为不确定性有限状态自动机的原因。图中有两个圈的状态(s2, s6, s7)表示终结状态,即找到匹配正则表达式的字符串。
与NFA相对应,DFA (deterministic finite-state automata)表示确定性有限状态自动机。与NFA不同,DFA不存在Epsilon转换,并且每一个状态转换函数的值只对应一个状态,即一个状态输入一个字符,只能有一个状态相对应。
显然,DFA更加适合我们进行字符串匹配,因为输入一个字符,总能从一个状态确定地转换为另一个状态,直到终结状态。NFA一个输入可能对应多个状态,因此需要进行回溯,先尝试一条路径,发现走不通,再回退到原来的状态尝试另外一条路径,显然匹配算法不如DFA简单。
给定一个NFA,总有一个DFA与之对应,即一个NFA可以转换成一个等价的DFA,我们将在2.3里介绍转换的算法(子集构造算法)。
显然,DFA更加适合我们进行字符串匹配,因为输入一个字符,总能从一个状态确定地转换为另一个状态,直到终结状态。NFA一个输入可能对应多个状态,因此需要进行回溯,先尝试一条路径,发现走不通,再回退到原来的状态尝试另外一条路径,显然匹配算法不如DFA简单。
给定一个NFA,总有一个DFA与之对应,即一个NFA可以转换成一个等价的DFA,我们将在2.3里介绍转换的算法(子集构造算法)。
2.2 将正则表达式转换为一个NFA (Thompson算法)
首先,我们将正则表达式转换为NFA,采用的算法是Thompson算法。对Epsilon和字符a,我们构造如下之等价的NFA:
对基本的操作符RS, R|S, R*,我们构造如下与之等价的NFA:
有了这些基本的NFA,我们可以根据这些NFA构造更为复杂的NFA。构造的方法可以采用类似计算表达式的方法。例如正则表达式(a|b)*cd,计算过程为:
PUSH a
PUSH b
UNION (POP b, POP a, 构造与a|b等价的NFA, PUSH a|b)
STAR (POP a|b, 构造与(a|b)*等价的NFA, PUSH (a|b)*)
PUSH c
CONCAT (POP c, POP (a|b)*, 构造与(a|b)*c等价的NFA, PUSH (a|b)*c)
PUSH d
CONCAT (POP d, POP (a|b)*c, 构造与(a|b)*cd等价的NFA, PUSH (a|b)*cd)
POP R (POP result)
在上面的例子中,我们需要比较运算符的优先级,比如"("的优先级比UNION高,UNION的优先级比")"高,CONCAT的优先级比UNION高。
PUSH a
PUSH b
UNION (POP b, POP a, 构造与a|b等价的NFA, PUSH a|b)
STAR (POP a|b, 构造与(a|b)*等价的NFA, PUSH (a|b)*)
PUSH c
CONCAT (POP c, POP (a|b)*, 构造与(a|b)*c等价的NFA, PUSH (a|b)*c)
PUSH d
CONCAT (POP d, POP (a|b)*c, 构造与(a|b)*cd等价的NFA, PUSH (a|b)*cd)
POP R (POP result)
在上面的例子中,我们需要比较运算符的优先级,比如"("的优先级比UNION高,UNION的优先级比")"高,CONCAT的优先级比UNION高。
2.3 将NFA转换为DFA (子集构造算法)
上一节,我们将正则表达式转换成了等价的NFA,本节我们介绍如何将一个NFA转换为DFA,我们采用的算法是子集构造算法,详细描述见红龙书《编译器-原理,技术以及工具》(Ullman等)。
这里我们通过一个例子来介绍子集构造算法。给定如下图所示的NFA,
与之等价的DFA为:
通过NFA的介绍,我们知道NFA存在两种转换,Epsilon转换和输入字符转换。对NFA中的初始状态s1,我们构造s1的Epsilon闭包(s1通过Epsilon转换所能到达的状态集),为{s1,s2,s4},对应DFA中的初始状态。从{s1,s2,s4}我们可以构造NFA对输入字符a的转换状态集{s3,s4},{s3,s4}的Epsilon闭包为{s3,s4}(产生新的状态集),对应DFA中初始状态{s1,s2,s4}对输入字符a的转换状态。同理,{s1,s2,s4}对输入字符b的转换状态集为{s5},{s5}的Epsilon闭包也为{s5}(产生新的状态集),对应DFA中初始状态{s1,s2,s4}对输入字符b的转换状态。从{s3,s4},{s5},我们又可以构造对每个输入字符的状态转换集,直到不再产生新的状态集。就得到与NFA等价的DFA。DFA中的状态集中只要包含一个NFA中的终结状态,该状态集就是DFA中的终结状态。
得到DFA,我们还可以进一步地优化DFA,找出DFA中只有入边没有出边的状态,假如节点不是终结状态,则该状态可以删除。
得到DFA,我们还可以进一步地优化DFA,找出DFA中只有入边没有出边的状态,假如节点不是终结状态,则该状态可以删除。
2.4 通过DFA进行正则表达式的字符串匹配
通过DFA,我们可以从初始状态节点开始,进行遍历,每输入一个字符,转换到另一个状态,如此进行下去,如果转换以后的状态为终结状态,则找到了一个匹配字符串的表达式。
至此,我们介绍了如何编写一个简单的正则表达式解析器的全过程,虽然没有给出具体的实现,但实现的方式已经十分明确。实际中使用的正则表达式比这里介绍的要复杂很多,但解析的方法基本和上面的方法一样。
通过DFA,我们可以从初始状态节点开始,进行遍历,每输入一个字符,转换到另一个状态,如此进行下去,如果转换以后的状态为终结状态,则找到了一个匹配字符串的表达式。
至此,我们介绍了如何编写一个简单的正则表达式解析器的全过程,虽然没有给出具体的实现,但实现的方式已经十分明确。实际中使用的正则表达式比这里介绍的要复杂很多,但解析的方法基本和上面的方法一样。