正则表达式的匹配算法与实现

注意:本文作为一篇洛谷日报发表,篇幅较长。本文中的C++代码都是C++11的。

0. 正则表达式

0.0 语言

Σ \Sigma Σ为某个字符集合(或称“字母表”),由 Σ \Sigma Σ中的符号构成的一个有序序列称为(string),串 s s s的长度通常记作 ∣ s ∣ |s| s。空串是一个长度为 0 0 0的串,通常记为 ϵ \epsilon ϵ

由某个字母表 Σ \Sigma Σ中的字符按某种方式构成的所有的串的集合称为语言(language)。

我们可以定义串上的连接(concatenation)运算:串 x x x和串 y y y的连接结果 x y xy xy是将 y y y附加到 x x x后面形成的串。空串是连接运算的单位元,即对任何串 s s s都有 ϵ s = s ϵ = s \epsilon s=s\epsilon=s ϵs=sϵ=s。如果将连接运算看做“乘法”,那么我们还可以定义“指数”运算: s 0 = ϵ s^0=\epsilon s0=ϵ,并且对于任意 i ∈ N ∗ i\in\N^* iN s i = s i − 1 s s^i=s^{i-1}s si=si1s,这也就是将一个串自身重复 i i i遍的结果。

我们还可以定义语言上的运算,其中最重要的是并、连接和闭包(closure)运算,它们的定义如下:

  • 语言 L L L M M M L ∪ M = { s ∣ s ∈ L  or  s ∈ M } L\cup M=\{s|s\in L\text{ or }s\in M\} LM={ssL or sM}
  • 语言 L L L M M M的连接: L M = { s t ∣ s ∈ L , t ∈ M } LM=\{st|s\in L,t\in M\} LM={stsL,tM}
  • 语言 L L L闭包,又称Kleene闭包: L ∗ = ⋃ i = 0 ∞ L i L^*=\bigcup\limits_{i=0}^{\infty}L^i L=i=0Li,指的是将 L L L自我连接 0 0 0次或多次的结果的集合。
  • 语言 L L L正闭包 L + = ⋃ i = 1 ∞ L i L^+=\bigcup\limits_{i=1}^{\infty}L^i L+=i=1Li,是将 L L L自我连接 1 1 1次或多次的结果的集合。

0.1 正则表达式

我们可以利用语言上的运算来递归地定义语言。例如,如果设 l e t t e r _ letter\_ letter_表示所有字母和下划线构成的字符集, d i g i t digit digit表示所有数字字符构成的集合,那么C++语言中,在不考虑保留字的情况下,所有合法的标识符可以定义为:
l e t t e r _ ( l e t t e r _ ∣ d i g i t ) ∗ letter\_(letter\_|digit)^* letter_(letter_digit)
即,开头可以是下划线或字母但不能是数字,后面可以接 0 0 0个或多个下划线、字母或数字。我们用竖线 ∣ | 表示“并”运算,直接将两个符号并列表示“连接”运算。例如这里我们将 l e t t e r _ letter\_ letter_与表达式剩余部分并列,表示 l e t t e r _ letter\_ letter_ ( l e t t e r _ ∣ d i g i t ) ∗ (letter\_|digit)^* (letter_digit)的连接。这样的表达式称为正则表达式。注意,这里的 d i g i t digit digit l e t t e r _ letter\_ letter_都是特殊的名字,而不表示字符串digit或者letter_本身。我们也可以用正则表达式为 d i g i t digit digit l e t t e r _ letter\_ letter_给出定义:
d i g i t → 0 ∣ 1 ∣ ⋯ ∣ 9 digit\rightarrow0|1|\cdots|9 digit019

l e t t e r _ → A ∣ B ∣ ⋯ ∣ Z ∣ a ∣ b ∣ ⋯ ∣ z ∣ _ letter\_\rightarrow A|B|\cdots|Z|a|b|\cdots|z|\_ letter_ABZabz_

类比语言上的运算,正则表达式具有基本的连接、并、闭包运算,并且闭包(及正闭包)的优先级最高,其次是连接,最后是并。我们还可以在正则表达式中加括号来改变运算顺序。

练习:描述下列正则表达式定义的语言。

  1. a(a|b)*a
  2. (a|b)*a(a|b)(a|b)
  3. a*ba*ba*ba*
  4. (aa|bb)*((ab|ba)(aa|bb)*(ab|ba)(aa|bb)*)*

答案:以上都是定义在字母表 Σ = { ′ a ′ , ′ b ′ } \Sigma=\{'a','b'\} Σ={a,b}上的语言,其中

  1. 'a'开头、以'a'结尾的串的集合
  2. 长度大于等于 3 3 3,且倒数第三位为'a'的串的集合
  3. 恰有 3 3 3'b'的串的集合
  4. 由偶数个'a'和偶数个'b'构成的串的集合

自从Kleene在上世纪50年代提出了含有并、连接、闭包运算的正则表达式之后,正则表达式的语法规则被不断扩展,以增强其描述串的简洁度。例如,我们用r?表示零个或一个r,用[a-e]表示a|b|c|d|e,用r{m,n}表示至少 m m m个、至多 n n nr重复出现,等等。但我们可以证明:这些扩展语法没有增强正则表达式的表达能力,也就是说扩展的正则表达式能够描述的语言集合与不带扩展的正则表达式所能描述的是完全一样的。现在常用的编程语言,如C/C++、Java、Python中都已经加入了正则表达式库。有兴趣的读者可以阅读这篇日报

本文中我们只考虑含有基本的并、连接和闭包运算的正则表达式。我们的最终目的是编写一个正则表达式匹配器,它能够支持输入一个正则表达式reg和一个字符串str,判断str是否属于reg所定义的语言,或者说str是否匹配reg。我们假定字母表是所有小写字母构成的集合。

0.2 有穷自动机

实现正则表达式匹配的方式有许多种,但它们都离不开有穷自动机。举个例子,我们可以为正则表达式a(a|b)*a构造如下状态转移图:

其中,编号为2的结点用双圈表示,代表一个接受状态。对于一个字符串 s s s,如果存在一条从start出发、到达某个接受状态的路径,满足这条路径上的边的标号依次连接得到的字符串恰好就是 s s s,就说明这个正则表达式匹配字符串 s s s,或者说,这个状态转移图所代表的自动机接受这个串 s s s

一个有穷自动机(finite automaton)本质上就是与上图所展示的状态转移图类似的图,但是有如下几点不同:

  • 有穷自动机是识别器,它只能对每个可能的输入串简单地回答“是”或“否”。
  • 有穷自动机分为两类:
    1. 不确定性有穷自动机(Non-deterministic Finite Automaton, NFA)对其边上的标号没有任何限制(只要它在字母表中,或者为 ϵ \epsilon ϵ)。对于一个结点,可以有多条离开该结点的边具有相同的标号,也可以含有标号为 ϵ \epsilon ϵ的边。
    2. 确定性有穷自动机(Deterministic Finite Automaton, DFA)要求对于每个状态和字母表中的每个符号,有且仅有一条离开该状态、标号为该符号的边。并且,不允许含有标号为 ϵ \epsilon ϵ的边。

NFA和DFA能够识别的语言的集合是相同的,这也正是正则表达式能够表示的语言集合,称为正则语言(regular language)。

我们可以用一个五元组 ( S , Σ , f , s 0 , F ) (S,\Sigma,f,s_0,F) (S,Σ,f,s0,F)表示一个有穷自动机,其中

  • S S S为状态集合,它必须是有穷集。
  • Σ \Sigma Σ是一个字母表,代表了所有可能出现在输入中的符号。我们假设空串 ϵ \epsilon ϵ不属于 Σ \Sigma Σ
  • f f f是一个转换函数, f ( s , a ) f(s,a) f(s,a)表示从状态 s s s经过输入符号 a a a转移到达的状态的集合。
  • s 0 ∈ S s_0\in S s0S是一个开始状态
  • F ⊂ S F\subset S FS接受状态集合。

