系列導航html
- (一)詞法分析介紹
- (二)輸入緩衝和代碼定位
- (三)正則表達式
- (四)構造 NFA
- (五)轉換 DFA
- (六)構造詞法分析器
- (七)總結
在上一篇文章中,已經獲得了與正則表達式等價的 NFA,本篇文章會說明如何從 NFA 轉換爲 DFA,以及對 DFA 和字符類進行化簡。git
1、DFA 的表示
DFA 的表示與 NFA 比較相似,不過要簡單的多,只須要一個添加新狀態的方法便可。Dfa 類的代碼以下所示:github
1
2
3
4
5
6
|
namespace
Cyjb.Compilers.Lexers {
class
Dfa : IList<DfaState> {
// 在當前 DFA 中建立一個新狀態。
DfaState NewState() {}
}
}
|
DFA 的狀態也比較簡單,必要的屬性只有兩個:符號索引和狀態轉移。正則表達式
符號索引表示當前的接受狀態對應的是哪一個正則表達式。不過 DFA 的一個狀態可能對應於 NFA 的多個狀態(詳見下面的子集構造法),因此 DFA 狀態的符號索引是一個數組。對於普通狀態,符號索引是空數組。算法
狀態轉移表示如何從當前狀態轉移到下一狀態,因爲在構造 NFA 時已經劃分好了字符類,因此在 DFA 中直接使用數組記錄下不一樣字符類對應的轉移(DFA 中是不存在 ϵϵ 轉移的,並且對每一個字符類有且只有一條轉移)。數組
在 NFA 的狀態定義中,還有一個狀態類型屬性,可是在 DFA 狀態中卻沒有這個屬性,是由於 Trailing 類型的狀態會在 DFA 匹配字符串的時候處理(會在下篇文章中說明),TrailingHead 類型的狀態會在構造 DFA 的時候與 Normal 類型的狀態合併(詳見 2.4 節)。函數
下面是 DfaState 類的定義:post
1
2
3
4
5
6
7
8
9
10
11
12
|
namespace
Cyjb.Compilers.Lexers {
class
DfaState {
// 獲取包含當前狀態的 DFA。
Dfa Dfa {
get
;
private
set
; }
// 獲取或設置當前狀態的索引。
int
Index {
get
;
set
; }
// 獲取或設置當前狀態的符號索引。
int
[] SymbolIndex {
get
;
set
; }
// 獲取或設置特定字符類轉移到的狀態。
DfaState
this
[
int
charClass] {
get
;
set
; }
}
}
|
DFA 的狀態中額外定義的兩個屬性 Dfa 和 Index 一樣是爲了方便狀態的使用。this
2、NFA 轉換爲 DFA
2.1 子集構造法
將 NFA 轉換爲 DFA,採用的是子集構造(subset construction)算法。該算法的過程與《C# 詞法分析器(三)正則表達式》的 3.1 節中提到的 NFA 匹配過程比較類似。在 NFA 的匹配過程當中,使用的都是 NFA 的一個狀態集合,那麼子集構造法就是用 DFA 的一個狀態來對應 NFA 的一個狀態集合,即 DFA 讀入輸入字符串 a1a2⋯ana1a2⋯an 以後到達的狀態,就對應於 NFA 讀入一樣的字符串 a1a2⋯ana1a2⋯an 以後到達的狀態的集合。atom
子集構造算法須要用到的操做有:
操做 | 描述 |
ϵ-closure(s)ϵ-closure(s) | 可以從 NFA 的狀態 ss 開始,只經過 ϵϵ 轉移可以到達的 NFA 狀態集合 |
ϵ-closure(T)ϵ-closure(T) | 可以從 TT 中某個 NFA 狀態 ss開始,只經過 ϵϵ 轉移可以到達的 NFA 狀態集合,即 ∪s∈Tϵ-closure(s)∪s∈Tϵ-closure(s) |
move(T,a)move(T,a) | 可以從 TT 中某個狀態 ss 出發,經過標號爲 aa 的轉移到達的 NFA 狀態集合 |
咱們須要找到的是當一個 NFA NN 讀入了某個輸入串後,可能位於的全部狀態集合。
首先,在讀入第一個字符以前,NN 能夠位於 ϵ-closure(s0)ϵ-closure(s0) 中的任何狀態,其中 s0s0 是 NN 的開始狀態。那麼,此時 ϵ-closure(s0)ϵ-closure(s0) 就表示 DFA 的開始狀態。
假設 NN 在讀入輸入串 xx 以後能夠位於集合 TT 中的狀態上,下一個輸入字符是 aa,那麼 NN 能夠當即移動到 move(T,a)move(T,a) 中的任何狀態,而且還能夠經過 ϵϵ 轉移來移動到 ϵ-closure(move(T,a))ϵ-closure(move(T,a)) 中的任何狀態上。這樣的每一個不一樣的 ϵ-closure(move(T,a))ϵ-closure(move(T,a)) 就表示了一個 DFA 的狀態。若是這個說明難以理解,能夠參考後面給出的示例。
據此,能夠獲得如下的算法(算法中的 T[a]=UT[a]=U 表示在狀態 TT 中的字符類 aa 上存在到狀態 UU 的轉移):
輸入:一個 NFA NN 輸出:與 NFA 等價的 DFA DD 一開始,ϵ-closure(s0)ϵ-closure(s0) 是 DD 中的惟一狀態,且未被標記 while (在 DD 中存在未被標記的狀態 TT) { 爲 TT 加上標記 foreach (每一個字符類 aa) { U=ϵ-closure(move(T,a))U=ϵ-closure(move(T,a)) if (UU 不在 DD 中) { 將 UU 加入 DD 中,且未被標記 } T[a]=UT[a]=U } }
若是某個 NFA 是終結狀態,那麼全部包含它的 DFA 狀態也是終結狀態,並且 DFA 狀態的符號索引就包含 NFA 狀態對應的符號索引。一個 DFA 狀態可能對應於多個 NFA 狀態,因此上面定義 DfaState 時,符號索引是一個數組。
計算 ϵ-closure(T)ϵ-closure(T) 的過程就是從一個狀態集合開始的簡單圖搜索過程,使用 DFS 便可實現,具體的算法以下(ϵ-closure(s)ϵ-closure(s) 的算法也同理,等價於 ϵ-closure({s})ϵ-closure({s})):
輸入:NFA 的狀態集合 TT 輸出:ϵ-closure(T)ϵ-closure(T) 將 TT 的全部狀態壓入堆棧 ϵ-closure(T)=Tϵ-closure(T)=T while (堆棧非空) { 彈出棧頂元素 tt foreach (uu : tt 能夠經過 ϵϵ 轉移到達 uu) { if (u∉ϵ-closure(T)u∉ϵ-closure(T)) { ϵ-closure(T)=ϵ-closure(T)∪{u}ϵ-closure(T)=ϵ-closure(T)∪{u} 將 uu 壓入堆棧 } } }
計算 move(T,a)move(T,a) 的算法更加簡單,只有一個循環:
輸入:NFA 的狀態集合 TT 輸出:move(T,a)move(T,a) move(T,a)=∅move(T,a)=∅ foreach (u∈Tu∈T) { if (uu 存在字符類 aa 上的轉移,目標爲 tt) { move(T,a)=move(T,a)∪{t}move(T,a)=move(T,a)∪{t} } }
2.2 子集構造法的示例
這裏以上一節中從正則表達式 (a|b)*baa 構造獲得的 NFA 做爲示例,將它轉化爲 DFA。這裏的輸入字母表 Σ={a,b}Σ={a,b}。
圖 1 正則表達式 (a|b)*baa 的 NFA
圖 2 構造 DFA 的示例
圖 3 最終獲得的 DFA
2.3 多個首狀態的子集構造法
上一節中構造獲得的 NFA 是具備多個開始狀態的(爲了支持上下文和行首限定符),不過對子集構造法並不會產生影響,由於子集構造法是從開始狀態開始,沿着 NFA 的轉移不斷構造相應的 DFA 狀態,只要對多個開始狀態分別調用本身構造法就能夠正確構造出多個 DFA,並且沒必要擔憂 DFA 之間的相互影響。爲了方便起見,這多個 DFA 仍然保存在一個 DFA 中,只不過仍是使用起始狀態來進行區分。
2.4 DFA 狀態的符號索引
一個 DFA 狀態對應 NFA 的一個狀態集合,那麼直接將這多個 NFA 狀態的符號索引全都拿來就能夠了。不過前面說到, TrailingHead 類型的 NFA 狀態會在構造 DFA 的時候與 Normal 類型的 NFA 狀態合併,這個合併指的就是符號索引的合併。
這個合併的方法也很簡單,Normal 類型的狀態直接將符號索引拿來,TrailingHead 類型的狀態,則將 int.MaxValue - SymbolIndex 的值做爲 DFA 狀態的符號索引,這樣兩種類型的狀態就能夠區分出來(因爲定義的符號數不會太多,因此沒必要擔憂出現重複或者負值)。
最後,再對 DFA 狀態的符號索引從小到大進行排序。這樣就會使 Normal 類型狀態的符號索引老是排在 TrailingHead 類型狀態的符號索引的前面,在後面進行詞法分析時可以更容易處理,效率也會有略微的提高。
2.5 子集構造法的實現
子集構造法的 C# 實現與上面給出的僞代碼基本一致,不過這裏有個問題須要解決,就是如何高效的從 NFA 的狀態集合獲得相應的 DFA 狀態。因爲 NFA 狀態集合是採用 HashSet<NfaState> 來保存的,因此我直接利用 Dictionary<HashSet<NfaState>, DfaState> 來解決這個問題,這裏須要採用自定義的弱哈希函數,使得集合對應的哈希值只與集合中的元素相關,而與元素順序無關。
下面就是定義在 Nfa 類中的方法:
在這個實現中,將 DFA 的起始狀態的符號索引設爲了空數組,這樣會使得空字符串 ϵϵ 不會被匹配(其它匹配不會受到影響),即 DFA 至少會匹配一個字符。這樣的作法在詞法分析中是有意義的,由於詞素不能是空字符串。
2.6 DFA 中的死狀態
嚴格說來,由以上的算法獲得的 DFA 可能並非一個 DFA,由於 DFA 要求每一個狀態在每一個字符類上有且只有一個轉移。而上面的算法生成的 DFA,在某些字符類上可能並無的轉移,由於在算法中,若是這個轉移對應的 NFA 狀態集合是空集,則無視這個轉移。若是是嚴格的 DFA 的話,這時應該添加一個到死狀態 ∅∅ 的轉移(死狀態在全部字符類上的轉移都到達其自身)。
可是在詞法分析中,須要知道何時已經不存在被這個 DFA 接受的可能性了,這樣纔可以知道是否已經匹配到了正確的詞素。所以,在詞法分析中,到達死狀態的轉移將被消除,若是沒有找到某個輸入符號上的轉換,就認爲這時候已經匹配到了正確的詞素(最後一個終結狀態對應的詞素)。
3、DFA 的化簡
3.1 DFA 最小化
上面雖然構造出了一個可用的 DFA,但它可能並非最優的,例以下面的兩個等價的 DFA,識別的都是正則表達式 (a|b)*baa,但具備不一樣的狀態數。
圖 4 兩個等價的 DFA
顯然,狀態數越少的 DFA,匹配時的效率越高,因此須要使用一些算法,來將 DFA 的狀態數最小化,即 DFA 的化簡。
化簡 DFA 的思想是尋找等價狀態——它們都(不)是接受狀態,並且對於任意的輸入,老是轉移到等價的狀態。找到全部等價的狀態後,就能夠將它們合併爲一個狀態,實現 DFA 狀態數的最小化。
尋找等價狀態通常有兩種方法:分割法和合並法。
- 分割法是先將全部接受狀態和全部非接受狀態看做兩個等價狀態集合,而後從裏面分割出不等價的狀態子集,直到剩下的全部等價狀態集合都不可再分。
- 合併法是先將全部狀態看做不等價的,而後從裏面找到兩個(或多個)等價的狀態,併合併爲一個狀態。
兩種方法均可以實現 DFA 的化簡,可是合併法比較複雜,所以這裏我使用分割法來對 DFA 進行化簡。
DFA 最小化的算法以下:
輸入:一個 DFA DD 輸出:與 DD 等價的最簡 DFA D′D′ 構造 DD 的初始劃分 ΠΠ,初始劃分包含兩個組:接受狀態組和非接受狀態組 while (true) { foreach (組 G∈ΠG∈Π) { 將 GG 劃分爲更小的組,使得兩個狀態 ss 和 tt 在同一組中當且僅當對於全部輸入符號,ss 和 tt 的轉移都到達 ΠΠ 中的同一組 } 將新劃分的組保存到 ΠnewΠnew 中 if (Πnew≠ΠΠnew≠Π) { Π=ΠnewΠ=Πnew } else { Πfinal=ΠΠfinal=Π break; } } 在 ΠfinalΠfinal 中的每一個組中都選取一個狀態做爲該組的表明,這些表明就構成了 D′D′ 的狀態。 D′D′ 的開始狀態是包含了 DD 的開始狀態的組的表明。 D′D′ 的接受狀態是包含了 DD 的接受狀態的組的表明。 令 ss 是 ΠfinalΠfinal 中某個組 GG 中的狀態(不是表明),那麼將 D′D′ 中到 ss 的轉移,都更改成到 GG 的表明的轉移。
由於接受狀態和非接受狀態在最開始就被劃分開了,因此不會存在某個組即包含接受狀態,又包含非接受狀態。
在實際的實現中,須要注意的是因爲一個 DFA 狀態可能對應多個不一樣的終結符,所以在劃分初始狀態時,對應的終結符不一樣的終結狀態也要被劃分到不一樣的組中。
3.2 DFA 最小化的示例
下面以圖 4(a) 爲例,給出 DFA 最小化的示例。
初始的劃分包括兩個組 {A,B,C,D}{A,B,C,D} 和 {E}{E},分別是非接受狀態組和接受狀態組。
第一次分割,在 {A,B,C,D}{A,B,C,D} 組中,對於字符 a,狀態 A,B,CA,B,C 都轉移到組內的狀態,而狀態 DD 轉移到組 {E}{E} 中,因此狀態 DD 須要被劃分出來。對於字符 b,全部狀態都轉移到該組內的狀態,不能區分;{E}{E} 組中,只含有一個狀態,無需進一步劃分。這一輪 Πnew={{A,B,C},{D},{E}}Πnew={{A,B,C},{D},{E}}。
第二次分割,在 {A,B,C}{A,B,C} 組中,對於字符 a,狀態 A,BA,B 都轉移到組內的狀態,而狀態 CC 轉移到組 {D}{D} 中,對於字符 b 則不能區分;組 {D}{D} 和組 {E}{E} 一樣不作劃分。這一輪 Πnew={{A,B},{C},{D},{E}}Πnew={{A,B},{C},{D},{E}}。
第三次分割,惟一可能被分割的組 {A,B}{A,B},對於字符 a 和字符 b,都會轉移到相同的組內,因此不會被分割。所以就獲得 Πfinal={{A,B},{C},{D},{E}}Πfinal={{A,B},{C},{D},{E}}。
最後,構造出最小化的 DFA,它有四個狀態,對應於 ΠfinalΠfinal 的四個分組。分別挑選 A,C,D,EA,C,D,E 做爲每一個分組的表明,其中,AA 是開始狀態,EE 是接受狀態。將全部狀態到 BB 的轉移都修改成到 AA 的轉移,最後獲得的 DFA 轉換表爲:
DFA 狀態 | a 上的轉移 | b 上的轉移 |
A | A | C |
C | D | C |
D | E | C |
E | A | C |
最後再將狀態從新排序,獲得的就是如圖 4(b) 所示的 DFA 了。
3.3 字符類最小化
在 DFA 最小化以後,還要將字符類也最小化,由於 DFA 的最小化過程會合並等價狀態,這時可能會使得某些字符類變得等價,如圖 5 所示。
圖 5 等價的字符類
等價字符類的尋找比等價狀態更簡單些,先將化簡後的 DFA 用表格的形式寫出來,以圖 5 中的 DFA 爲例:
DFA 狀態 | a 上的轉移 | b 上的轉移 | c 上的轉移 |
A | B | B | ∅∅ |
B | B | B | C |
C | ∅∅ | ∅∅ | ∅∅ |
表格中的第一列是 DFA 的狀態,後面的三列分別表明不一樣字符類上的轉移。表格的第二行到第四行分別對應着 A、B、C 三個狀態的轉移。那麼,若是在這個表格中某兩列徹底相同,那麼對應的字符類就是等價的。
化簡 DFA 和字符類的實現代碼比較多,這裏就不貼了,請參見 Dfa 類。
最後化簡獲得的 DFA,通常是用轉移表的形式保存(即上面的表格形式),使用下面三個數組就能夠完整表示出 DFA 了。
1
2
3
|
int
[] CharClass;
int
[,] Transitions;
int
[][] SymbolIndex;
|
其中,CharClass 是字符類的映射表,它是長爲 65536 的數組,用於將字符映射爲相應的字符類;Transitions 是 DFA 的轉移表,行數等於 DFA 中的狀態數,列數爲字符類的個數;SymbolIndex 則是每一個狀態對應的符號索引。而在實際的代碼中,爲了兼顧可用性,表示與此略有不一樣。
固然也能夠對 DFA 的轉移表和符號索引進行壓縮以節約內存,不過這個留在之後再說。
下一篇就會介紹如何以 DFA 爲基礎,構造一個詞法分析器。
Dfa 的構造等方法在 LexerRule<T> 類中,其它相關代碼均可以在這裏找到,一些基礎類(如輸入緩衝)則在這裏。