算法之正则表达式匹配与NFA构建全解析
在计算机科学领域,算法的学习与探索永无止境。我希望通过这篇博客,能和大家一起深入理解算法相关知识,在技术之路上共同进步。今天,我们聚焦于正则表达式匹配和非确定性有限自动机(NFA)构建这两个关键技术点,深入剖析其原理,并结合Java代码实例,让大家能轻松掌握这些复杂的概念。
一、正则表达式匹配的时间复杂度
在进行文本处理时,判断一个正则表达式能否识别一段文本是很常见的需求。有个重要的结论是:判定一个长度为M的正则表达式所对应的NFA能否识别一段长度为N的文本,在最坏情况下,所需时间和 (MN) 成正比。
这是怎么得到的呢?对于长度为N的文本中的每个字符,都要遍历一个大小不超过M的状态集合,还要在由E-转换构成的有向图中进行深度优先搜索。这个有向图中的边数不会超过 (2M) 条,所以每次深度优先搜索在最坏情况下的运行时间与M成正比。整体算下来,时间复杂度就是 (MN) 。这和简单的固定子字符串查找算法在最坏情况下的成本相同,是不是很神奇?
用通俗的话来讲,就好比我们有一本单词书(正则表达式)和一篇文章(文本)。单词书里的单词拼写规则可能很复杂,而文章很长。我们要检查文章里有没有符合单词书里拼写规则的单词,每看文章里的一个字符,都要对照单词书里的各种规则(状态集合),并且在一个由这些规则连接起来的“迷宫”(有向图)里找路,找路的过程就是深度优先搜索。这个“迷宫”的路(边数)最多是单词书长度(M)的两倍,所以找路的时间和单词书长度有关,整体检查完文章的时间就是文章长度(N)和单词书长度(M)的乘积。
二、NFA的构建规则
NFA的构建过程是理解正则表达式匹配的关键,它和我们熟悉的算术表达式求值过程有相似之处,但也有自己的特点。下面我们来详细看看它的构建规则。
(一)连接操作
连接操作在NFA里很好实现。简单来说,状态的匹配转换和字母表中的字符对应起来,这就是连接操作。打个比方,我们有两个字符 “a” 和 “b”,在NFA里,从代表 “a” 的状态到代表 “b” 的状态,通过它们之间的转换关系,就实现了 “a” 和 “b” 的连接。
(二)括号处理
处理括号时,我们把正则表达式里所有左括号的索引压入栈中。遇到右括号时,就把对应的左括号从栈中弹出。这就像我们写数学公式时,括号有配对关系,栈能很好地处理这种嵌套的括号关系。比如在正则表达式 “(a(b|c))” 里,遇到第一个左括号时,把它的索引存到栈里,遇到右括号时,就从栈里取出对应的左括号索引,知道这一对括号的范围。
(三)闭包操作
闭包运算符 “” 有两种情况。如果它出现在单个字符之后,就在这个字符和 “” 之间添加两条相互指向的E-转换;要是出现在右括号之后,就在对应的左括号(栈顶元素)和 “” 之间添加两条相互指向的E-转换。例如,对于 “a”,“a” 和 “” 对应的状态之间就有两条E-转换,这样NFA就能在 “a” 状态和 “” 状态之间来回转换,表示 “a” 可以出现0次或多次。
(四)“或” 表达式处理
在形如 “(A|B)” 的正则表达式中,A和B都是正则表达式。我们要添加两条E-转换,一条从左括号对应的状态指向B中第一个字符对应的状态,另一条从 “|” 字符对应的状态指向右括号对应的状态。同时,把 “|” 运算符和左括号的索引压入栈中,这样到右括号时就能获取所需信息。比如 “(a|b)”,从左括号对应的状态可以通过E-转换到达 “b” 对应的状态,从 “|” 对应的状态可以到达右括号对应的状态,NFA就能在 “a” 和 “b” 之间选择匹配。
下面用表格总结NFA构建规则:
操作类型 | 规则描述 |
---|---|
连接操作 | 状态的匹配转换与字符对应实现连接 |
括号处理 | 左括号索引入栈,右括号弹出对应左括号索引 |
闭包操作 | 单个字符后或右括号后,在相应位置添加相互指向的两条E-转换 |
“或”表达式处理 | 添加两条E-转换,相关索引压栈辅助处理 |
三、Java代码实现示例
下面是一个简单的Java代码示例,展示如何构建一个简单正则表达式对应的NFA,并判断文本是否匹配。
import java.util.Stack;
class Digraph {
private int V;
private int[][] adj;
public Digraph(int v) {
V = v;
adj = new int[v][v];
}
public void addEdge(int v, int w) {
ad