C#词法分析器之构造NFA详解

有了上一节中得到的正则表达式,那么就可以用来构造 NFA 了。NFA 可以很容易的从正则表达式转换而来,也有助于理解正则表达式表示的模式。

一、NFA 的表示方法

在这里,一个 NFA 至少具有两个状态:首状态和尾状态,如图 1 所示,正则表达式 $t$ 对应的 NFA 是 N(t),它的首状态是 $H$,尾状态是 $T$。图中仅仅画出了首尾两个状态,其它的状态和状态间的转移都没有表示出来,这是因为在下面介绍的递归算法中,仅需要知道 NFA 的首尾状态,其它的信息并不需要关心。

图 1 NFA 的表示

我使用下面的 Nfa 类来表示一个 NFA,只包含首状态、尾状态和一个添加新状态的方法。


代码如下:

namespace Cyjb.Compiler.Lexer {
     class Nfa {
         // 获取或设置 NFA 的首状态。
         NfaState HeadState { get; set; }
         // 获取或设置 NFA 的尾状态。
         NfaState TailState { get; set; }
         // 在当前 NFA 中创建一个新状态。
         NfaState NewState() {}
     }
 }

NFA 的状态中,必要的属性只有三个:符号索引、状态转移和状态类型。只有接受状态的符号索引才有意义,它表示当前的接受状态对应的是哪个正则表达式,对于其它状态,都会被设为 -1。

状态转移表示如何从当前状态转移到下一状态,虽然 NFA 的定义中,每个节点都可能包含多个 ϵ  转移和多个字符转移(就是边上标有字符的转移)。但在这里,字符转移至多有一个,这是由之后给出的 NFA 构造算法的特点所决定的。

状态类型则是为了支持向前看符号而定义的,它可能是 Normal、TrailingHead 和 Trailing 三个枚举值之一,这个属性将在处理向前看符号的部分详细说明。

下面是 NfaState 类的定义:


代码如下:

namespace Cyjb.Compiler.Lexer {
     class NfaState {
         // 获取包含当前状态的 NFA。
         Nfa Nfa;
         // 获取当前状态的索引。
         int Index;
         // 获取或设置当前状态的符号索引。
         int SymbolIndex;
         // 获取或设置当前状态的类型。
         NfaStateType StateType;
         // 获取字符类的转移对应的字符类列表。
         ISet<int> CharClassTransition;
         // 获取字符类转移的目标状态。
         NfaState CharClassTarget;
         // 获取 ϵ 转移的集合。
         IList<NfaState> EpsilonTransitions;
         // 添加一个到特定状态的转移。
         void Add(NfaState state, char ch);
         // 添加一个到特定状态的转移。
         void Add(NfaState state, string charClass);
         // 添加一个到特定状态的ε转移。
         void Add(NfaState state);
     }
 }

我在 NfaState 类中额外定义的两个属性 Nfa 和 Index 单纯是为了方便状态的使用。$\epsilon$ 转移直接被定义为一个列表,而字符转移则被定义为两个属性:CharClassTarget 和 CharClassTransition,CharClassTarget 表示目标状态,CharClassTransition 表示字符类,字符类会在下面详细解释。

NfaState 类中还定义了三个 Add 方法,分别是用来添加单个字符的转移、字符类的转移和 $\epsilon$ 转移的。

二、从正则表达式构造 NFA

这里使用的递归算法是 McMaughton-Yamada-Thompson 算法(或者叫做 Thompson 构造法),它比 Glushkov 构造法更加简单易懂。

2.1 基本规则

    对于正则表达式 $\epsilon$,构造如图 2(a) 的 NFA。对于包含单个字符 $a$ 的正则表达式 $\bf{a}$,构造如图 2(b) 的 NFA。

图 2 基本规则

上面的第一个基本规则在这里其实是用不到的,因为在正则表达式的定义中,并没有定义 $\epsilon$。第二个规则则在表示字符类的正则表达式 CharClassExp 类中使用,代码如下:


代码如下:

void BuildNfa(Nfa nfa) {
     nfa.HeadState = nfa.NewState();
     nfa.TailState = nfa.NewState();
     // 添加一个字符类转移。
     nfa.HeadState.Add(nfa.TailState, charClass);
 }

2.2 归纳规则
有了上面的两个基本规则,下面介绍的归纳规则就可以构造出更复杂的 NFA。

假设正则表达式 s  和 t  的 NFA 分别为 N(s)  和 N(t) 。