为一个正则表达式构建一个NFA是容易的,我们只要处理好并、连接、闭包这三种运算即可。

  • 假设 r = s ∣ t r=s|t r=st,即 s s s t t t的并,记 s , t s,t s,t对应的NFA分别为 N ( s ) N(s) N(s) N ( t ) N(t) N(t),我们只要添加两组 ϵ \epsilon ϵ边:

  • 假设 r = s t r=st r=st,即 s s s t t t的连接,那么只要把 N ( s ) N(s) N(s) N ( t ) N(t) N(t)首尾相连就好了。

  • 假设 r = s + r=s^+ r=s+,即 s s s的正闭包,我们只需在 N ( s ) N(s) N(s)的前后各添上一条 ϵ \epsilon ϵ边,并添加一条从 N ( s ) N(s) N(s)的接受结点到 N ( s ) N(s) N(s)的开始结点的 ϵ \epsilon ϵ边,代表它可以随时从 N ( s ) N(s) N(s)的末尾回到 N ( s ) N(s) N(s)的开头,就完成了正闭包的NFA的构建。

  • 假设 r = s ∗ r=s^* r=s,将它写成 r = ϵ ∣ s + r=\epsilon|s^+ r=ϵs+,使用上面的方法即可。

如果我们已经为一个正则表达式构建出了DFA,使用该DFA对输入串 s s s进行模式匹配是非常容易的,算法伪代码如下:

cur = s_0;
for (char c : s)
	cur = nextState(cur, c);
if (s in F)
	return "yes";
else
	return "no";

其中nextState函数是状态转移函数,nextState(cur, c)表示从状态cur经过标号为c的边能够到达的状态。

但是如果我们拥有的不是DFA而是NFA,事情就会复杂一些。由于一个状态可能有多条标号相同的出边,我们没法判定究竟应该选哪条,所以我们需要保存一个状态集合,表示从开始状态出发沿着标号为输入符号的边能够到达的所有可能状态。最后,我们看看最终的状态集合与接受状态集合是否有交集。这其实也是将一个NFA转换为等价的DFA的算法所基于的思想:DFA的一个状态代表的是NFA的某些状态的集合。

注意一个细节:我们无论是在伪代码中还是在算法的描述中,都将输入每一个符号以及 Σ \Sigma Σ中的每个元素看做“字符”。但是当我们使用C++代码实现时,请务必记得用int而不是char存储字符,尤其是当我们直接从输入中读取字符时。因为C++标准并未规定char究竟是signed char还是unsigned char,这取决于机器和编译器。如果char被实现为unsigned char,那么你很有可能读取不到文件结束符EOF,因为EOF的值通常是-1

我们有办法将一个NFA转换成DFA,也有办法直接模拟一个NFA来进行模式匹配,但本文将要介绍一种利用语法树直接构建DFA的方式,它得到的状态数也比通过NFA转换得到的DFA状态数少。

1. 语法制导翻译

这一部分,我们的目的是建立一棵语法树,它其实就是我们熟知的表达式树。我们可以为数学表达式3+4*5-6建立如下的表达式树:

我们不妨先以我们熟悉的数学表达式为例,讲一讲语法树是如何建立的。我们将处理含有加、减、乘、除、括号的数学表达式,并且我们约定所有的运算数都是一位数字,这样我们就不必考虑词法分析。毕竟,正则表达式的运算对象也都是单个字符。

1.0 上下文无关文法

首先,我们写出表达式的上下文无关文法。读者可能并不熟悉这种文法表示方式,它有点类似于数学里的递推式。如果表达式中不含乘法和除法,我们就不必考虑运算符的优先级关系,我们可以写出“表达式” e x p r expr expr的如下产生式
e x p r → e x p r + t e r m   ∣   e x p r − t e r m   ∣   t e r m t e r m → d i g i t   ∣   ( e x p r ) d i g i t → 0   ∣   1   ∣   2   ∣ ⋯   ∣   9 \begin{aligned} expr&\rightarrow expr+term\ |\ expr-term\ |\ term\\ term&\rightarrow digit\ |\ (expr)\\ digit&\rightarrow 0\ |\ 1\ |\ 2\ |\cdots\ |\ 9 \end{aligned} exprtermdigitexpr+term  exprterm  termdigit  (expr)0  1  2   9
其中,竖线表示“或”,也就是说第一行定义了 e x p r expr expr的三个产生式,它们之间具有“或”的关系。第二行定义了“项” t e r m term term的产生式,它可能是一个数字,也可能是一个由括号括起来的表达式。

如果引入了乘除法,我们就必须仔细考虑文法的设计,因为运算符的优先级产生了差异。我们的解决方案是:
e x p r → e x p r + t e r m   ∣   e p x r − t e r m   ∣   t e r m t e r m → t e r m ∗ f a c t o r   ∣   t e r m / f a c t o r   ∣   f a c t o r f a c t o r → d i g i t   ∣   ( e x p r ) d i g i t → 0   ∣   1   ∣   2   ∣ ⋯   ∣   9 \begin{aligned} expr&\rightarrow expr+term\ |\ epxr-term\ |\ term\\ term&\rightarrow term*factor\ |\ term/factor\ |\ factor\\ factor&\rightarrow digit\ |\ (expr)\\ digit&\rightarrow 0\ |\ 1\ |\ 2\ |\cdots\ |\ 9 \end{aligned} exprtermfactordigitexpr+term  epxrterm  termtermfactor  term/factor  factordigit  (expr)0  1  2   9
我们使用了三个符号 e x p r , t e r m , f a c t o r expr, term, factor expr,term,factor来体现这种优先级差异。一般地,如果文法的运算符具有 n n n层不同的优先级,则需要 n + 1 n+1 n+1个不同的符号。

如果需要允许运算数具有不止一位数字,我们可以使用专门的词法分析器来将一个数作为一个符号,也可以用更简单的方法,引入 d i g i t s → d i g i t s   d i g i t   ∣   d i g i t digits\rightarrow digits\ digit\text{ }|\text{ }digit digitsdigits digit  digit即可,这也可以从运算符的角度理解,相当于我们定义了数字字符的二元运算“连接”。

1.1 消除左递归

我们将使用递归下降的语法分析器处理上下文无关文法。程序始终保存了一个“向前看”符号(lookahead),该符号是刚刚从输入流中读取进来、等待被处理的第一个符号。我们将根据lookahead的值来确定它究竟属于一个 e x p r expr expr还是 t e r m term term,以此决定究竟使用 e x p r + t e r m , e x p r − t e r m expr+term,expr-term expr+term,exprterm t e r m term term中的哪一个产生式。然而,像 e x p r → e x p r + t e r m expr\rightarrow expr+term exprexpr+term这样的产生式,其产生式体的最左边的符号和产生式头部的符号相同,这样的产生式是左递归的,它会使得处理 e x p r expr expr的函数被无限递归调用,程序永远不会前进。但是右递归就不会有这样的问题。

通过改写有问题的产生式,我们可以将左递归改为右递归。考虑如下产生式:
A → A α   ∣   β A\rightarrow A\alpha\ |\ \beta AAα  β
其中 α \alpha α β \beta β都是不以 A A A开头的符号序列。例如,在产生式 e x p r → e x p r + t e r m   ∣   t e r m expr\rightarrow expr+term\ |\ term exprexpr+term  term中, β = t e r m \beta=term β=term α = + t e r m \alpha=+term α=+term,其中的加号不能少。它对应的语法树如下图所示,最终会得到一个以 β \beta β开头、跟着若干个 α \alpha α的序列。

现在令
A → β R R → α R   ∣   ϵ \begin{aligned} A&\rightarrow\beta R\\ R&\rightarrow \alpha R\text{ }|\text{ }\epsilon \end{aligned} ARβRαR  ϵ
我们引入了一个新的符号 R R R,来将这个文法改造成右递归的文法,它的语法树如下图所示,最终仍然得到一个以 β \beta β开头、跟着若干个 α \alpha α的序列,效果是一样的。

