正则表达式(Regular Expression)即正则语言是现代计算机语言的重要基石,虽然原始,却非常强大。之所以写此文是因为去年看Udacity上面Peter Norvig的教程Design of Computer Programs时对编译方面的内容感到理解困难。该教程留了一份练习要求用上下文无关语法(Contex-Free Grammar)和递归下降法(Recursive-Descendent Parsing)来解析正则表达式,耗费许久仍未找到思路,一直耿耿于怀。后来结合一些编译方面的入门资料,意识到正则语法基于非确定性状态自动机(NFA),是比上下文无关语法更低层的语法,用后者描述前者有本末倒置之嫌(文末继续讨论该问题)。后来读了http://swtch.com/~rsc/regexp/regexp1.html这篇文章,文章不错,较系统地用C语言描述了构造NFA和DFA的方法,值得一读。本文用Python对该文中的部分内容进行转述,以求进一步的理解。
在Python中利用class来模拟C语言中的结构。状态State有两种,常规状态是只有一个出口的,只有一种状态SplitState是有两个出口的(对State扩展而得),其数据结构和链表并没有本质区别。状态机片段Fragment相当于是一个黑盒,包装了一系列的状态流程。
class State:
counter = 0
def __init__(self, entry, next1=None):
self._num = State.counter
self.entry = entry
self.next1 = next1
State.counter += 1
def __repr__(self): ## 可视化, 用于调试
return '(No.%d: %s)' % (self._num, self.entry)
_SPLIT = 'SPLIT'
_MERGE = 'MERGE'
class SplitState(State):
def __init__(self, next1, next2):
super(SplitState, self).__init__(_SPLIT, next1)
self.next2 = next2
class Fragment:
def __init__(self, begin, end=None):
self.begin = begin
self.end = end if end else begin
def __repr__(self): ## 可视化, 用于调试
return 'Frag[%s -> %s]' % (self.begin, self.end)
为对每个基本状态以及根据不同的正则关键词如 * 和 ? 对片段进行打包,需要准备一些工厂函数。右侧插图转自http://swtch.com/~rsc/regexp/regexp1.html,但本文中相比图中要增加一个汇合状态即_MERGE_STATE
_BEGIN = '^'
def make_begin():
return Fragment(State(_BEGIN))
_END = '$'
_END_STATE = State(_END)
def make_end():
return Fragment(_END_STATE)
def make_atom(char):
return Fragment(State(char))
def make_seq(frag1, frag2):
if frag2 is None: return frag1
else:
frag1.end.next1 = frag2.begin
return Fragment(frag1.begin, frag2.end)
def make_alt(frag1, frag2):
split = SplitState(frag1.begin, frag2.begin)
merge = frag1.end.next1 = frag2.end.next1 = State(_MERGE)
return Fragment(split, merge)
def make_opt(frag1):
split = SplitState(frag1.begin, None)
merge = frag1.end.next1 = split.next2 = State(_MERGE)
return Fragment(split, merge)
def make_star(frag1):
split = SplitState(frag1.end.next1, frag1.begin)
frag1.end.next1 = split
return Fragment(split)
构建自动机的函数利用更为简洁的尾递归表达(也可显式地调用栈),类似scheme的风格。为了更符合状态机读取“纸带”的模型,此处默认输入的字符串已被iter(...)函数转化为一个迭代器即参数@inp,之后用next(...)函数来右移字符指针。参数@frag用于保存已经构建好的状态机片段。参数@alpha即字母表也应指定,从而判断输入的合法性。
该函数运行的过程中借助了一个中间参数@temp,初始值为None,用于存储仍待完成构造的自动机片段,参数@frag用于存储已购造完成的自动机片段。优先处理的应是读得终止符$即终点情形。每当所读字符c为修饰符即 ? 、 + 或 * 时即调用相应工厂函数对@temp进行打包,@frag保持不变,进行下一步递归。其余情形皆需要将当前@temp和@frag用make_seq函数打包,并传入下一步递归。另外,当所读字符为 | 或 ( 时,要重新开辟一个自动机,再将开辟所得的自动机和当前自动机片段进行打包。
def build_nfa(pat, alpha='abcdefghijklmnopqrstuvwxyz'):
def build(pat, temp, frag):
c = next(pat)
if c == '$':
return make_seq(frag, temp)
elif c in alpha:
return build(pat, make_atom(c), make_seq(frag, temp))
elif c == '?':
return build(pat, make_opt(temp), frag)
elif c == '*':
return build(pat, make_star(temp), frag)
elif c == '+':
return build(pat, make_plus(temp), frag)
elif c == '|':
return make_alt(make_seq(frag, temp),
build(pat, None, make_begin()))
elif c == '(':
return build(pat, build(pat, None, make_begin()), make_seq(frag, temp))
elif c == ')':
return make_seq(frag, temp)
else:
raise ValueError("Symbol '%s' not recognizable." % c)
return make_seq(build(pat, None, make_begin()), make_end())
至此可以通过该函数生成完整的NFA状态机。合法的输入字符串应形如a(b|cd)*ef$模式,开头字符上折号^此处予以忽略,结尾字符美元号$不能忽略。
在此基础上,验证一个输入是否符合给定正则表达式便可通过下一函数完成。算法的原型可参见龙书Algorithm 3.22,基本思路是用一个当前集合curr_set来维持匹配至今的合法状态,每接纳一个新的输入就对curr_set中的所有状态进行转移,其中一部分因无法转移而被过滤掉。此处有两个要点:一. ipsilon闭包转移问题,即所有过渡状态(即BEGIN,SPLIT和MERGE)都需要连续转移至非过渡状态为止;二. 某些状态作为多个现有状态基于某一输入的共同转移目标,可能会被重复添加进curr_set,此处采用哈希集来避免重复添加,省略了判断是否重复添加的语句。
关于循环的终止,此函数所进行的是贪婪匹配,即只要curr_set不为空,就继续读取输入进行下一步匹配,无论之前是否已成功匹配多少次。所有匹配成功的结尾位置会被数组matches记录下来(例如匹配成功字符inp[k],即该匹配状态有终点状态作为下一步,则k+1被记录下来)。最终结果将记录下所有成功匹配的位置。
def check(pat, inp, alpha='abcdefghijklmnopqrstuvwxyz'):
nfa = build_nfa(iter(pat), alpha=alpha)
curr_set = { nfa.begin }
matches = []
i = 0
while i < len(inp):
matched_set = set()
while curr_set: # 若当前状态集不为空,则尝试转移其中每一状态
s = curr_set.pop()
if s.entry == _BEGIN or \
s.entry == _MERGE:
curr_set.add(s.next1)
elif s.entry == _SPLIT:
curr_set.add(s.next1)
curr_set.add(s.next2)
elif s.entry == _END: # 若当前状态集包含终点状态,即从0到i-1位置皆匹配成功,记录i
matches.append(i)
elif s.entry == inp[i]: # 单个字符匹配成功,
matched_set.add(s)
else: continue # 既无过渡也无匹配状态被抛弃
if matched_set:
curr_set = { x.next1 for x in matched_set }
i += 1
else:
return matches
return matches
输入测试:
>>> check( '()$', '\0' ) # 空串测试
[0]
>>> check( '(a|b|)$', 'a\0' ) # 空分支测试
[0, 1]
>>> check( 'a(b|cd)*e?$', 'acdbbbbbcd\0' ) # 顺序重复匹配
[1, 3, 4, 5, 6, 7, 8, 10]
>>> check( 'a(b|cd)*e?$', 'ae\0' )
[1, 2]
>>> check( '(a|b)*(cd|ef)*$', 'cdef\0')
[0, 2, 4]
>>> check( '(a|b)*(cd|ef)*$', 'cdefcc\0') # 仅成功匹配至第4个字符,之后无匹配
[0, 2, 4]
回到上下文无关语法的问题,我曾尝试完成Peter Norvig布置的练习,按照上下文无关语法构建的正则语法产生式如下:
seq -> seq exp | exp
exp -> lit | star | opt | alt | ( seq )
star -> lit * | ( seq ) *
opt -> lit ? | ( seq ) ?
alt -> seq | seq
lit -> a | b | c | ... | y | z
个人感觉在构建语法时会遭遇不少问题。按上述语法,alt -> seq | seq、seq -> exp、exp -> alt这三个式形成的回路,故消除左递归的方法不适用。到现在我仍未想出有效的无回路的正则语法。另外,正则语法的关键词如*,+,?等是后置的,完全不符合递归下降法的初衷,递归下降法更适用于关键词开头的、已经近似于树状结构的语法。也有可能通过词法分析(Tokenizing)对输入字串进行前处理,但这使问题变得复杂了。具体答案还在搜寻中-_-,若有朋友思考过类似问题,还望多多指教。