1. 对于 r=s|t ,构造如图 3 的 NFA,添加一个新的首状态 H  和新的尾状态 T ,然后从 H  到 N(s)  和 N(t)  的首状态各有一个 ϵ  转移,从 H  到 N(s)  和 N(t)  的尾状态各有一个 ϵ  转移到新的尾状态 T 。很显然,到了 H  后,可以选择是匹配 N(s)  或者是 N(t) ,并最终一定到达 T 。

图 3 归纳规则 AlternationExp

这里必须要注意的是,$N(s)$ 和 $N(t)$ 中的状态不能够相互影响,也不能存在任何转移,否则可能会导致识别的结果不是预期的。

AlternationExp 类中的代码如下:


代码如下:

void BuildNfa(Nfa nfa) {
     NfaState head = nfa.NewState();
     NfaState tail = nfa.NewState();
     left.BuildNfa(nfa);
     head.Add(nfa.HeadState);
     nfa.TailState.Add(tail);
     right.BuildNfa(nfa);
     head.Add(nfa.HeadState);
     nfa.TailState.Add(tail);
     nfa.HeadState = head;
     nfa.TailState = tail;
 }

2. 对于 $r=st$,构造如图 4 的 NFA,将 $N(s)$ 的首状态作为 $N(r)$ 的首状态,$N(t)$ 的尾状态作为 $N(r)$ 的尾状态,并在 $N(s)$ 的尾状态和 $N(t)$ 的首状态间添加一条 $\epsilon$ 转移。

图 4 归纳规则 ConcatenationExp

ConcatenationExp 类中的代码如下:


代码如下:

void BuildNfa(Nfa nfa) {
     left.BuildNfa(nfa);
     NfaState head = nfa.HeadState;
     NfaState tail = nfa.TailState;
     right.BuildNfa(nfa);
     tail.Add(nfa.HeadState);
     nfa.HeadState = head;
 }

LiteralExp 也可以看成是多个 CharClassExp 连接而成,所以可以多次应用这个规则来构造相应的 NFA。

3. 对于 $r=s*$,构造如图 5 的 NFA,添加一个新的首状态 $H$ 和新的尾状态 $T$,然后添加四条 $\epsilon$ 转移。不过这里的正则表达式定义中,并没有显式定义 $r*$,因此下面给出 RepeatExp 对应的规则。

图 5 归纳规则 s*

4. 对于 $r=s\{m,n\}$,构造如图 6 的 NFA,添加一个新的首状态 $H$ 和新的尾状态 $T$,然后创建 $n$ 个 $N(s)$ 并连接起来,并从第 $m - 1$ 个 $N(s)$ 开始,都添加一条尾状态到 $T$ 的 $\epsilon$ 转移(如果 $m=0$,就添加从 $H$ 到 $T$ 的 $\epsilon$ 转移)。这样就保证了至少会经过 $m$ 个 $N(s)$,至多会经过 $n$ 个 $N(s)$。

图 6 归纳规则 RepeatExp

不过如果 $n = \infty$,就需要构造如图 7 的 NFA,这时只需要创建 $m$ 个 $N(s)$,并在最后一个 $N(s)$ 的首尾状态之间添加一个类似于 $s*$ 的 $\epsilon$ 转移,就可以实现无上限的匹配了。如果此时再有 $m=0$,情况就与 $s*$ 相同了。

图 7 归纳规则 RepeatExp $n = \infty$

综合上面的两个规则,得到了 RepeatExp 类的构造方法:


代码如下:

void BuildNfa(Nfa nfa) {
     NfaState head = nfa.NewState();
     NfaState tail = nfa.NewState();
     NfaState lastHead = head;
     // 如果没有上限,则需要特殊处理。
     int times = maxTimes == int.MaxValue ? minTimes : maxTimes;
     if (times == 0) {
         // 至少要构造一次。
         times = 1;
     }
     for (int i = 0; i < times; i++) {
         innerExp.BuildNfa(nfa);
         lastHead.Add(nfa.HeadState);
         if (i >= minTimes) {
             // 添加到最终的尾状态的转移。
             lastHead.Add(tail);
         }
         lastHead = nfa.TailState;
     }
     // 为最后一个节点添加转移。
     lastHead.Add(tail);
     // 无上限的情况。
     if (maxTimes == int.MaxValue) {
         // 在尾部添加一个无限循环。
         nfa.TailState.Add(nfa.HeadState);
     }
     nfa.HeadState = head;
     nfa.TailState = tail;
 }

