起因
这学期上了一门《计算机软件理论》的课,其实就是本科的《自动机与形式语言》的翻版。由于疫情的缘故,这门课从原来的笔试改成了大作业的形式,其中有一道题目就是让我们将正则表达式转换成自动机,并用自动机来判断输入的文本时候满足正则表达式的描述。写完之后觉得蛮好玩的,就决定写个博客记录一下。
BTW,这里只是实现了一个非常简易的版本。
理论
在这门课中证明了正则表达式与自动机的对应关系,可以通过递归构造的方式先将一个正则表达式转换为子表达式,然后构建子表达式对应的子自动机(或者说$\epsilon-NFA$),并通过一定的规则将这些子自动机连接成一个大的自动机。
不妨假设正则表达式中只有ab
,a|b
,a*
三种运算(当然这里支持用括号嵌套),不妨假设,a 和 b 对应自动机分别为:FA1 和 FA2,那么我们这三种运算所对应的链接方式分别为:
- ab
- a|b
- a*
利用上面的规则,我们就可以将正则表达式转换为一个$\epsilon-NFA$,然后当读入输入的时候,我们就默认$\epsilon-NFA$对输入进行判断,当然我们也可以先将$\epsilon-NFA$转换为$DFA$,在对输入进行判断。
实现
为了方便,我们先将连接操作ab
转换为a-b
:
ab
转换为a-b
的规则可以通过找规律得到,即当左边的字符不为(
或|
,且右边的字符不为*
、)
或|
时,两个字符之间就是连接关系,应该加入-
符号:
1def addConcatOp(s):2 if len(s) == 0:3 return s4 output = s[0]5 for i in range(len(s) - 1):6 L, R = s[i], s[i+1]7 if L not in '(|' and R not in '*)|':8 output += '-'9 output += R10 return output
然后将正则表达式从中缀表示转换为后缀表示,这样在后面解析的时候会方便许,中缀转后缀只需要利用一个栈来记录操作即可搞定:
1def infix2postfix(s):2 output = ''3 st = []4 for c in s:5 if c in '(|-':6 st.append(c)7 elif c == ')':8 assert(len(st) != 0)9 assert(st[-1] == '(')10 st.pop()11 else:12 output += c13 # print(st, c)14 if len(st) != 0 and st[-1] != '(':15 output += st[-1]5 collapsed lines
16 st.pop()17 while len(st) != 0:18 output += st[-1]19 st.pop()20 return output
在实现正则表达式之前,我们先定义一下 State 和 NFA 的数据结构,我们在State
中存储一个isEnd
的 Flag 来记录是否为接收状态以及一个字典来记录跳转,为了实现空跳转,我们用一个特殊的字符串Epsilon
来表示$\epsilon$:
1Epsilon = 'Epsilon'2
3class State:4
5 def __init__(self, isEnd = False):6 self.isEnd = isEnd7 self.next = {}8
9 def addNext(self, token, to):10 # print(self.next, token, to)11 if token not in self.next:12 self.next[token] = []13 self.next[token].append(to)
NFA 的数据结构只需要记住一个开始状态和接收状态即可,记住开始状态就可以遍历出整个 NFA,而记住接收状态是为了后面连接 NFA 时方便操作,而之所以只有一个接收状态是因为如果有多个接收状态,可以通过空跳转将其转换成只有一个接收状态。因此其数据结构为:
1class NFA:2 def __init__(self, start, end):3 self.start = start4 self.end= end
然后是就是正则表达式转换为 NFA 的过程,我们逐字符读取已经转成后缀表示的正则表达式:
- 如果读到一个字符,我们就构造一个
start -- token --> end
形式的 NFA,并将其压入栈中。 - 如果读到一个
-
或者|
,我们就从栈中弹出两个 NFA,并按照上面提到的规则重新构造一个NFA,并将其压入栈中。 - 如果读到一个
*
,我们就从栈中弹出一个 NFA,并按照上面提到的规则构造一个NFA,并将其压入栈中。
最后在栈中留下来的 NFA 就是正则表达式对应的 NFA:
1def re2nfa(pattern):2 st = []3 for c in pattern:4 if c == '|':5 assert(len(st) >= 2)6 nfa1, nfa2 = st[-2], st[-1]7 st.pop(), st.pop()8 st.append(NFA.OR(nfa1, nfa2))9 elif c == '-':10 assert(len(st) >= 2)11 nfa1, nfa2 = st[-2], st[-1]12 st.pop(), st.pop()13 st.append(NFA.CONCAT(nfa1, nfa2))14 elif c == '*':15 assert(len(st) >= 1)6 collapsed lines
16 nfa = st[-1]17 st.pop()18 st.append(NFA.CLOSURE(nfa))19 else:20 st.append(NFA.BASIC(c))21 return st[-1]
其中,构造 NFA 的过程被封装到NFA
类的静态方法中去了,其实现为:
1class NFA:2 def __init__(self, start, end):3 self.start = start4 self.end= end5
6 @staticmethod7 def _initState():8 return State(), State(True)9
10 @staticmethod11 def BASIC(token):12 start, end = NFA._initState()13 start.addNext(token, end) # S -- token --> E14 return NFA(start, end)15
34 collapsed lines
16 @staticmethod17 def OR(nfa1, nfa2):18 start, end = NFA._initState()19
20 start.addNext(Epsilon, nfa1.start) # S -- e --> NFA121 start.addNext(Epsilon, nfa2.start) # S -- e --> NFA222 nfa1.end.addNext(Epsilon, end) # NFA1 -- e --> E23 nfa2.end.addNext(Epsilon, end) # NFA2 -- e --> E24
25 nfa1.end.isEnd, nfa2.end.isEnd = False, False26 return NFA(start, end)27
28 @staticmethod29 def CONCAT(nfa1, nfa2):30 start, end = NFA._initState()31
32 start.addNext(Epsilon, nfa1.start) # E -- e --> NFA133 nfa1.end.addNext(Epsilon, nfa2.start) # NFA1 -- e --> NFA234 nfa2.end.addNext(Epsilon, end) # NFA2 -- e --> NFA135
36 nfa1.end.isEnd = False37 nfa2.end.isEnd = False38
39 return NFA(start, end)40
41 @staticmethod42 def CLOSURE(nfa):43 start, end = NFA._initState()44 start.addNext(Epsilon,nfa.start) # S -- e --> NFA45 nfa.end.addNext(Epsilon,end) # NFA -- e --> NFA46 end.addNext(Epsilon, start) # E -- e --> S47
48 nfa.end.isEnd = False49 return NFA(start, end)
由于我们的 NFA 是一个 $\epsilon-NFA$,所以在使用它时需要大量进行求空闭包的操作,所以我们在实现match
操作前,我们需要实现求闭包的操作。我们用一个队列来实现空跳转的遍历,然后返回遍历到的状态:
1def GetEpsilonClosure(states):2 visited = set(states)3 q = deque(states)4 while len(q):5 state = q.popleft()6 for item in state.next.get(Epsilon, []):7 if item not in visited:8 q.append(item)9 visited.add(item)10 return visited
然后是match
操作,就是根据读入的字符串来决定如何跳转,遍历完字符串之后再判断一下当前状态是否存在接收状态即可:
1def match(self, s):2 states = set([self.start])3 for c in s:4 # Epsilon ->5 states = NFA.GetEpsilonClosure(states)6 nextStates = set()7 for state in states:8 for nextState in state.next.get(c, []):9 nextStates.add(nextState)10 states = nextStates11 if len(states) == 0:12 break13 states = NFA.GetEpsilonClosure(states)14 for state in states:15 if state.isEnd:2 collapsed lines
16 return True17 return False
上面实现的部分已经可以实现一个支持a*
,a|b
,ab
三种操作的正则表达式了,为了支持更多的特性,我们可以可以通过转换的方式,将其他操作转化为上面三种操作,如{a,b,c}
可以转换为(a|b|c)
,a+
可以转换为aa*
等。
同时,我们还实现了将 NFA 使用graphviz
绘制成图片和$\epsilon-NFA$到$DFA$的转换的功能。
因为使用了graphviz
库,所以需要先安装对应的包:
1pip install graphviz
绘制的过程其实就是用广度优先遍历去遍历所有状态,在遍历的时候给这些状态标好序,同时记录所有跳转,然后在用graphviz
绘制即可:
1def toGraph(self, state_prefix='State'):2 dot = Digraph(name='nfa', format="png")3 dot.attr(rankdir='LR')4 state2index = {}5 q = deque()6 q.append(self.start)7 g = []8
9 while len(q):10 state = q.popleft()11 if state in state2index:12 continue13 state2index[state] = state_prefix + str(len(state2index))14 index = state2index[state]15 dot.node(name = index, label=index, shape= 'doublecircle' if state.isEnd else 'circle')16 collapsed lines
16 for token, states in state.next.items():17 for nextState in states:18 g.append((state, token, nextState))19 q.append(nextState)20
21 # output22 for start, token, end in g:23 startIndex, endIndex = state2index[start], state2index[end]24 print('{} -- {} --> {}'.format(startIndex, token, endIndex))25 dot.edge(startIndex, endIndex, label=token)26
27 dot.node(name='start', label='S', shape="plaintext")28 dot.edge('start', state2index[self.start])29
30 dot.render(filename='nfa', directory='./')31 return state2index
因为在实现$\epsilon-NFA$到$DFA$的转换时需要先知道字母表,所以我们同样用遍历的方式得到字母表,同时构造一个状态到序号的字典,先给 NFA 的状态标序可以帮助我们给后面构造 DFA 时判断两个状态集合是否相同提供便利:
1def geneState2Index(self, state_prefix='State', alphabet_flag = False):2 state2index = {}3 alphabet = []4 q = deque()5 q.append(self.start)6
7 while len(q):8 state = q.popleft()9 if state in state2index:10 continue11 state2index[state] = state_prefix + str(len(state2index))12 index = state2index[state]13 for token, states in state.next.items():14 if alphabet_flag:15 alphabet.append(token)5 collapsed lines
16 for nextState in states:17 q.append(nextState)18 if alphabet_flag:19 return state2index, set(alphabet)20 return state2index
NFA 转 DFA 的基本原理就是 NFA 中一个状态集合对应与 DFA 中的一个状态,所以我们刚才生成的状态到序号的字典来生成 DFA 的序号,其实就是简单拼接字符串,只不过拼接前先做一次排序,我们将其写成一个函数hashfun
,利用hashfun
就可以将其 NFA 的状态集合映射成 DFA 的序号。我们通过递归的方式来生成 DFA
,从 NFA 开始状态为初始的状态集合(当然这里要先求个克林闭包),调用递归函数GeneNewState
:
- 通过
hashfun
来生成 DFA 中的序号,如果这个序号对应的 DFA 状态已经存在了(被记录在一个全局的字典中),我们就直接返回该状态。 - 如果不存在,我们就生成一个新的 DFA 状态与该序号对应起来,这个状态是否为接收状态通过输入的状态集合中时候存在接收状态来判断;然后遍历字母表中的所有字符来确定输入了这个字符后,当前状态集合会跳转到的状态集合
next_states
(别忘记了求克林闭包),然后对next_states
递归调用GeneNewState
,并将返回的状态与当前状态连接起来即可
实现代码如下:
1def toDFA(self):2 state2index, alphabet = self.geneState2Index('', alphabet_flag=True)3 alphabet.remove(Epsilon)4 # print(alphabet)5 index2state = {}6 def hashfunc(states):7 return '#'.join(sorted(set([state2index[state] for state in states])))8
9 def statesIsEnd(states):10 for state in states:11 if state.isEnd:12 return True13 return False14
15 def GeneNewState(states):19 collapsed lines
16 index = hashfunc(states)17 if index not in index2state:18 isEnd = statesIsEnd(states)19 new_state = State(isEnd)20 index2state[index] = new_state21 for c in alphabet:22 next_states = []23 for i_state in states:24 next_states += i_state.next.get(c, [])25 next_states = NFA.GetEpsilonClosure(set(next_states))26 next_index = hashfunc(next_states)27 next_state = GeneNewState(next_states)28 next_state = index2state[next_index]29 new_state.addNext(c, next_state)30 return index2state[index]31
32 states = NFA.GetEpsilonClosure([self.start])33 start = GeneNewState(states)34 return NFA(start, None)