C# 詞法分析器(四)構造 NFA

系列導航html

  1. (一)詞法分析介紹
  2. (二)輸入緩衝和代碼定位
  3. (三)正則表達式
  4. (四)構造 NFA
  5. (五)轉換 DFA
  6. (六)構造詞法分析器
  7. (七)總結

有了上一節中獲得的正則表達式,那麼就能夠用來構造 NFA 了。NFA 能夠很容易的從正則表達式轉換而來,也有助於理解正則表達式表示的模式。git

1、NFA 的表示方法

在這裏,一個 NFA 至少具備兩個狀態:首狀態和尾狀態,如圖 1 所示,正則表達式 tt 對應的 NFA 是 N(t),它的首狀態是 HH,尾狀態是 TT。圖中僅僅畫出了首尾兩個狀態,其它的狀態和狀態間的轉移都沒有表示出來,這是由於在下面介紹的遞歸算法中,僅須要知道 NFA 的首尾狀態,其它的信息並不須要關心。github

圖 1 NFA 的表示正則表達式

我使用下面的 Nfa 類來表示一個 NFA,只包含首狀態、尾狀態和一個添加新狀態的方法。算法

1
2
3
4
5
6
7
8
9
10
namespace  Cyjb.Compilers.Lexers {
     class  Nfa : IList<NfaState> {
         // 獲取或設置 NFA 的首狀態。
         NfaState HeadState {  get set ; }
         // 獲取或設置 NFA 的尾狀態。
         NfaState TailState {  get set ; }
         // 在當前 NFA 中建立一個新狀態。
         NfaState NewState() {}
     }
}

NFA 的狀態中,必要的屬性只有三個:符號索引、狀態轉移和狀態類型。只有接受狀態的符號索引纔有意義,它表示當前的接受狀態對應的是哪一個正則表達式,對於其它狀態,都會被設爲 -1。數組

狀態轉移表示如何從當前狀態轉移到下一狀態,雖然 NFA 的定義中,每一個節點均可能包含多個 ϵϵ 轉移和多個字符轉移(就是邊上標有字符的轉移)。但在這裏,字符轉移至多有一個,這是由以後給出的 NFA 構造算法的特色所決定的。post

狀態類型則是爲了支持向前看符號而定義的,它多是 Normal、TrailingHead 和 Trailing 三個枚舉值之一,這個屬性將在處理向前看符號的部分詳細說明。性能

