本文主要关于我在学习《编译原理》的过程中,实现简易正则表达引擎时的理解和思考,以及大致的实现思路。我的实现基于C语言,但本文不针对任何语言,不罗列代码,仅通过类似 C++ 的语法进行算法的伪代码说明,以及一些数据结构的定义。
原文发布在我的博客上:(顺便推销)
https://www.sardinefish.com/blog/?pid=336www.sardinefish.com本文的大致思路如下:
- 介绍关于正则表达式的定义和概念
- 介绍关于有限自动机的相关概念,包括 NFA, DFA 以及 NFA 到 DFA 的转换算法,同时介绍 DFA 和 NFA 的模拟算法。
- 介绍从正则表达式字符串分析其抽象语法树,随后生成等效的 NFA,最后模拟 NFA/DFA 进行正则表达式匹配的思路和算法。
关于正则表达式
接触过开发,特别是 Web 开发,对正则表达式通常不会陌生,在大多数开发教程中,正则表达式是作为一种匹配某种模式的字符串的方法来讲解,很容易理解其中一些符号的意义和用法。这里是我在阅读“紫龙书”(《编译原理(第2版)》)的时候看到的关于正则表达式偏向学术性的定义,从串和语言的角度对正则表达式做出定义,感觉跟以往学习过程中先入为主的理解角度有很大的区别。于是在这里摘取了其中的主要部分。
字母表 (alphabet) 是一个有限的符号集合,这里的符号包括字母、数字、标点符号等(通常又被成为字符,下文将统一称作字符)。ASCII 字符集就是一个字母表的重要例子,类似的还有 Unicode、UTF-8 等编码所能表示的字符的集合。
串 (string) 通常又称字符串(出于表示习惯,后文将统一称作字符串),是字母表中字符的一个有穷序列。串中字符的个数称为串的长度,字符串 s 的长度表示为 |s|,例如字符串"banana"
的长度为6,这与绝大多数编程语言中字符串的长度定义一致。空串 (empty string) 是长度为0的字符串(即""
),用 ε (epsilon) 表示。
语言 (language) 是在某个给定字母表上一个任意可数的字符串的集合。例如集合 {"apple", "apples", "banana"}
是语言,空集 Ø 和仅包含一个空串的集合 { ε } 都是语言。
语言上的运算
为了表示方便,这里定义语言L = { “apple” , "banana" }
,语言 M = { "A" }
正则表达式
上文提到的语言表示一个字符串的集合,为了准确的描述一个语言,我们可以列举集合中的字符串,例如语言 L = { “apple” , "banana" }
,但对于一些无法使用列举法描述的语言,例如所有正整数的字符串表示。可以使用正则表达式进行描述,利用一些字符,应用以上的运算规则而得到的语言。例如对于所有正整数的字符串,可以用正则表达式表示为 (1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*
。正则表达式 r 所表达的语言记作 L(r)。
正则表达式可以由较小的正则表达式按如下规则递归地构建。
ε
是一个正则表达式,L(ε) = { ε },即该语言只包含空串- 如果
a
是字母表上的一个字符,那么a
是一个正则表达式,并且 L(a) = { a } r|s
是一个正则表达式,表示语言 L(r) | L(s)rs
是一个正则表达式,表示语言 L(r)L(s)r*
是一个正则表达式,表示语言 L(r)*(r)
是一个正则表达式,表示语言 L(r)
其中运算符的约定:
- 一元运算符*具有最高优先级,并且是左结合的
- 连接具有次高的优先级,也是左结合的
- |运算的优先级最低,也是左结合的
因此,(a)|((b)*(c))
等价于 a|b*c
。
正则表达式代数定理
下面这些定理,刚学过离散数学还挂了科的我,在看到的时候感到略微的奇妙
正则表达式的扩展
除了上述基本的正则表达式运算符,在后续的发展过程中,陆续出现了很多种正则表达式的扩展,这些扩展在目前的行业领域也有广泛的应用。
- 正闭包运算符
+
表示一个或多个实例,该运算符也可以表示为基本运算符,例如r+
可以表示为(rr*)
?
运算符,表示零个或多个实例,r?
等价于(r|ε)
- 字符类,对于正则表达式
(a|b|c|d)
,将其缩写为[abcd]
,其中 a, b, c, d 均为字母表中的单个字符,若这些字符形成一个逻辑上连续的序列(例如 ASCII 编码中连续编码的字符),则可以进一步缩写为[a-d]
,因此上文中提到的表示所有正整数的字符串可以用正则表达式[1-9][0-9]*
表示。
有限自动机
有限自动机 (Finite Automata) 是一个识别器 (recognizer),从起始状态开始,一个字符接一个字符地读入一个字符串,并根据给定的转移函数一步一步地转移至下一个状态,直至读完该字符串,并根据自动机的当前状态决定是否接受该字符串。
从图论的角度来说,有限自动机是一个有向图 (V, E),图的顶点集 V 为该自动机的状态集,图的边表示该自动机的状态转移。如下图的有限自动机具有4种状态 { S, F, 1, 2},其中状态 S 是起始状态,F 是接受状态。状态之间的转换是图中的边,每条边具有一个标号,表示从某个状态读入该字符则沿该边转换到下一个状态。该自动机可以接受语言 {"ab", "ac"}
。
有限自动机能够识别的语言集合与正则表达式所能识别的语言集合相同,因此一个正则表达式可以表示为一个有限自动机,有限自动机和正则表达式所能识别的语言集合称为正则语言 (Regular Language)。
有限自动机分为两类:
DFA
确定有限自动机 (Deterministic Finite Automata, DFA),对于每个状态 s 和每个输入符号 a,有且只有一条标号为 a 的边离开 s。并且没有 ε 的转换。如上图就是一个 DFA。
NFA
非确定有限自动机 (Nondeterministic Finite Automata, NFA) 对其边上的符号没有限制,一个状态 s 可以允许多条标号为 a 的边离开 s,空串 ε 也可以作为边上的标号。如下图是一个 NFA 的实例。该 NFA 可以接受语言{"ab", "ac"}
,即该 NFA 和上图的 DFA 是等价的,事实上 NFA 和 DFA 之间可以相互转换。
转换表
对于一个有限自动机,可以使用一张转换表 (Transition Table) 来表示状态之间的转换关系。
下表为 DFA 的转换表,转换表描述了从一个指定状态 s ,读入某一字符后转换到的下一个状态。
例如下表为上文中 NFA 的转换表,与 DFA 的转换表不同,NFA 转换表描述了从一个状态 s,读入某一字符后转换到的下一个状态集合,这充分体现了 DFA 与 NFA 的区别。
注意区别状态转换表和图论中邻接矩阵的差别
String Recognize
有限自动机识别字符串是一种模拟算法,即从有限自动机的起始状态开始,不断从字符串中读入字符,根据状态转换表转换到下一状态(集),不断迭代这一过程直到字符串读入完毕,根据当前状态是否处于接受状态,得出该字符串是否被接受。
以下为 DFA 和 NFA 模拟的【伪 · C】代码
模拟 DFA
bool simulateDFA(DFA dfa, string input)
{
State currentState = dfa.initialState;
for(int i = 0; i < input.length; i++)
{
char chr = input[i];
// 这里表示 DFA 转换表中 state 状态读入 chr 后转移到的下一个状态
if(currentState.transit(chr))
currentState = currentState.transit(chr);
else
return false;
}
return true;
}
模拟 NFA
由于 NFA 中存在 ε 转换,即可以从某一状态无条件地经过 ε 边转换到下一状态。如上文中的 NFA 示例,
Set emptyTransit(Set stateSet)
{
Set result = {};
for(int i = 0;i < stateSet.count; i++)
{
result.add(stateSet[i]);
result.add(emptyTransit(stateSet.emptyTransit()));
}
return result;
}
bool simulateNFA(NFA nfa, string input)
{
Set currentState = {nfa.initialState};
Set nextState = {};
for(int i = 0; i < input.length; i++)
{
char chr = input[i];
for(int j = 0; j < currentState.count; i++)
{
State state = currentState[j];
// 这里表示状态转换表中 state 状态读入 chr 后转移到的下一个状态集合
nextState.add(state.transit(chr));
}
currentState = nextState;
nextState = {};
}
if(currentState.contains(nfa.acceptState))
return true;
else
return false;
}
时间复杂度
若字符串长度为 N,有限自动机的状态数为 M,则模拟 DFA 进行字符串识别的时间复杂度为 O(N),而模拟 NFA 的时间复杂度最坏情况下为 O(MN)。
NFA to DFA
从上文可知,模拟 DFA 的效率高于模拟 NFA。事实上 NFA 可以转换为 DFA 进行模拟,接下来将介绍利用子集构造法将 NFA 转换为 DFA 进行模拟。
如下图表示的一个在字母表 { a, b } 上的 NFA,这里将逐步说明如何将该 NFA 转换为等效的 DFA。
- 将 NFA 的初始状态集 { S } 作为 DFA 的初始状态 S ;
- 计算从 { S } 状态集经过一次 a 转换到的下一个状态集,得到 {1, 3};
- 计算 { 1, 3 } 状态集经过若干次 ε 转换能到达的状态集,并与其合并,得到 { 1, 2, 3 };
- 将该状态集作为 DFA 状态 A ,并向 DFA 中添加从 S 经过 a 转换到 A 的边;
- 由于没有离开 S 的 b 转换,因此跳过;
- 从 DFA 中选取下一个状态 A ,其表示 NFA 中的状态集 { 1, 2, 3 };
- 从 NFA 中状态集 { 1, 2, 3 } 开始,同过程2、3、4, 得到 { 1, 2, 3 },与 DFA 状态 A 相同;
- 继续从 NFA 中状态集 { 1, 2, 3 } 开始计算经过 b 转换到达的状态集,同过程2、3、4, 得到 { F },将其作为 DFA 中状态 B;
- 重复这些过程直至 DFA 中状态已经被处理完毕,得到以下的 DFA:
模拟该 DFA 进行字符串识别与模拟其对应的 NFA 是等效的。由于 NFA 中的接受状态 F 包含在 DFA 状态 B 中,因此该 DFA 的接受状态为 B。若 NFA 中的接受状态 F 同时属于多个 DFA 状态,则这些 DFA 状态均可以作为接受状态。
在这个示例中,NFA 转换到等效的 DFA 似乎变得更加简介,实际上一个 NFA 的等效 DFA 的状态数可能是 NFA 状态数的指数,在这样的情况下,将 NFA 转换为 DFA 会带来巨大的内存开销,这也是一个时间和空间的权衡问题。
正则表达式的实现
由上文可知,一个正则表达式可以表示为一个有限自动机,模拟运行该有限自动机进行字符串识别,即可实现正则表达式匹配。
接下来将用三个模块实现一个简单的正则表达引擎:
- Parser: 正则表达式解析器,即将正则表达式字符串解析为正则表达式抽象语法树,方便后续处理。
- Compiler: 正则表达式编译器,即将正则表达式转换为等效的 NFA,再根据情况是否将 NFA 转换为 DFA。
- Matcher: 正则表达式匹配器,运行该正则表达式的等效 NFA 或 DFA,对输入字符串进行识别匹配。
这里要实现的简易正则表达式引擎将包含以下 Features
- 基于 ASCII 字符集,字符
'0'
不在考虑的范围内 - 简易的贪婪模式,试图匹配字符串输入中最长的匹配项
- 简易的非贪婪模式,直接返回首次匹配到接受状态的匹配序列
- 多行模式,特殊字符
.
将匹配包括n
在内的所有字符 - 单行模式,特殊字符
.
将匹配除n
外的其他字符 - 部分正则表达式特殊字符,见下表,其具体意义参照 MDN 上的正则表达式规范 MDN - 正则表达式
Parser
这里的正则表达式解析器,以正则表达式字符串为输入,分析正则表达式的语法层次结构,输出正则表达式的抽象语法树 (Abstract Syntax Tree, AST)。同时将一些特殊字符转换为等价的字符集表达,如将d
转换为[0-9]
,以降低复杂度。
这里给出正则表达式 AST 节点的类型结构定义:
class RegExpNodeBase
{
bool optional; // 表示当前项是否匹配0次
bool multiple; // 表示当前项是否能匹配超过1次
};
class RegExpCharSet: RegExpNodeBase
{
char from;
char to;
};
class RegExpGroup: RegExpNodeBase
{
RegExpNodeBase sequence[];
};
class RegExpSelection: RegExpNodeBase
{
RegExpNodeBase selection[];
};
具体说明:
RegExpNodeBase
:正则表达式项的基类。RegExpCharSet
: 正则表达式字符集。为了降低复杂度,这里不设置单字符的节点类型,而是统一作为字符集处理,例如正则表达式abc*
将被当作[a][b][c]*
。而字符集[Aa-z0-9]
将被拆分作为([A]|[a-z]|[0-9])
。对于单字符,其from
和to
值相同,均为该字符。RegExpGroup
:用于处理用括号内的正则表达式项,(abc)
将会解析为一个RegExpGroup
,其中的sequence
中将包含三个RegExpCharSet
对象。RegExpSelection
:处理用|
符号表示的匹配选择项,例如abc|def
将被当作(abc)|(def)
解析为一个RegExpSelection
对象,其selection
中将包含2个RegExpGroup
对象。
Compiler
正则表达式编译器以 Parser 中生成的正则表达式 AST 作为输入,将其转换为一个等效的 NFA。
接下来以一个递归的方式构造 NFA:
- 对于一个正则表达式的项 a,构造下面的 NFA:
对于正则表达式的任意一个子项(相当于 AST 中的每一个RegExpNodeBase
对象)都可以表示为一个具有起始状态 S 和结束状态 F 的一个 NFA 子结构。
- 假设正则表达式项 r = s|t,构造如下的 NFA:
- 假设 r = st,构造如下的 NFA:
- 假设 r = s*,构造如下的 NFA:
- 假设 r = s?,构造如下的 NFA:
- 对于 r = s+,将其作为 r = (ss*) 进行构造
递归地进行以上步骤,将正则表达式构造成 NFA,例如对C语言中标识符的正则表达式[_A-Za-z][_A-Za-z0-9]*
,得到的 NFA 如图:(这里对字符集的边进行了压缩处理,使得一条边可以表示一个字符集范围)(长相奇特,画出图的时候我都惊了)
接下来可以考虑是否将 NFA 转换为等效的 DFA,以将之后匹配的时间复杂度降低到 O(N),对于一般情况下的正则表达式,其 DFA 对空间的需求在可接受的程度,转换算法见上文。
Matcher
正则表达式匹配器以待匹配的源字符串和 Compiler 中得到的 NFA/DFA 作为输入,模拟 NFA 或 DFA 对源字符串进行识别,输出匹配结果,模拟算法参考上文。
匹配的贪婪模式和非贪婪模式区别在于匹配时的终止条件不同:
Greedy Mode
贪婪模式将会匹配从起始位置开始,匹配到最长的匹配串,或直到源字符串读取完毕。
在模拟 NFA/DFA 时,没次状态转移后,查询转移到的当前状态(集)中是否存在接收状态,若存在,保存当前的匹配长度。直到转移后的当前状态为空,即无法继续匹配更多的字符,或者源字符串已读取完毕。将最后保存的匹配长度作为最终匹配长度,从源字符串中截取相应部分作为匹配结果输出。
Non-Greedy Mode
非贪婪模式将匹配从起始位置开始,最早匹配到接收状态的匹配串。
与贪婪模式的区别是,若转移后的当前状态中存在接受状态,立即返回当前的匹配结果。
Source Code
我在我的课程设计里实现了这样一个简易正则表达式引擎,基本上与上文的描述相同,课设要求使用C语言,可读性较差,并且也很明确的知道里面有好几处导致内存泄漏的 BUG (malloc 太多了我实在是懒得 free)。
GitHub: https://github.com/SardineFish/code-formatter
最后,本文如有不严谨之处欢迎评论指出,转载请注明出处。
Reference
《编译原理(第2版)》机械工业出版社
Wikipedia - Abstract syntax tree https://en.wikipedia.org/wiki/Abstract_syntax_tree
Wikipedia - Nondeterministic finite automaton https://en.wikipedia.org/wiki/Nondeterministic_finite_automaton
MDN - RegExp https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp