C#詞法分析器之構造NFA詳解

來源:互聯網
上載者:User

有了上一節中得到的Regex,那麼就可以用來構造 NFA 了。NFA 可以很容易的從Regex轉換而來,也有助於理解Regex表示的模式。

一、NFA 的表示方法

在這裡,一個 NFA 至少具有兩個狀態:首狀態和尾狀態, 1 所示,Regex $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 的狀態中,必要的屬性只有三個:符號索引、狀態轉移和狀態類型。只有接受狀態的符號索引才有意義,它表示當前的接受狀態對應的是哪個Regex,對於其它狀態,都會被設為 -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$ 轉移的。

二、從Regex構造 NFA

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

2.1 基本規則

    對於Regex $\epsilon$,構造 2(a) 的 NFA。對於包含單個字元 $a$ 的Regex $\bf{a}$,構造 2(b) 的 NFA。

圖 2 基本規則

上面的第一個基本規則在這裡其實是用不到的,因為在Regex的定義中,並沒有定義 $\epsilon$。第二個規則則在表示字元類的Regex CharClassExp 類中使用,代碼如下:

複製代碼 代碼如下:void BuildNfa(Nfa nfa) {
nfa.HeadState = nfa.NewState();
nfa.TailState = nfa.NewState();
// 添加一個字元類轉移。
nfa.HeadState.Add(nfa.TailState, charClass);
}

2.2 歸納規則
有了上面的兩個基本規則,下面介紹的歸納規則就可以構造出更複雜的 NFA。

假設Regex 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$ 轉移。不過這裡的Regex定義中,並沒有顯式定義 $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 Regex構造 NFA 的樣本

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

圖 8 Regex (a|b)*baa 構造 NFA 樣本

最後得到的 NFA 就如所示,總共需要 14 個狀態,在 NFA 中可以很明顯的區分出Regex的每個部分。這裡構造的 NFA 並不是最簡的,因此與上一節《C# 詞法分析器(三)Regex》中的 NFA 不同。不過 NFA 只是為了構造 DFA 的必要存在,不用費工夫化簡它。

三、劃分字元類

現在雖然得到了 NFA,但這個 NFA 還是有些細節問題需要處理。例如,對於Regex [a-z]z,構造得到的 NFA 應該是什麼樣的?因為一條轉移只能對應一個字元,所以一個可能的情形 9 所示。

圖 9 [a-z]z 構造的 NFA

前兩個狀態間總共需要 26 個轉移,後兩個狀態間需要 1 個轉移。如果Regex的字元範圍再廣些呢,比如 Unicode 範圍?添加 6 萬多條轉移,顯然無論是時間還是空間都是不能承受的。所以,就需要利用字元類來減少需要的轉移個數。

字元類指的是字元的等價類別,意思是一個字元類對應的所有字元,它們的狀態轉移完全是相同的。或者說,對自動機來說,完全沒有必要區分一個字元類中的字元——因為它們總是指向相同的狀態。

就像上面的Regex [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;
}

四、多條Regex、限定符和上下文

通過上面的演算法,已經可以實現將單個Regex轉換為相應的 NFA 了,如果有多條Regex,也非常簡單,只要 11 那樣添加一個新的首節點,和多條到每個Regex的首狀態的 $\epsilon$ 轉移。最後得到的 NFA 具有一個起始狀態和 $n$ 個接受狀態。

圖 11 多條Regex的 NFA

對於行尾限定符,可以直接看成預定義的向前看符號,r\$ 可以看成 r/\n 或 r/\r?\n(這樣可以支援 Windows 換行和 Unix 換行),事實上也是這麼做的。

對於行首限定符,僅當在行首時才會匹配這條Regex,可以考慮把這樣的Regex單獨拿出來——當從行首開始匹配時,就使用行首限定的Regex進行匹配;從其它位置開始匹配時,就使用其它的Regex進行匹配。

當然,即使是從行首開始匹配,非行首限定的Regex也是可以匹配的,所以就將所有Regex分為兩個集合,一個包含所有的Regex,用於從行首匹配是使用;另一個只包含非行首限定的Regex,用於從其它位置開始匹配時使用。然後,再為這兩個集合分別構造出相應的 NFA。

對於我的詞法分析器,還會支援上下文。可以為每個Regex指定一個或多個上下文,這個Regex就會只在給定的上下文環境中生效。利用上下文機制,就可以更精細的控制字元串的匹配情況,還可能構造出更強大的詞法分析器,例如可以在匹配字串的同時處理字串內的逸出字元。

內容相關的實現與上面行首限定符的思想相同,就是為將每個上下文對應的Regex分為一組,並分別構造 NFA。如果某個Regex屬於多個上下文,就會將它複製並分到多個組中。

假設現在定義了 $N$ 個上下文,那麼加上行首限定符,總共需要將Regex分為 $2N$ 個集合,並為每個集合分別構造 NFA。這樣不可避免的會有一些記憶體浪費,但字串匹配速度會非常快,而且可以通過壓縮的辦法一定程度上減少記憶體的浪費。如果通過為每個狀態維護特定的資訊來實現上下文和行首限定符的話,雖然 NFA 變小了,但儲存每個狀態的資訊也會消耗額外的記憶體,在匹配時還會出現很多回溯的情況(回溯是效能殺手),效果可能並不好。

雖然需要構造 $2N$ 個 NFA,但其實只需要構造一個具有 $2N$ 個起始狀態的 NFA 即可,每個起始狀態對應於一個內容相關的(非)行首限定Regex集合,這樣做是為了保證這 $2N$ 個 NFA 使用的字元類是同一個,否則後面處理起來會非常麻煩。

現在,Regex對應的 NFA 就構造好了,下一篇文章中,我就會介紹如何將 NFA 轉換為等價的 DFA。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.