下面是 NfaState 類的定義:ui

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace  Cyjb.Compilers.Lexers {
     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 單純是爲了方便狀態的使用。ϵϵ 轉移直接被定義爲一個列表,而字符轉移則被定義爲兩個屬性:CharClassTarget 和 CharClassTransition,CharClassTarget 表示目標狀態,CharClassTransition 表示字符類,字符類會在下面詳細解釋。atom

NfaState 類中還定義了三個 Add 方法,分別是用來添加單個字符的轉移、字符類的轉移和 ϵϵ 轉移的。

2、從正則表達式構造 NFA

這裏使用的遞歸算法是 McMaughton-Yamada-Thompson 算法(或者叫作 Thompson 構造法),它比 Glushkov 構造法更加簡單易懂。

2.1 基本規則

  1. 對於正則表達式 ϵϵ,構造如圖 2(a) 的 NFA。
  2. 對於包含單個字符 aa 的正則表達式 aa,構造如圖 2(b) 的 NFA。

圖 2 基本規則

上面的第一個基本規則在這裏實際上是用不到的,由於在正則表達式的定義中,並無定義 ϵϵ。第二個規則則在表示字符類的正則表達式 CharClassExp 類中使用,代碼以下:

1
2
3
4
5
6
void  BuildNfa(Nfa nfa) {
     nfa.HeadState = nfa.NewState();
     nfa.TailState = nfa.NewState();
     // 添加一個字符類轉移。
     nfa.HeadState.Add(nfa.TailState, charClass);
}

2.2 概括規則

有了上面的兩個基本規則,下面介紹的概括規則就能夠構造出更復雜的 NFA。

假設正則表達式 ss 和 tt 的 NFA 分別爲 N(s)N(s) 和 N(t)N(t)。

1. 對於 r=s|tr=s|t,構造如圖 3 的 NFA,添加一個新的首狀態 HH 和新的尾狀態 TT,而後從 HH 到 N(s)N(s) 和 N(t)N(t) 的首狀態各有一個 ϵϵ 轉移,從 HH 到 N(s)N(s) 和 N(t)N(t) 的尾狀態各有一個 ϵϵ 轉移到新的尾狀態 TT。很顯然,到了 HH 後,能夠選擇是匹配 N(s)N(s) 或者是 N(t)N(t),並最終必定到達 TT。

圖 3 概括規則 AlternationExp

這裏必需要注意的是,N(s)N(s) 和 N(t)N(t) 中的狀態不可以相互影響,也不能存在任何轉移,不然可能會致使識別的結果不是預期的。

AlternationExp 類中的代碼以下:

1
2
3
4
5
6
7
8
9
10
11
12
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=str=st,構造如圖 4 的 NFA,將 N(s)N(s) 的首狀態做爲 N(r)N(r) 的首狀態,N(t)N(t) 的尾狀態做爲 N(r)N(r) 的尾狀態,並在 N(s)N(s) 的尾狀態和 N(t)N(t) 的首狀態間添加一條 ϵϵ 轉移。

圖 4 概括規則 ConcatenationExp

ConcatenationExp 類中的代碼以下:

1
2
3
4
5
6
7
8
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=sr=s∗,構造如圖 5 的 NFA,添加一個新的首狀態 HH 和新的尾狀態 TT,而後添加四條 ϵϵ 轉移。不過這裏的正則表達式定義中,並無顯式定義 rr∗,所以下面給出 RepeatExp 對應的規則。

圖 5 概括規則 s*

4. 對於 r=s{m,n}r=s{m,n},構造如圖 6 的 NFA,添加一個新的首狀態 HH 和新的尾狀態 TT,而後建立 nn 個 N(s)N(s) 並鏈接起來,並從第 m1m−1 個 N(s)N(s) 開始,都添加一條尾狀態到 TT 的 ϵϵ 轉移(若是 m=0m=0,就添加從 HH 到 TT 的 ϵϵ 轉移)。這樣就保證了至少會通過 mm 個 N(s)N(s),至多會通過 nn 個 N(s)N(s)。

圖 6 概括規則 RepeatExp

不過若是 n=n=∞,就須要構造如圖 7 的 NFA,這時只須要建立 mm 個 N(s)N(s),並在最後一個 N(s)N(s) 的首尾狀態之間添加一個相似於 ss∗ 的 ϵϵ 轉移,就能夠實現無上限的匹配了。若是此時再有 m=0m=0,狀況就與 ss∗ 相同了。

圖 7 概括規則 RepeatExp n=n=∞

綜合上面的兩個規則,獲得了 RepeatExp 類的構造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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/tr=s/t 這種向前看符號,狀況要特殊一些,這裏僅僅是將 N(s)N(s) 和 N(t)N(t) 鏈接起來(同規則 2)。由於匹配向前看符號時,若是 tt 匹配成功,那麼須要進行回溯,來找到 ss 的結尾(這纔是真正匹配的內容),因此須要將 N(s)N(s) 的尾狀態標記爲 TrailingHead 類型,並將 N(T)N(T) 的尾狀態標記爲 Trailing 類型。標記以後的處理,會在下節轉換爲 DFA 時說明。

2.3 正則表達式構造 NFA 的示例

這裏給出一個例子,來直觀的看到一個正則表達式 (a|b)*baa 是如何構造出對應的 NFA 的,下面詳細的列出了每個步驟。

圖 8 正則表達式 (a|b)*baa 構造 NFA 示例

最後獲得的 NFA 就如上圖所示,總共須要 14 個狀態,在 NFA 中能夠很明顯的區分出正則表達式的每一個部分。這裏構造的 NFA 並非最簡的,所以與上一節《C# 詞法分析器(三)正則表達式》中的 NFA 不一樣。不過 NFA 只是爲了構造 DFA 的必要存在,不用費工夫化簡它。

3、劃分字符類

如今雖然獲得了 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 範圍
輸入:新添加的轉移 tt 輸出:新添加的轉移對應的字符類 cctcct for each (每一個現有的字符類 CCCC) { cc1={c|ct&cCC}cc1={c|c∈t&c∈CC} if (cc1=cc1=∅) { continue; } cc2={c|cCC&ct}cc2={c|c∈CC&c∉t} 將 CCCC 劃分爲 cc1cc1 和 cc2cc2 cct=cc1cctcct=cc1∪cct t={c|ct&cCC}t={c|c∈t&c∉CC} if (t=t=∅) { break; } }

這裏須要注意的是,每當一個現有的字符類 CCCC 被劃分爲兩個子字符類 cc1cc1 和 cc2cc2,以前的全部包含 CCCC 的轉移對應的字符類都須要更新爲 cc1cc1 和 cc2cc2,以包含新添加的子字符類。

我在 CharClass 類中實現了該算法,其中充分利用了 CharSet 類集合操做效率高的特色。

4、多條正則表達式、限定符和上下文

經過上面的算法,已經能夠實現將單個正則表達式轉換爲相應的 NFA 了,若是有多條正則表達式,也很是簡單,只要如圖 11 那樣添加一個新的首節點,和多條到每一個正則表達式的首狀態的 ϵϵ 轉移。最後獲得的 NFA 具備一個起始狀態和 nn 個接受狀態。

圖 11 多條正則表達式的 NFA

對於行尾限定符,能夠直接當作預約義的向前看符號,r$ 能夠當作 r/\n 或 r/\r?\n(這樣能夠支持 Windows 換行和 Unix 換行),事實上也是這麼作的。

對於行首限定符,僅當在行首時纔會匹配這條正則表達式,能夠考慮把這樣的正則表達式單獨拿出來——當從行首開始匹配時,就使用行首限定的正則表達式進行匹配;從其它位置開始匹配時,就使用其它的正則表達式進行匹配。

固然,即便是從行首開始匹配,非行首限定的正則表達式也是能夠匹配的,因此就將全部正則表達式分爲兩個集合,一個包含全部的正則表達式,用於從行首匹配是使用;另外一個只包含非行首限定的正則表達式,用於從其它位置開始匹配時使用。而後,再爲這兩個集合分別構造出相應的 NFA。

對於個人詞法分析器,還會支持上下文。能夠爲每一個正則表達式指定一個或多個上下文,這個正則表達式就會只在給定的上下文環境中生效。利用上下文機制,就能夠更精細的控制字符串的匹配狀況,還可能構造出更強大的詞法分析器,例如能夠在匹配字符串的同時處理字符串內的轉義字符。

上下文的實現與上面行首限定符的思想相同,就是爲將每一個上下文對應的正則表達式分爲一組,並分別構造 NFA。若是某個正則表達式屬於多個上下文,就會將它複製並分到多個組中。

假設如今定義了 NN 個上下文,那麼加上行首限定符,總共須要將正則表達式分爲 2N2N 個集合,併爲每一個集合分別構造 NFA。這樣不可避免的會有一些內存浪費,但字符串匹配速度會很是快,並且能夠經過壓縮的辦法必定程度上減小內存的浪費。若是經過爲每一個狀態維護特定的信息來實現上下文和行首限定符的話,雖然 NFA 變小了,但存儲每一個狀態的信息也會消耗額外的內存,在匹配時還會出現不少回溯的狀況(回溯是性能殺手),效果可能並很差。

雖然須要構造 2N2N 個 NFA,但其實只須要構造一個具備 2N2N 個起始狀態的 NFA 便可,每一個起始狀態對應於一個上下文的(非)行首限定正則表達式集合,這樣作是爲了保證這 2N2N個 NFA 使用的字符類是同一個,不然後面處理起來會很是麻煩。

如今,正則表達式對應的 NFA 就構造好了,下一篇文章中,我就會介紹如何將 NFA 轉換爲等價的 DFA。

相關代碼均可以在這裏找到,一些基礎類(如輸入緩衝)則在這裏

相關文章
相關標籤/搜索