5. 对于 $r=s/t$ 这种向前看符号,情况要特殊一些,这里仅仅是将 $N(s)$ 和 $N(t)$ 连接起来(同规则 2)。因为匹配向前看符号时,如果 $t$ 匹配成功,那么需要进行回溯,来找到 $s$ 的结尾(这才是真正匹配的内容),所以需要将 $N(s)$ 的尾状态标记为 TrailingHead 类型,并将 $N(T)$ 的尾状态标记为 Trailing 类型。标记之后的处理,会在下节转换为 DFA 时说明。

2.3 正则表达式构造 NFA 的示例

这里给出一个例子,来直观的看到一个正则表达式 (a|b)*baa 是如何构造出对应的 NFA 的,下面详细的列出了每一个步骤。

图 8 正则表达式 (a|b)*baa 构造 NFA 示例

最后得到的 NFA 就如上图所示,总共需要 14 个状态,在 NFA 中可以很明显的区分出正则表达式的每个部分。这里构造的 NFA 并不是最简的,因此与上一节《C# 词法分析器(三)正则表达式》中的 NFA 不同。不过 NFA 只是为了构造 DFA 的必要存在,不用费工夫化简它。

三、划分字符类

现在虽然得到了 NFA,但这个 NFA 还是有些细节问题需要处理。例如,对于正则表达式 [a-z]z,构造得到的 NFA 应该是什么样的?因为一条转移只能对应一个字符,所以一个可能的情形如图 9 所示。

图 9 [a-z]z 构造的 NFA

前两个状态间总共需要 26 个转移,后两个状态间需要 1 个转移。如果正则表达式的字符范围再广些呢,比如 Unicode 范围?添加 6 万多条转移,显然无论是时间还是空间都是不能承受的。所以,就需要利用字符类来减少需要的转移个数。

字符类指的是字符的等价类,意思是一个字符类对应的所有字符,它们的状态转移完全是相同的。或者说,对自动机来说,完全没有必要区分一个字符类中的字符——因为它们总是指向相同的状态。

就像上面的正则表达式 [a-z]z 来说,字符 a-y 完全没有必要区分,因为它们总是指向相同的状态。而字符 z 需要单独拿出来作为一个字符类,因为在状态 1 和 2 之间的转移使得字符 z 和其它字符区分开来了。因此,现在就得到了两个字符类,第一个字符类对应字符 a-y,第二个字符类对应字符 z,现在得到的 NFA 如图 10 所示。

图 10 [a-z]z 使用字符类构造的 NFA

使用字符类之后,需要的转移个数一下就降到了 3 个,所以在处理比较大的字母表时,字符类是必须的,它即能加快处理速度,又能降低内存消耗。

而字符类的划分,就是将 Unicode 字符划分到不同的字符类中的过程。我目前采用的算法是一个在线算法,即每当添加一个新的转移时,就会检查当前的字符类,判断是否需要对现有字符类进行划分,同时得到转移对应的字符类。字符类的表示是使用一个 ISet<int>,因为一个转移可能对应于多个字符类。

初始:字符类只有一个,表示整个 Unicode 范围
输入:新添加的转移 $t$
输出:新添加的转移对应的字符类 $cc_t$
for each (每个现有的字符类 $CC$) {
  $cc_1 = \left\{ c|c \in t\& c \in CC \right\}$
  if ($cc_1= \emptyset$) { continue; }
  $cc_2 = \left\{ c|c \in CC\& c \notin t \right\}$
  将 $CC$ 划分为 $cc_1$ 和 $cc_2$
  $cc_t = cc_1 \cup cc_t$
  $t = \left\{ c|c \in t\& c \notin CC \right\}$
  if ($t = \emptyset$) { break; }
}

这里需要注意的是,每当一个现有的字符类 $CC$ 被划分为两个子字符类 $cc_1$ 和 $cc_2$,之前的所有包含 $CC$ 的转移对应的字符类都需要更新为 $cc_1$ 和 $cc_2$,以包含新添加的子字符类。

我在 CharClass 类中实现了该算法,其中充分利用了 CharSet 类集合操作效率高的特点。


代码如下:

View Code
 HashSet<int> GetCharClass(string charClass) {
     int cnt = charClassList.Count;
     HashSet<int> result = new HashSet<int>();
     CharSet set = GetCharClassSet(charClass);
     if (set.Count == 0) {
         // 不包含任何字符类。
         return result;
     }
     CharSet setClone = new CharSet(set);
     for (int i = 0; i < cnt && set.Count > 0; i++) {
         CharSet cc = charClassList[i];
         set.ExceptWith(cc);
         if (set.Count == setClone.Count) {
             // 当前字符类与 set 没有重叠。
             continue;
         }
         // 得到当前字符类与 set 重叠的部分。
         setClone.ExceptWith(set);
         if (setClone.Count == cc.Count) {
             // 完全被当前字符类包含,直接添加。
             result.Add(i);
         } else {
             // 从当前的字符类中剔除被分割的部分。
             cc.ExceptWith(setClone);
             // 更新字符类。
             int newCC = charClassList.Count;
             result.Add(newCC);
             charClassList.Add(setClone);
             // 更新旧的字符类......
         }
         // 重新复制 set。
         setClone = new CharSet(set);
     }
     return result;
 }

四、多条正则表达式、限定符和上下文

通过上面的算法,已经可以实现将单个正则表达式转换为相应的 NFA 了,如果有多条正则表达式,也非常简单,只要如图 11 那样添加一个新的首节点,和多条到每个正则表达式的首状态的 $\epsilon$ 转移。最后得到的 NFA 具有一个起始状态和 $n$ 个接受状态。

图 11 多条正则表达式的 NFA

对于行尾限定符,可以直接看成预定义的向前看符号,r\$ 可以看成 r/\n 或 r/\r?\n(这样可以支持 Windows 换行和 Unix 换行),事实上也是这么做的。

对于行首限定符,仅当在行首时才会匹配这条正则表达式,可以考虑把这样的正则表达式单独拿出来——当从行首开始匹配时,就使用行首限定的正则表达式进行匹配;从其它位置开始匹配时,就使用其它的正则表达式进行匹配。

当然,即使是从行首开始匹配,非行首限定的正则表达式也是可以匹配的,所以就将所有正则表达式分为两个集合,一个包含所有的正则表达式,用于从行首匹配是使用;另一个只包含非行首限定的正则表达式,用于从其它位置开始匹配时使用。然后,再为这两个集合分别构造出相应的 NFA。

对于我的词法分析器,还会支持上下文。可以为每个正则表达式指定一个或多个上下文,这个正则表达式就会只在给定的上下文环境中生效。利用上下文机制,就可以更精细的控制字符串的匹配情况,还可能构造出更强大的词法分析器,例如可以在匹配字符串的同时处理字符串内的转义字符。

上下文的实现与上面行首限定符的思想相同,就是为将每个上下文对应的正则表达式分为一组,并分别构造 NFA。如果某个正则表达式属于多个上下文,就会将它复制并分到多个组中。

假设现在定义了 $N$ 个上下文,那么加上行首限定符,总共需要将正则表达式分为 $2N$ 个集合,并为每个集合分别构造 NFA。这样不可避免的会有一些内存浪费,但字符串匹配速度会非常快,而且可以通过压缩的办法一定程度上减少内存的浪费。如果通过为每个状态维护特定的信息来实现上下文和行首限定符的话,虽然 NFA 变小了,但存储每个状态的信息也会消耗额外的内存,在匹配时还会出现很多回溯的情况(回溯是性能杀手),效果可能并不好。

虽然需要构造 $2N$ 个 NFA,但其实只需要构造一个具有 $2N$ 个起始状态的 NFA 即可,每个起始状态对应于一个上下文的(非)行首限定正则表达式集合,这样做是为了保证这 $2N$ 个 NFA 使用的字符类是同一个,否则后面处理起来会非常麻烦。

现在,正则表达式对应的 NFA 就构造好了,下一篇文章中,我就会介绍如何将 NFA 转换为等价的 DFA。

(0)