因此,对于含有加减运算和括号的表达式的文法,我们引入符号 e x p r r e s t exprrest exprrest,就可以将 e x p r expr expr的产生式中的左递归消除。消除后的整个文法如下:
e x p r → t e r m   e x p r r e s t e x p r r e s t → +   t e r m   e x p r r e s t ∣      − t e r m   e x p r r e s t ∣        ϵ t e r m → d i g i t   ∣   ( e x p r ) d i g i t → 0   ∣   1   ∣ ⋯   ∣   9 \begin{aligned} expr&\rightarrow term\ exprrest\\ exprrest&\rightarrow+\ term\ exprrest\\ &|\ \ \ \ -term\ exprrest\\ &|\ \ \ \ \ \ \epsilon\\ term&\rightarrow digit\ |\ (expr)\\ digit&\rightarrow 0\ |\ 1\ |\cdots\ |\ 9 \end{aligned} exprexprresttermdigitterm exprrest+ term exprrest    term exprrest      ϵdigit  (expr)0  1   9

1.2 预测分析器

假设我们现在在处理一个 e x p r r e s t exprrest exprrest,那么我们就需要根据当前lookahead的值,来决定究竟是选择 e x p r r e s t exprrest exprrest的三个生成式中的哪一个。在这个文法中要做到这一点并不困难,因为这三个生成式的第一个符号是完全不同的。 + t e r m   e x p r r e s t +term\ exprrest +term exprrest的第一个符号是'+' − t e r m   e x p r r e s t -term\ exprrest term exprrest的第一个符号是'-' ϵ \epsilon ϵ的第一个符号就是空串 ϵ \epsilon ϵ。一般地,假设 α \alpha α是某个产生式,记 F I R S T ( α ) \mathrm{FIRST}(\alpha) FIRST(α)表示该产生式的可能的第一个符号的集合,例如 F I R S T ( t e r m ) = F I R S T ( d i g i t ) ∪ { ′ ( ′ } \mathrm{FIRST}(term)=\mathrm{FIRST}(digit)\cup\{'('\} FIRST(term)=FIRST(digit){(},而 F I R S T ( d i g i t ) \mathrm{FIRST}(digit) FIRST(digit)就是十个数字字符构成的集合。我们希望对于每个符号所具有的若干个产生式,这些产生式的 F I R S T \mathrm{FIRST} FIRST都不相交(在不考虑 ϵ \epsilon ϵ的情况下),这样我们就可以直接根据lookahead的值判断使用哪一个产生式了。依赖这一性质的递归下降语法分析器称为预测分析器(predictive parser),因为它总是在预测下一个符号对应哪一个产生式。所幸,本文涉及到的所有例子都是满足这一性质的,因此本文将使用这种分析方法。

现在我们可以对含有加减运算和括号的表达式的文法给出一个预测分析器的伪代码:

void expr() {
	term();
	exprrest();
}
void exprrest() {
	if (lookahead == '+') {
		match('+'); term(); exprrest();
	} else if (lookahead == '-') {
		match('-'); term(); exprrest();
	} else { /* do nothing */ }
}
void term() {
	if (isdigit(lookahead))
		digit();
	else if (lookahead == '(') {
		match('(');
		expr();
		match(')');
	} else
		report("Syntax error");
}
void match(int t) {
	if (lookahead == t)
		lookahead = nextChar();
	else
		report("Syntax error");
}

这份伪代码已经比较清晰地展示了它与文法的对应关系,不过还有两个小小的优化。注意到exprrest函数是递归的,但是对自身的调用总是发生在最后,我们称这样的递归函数是尾递归的,它容易改写成非递归的:

void exprrest() {
	while (true) {
		if (lookahead == '+') {
			match('+'); term(); continue;
		} else if (lookahead == '-') {
			match('-'); term(); continue;
		} else break;
	}
}

此外,我们完全可以将expr函数和exprrest函数合并为一个函数,道理是显而易见的。

1.3 加入语义动作

容易看出,上面的伪代码中,各个函数的调用形成了一个树形结构,这棵树跟我们想要的语法树有很密切的联系。自然,我们可以在这个过程中顺手地做一些事情,比如建立一些结点,来构造出一棵语法树。这就需要我们向文法中添加一些动作,称为语义动作

有一些语义动作是非常简单的。例如,考虑一个仅含有加减运算和括号的数学表达式,假如我们想把它翻译成后缀表达式,只需在正确的位置打印字符即可。
e x p r → e x p r + t e r m   { p r i n t ( ′ + ′ ) } ∣       e x p r − t e r m   { p r i n t ( ′ − ′ ) } ∣       t e r m t e r m → d i g i t   ∣   ( e x p r ) d i g i t → 0   { p r i n t ( ′ 0 ′ ) } ∣       1   { p r i n t ( ′ 1 ′ ) } ∣       2   { p r i n t ( ′ 2 ′ ) } ∣ ⋯ ∣       9   { p r i n t ( ′ 9 ′ ) } \begin{aligned} expr&\rightarrow expr+term\ \{print('+')\}\\ &|\ \ \ \ \ expr-term\ \{print('-')\}\\ &|\ \ \ \ \ term\\ term&\rightarrow digit\ |\ (expr)\\ digit&\rightarrow 0\ \{print('0')\}\\ &|\ \ \ \ \ 1\ \{print('1')\}\\ &|\ \ \ \ \ 2\ \{print('2')\}\\ &|\cdots\\ &|\ \ \ \ \ 9\ \{print('9')\} \end{aligned} exprtermdigitexpr+term {print(+)}     exprterm {print()}     termdigit  (expr)0 {print(0)}     1 {print(1)}     2 {print(2)}     9 {print(9)}
如上所示,我们将语义动作添加到文法中,并用花括号标识。添加了语义动作的文法称为语法制导翻译方案。这个翻译方案仍然包含左递归,我们用刚才的办法来消除左递归,将 e x p r expr expr的产生式改写成
e x p r → t e r m   e x p r r e s t e x p r r e s t → +   t e r m   { p r i n t ( ′ + ′ ) }   e x p r r e s t ∣      − t e r m   { p r i n t ( ′ − ′ ) }   e x p r r e s t ∣        ϵ \begin{aligned} expr&\rightarrow term\ exprrest\\ exprrest&\rightarrow+\ term\ \{print('+')\}\ exprrest\\ &|\ \ \ \ -term\ \{print('-')\}\ exprrest\\ &|\ \ \ \ \ \ \epsilon \end{aligned} exprexprrestterm exprrest+ term {print(+)} exprrest    term {print()} exprrest      ϵ
注意,语义动作的位置是很关键的,它并不是总出现在最后或者开头,也不能随意放置。将这些动作同步地添加到代码中相应的位置,我们就完成了一个仅含有加减运算和括号的表达式向后缀表达式的翻译。

练习:写出一个含有加减乘除运算和括号的表达式向后缀表达式的翻译器。

如果想要建立语法树,当然也可以通过添加语义动作来完成,但是这时会有一些问题。首先,考虑 e x p r → e x p r + t e r m expr\rightarrow expr+term exprexpr+term这条产生式,它的左右两侧的符号 e x p r expr expr应该具有不同的值,我们给右侧的 e x p r expr expr添上一个下标 1 1 1来表示它们是不同的 e x p r expr expr。我们需要创建一个加号结点,它的两个子节点分别是右侧的 e x p r expr expr t e r m term term对应的结点。我们可以用 A . n A.n A.n表示符号 A A A对应的结点,则这条产生式应该改写为
e x p r → e x p r 1 + t e r m   { e x p r . n = n e w   P l u s N o d e ( e x p r 1 . n , t e r m . n ) } expr\rightarrow expr_1+term\ \{expr.n=new\ PlusNode(expr_1.n,term.n)\} exprexpr1+term {expr.n=new PlusNode(expr1.n,term.n)}
注意,这里的语义动作仅仅是伪代码,我们将在下一部分仔细讨论这些结点的实现。下面给出创建语法树的完整翻译方案:
e x p r → e x p r 1 + t e r m   { e x p r . n = n e w   P l u s N o d e ( e x p r 1 . n , t e r m . n ) } ∣       e x p r 1 − t e r m   { e x p r . n = n e w   M i n u s N o d e ( e x p r 1 . n , t e r m . n ) } ∣       t e r m   { e x p r . n = t e r m . n } t e r m → d i g i t   { t e r m . n = n e w   D i g i t N o d e ( d i g i t . v a l u e ) } ∣       ( e x p r )   { t e r m . n = e x p r . n } d i g i t → 0   ∣   1   ∣ ⋯   ∣   9 \begin{aligned} expr&\rightarrow expr_1+term\ \{expr.n=new\ PlusNode(expr_1.n,term.n)\}\\ &|\ \ \ \ \ expr_1-term\ \{expr.n=new\ MinusNode(expr_1.n,term.n)\}\\ &|\ \ \ \ \ term\ \{expr.n=term.n\}\\ term&\rightarrow digit\ \{term.n=new\ DigitNode(digit.value)\}\\ &|\ \ \ \ \ (expr)\ \{term.n=expr.n\}\\ digit&\rightarrow 0\ |\ 1\ |\cdots\ |\ 9 \end{aligned} exprtermdigitexpr1+term {expr.n=new PlusNode(expr1.n,term.n)}     expr1term {expr.n=new MinusNode(expr1.n,term.n)}     term {expr.n=term.n}digit {term.n=new DigitNode(digit.value)}     (expr) {term.n=expr.n}0  1   9
这里我偷了一点懒,将 D i g i t N o d e DigitNode DigitNode的创建工作交给了 t e r m term term而不是 d i g i t digit digit,并用 d i g i t . v a l u e digit.value digit.value表示这个数字字符的值。现在困难的问题出现了:如何消除左递归?

考虑一个一般情形,假设现在有左递归的翻译方案
A → A 1 Y   { A . n = f ( A 1 . n , Y . n ) } ∣       X   { A . n = X . n } \begin{aligned} A&\rightarrow A_1Y\ \{A.n=f(A_1.n,Y.n)\}\\ &|\ \ \ \ \ X\ \{A.n=X.n\} \end{aligned} AA1Y {A.n=f(A1.n,Y.n)}     X {A.n=X.n}
我们自然希望将它改成右递归,但是不能再像之前那样简单地令 R → Y { A . n = f ( A 1 . n , Y . n ) } R\rightarrow Y\{A.n=f(A_1.n,Y.n)\} RY{A.n=f(A1.n,Y.n)},因为这里不存在 A 1 A_1 A1。考虑原文法对应的语法树:

其右递归版本应该是这样

现在我们要考虑其中的语义动作。根节点的 A . n A.n A.n是我们最终要得到的信息,这个信息是随着 X , Y 1 , Y 2 , ⋯ X,Y_1,Y_2,\cdots X,Y1,Y2,逐个被访问而累积起来的。注意,整个翻译过程就是在对语法树进行深度优先遍历(并且从左向右访问每个孩子),所以在左递归的语法树中,每一层的 A . n A.n A.n都是在访问完它的最右边的孩子之后计算出来,并向上返回的,因此最终信息自然传递到了顶端。

然而,在右递归的语法树中,每一层提供信息的符号 X X X或者 Y i Y_i Yi对应的结点是 R R R的兄弟结点,并且排在 R R R的左侧,也就是说在刚刚访问 R R R的时候,我们就可以计算它左上方的所有 X X X Y i Y_i Yi提供的信息的总和了,然后我们访问下一层的 Y Y Y,并将刚才的 R R R上计算出来的信息传递给下一层的 R R R。所以,当最后一个 R R R刚刚被访问的时候,根节点的 A . n A.n A.n所需的信息其实已经计算完毕了,它保存在最下层的 R R R中。因此我们还需要在向上回溯的过程中将这个信息一步一步往上传。我们用 R . t R.t R.t表示第一次计算出的信息,用 R . n R.n R.n表示向上传的信息,它对应的语法树如下图所示( R . n R.n R.n的计算未写出):

它的语法制导翻译方案如下
A → X   { R . t = X . n }   R   { A . n = R . n } R → Y   { R 1 . t = f ( R . t , Y . n ) }   R 1   { R . n = R 1 . n } ∣       ϵ   { R . n = R . t } \begin{aligned} A&\rightarrow X\ \{R.t=X.n\}\ R\ \{A.n=R.n\}\\ R&\rightarrow Y\ \{R_1.t=f(R.t,Y.n)\}\ R_1\ \{R.n=R_1.n\}\\ &|\ \ \ \ \ \epsilon\ \{R.n=R.t\} \end{aligned} ARX {R.t=X.n} R {A.n=R.n}Y {R1.t=f(R.t,Y.n)} R1 {R.n=R1.n}     ϵ {R.n=R.t}
用这种方法,我们可以消除 e x p r expr expr产生式中的左递归了:
e x p r → t e r m   { r e s t . t = t e r m . n }   r e s t   { e x p r . n = r e s t . n } r e s t → + t e r m   { r e s t 1 . t = n e w   P l u s N o d e ( r e s t . t , t e r m . n ) }   r e s t 1   { r e s t . n = r e s t 1 . n } ∣       − t e r m   { r e s t 1 . t = n e w   M i n u s N o d e ( r e s t . t , t e r m . n ) }   r e s t 1   { r e s t . n = r e s t 1 . n } ∣       ϵ   { r e s t . n = r e s t . t } \begin{aligned} expr&\rightarrow term\ \{rest.t=term.n\}\ rest\ \{expr.n=rest.n\}\\ rest&\rightarrow +term\ \{rest_1.t=new\ PlusNode(rest.t, term.n)\}\ rest_1\ \{rest.n=rest_1.n\}\\ &|\ \ \ \ \ -term\ \{rest_1.t=new\ MinusNode(rest.t, term.n)\}\ rest_1\ \{rest.n=rest_1.n\}\\ &|\ \ \ \ \ \epsilon\ \{rest.n=rest.t\} \end{aligned} exprrestterm {rest.t=term.n} rest {expr.n=rest.n}+term {rest1.t=new PlusNode(rest.t,term.n)} rest1 {rest.n=rest1.n}     term {rest1.t=new MinusNode(rest.t,term.n)} rest1 {rest.n=rest1.n}     ϵ {rest.n=rest.t}
最后的问题是,这样的语义动作如何添加到代码中?因为我们代码中的rest并不是一个对象,而是一个函数,如何给它绑定这样的一前一后的属性值呢?

仔细想想就能想到,函数的参数和返回值正好就对应了它调用前计算和调用结束时计算的两部分信息,所以rest函数应该接受一个参数表示 r e s t . t rest.t rest.t,返回一个 r e s t . n rest.n rest.n

这部分的技术已经全部介绍完了,现在我们回到正则表达式的问题上。正则表达式的语法制导翻译方案如下(由于内容太长,以代码而不是数学公式的形式呈现)。注意,我们给并运算符|加了引号来与文法中的|区分开。

expr      -> expr1 '|' term        { expr.n = new OrNode(expr1.n, term.n) }
          |  term                  { expr.n = term.n }
term      -> term1 cat             { term.n = new CatNode(term1.n, cat.n) }
          |  cat                   { term.n = cat.n }
cat       -> single*               { cat.n = new ClosureNode(single.n) }
          |  single+               { cat.n = new PositiveClosureNode(single.n )}
          |  single                { cat.n = single.n }
single    -> letter                { single.n = letter.n }
          | (expr)                 { single.n = expr.n }
letter    -> a | b | ... | z       { letter.n = new LetterNode(letter.value) }

消除左递归后:

expr      -> term             { exprrest.tmp = term.n }                              exprrest  { expr.n = exprrest.n }
exprrest  -> '|' term         { exprrest1.tmp = new OrNode(exprrest.tmp, term.n) }  exprrest1  { exprrest.n = exprrest1.n }
          |  epsilon          { exprrest.n = exprrest.tmp }
term      -> cat              { termrest.tmp = cat.n }                               termrest  { term.n = termrest.n }
termrest  -> cat              { termrest1.tmp = new CatNode(termrest.tmp, cat.n) }  termrest1  { termrest.n = termrest1.n }
          |  epsilon          { termrest.n = termrest.tmp }
cat       -> single*          { cat.n = new ClosureNode(single.n) }
          |  single+          { cat.n = new PositiveClosureNode(single.n) }
          |  single           { cat.n = single.n }
single    -> letter           { single.n = letter.n }
          | (expr)            { single.n = expr.n }
letter    -> a | b | ... | z  { letter.n = new LetterNode(letter.value) }

代码:Parser.h

注意,一个正则表达式对应的自动机本身可能含有多个接受状态,而这不利于我们处理问题。我们可以为输入的正则表达式在末尾附加一个特殊的字符'#',来使得它只含有一个接受状态。下面的代码假定传递给构造函数的字符串s'#'结束。

代码中已经将exprexprresttermtermrest合并。

#ifndef PARSER_H
#define PARSER_H

#include "Node.h"
#include <iostream>
#include <cctype>

class Parser {
public:
    explicit Parser(const std::string &s) : source(s), cur(source.begin()), lookahead(*cur) {}
    Node build() { return expr(); }

private:
    std::string source;
    std::string::iterator cur;
    int lookahead;

    Node expr() {
        Node tmp = term();
        while (true) {
            if (lookahead == '|') {
                match('|');
                tmp = tmp | term();		// 创建了一个 OrNode
            } else
                return tmp;
        }
    }
    Node term() {
        Node tmp = cat();
        while (true) {
            if (islower(lookahead) || lookahead == '(' || lookahead == '#')
                tmp = tmp & cat();		// 创建了一个 CatNode
            else
                return tmp;
        }
    }
    Node cat() {
        Node single_n = single();
        switch (lookahead) {
        case '*':
            match('*');
            return closure(single_n);	// 创建了一个表示闭包的 ClosureNode
            break;
        case '+':
            match('+');
            return positive(single_n);	// 创建了一个表示正闭包的 ClosureNode
            break;
        default:
            return single_n;
            break;
        }
    }
    Node single() {
        if (islower(lookahead) || lookahead == '#') {
            int t = lookahead;
            match(t);
            return Node(t);				// 创建了一个 LetterNode
        } else if (lookahead == '(') {
            match('(');
            Node expr_n = expr();
            match(')');
            return expr_n;
        } else
            throw "Syntax error";
    }
    void match(int t) {
        if (lookahead == t) {
            if (++cur == source.end())	// 小心
                lookahead = 0;
            else
                lookahead = *cur;
        } else
            throw "Syntax_error";
    }
};

#endif // PARSER_H

解释一个细节:Parser类有一个接受单个const string &参数的构造函数,我在它前面加了关键字explicit,这是因为构造函数同时也是一个类型转换,加上explicit是将这个类型转换声明为显式的,那么这个类型转换就不会被自动地、隐式地调用,这是为了防止在不经意间把一个string转换成一个Parser。多个参数的构造函数也可能被隐式调用(C++11起),方法是使用花括号括起来的初始值列表,因此多个参数的构造函数也可以声明为显式的。

读者可能会对其中的结点创建和传递略感困惑,下面我们介绍其中的技术。

2. 结点类的设计

2.0 目的

我们需要建立一棵语法树,然后通过这棵语法树建立一个DFA,来实现最终的功能。以(a|b)*abb#为例,我们构造出的语法树如下:(注意,我们忽略了括号)

其中小圆圈表示连接结点。

我们为每一个字母结点赋予一个编号(注意,其它结点没有编号的必要),为了方便起见,编号从0开始。下面的算法类似于树形DP,我们需要计算的函数包括:

  • n u l l a b l e ( n ) nullable(n) nullable(n),表示以结点 n n n为根的子树对应的子表达式是否能匹配 ϵ \epsilon ϵ,也就是说它能否生成空串。
  • f i r s t p o s ( n ) firstpos(n) firstpos(n),表示以结点 n n n为根的子树对应的子表达式的可能的第一个字母构成的集合。例如,如果该子表达式是a|(bc)*,则它的 f i r s t p o s firstpos firstpos值为集合 { ′ a ′ , ′ b ′ } \{'a','b'\} {a,b}
  • l a s t p o s ( n ) lastpos(n) lastpos(n),表示以结点 n n n为根的子树对应的子表达式的可能的最后一个字母构成的集合。
  • f o l l o w p o s ( p ) , p ∈ N followpos(p),p\in\N followpos(p),pN。假设编号为 p p p的结点对应的字母为 c 1 c_1 c1,编号为 q q q的结点对应的字母为 c 2 c_2 c2,则 q ∈ f o l l o w p o s ( p ) q\in followpos(p) qfollowpos(p)当且仅当存在一个被该正则表达式匹配的串,它的第 i i i个字符匹配 c 1 c_1 c1而第 i + 1 i+1 i+1个字符匹配 c 2 c_2 c2。也就是说, f o l l o w p o s ( p ) followpos(p) followpos(p)是编号为 p p p的字母结点的可能的后继字母的结点的编号集合。

结点分为五大类:字母结点、正闭包结点、闭包结点、并结点和连接结点。我们需要为每一类结点分别考虑这些函数的计算方式,具体的算法在下一部分讲解。最终建立自动机时,我们需要五样东西:

  • f o l l o w p o s ( p ) followpos(p) followpos(p)。注意,在我们的实现中, n u l l a b l e ( n ) nullable(n) nullable(n) f i r s t p o s ( n ) firstpos(n) firstpos(n) l a s t p o s ( n ) lastpos(n) lastpos(n)均被实现为成员n.nullablen.firstposn.lastpos,但是 f o l l o w p o s followpos followpos必须是函数,原因在后面解释。
  • 根节点的 f i r s t p o s firstpos firstpos,它与自动机的开始状态有关。
  • 对应字母为'#'的字母结点的编号,这与自动机的接受状态有关。
  • 每个字母结点对应的字母。
  • 字母结点的数量。

f i r s t p o s firstpos firstpos l a s t p o s lastpos lastpos f o l l o w p o s followpos followpos都是集合,如何表示一个集合?这里有两个选择,可以用std::bitset,也可以用关联容器类,如std::setstd::unordered_set。这三样东西各有优劣。bitset虽然速度快,但它需要在编译期设定大小,适用于已知数据范围的算法竞赛题;如果不知道数据范围的话,我们可以根据之后算法的时间复杂度来设置一个最大的能处理的值,但这可能会造成空间上的浪费。set时间上比unordered_set多一个 log ⁡ n \log n logn,相比之下我们肯定选择unordered_set。在LOJ的【正则表达式】一题中,约定输入的字符串长度不超过100,我们就开一个大小为101的bitset(要算上末尾的'#')。

2.1 继承体系

我们将会使用面向对象的技法来建立这些结点类。首先有一个基类Node_base,它应该是一个抽象基类(虽然在我们的例子中它不含纯虚函数),然后令LetterNodeClosureNode直接继承自Node_base,分别表示字母结点和闭包结点。我们在之后的分析中会看到,正闭包和闭包的区别微乎其微,所以不用两个类区分它们,而是在类中添加一个bool成员标注它是正闭包还是闭包。当然了,用两个类来表示也无可厚非。另外还有两个二元运算CatNodeOrNode,常见的处理方式是先自Node_base继承一个BinaryNode,再令CatNodeOrNode都继承自BinaryNode。这里我是这样实现的,但是由于我们的结点上的操作比较简单,直接令它们继承自Node_base也是可以的。

继承体系的设计往往没有唯一的最佳方案,任何合理的设计都可以采用,无需纠结。

大致框架如下:

class Node_base {
protected:
    using State = std::bitset<101>;
    bool nullable;
    State firstpos, lastpos;
    Node_base() = default;
    // 虚析构函数不可少,因为 Node_base 是基类。
    virtual ~Node_base() = default;
};
class LetterNode : public Node_base {
    int letter, id;
    LetterNode(int l);
};
class ClosureNode : public Node_base {
    bool positive;
    Node_base *content;
    ClosureNode(bool p, Node_base *ct);
};
class BinaryNode : public Node_base {
protected:
    Node_base *left, *right;
    BinaryNode(Node_base *l, Node_base *r);
    virtual ~BinaryNode() {}
};
class OrNode : public BinaryNode {
    OrNode(Node_base *l, Node_base *r);
};
class CatNode : public BinaryNode {
    CatNode(Node_base *l, Node_base *r);
};

一个闭包结点有一个子结点,一个二元运算结点有两个子结点。这些子结点都必须是Node_base*类型,因为我们在实际创建闭包或者二元运算结点的时候,并不知道其子结点究竟是什么结点。如果不用指针,直接使用Node_base,会导致一个子类向父类的类型转换,这种转换会将子类中不包含在父类中的部分裁剪掉,发生错误。使用指针,我们利用了动态绑定机制,父类的指针或引用可以指向子类对象。当我们对一个Node_base*调用某个虚函数时,将会进行运行时类型识别(Run-Time Type Identification, RTTI)来判断究竟调用哪一个版本。

2.2 内存管理的问题

满天飞的指针带来的压力是内存管理。想象一下,外部代码应该如何为正则表达式a|b*创建语法树?我们得写new OrNode(new LetterNode('a'), new ClosureNode(0, 'b')),我们还得时时刻刻记得释放动态分配的内存。

你也许会问:为何不在一个结点的析构函数中释放它的子结点的内存?但是仔细想想,删除一个结点真的需要删除它的子结点吗?如果我们一不小心(比如在函数的参数传递时)拷贝了一个OrNode,如果删除一个OrNode会删除它的子结点,那么删除这个副本就会导致原来那个OrNode的子结点也被删除!一种解决方法是干脆在OrNode的拷贝操作中对子结点也进行拷贝,但这样就会导致整棵子树被拷贝一遍,而这些拷贝可能是完全不需要的。

那干脆禁止拷贝好了!这也是一个办法,而且在本例中的确不需要什么拷贝操作。但是我们将会看到一个更加通用的、巧妙的解决方案:我们新增一个中间层来完成内存管理工作,这个中间层会绑定到一个Node_base*上,并且会在恰当的时机释放内存。由于行为类似指针,它们有时也被称为智能指针(smart pointer)。这种技术其实就是C++11引入的智能指针std::shared_ptr采用的技术——句柄(handle)。

2.3 句柄

思考这样一个问题:一个动态分配的对象,其内存应该何时被释放?

void fun() {
    int *p = new int(42);
    // ...
    // 指针 p 的作用域结束了,它指向的内存应该在这里被释放吗?
}

显然,指针p的作用域结束了并不能随随便便delete p,因为可能还有别的指针指向了p所指向的对象。事实上,当且仅当一个对象没有任何指针指向它的时候,它的内存才应该被释放。因此,我们应该给对象设置一个引用计数(use count),当有一个新的指针指向它的时候就递增引用计数。

那么什么时候递减引用计数呢?如果有一个指针,它原来指向这个对象,在某次操作后指向了别的对象,或者这个指针的作用域结束了,被销毁了,这时就应该递减它所指向的对象的引用计数。可是指针人人都能创建,这自然是非常不可控的,我们必须有一种更智能的指针,能够自己控制它所指向的对象的引用计数,并且不能允许它与普通指针之间的转换,要保证它所指向的对象不能被其它的普通指针指着。

不要混用普通指针与智能指针/句柄。

我们定义一个新的类,叫做Node,它保存一个指向Node_base的指针nb

class Node_base {
    friend class Node;	// Node 需要访问 private 成员 use
    int use;			// use 必须是 private 成员,不能被 Node 以外的代码修改
protected:
    using State = std::bitset<101>;
    bool nullable;
    State firstpos, lastpos;
    Node_base() : use(1) {}	// 一开始引用计数是 1
    virtual ~Node_base() = default;
};
class Node {
    Node_base *nb;
public:
    using State = Node_base::State;
    // 拷贝控制
    Node(const Node &);
    Node &operator=(const Node &);
    ~Node();
};

现在来考虑Node的拷贝控制函数。拷贝构造函数简单,直接递增引用计数即可。

Node::Node(const Node &n) : nb(n.nb) { ++nb->use; }

析构函数也很简单:先递减引用计数,如果引用计数为零则释放内存。

Node::~Node() { if (!--nb->use) delete nb; }

拷贝赋值运算符需要将这个句柄绑定到另一个对象上,意味着要递减原来的对象的引用计数、递增新对象的引用计数。但是我们不能这样写:

Node &Node::operator=(const Node &rhs) {
    if (!--nb->use)		// 错误!没有正确地处理自赋值。
        delete nb;
    nb = rhs.nb;
    ++nb->use;
    return *this;
}

这是非常典型的错误定义。我们在定义拷贝赋值运算符的时候必须考虑到自赋值的情况,如果rhs就是*this本身,递减引用计数可能会直接释放nb所指的内存,nb也成了野指针。

正确的办法是,要么直接判断if (this != &rhs),要么先递增引用计数,再递减引用计数。

Node &Node::operator=(const Node &rhs) {
    ++rhs.nb->use;
    if (!--nb->use)
        delete nb;
    nb = rhs.nb;
    return *this;
}

C++11还有移动操作,但这里我们就不去管它了,因为Node其实就是个指针,移动操作也并没有什么特殊性(如果你不知道什么是移动,可以跳过这段话)。当我们定义了拷贝构造函数(拷贝赋值运算符)时,编译器就不会为我们合成移动构造函数(移动赋值运算符);当我们定义了析构函数时,编译器就不会为我们合成移动操作。在没有定义移动操作的情况下,将会用拷贝来代替移动。

既然定义了句柄,那么所有指向子结点的指针都应该改成句柄。

class ClosureNode : public Node_base {
    Node content;	// 而不是 Node_base *content
    ClosureNode(bool pos, Node ct);	// 而不是 Node_base *ct
    // 其它成员不变
};
class BinaryNode : public Node_base {
    Node left, right;	// 而不是 Node_base *left, *right;
    BinaryNode(Node l, Node r);	// 而不是 Node_base *l, Node_base *r
    // 其它成员不变
};

当我们在计算 n u l l a b l e , f i r s t p o s , l a s t p o s nullable,firstpos, lastpos nullable,firstpos,lastpos的时候,显然是需要用到子结点的这些值的,我们给Node加上三个成员函数来返回这些信息。

class Node {
    // 其他成员不变
public:
    bool nullable() const { return nb->nullable; }
    const State &firstpos() const { return nb->firstpos; }
    const State &lastpos() const { return nb->lastpos; }
};

这三个函数必须是常量成员函数,因为它们并不改变Node的成员的值。并且你也不能修改子结点的函数值,同时也为了避免拷贝,我们将firstposlastpos的返回值都设置为常量引用。

注意,除了Node的友元(目前还没有友元)之外,没有人能够访问到Node的成员nb,也就不可能将外部的某个指针赋值给nb,也不能将nb拷贝给外部代码,因此我们的确将句柄和其它指针完全分开了。

2.4 创建结点

现在思考我们如何创建结点。语法制导方案中,我们写的是new OrNode(a, b)这样的语句,但这条语句创建的是一个普通指针,它不能被转换成一个句柄。我们也不能直接定义一个Node::Node(Node_base *)来允许这样的转换,否则这种转换就有被滥用的风险,句柄和引用计数就白写了。

一个办法是直接定义用Node创建Node的构造函数。可是,如果这个构造函数接受两个Node,我们又该如何决定创建OrNode还是CatNode呢?可以再显式地增加一个bool参数,或者索性定义一个enum Tag { LETTER, OR, CAT, CLOSURE, POSITIVE };之类的(这其实就是我一开始写这个程序时用的办法)。写自然是可以写的,读者可以自己试一试,但是这些办法都不够简洁。

事实上,从普通指针向句柄的转换是可以定义的,但是它必须是private的,那么外部就只有友元可以使用这个转换了。以Or结点为例,我们可以定义一个makeOr函数如下:

inline Node makeOr(const Node &lhs, const Node &rhs) {
    return new OrNode(lhs, rhs);
}

并将makeOr设置为Node的友元。通过这种方式,我们仍然将普通指针向句柄的转换限制在可控的范围内,并且外部代码只要makeOr(a, b)就可以建立一个Or结点了,也彻底不用参与内存管理了(连new都不用了)。

我们还可以实现得更漂亮一点,运用C++重载运算符的机制,把makeOr改成operator|,于是只要a | b就可以创建一个以ab为子结点的Or结点了。在我自己的实现中,我用|表示并,用&表示连接,而闭包则是用两个命名的函数closurepositive来创建。你当然也可以用一元运算符*创建闭包,用一元正号+创建正闭包,自由发挥好了。

对于单个字母的结点,直接用Node创建就好了:

class Node {
    friend Node operator|(const Node &, const Node &);
    friend Node operator&(const Node &, const Node &);
    friend Node closure(const Node &);
    friend Node positive(const Node &);
    // 其他成员不变
public;
    explicit Node(int l);
private:
    Node(Node_base *s) : nb(s) {}
};

特别说一下LetterNode,我们需要给每个LetterNode一个编号,还需要存储它的 f o l l o w p o s followpos followpos,以及corresLetter[i]表示编号为iLetterNode对应的字母。我们将followposcorresLetter直接实现为全局变量,为了减少名字冲突的可能性,给它们前面各加两个下划线。

std::vector<Node::State> __followpos;
std::vector<int> __corresLetter;
class LetterNode : public Node_base {
    friend class Node;
    int letter, id;
    LetterNode(int le) : letter(le), id(__corresLetter.size()) {
        __followpos.push_back(State());
        __corresLetter.push_back(letter);
        // 其它计算(后面再说)
    }
};
Node::Node(int l) : nb(new LetterNode(l)) {}

我们可以直截了当地把四个函数的计算过程在构造函数里实现,具体的计算后面再说。

闭包结点:

class ClosureNode : public Node_base {
    friend class Node;
    friend Node closure(const Node &);
    friend Node positive(const Node &);
    bool positive;
    Node content;
    ClosureNode(bool pos, Node ct) : positive(pos), content(ct) {
        // 其它计算
    }
};
inline Node closure(const Node &n) {
    return new ClosureNode(0, n);
}
inline Node positive(const Node &n) {
    return new ClosureNode(1, n);
}

二元运算结点:

class BinaryNode : public Node_base {
    friend class Node;
protected:
    Node left, right;
    BinaryNode(Node l, Node r) : left(l), right(r) {}
    virtual ~BinaryNode() {}
};
class OrNode : public BinaryNode {
    friend class Node;
    friend Node operator|(const Node &, const Node &);
    OrNode(Node l, Node r) : BinaryNode(l, r) {
        // 其它计算
    }
};
class CatNode : public BinaryNode {
    friend class Node;
    friend Node operator&(const Node &, const Node &);
    CatNode(Node l, Node r) : BinaryNode(l, r) {
        // 其它计算
    }
};
inline Node operator|(const Node &lhs, const Node &rhs) {
    return new OrNode(lhs, rhs);
}
inline Node operator&(const Node &lhs, const Node &rhs) {
    return new CatNode(lhs, rhs);
}

注意,将followposcorresLetter实现为全局变量会产生一定的局限性:程序没法同时处理多个正则表达式,因为一个程序只能有一个followpos和一个corresLetter。如果是多测,务必记得清空:

inline void init() {
    __corresLetter.clear();
    __followpos.clear();
}

我们暂时不去考虑其它的局限性,读者也可以自行思考。

2.5 使用智能指针

我们可以利用C++11引入的智能指针std::shared_ptr位于标准库文件memory)来实现这个句柄,方法是把句柄里的指针nb改成智能指针。所有的拷贝控制操作都可以直接调用shared_ptr上的对应的操作,于是我们就完全不用管拷贝控制了。shared_ptr会自动维护引用计数,并且在合适的时机释放内存。

首先,Node_base中不用保存引用计数了,默认构造函数也可以直接写= default();Node类的定义如下:

class Node {
    friend Node operator|(const Node &, const Node &);
    friend Node operator&(const Node &, const Node &);
    friend Node closure(const Node &);
    friend Node positive(const Node &);
    std::shared_ptr<Node_base> nb;		// 智能指针

public:
    using State = Node_base::State;
    explicit Node(int l);
    bool nullable() const { return nb->nullable; }
    const State &firstpos() const { return nb->firstpos; }
    const State &lastpos() const { return nb->lastpos; }

private:
    Node(const std::shared_ptr<Node_base> &s) : nb(s) {}
};

剩下的代码几乎都是一样的,只是operator|operator&closurepositive这四个函数需要改一改:

inline Node closure(const Node &n) {
    return std::shared_ptr<Node_base>(new ClosureNode(0, n));
}
inline Node positive(const Node &n) {
    return std::shared_ptr<Node_base>(new ClosureNode(1, n));
}
inline Node operator|(const Node &lhs, const Node &rhs) {
    return std::shared_ptr<Node_base>(new OrNode(lhs, rhs));
}
inline Node operator&(const Node &lhs, const Node &rhs) {
    return std::shared_ptr<Node_base>(new CatNode(lhs, rhs));
}

语法知识可能没有算法那么有趣,但应该也不算太枯燥吧。

3. 建立自动机

3.0 计算四个函数

为了建立自动机,我们需要先计算 n u l l a b l e nullable nullable f i r s t p o s firstpos firstpos l a s t p o s lastpos lastpos f o l l o w p o s followpos followpos,这有点像一个树形DP,或者在线段树之类的数据结构上合并子树信息。具体规则如下:

  • 字母结点的 n u l l a b l e nullable nullable F a l s e \mathrm{False} False f i r s t p o s firstpos firstpos l a s t p o s lastpos lastpos就是仅包含该结点对应的字母的单元素集合。
  • 闭包或正闭包结点。假设该结点对应的表达式是 r = ( s ) ∗ r=(s)^* r=(s),则 n u l l a b l e ( r ) = T r u e nullable(r)=\mathrm{True} nullable(r)=True f i r s t p o s ( r ) = f i r s t p o s ( s ) firstpos(r)=firstpos(s) firstpos(r)=firstpos(s) l a s t p o s ( r ) = l a s t p o s ( s ) lastpos(r)=lastpos(s) lastpos(r)=lastpos(s),并且对于每个 i ∈ l a s t p o s ( r ) i\in lastpos(r) ilastpos(r),应有 f i r s t p o s ( r ) ⊂ f o l l o w p o s ( i ) firstpos(r)\subset followpos(i) firstpos(r)followpos(i)。正闭包与普通闭包唯一的区别是 n u l l a b l e ( r ) = n u l l a b l e ( s ) nullable(r)=nullable(s) nullable(r)=nullable(s)
  • 连接结点。假设该结点对应的表达式是 r = s t r=st r=st,则 n u l l a b l e ( r ) = n u l l a b l e ( s ) ∧ n u l l a b l e ( t ) nullable(r)=nullable(s)\land nullable(t) nullable(r)=nullable(s)nullable(t)(符号 ∧ \land 表示“且”),而 f i r s t p o s ( r ) firstpos(r) firstpos(r)需要根据 n u l l a b l e ( s ) nullable(s) nullable(s)来定。如果 n u l l a b l e ( s ) = T r u e nullable(s)=\mathrm{True} nullable(s)=True,说明左侧连接对象可以产生空串,则 f i r s t p o s ( r ) = f i r s t p o s ( s ) ∪ f i r s t p o s ( t ) firstpos(r)=firstpos(s)\cup firstpos(t) firstpos(r)=firstpos(s)firstpos(t);否则 f i r s t p o s ( r ) = f i r s t p o s ( s ) firstpos(r)=firstpos(s) firstpos(r)=firstpos(s) l a s t p o s ( r ) lastpos(r) lastpos(r)的计算同理。此外,对于每个 i ∈ l a s t p o s ( s ) i\in lastpos(s) ilastpos(s),都应有 f i r s t p o s ( t ) ⊂ f o l l o w p o s ( i ) firstpos(t)\subset followpos(i) firstpos(t)followpos(i)
  • 并结点。假设该结点对应的表达式是 r = s ∣ t r=s|t r=st,则 n u l l a b l e ( r ) = n u l l a b l e ( s ) ∨ n u l l a b l e ( t ) nullable(r)=nullable(s)\lor nullable(t) nullable(r)=nullable(s)nullable(t)(符号 ∨ \lor 表示“或”), f i r s t p o s ( r ) firstpos(r) firstpos(r) l a s t p o s ( r ) lastpos(r) lastpos(r)都是子节点 s s s t t t的相应的集合的并集。

这些过程直接写在构造函数里,也省得递归了。四个构造函数如下:

LetterNode::LetterNode(int le) : letter(le), id(__corresLetter.size()) {
    __followpos.push_back(State());
    __corresLetter.push_back(letter);
    nullable = false;
    firstpos.set(id);
    lastpos.set(id);
}
ClosureNode::ClosureNode(bool pos, Node ct) : positive(pos), content(ct) {
    nullable = !positive || content.nullable();
    firstpos = content.firstpos();
    lastpos = content.lastpos();
    for (int i = 0, sz = __corresLetter.size(); i < sz; ++i)
        if (lastpos.test(i))
            __followpos[i] |= firstpos;
}
OrNode::OrNode(Node l, Node r) : BinaryNode(l, r) {
    nullable = left.nullable() | right.nullable();
    firstpos = left.firstpos() | right.firstpos();
    lastpos = left.lastpos() | right.lastpos();
}
CatNode::CatNode(Node l, Node r) : BinaryNode(l, r) {
    nullable = left.nullable() & right.nullable();
    firstpos = left.nullable() ? (left.firstpos() | right.firstpos()) : left.firstpos();
    lastpos = right.nullable() ? (left.lastpos() | right.lastpos()) : right.lastpos();
    for (int i = 0, sz = __corresLetter.size(); i < sz; ++i)
        if (left.lastpos().test(i))
            __followpos[i] |= right.firstpos();
}

bitset的好处就是集合的运算非常方便,而且效率不低。

3.1 构建DFA

f o l l o w p o s followpos followpos是一个非常有趣的函数。我们可以建立这样一张有向图,其中结点 i i i j j j有一条边当且仅当 j ∈ f o l l o w p o s ( i ) j\in followpos(i) jfollowpos(i)。考虑 f o l l o w p o s followpos followpos的定义,不难理解:这个有向图几乎就是相应的正则表达式的不含 ϵ \epsilon ϵ转换的NFA。之前已经解释过,DFA的状态对应于NFA的状态集合,自然也用一个bitset来表示。

我们需要知道这个DFA的开始状态startState,构造出它的状态集dstates和状态转换表dtrandtran[S][a]表示从状态S出发经过标号为a的边到达的状态),并且要能够辨别接受状态。算法伪代码如下:

startState = root.firstpos();	// root 是语法树的根结点
dstates.insert(startState);
queue.push(startState);
while (!queue.empty()) {
	S = queue.front(); q.pop();
	for (每个字母 a) {
		令 U 为 S 中和 a 对应的所有编号 p 的 followpos(p) 的并集;
		if (U 不在 dstates 中) {
			dstates.insert(U);
			queue.push(U);
		}
		dtran[S][a] = U;
	}
}

在实现中,我们需要知道字母结点的数量letterCnt,以及标号为'#'的字母结点的编号finalLetterNo,这其实是很简单的。字母结点的数量就是__corresLetter.size(),而'#'结点一定是最后一个被创建的字母结点,其编号就是letterCnt - 1

我们可以用一个std::unordered_set<State>来表示dstates,用一个std::unordered_map<State, State[sigma]>来表示dtran,其中sigma=27是字母表的大小。注意,如果Statebitset,那么标准库已经为它提供了hash函数,可以直接塞进无序关联容器中;但如果用别的方式(比如set)实现State,则需要先为它特例化std::hashstd::equal_to

最后,我们判断一个状态是否包含finalLetterNo,来判定它是否到达了接受状态。下面给出DFA的完整代码:(DFA.h)

#ifndef DFA_H
#define DFA_H