相关推荐

  • java使用Nagao算法实现新词发现、热门词的挖掘

    采用Nagao算法统计各个子字符串的频次,然后基于这些频次统计每个字符串的词频.左右邻个数.左右熵.交互信息(内部凝聚度). 名词解释: Nagao算法:一种快速的统计文本里所有子字符串频次的算法.详细算法可见http://www.doc88.com/p-664123446503.html   词频:该字符串在文档中出现的次数.出现次数越多越重要.   左右邻个数:文档中该字符串的左边和右边出现的不同的字的个数.左右邻越多,说明字符串成词概率越高.   左右熵:文档中该字符串的左边和右边出现的不

  • JavaEE Filter敏感词过滤的方法实例详解

    我们在聊天的时候的或者留言的时候,有部分词是不允许发表出来.我们可以采用过滤器实现这个功能. 我们只是简单利用过滤器实现这个过滤的功能,有些地方没写的很全 前台代码: <body> <form action="<c:url value='/WordServlet'/>" method="post"> 姓名:<input type="text" name="name"/><b

  • 利用Java实现简单的词法分析器实例代码

    首先看下我们要分析的代码段如下: 输出结果如下: 输出结果(a).PNG 输出结果(b).PNG 输出结果(c).PNG 括号里是一个二元式:(单词类别编码,单词位置编号) 代码如下: package Yue.LexicalAnalyzer; import java.io.*; /* * 主程序 */ public class Main { public static void main(String[] args) throws IOException { Lexer lexer = new

  • java 字符串词频统计实例代码

    复制代码 代码如下: package com.gpdi.action; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; public class WordsStatistics { class Obj {         int count ;         Obj(int count)

  • Python的词法分析与语法分析

    词法分析(Lexical Analysis):分析由字符组成的单词是否合法,如果没有问题的话,则产生一个单词流. 语法分析(Syntactic Analysis):分析由单词组成的句子是否合法,如果没有问题的话,则产生一个语法树. 在词法分析器分析源代码文本的时候,有一个概念需要明确: 1.物理行:由回车字符序列(在Windows上是CR LF,在Unix上是LF)结尾的字符序列组成一个物理行. 2.逻辑行:由一个或者多个物理行组成,可以明确地使用反斜杠(\)来连接多个物理行使之成为一个逻辑行:

  • C#词法分析器之词法分析的使用详解

    虽然文章的标题是词法分析,但首先还是要从编译原理说开来.编译原理应该很多人都听说过,虽然不一定会有多么了解. 简单的说,编译原理就是研究如何进行编译--也就如何从代码(*.cs 文件)转换为计算机可以执行的程序(*.exe 文件).当然也有些语言如 JavaScript 是解释执行的,它的代码是直接被执行的,不需要生成可执行程序. 编译过程是很复杂的,它涉及到很多步骤,直接拿<编译原理>(Compilers: Principles, Techniques and Tools,红龙书)上的图来看

  • C#词法分析器之转换DFA详解

    在上一篇文章中,已经得到了与正则表达式等价的 NFA,本篇文章会说明如何从 NFA 转换为 DFA,以及对 DFA 和字符类进行化简. 一.DFA 的表示 DFA 的表示与 NFA 比较类似,不过要简单的多,只需要一个添加新状态的方法即可.Dfa 类的代码如下所示: 复制代码 代码如下: namespace Cyjb.Compiler.Lexer {     class Dfa {         // 在当前 DFA 中创建一个新状态.         DfaState NewState()

  • C#词法分析器之正则表达式的使用

    正则表达式是一种描述词素的重要表示方法.虽然正则表达式并不能表达出所有可能的模式(例如"由等数量的 a 和 b 组成的字符串"),但是它可以非常高效的描述处理词法单元时要用到的模式类型. 一.正则表达式的定义正则表达式可以由较小的正则表达式按照规则递归地构建.每个正则表达式 r  表示一个语言 L(r) ,而语言可以认为是一个字符串的集合.正则表达式有以下两个基本要素: 1.ϵ  是一个正则表达式, L(ϵ)=ϵ ,即该语言只包含空串(长度为 0 的字符串).2.如果 a  是一个字符

  • javascript 词法作用域和闭包分析说明

    复制代码 代码如下: var classA = function(){ this.prop1 = 1; } classA.prototype.func1 = function(){ var that = this, var1 = 2; function a(){ return function(){ alert(var1); alert(this.prop1); }.apply(that); }; a(); } var objA = new ClassA(); objA.func1(); 大家应

  • C#词法分析器之输入缓冲和代码定位的应用分析

    一.输入缓冲 在介绍如何进行词法分析之前,先来说说一个不怎么被提及的问题--怎么从源文件中读取字符流.为什么这个问题这么重要呢?是因为在词法分析中,对字符流是有要求的,它必须能够支持回退操作(就是将多个字符放回到流中,以后会再次被读取). 先来解释下为什么需要支持回退操作,举个简单的例子来说,现在要对两个模式进行匹配: 图 1 流的回退过程 上面是一个简单的匹配过程,仅为了展示回退过程,在后面实现 DFA 模拟器时会详细解释是如何匹配词素的. 现在来看看 C# 中与输入相关的类,有 Stream

随机推荐