#include "Parser.h"
#include <unordered_map>
#include <unordered_set>
#include <bitset>
#include <queue>
#include <string>
#include <vector>

class DFA {
    using State = Node::State;
    static constexpr int sigma = 27;
    State startState;
    int letterCnt, finalLetterNo;
    std::unordered_map<State, State[sigma]> dtran;
    std::unordered_set<State> dstates;

    int encode(int c) const {
        return c == '#' ? 26 : c - 97;
    }
    bool isFinal(const State &s) {
        return s.test(finalLetterNo);
    }
    // 一个合格的 OIer 应该试着自己编写这个函数,它就是个图搜索算法而已。
    void build() {
        static std::queue<State> q;
        dstates.insert(startState);
        q.push(startState);
        while (!q.empty()) {
            State s = q.front();
            q.pop();
            std::vector<int> cor[sigma];
            for (int i = 0; i < letterCnt; ++i)
                if (s.test(i))
                    cor[encode(__corresLetter[i])].push_back(i);
            for (int i = 0; i < sigma; ++i) {
                State u;
                for (int v : cor[i])
                    u |= __followpos[v];
                if (dstates.find(u) == dstates.end()) {
                    dstates.insert(u);
                    q.push(u);
                }
                dtran[s][i] = u;
            }
        }
    }

public:
    // 构造函数直接从一个正则表达式创建自动机,约定这个正则表达式是以 '#' 结尾的。
    explicit DFA(const std::string &s)
        : startState(Parser(s).build().firstpos()),
            letterCnt(__corresLetter.size()), finalLetterNo(letterCnt - 1) {
        build();
    }
    // 匹配只要沿着边走就好了。
    bool match(const std::string &s) {
        State cur = startState;
        for (const auto &c : s) {
            cur = dtran[cur][encode(c)];
            if (cur.none())		// 一个小优化,如果走到了空状态就不用再走了。
                return false;
        }
        return isFinal(cur);
    }
};

#endif // DFA_H

最后是主函数:

#include "DFA.h"
#include <iostream>
#include <string>

int main() {
    std::string pat, str;
    while (std::cin >> pat >> str) {
        init();
        std::cout << (DFA(pat += "#").match(str) ? "Yes" : "No") << std::endl;
    }
    return 0;
}

把这些头文件粘过来连在一起,就得到了一份LOJ118的AC代码。


参考资料:

Alfred V. Aho, Monica S. Lam, Ravi Sethi, Jeffery D. Ullman 《编译原理》第二版

Andrew Koenig, Barbara Moo 《C++ 沉思录》

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值