NFA/DFA算法

陳梓瀚 [email protected]  http://www.cppblog.com/vczh/

1、問題概述

隨着計算機語言的結構越來越複雜,爲了開發優秀的編譯器,人們已經漸漸感到將詞 法分析獨立出來做研究的重要性。不過詞法分析器的作用卻不限於此。回想一下我們的老師剛剛開始向我們講述程序設計的時候,總是會出一道題目:給出一個填入 了四則運算式子的字符串,寫程序計算該式子的結果。除此之外,我們有時候建立了比較複雜的配置文件,譬如XML的時候,分析器首先也要對該文件進行詞法分 析,把整個字符串斷成了一個一個比較短小的記號(指的是具有某種屬性的字符串),之後才進行結構上的分析。再者,在實現某種控制檯應用程序的時候,程序需 要分析用戶打進屏幕的命令。如果該命令足夠複雜的話,我們也首先要對這個命令進行詞法分析,之後得到的結果會大大方便進行接下去的工作。

當然,這些問題大部分已經得到了解決,而且歷史上也有人做出了各種各樣專門的或 者通用的工具(Lex、正則表達式引擎等)來解決這一類問題。我們在使用這種工具的時候,爲了更加高效地書寫配置,或者我們在某種特殊情況下需要自己製作 類似的工具,就需要了解詞法分析背後的原理。本文將給出一個構造通用詞法分析工具所需要的原理。由於實現的代碼過長,本文將不附帶實現。

究竟什麼是“把一個字符串斷成一些記號”呢?我們先從四則運算式子入手。一個四 則運算式子是一個字符數列,可是我們關心的對象實際上是操作符、括號和數字。於是此法分析的作用就是把一個字符串斷開成我們關心的帶有屬性的記號。舉個例 子:(11+22)*(33+44)是一個合法的四則運算式子,如果輸入是(左括號,”(“) (數字,”11”) (一級操作符,”+”) (數字,”22”) (右括號,”)”) (二級操作符,”*”) (左括號,”(“) (數字,”33”) (一級操作符,”+”) (數字,”44”) (右括號,”)”)的話,我們在檢查結構的時候只需要關心這個記號的屬性(也就是左括號、右括號、數字、操作符等)就行了,具體計算的時候才需要關心這個 記號實際上的內容。如果式子裏邊有空格的話,我們也僅僅需要把空格當成是一種記號類型,在詞法分析得出結果之後,將具有空格屬性的記號丟棄掉就可以了,接 下去的步驟不需變化。

但需要注意的是,詞法分析得到的結果是沒有層次結構的,所有的記號都是等價的對象。我們在計算表達式的時候把+和*看成了不同層次的操作符,類似的結構是具有嵌套的層次的。詞法分析不能得出嵌套層次結構的信息,最多隻能得到關於重複結構的信息。

2、正則表達式

我們現在需要尋找一種可以描述記號類型的工具,在此之前我們首先研究一下常見的記號的結構。爲了表示出具有某種共性的字符串的集合,我們需要書寫出一些能代表字符串集合的規則。這個集合中的所有成員都將被認爲是一種特定類型的記號。

首先,規則可以把一個特定的字符或者是空字符串認爲是一種類型的記號的全部。 上文所說到的四則運算式子的例子,“左括號”這種類型的記號就僅僅對應着字符”(“,其他的字符或者字符串都不可能是“左括號”這個類型的記號。

其次,規則可以進行串聯。 串聯的意思是這樣 的,我們可以讓一個字符串的前綴符合某一個指定的規則,剩下的部分的前綴符合第二個規則,剩下的部分的前綴符合第三個規則等等,一直到最後一個部分的全部 要符合最後一個規則。如果我們把”function”這個字符串作爲一個記號類型來處理的話,我們可以把”function”這個字符串替換成8個串聯的 規則:”f”,”u”,”n”,”c”,”t”,”i”,”o”,”n”。首先,字符串”function”的前綴”f”符合規則”f”,剩下的部 分”unction”的前綴”u”符合規則”u”,等等,一直到最後一個部分”n”的全部符合規則”n”。

第三,規則可以進行並聯。 並聯的意思就是, 如果一個字符串符合一系列規則中的其中一個的話,我們就說這個字符串符合這一些規則的並聯。於是這些規則的並聯就構成了一個新的規則。一個典型的例子就是 判斷一個字符串是否關鍵字。關鍵字可以是”if”,可以是”else”,可以是”while”等等。當然,一個關鍵字是不可能同時符合這些規則的,不過只 要一個字符串符合這些規則的其中一個的話,我們就說這個字符串是關鍵字。於是,關鍵字這個規則就是”if”、”else”、”while”等規則的並聯。

第四,一個規則可以是可選的。 可選的規則實 際上是屬於並聯的一種特殊形式。加入我們需要規則”abc”和”abcde”並聯,我們會發現這兩個規則有着相同的前綴”abc”,而且這個前綴恰好就是 其中的一個規則。於是我們可以把規則改寫成”abc”與””和”de”的並聯的串聯。但是規則””指定的規則是空串,因此這個規則與”de”的並聯就可以 看成是一個可選的規則”de”。

第五,規則可以被重複。 有限次的重複可以使 用串聯表示,但是如果我們不想限制重複的次數的話,串聯就沒法表示這個規則了,於是我們引入了“重複”。一個典型的例子就是程序設計語言的標識符。標識符 可以是一個變量的名字或者是其他東西。一門語言通常沒有規定變量名的最大長度。因此爲了表示這個規則,就需要將52個字母進行並聯,然後對這個規則進行重 復。

上述的5種構造規則的方法中,後面的4個方法被用於把規則組合成爲更大的規則。爲了給出這種規則的形式化表示,我們引入了一種範式。這種範式有以下語法:

1:字符用雙引號包圍起來,空串使用ε代替。

2:兩個規則頭尾連接代表規則的串聯。

3:兩個規則使用 | 隔開代表規則的並聯。

4:規則使用[]包圍代表該規則是可選的,規則使用{}包圍代表該規則是重複的。

5:規則使用()包圍代表該規則是一個整體,通常用於改變操作符 | 的優先級。

舉個例子,一個實數的規則書寫如下:

{“0”|”1”|”2”|”3”|”4”|”5”|”6”|”7”|”8”|”9”}”.”[{“0”|”1”|”2”|”3”|”4”|”5”|”6”|”7”|”8”|”9”}]。

但是,我們如何表示“不是數字的其他字符呢”?字符的數量是有限的,因此我們可 以使用規則的並聯來表示。但是所有的字符實在是太多(ASCII字符集有127個字符,UTF-16字符集有65535個字符),因此後來人們想出了各種 各樣的簡化規則書寫的辦法。比較著名的有BNF範式。BNF範式經常被用於理論研究,但是更加實用的是正則表達式。

正則表達式的字符不需要用雙引號括起來,但是如果需要表示一些被定義了的字符 (如 “|” )的話,就使用轉義字符的方法表示(如 “/|”)。其次,X?代表[X],X+代表{X},X*代表[{X}]。字符集合可以用區間來表示,[0-9]可以表示 “0”|”1”|”2”|”3”|”4”|”5”|”6”|”7”|”8”|”9”,[^0-9]則表示“除了數字以外的其他字符”。正則表達式還有各種 各樣的其他規則來簡化我們的書寫,不過由於本文並不是“精通正則表達式”,因此我們只保留若干比較本質的操作來進行詞法分析原理的描述。

正則表達式的表達能力極強,小數的規則可以使用[0-9]+.[0-9]來表示,C語言的註釋可以表示爲//*([^/*]|/*+[^/*/])*/*+/來表示。

3、有窮狀態自動機

人閱讀正則表達式會比較簡單,但是機器閱讀正則表達式就是一件非常困難的事情 了。而且,直接使用正則表達式進行匹配配的話,不僅工作量大,而且速度緩慢。因此我們還需要另外一種專門爲機器設計的表達方式。本文在以後的章節中會給出 一種算法把正則表達式轉換爲機器可以閱讀的形式,就是這一章節所描述的有窮狀態自動機。

有窮狀態自動機這個名字聽起來比較可怕,不過實際上這種自動機並沒有想象中的那 麼複雜。狀態機的這種概念被廣泛的應用在各種各樣的領域中。軟件工程的統一建模語言(UML)有狀態圖,數字邏輯中也有狀態轉移圖。不過這些各種各樣的圖 在本質上都跟狀態機沒有什麼區別。我將會通過一個例子來講述狀態的實際意義。

假設我們現在需要檢查一個字符串中a的數量和b的數量是否都是偶數。當然我們可 以用一個正則表達式來描述它。不過對於這個問題來說,用正則表達式來描述遠遠不如構造狀態機方便。我們可以設計出一個狀態的集合,然後指定集合中的某一個 元素爲“起始狀態”。其實狀態就是在工作還沒開始的時候,分析器所處的狀態。分析器在每一次進行一項新的工作的時候,都要把狀態重置爲起始狀態。分析器每 讀入一個字符就修改一次狀態,修改的方法我們也可以指定。分析器在讀完所有的字符以後,必然停留在一個確定的狀態中。如果這個狀態跟我們所期望的狀態一致 的話,我們就說這個分析器接受了這個字符串,否則我們就說這個分析器拒絕了這個字符串。

如何通過設計狀態及其轉移方法來實現一個分析器呢?當然,如果一個字符串僅僅包 含a或者b的話,那麼分析器的狀態只有四種:“奇數a奇數b”、“奇數a偶數b”、“偶數a奇數b”、“偶數a偶數 b”。我們把這些狀態依次命名爲aa、aB、Ab、AB。大寫代表偶數,小寫代表奇數。當工作還沒開始的時候,分析器已經讀入的字符串是空串,那麼理所當 然的起始狀態應當是AB。當分析器讀完所有字符的時候,我們期望讀入的字符串的a和b的數量都是偶數,那麼結束的狀態也應該是AB。於是我們給出這樣的一 個狀態圖:

clip_image001

圖3.1

檢查一個字符串是否由偶數個a和偶數個b組成的狀態圖

在這個狀態圖裏,有一個短小的箭頭指向了AB,代表AB這個狀態是初始狀態。 AB狀態有粗的邊緣,代表AB這個狀態是結束的可接受狀態。一個狀態圖的結束狀態可以是一個或者多個。在這個例子裏,起始狀態和結束狀態剛好是同一個狀 態。標有字符”a”的箭頭從AB指向aB,代表如果分析器處於狀態AB並且讀入的字符是a的話,就轉移到狀態aB上。

我們把這個狀態圖應用在兩個字符串上,分別是”abaabbba”和”aababbaba”。其中,第一個字符串是可以接受的,第二個字符串是不可接受的(因爲有5個a和4個b)。

分析第一個字符串的時候,狀態機所經過的狀態是:

AB [a]aB[b]ab[a]Ab[a]ab[b]aB[b]ab[b]aB[a]AB

分析第二個字符串的時候,狀態機所經過的狀態是:

AB [a]aB[a]AB [b]Ab[a]ab[b]aB[b]ab[a]Ab[b]AB [a]aB

第一個字符串”abaabbba”讓狀態機在狀態AB上停了下來,於是這個字符串是可以接受的。第二個字符串”aababbaba”讓狀態機在狀態aB上停了下來,於是這個字符串是不可以接受的。

在機器內部表示這個狀態圖的話,我們可以使用一種比較簡單的方法。這種方法僅僅把狀態與狀態之間的箭頭、起始狀態和結束狀態集合記錄下來。對應於這個狀態圖的話,我們就可以把這個狀態圖表示成以下形式:

起始狀態:AB

結束狀態集合:AB

(AB,a,aB)

(AB,b,Ab)

(aB,a,AB)

(aB,b,ab)

(Ab,a,ab)

(Ab,b,AB)

(ab,a,Ab)

(ab,b,aB)

用一個狀態圖來表示狀態機的時候有時候會遇到確定性與非確定性的問題。所謂的確 定性就是指對於任何一個狀態,輸入一個字符都可以跳轉到另一個確定的狀態中去。確定性和非確定性的區別有一個直觀的描述:狀態圖的任何一個狀態都可以有不 定數量的邊指向另一個狀態,如果在這些邊裏面,存在兩條邊,它們所承載的字符如果相同,那麼這個狀態輸入這個就字符可以跳轉到另外兩個狀態中去,於是該狀 態機就是不確定的。如圖所示:

clip_image002

圖3.2

正則表達式ba*b的一個確定的狀態機表示

clip_image003

圖3.3

正則表達式ba*b的一個非確定的狀態機表示

圖3.3中的狀態機的起始狀態讀入字符b後可以跳轉到中間的兩個狀態裏,因此這個狀態機是非確定的。相反,圖3.2中的狀態機,雖然功能跟圖3.3的狀態機一致,但卻是確定的。我們還可以使用一種特殊的邊來進行狀態的轉換。我們用ε邊來表示一個狀態可以不讀入字符就跳轉到另一個狀態 上。下圖給出了一個跟圖3.3功能一致的包含ε邊的非確定的狀態機:

clip_image005

圖3.4

正則表達式ba*b的一個帶有ε邊的非確定的狀態機

在教科書中,通常把確定的有窮狀態自動機(有窮狀態自動機也就是本文討論的這種狀態機)稱爲DFA,把非確定的有窮狀態自動機稱爲NFA,把帶有ε邊的非確定的狀態機稱爲ε-NFA。下文中也將採用這幾個術語來指示各種類型的有窮狀態自動機。

在剛剛接觸到ε邊的時候,一個通常的疑問就是這種邊存在的理由。事實上如果是人 直接畫狀態機的話,有時候也可以直接畫出一個確定的狀態機,複雜一點的話也可以畫出一個非確定的狀態機,在有些極端的情況下我們需要使用ε邊來更加簡潔的 表示我們的意圖。不過ε邊存在的最大的理由就是:我們可以通過使用ε邊來給出一個簡潔的算法把一個正則表達式轉換成ε-NFA。

4、從正則表達式到ε-NFA

通過第二節所描述的內容,我們知道一個正則表達式的基本元素就是字符集。通過對 規則的串聯、並聯、重複、可選等操作,我們可以構造除更復雜的正則表達式。如果從正則表達式構造狀態機的時候也可以用這幾種操作對狀態圖進行組合的話,那 麼方法將會變得很簡單。接下來我們將一一對這5個構造正則表達式的方法進行討論。使用下文描述的算法構造出來的所有ε-NFA都有且只有一個結束狀態 。

1 :字符集

字符集是正則表達式最基本的元素,因此反映到狀態圖上,字符集也會是構成狀態圖的基本元素。對於字符集C,如果有一個規則只接受C的話,這個規則對應的狀態圖將會被構造成以下形式:

clip_image006

圖4.1

這個狀態圖的初始狀態是Start,結束狀態是End。Start狀態讀入字符集C跳轉到End狀態,不接受其他字符集。

2 :串聯

如果我們使用A⊙B表示規則A和規則B的串聯,我們可以很容易的知道串聯這個操 作具有結合性,也就是說(A⊙B)⊙C=A⊙(B⊙C)。因此對於n個規則的串聯,我們只需要先將前n-1個規則進行串連,然後把得到的規則看成一個整 體,跟最後一個規則進行串聯,那麼就得到了所有規則的串聯。如果我們知道如何將兩個規則串聯起來的話,也就等於知道了如何把n個規則進行串聯。

爲了將兩個串聯的規則轉換成一個狀態圖,我們只需要先將這兩個規則轉換成狀態 圖,然後讓第一個狀態的結束狀態跳轉到第二個狀態圖的起始狀態。這種跳轉必須是不讀入字符的跳轉,也就是令這兩個狀態等價。因此,第一個狀態圖跳轉到了結 束狀態的時候,就可以當成第二個狀態圖的起始狀態,繼續第二個規則的檢查。因此我們使用了ε邊連接兩個狀態圖:

clip_image007

圖4.2

3 :並聯

並聯的方法跟串聯類似。爲了可以在起始狀態讀入一個字符的時候就知道這個字符可 能走的是並聯的哪一些分支並進行跳轉,我們需要先把所有分支的狀態圖構造出來,然後把起始狀態連接到所有分支的起始狀態上。而且,在某個分支成功接受了一 段字符串之後,爲了讓那個狀態圖的結束狀態反映在整個狀態圖的結束狀態上,我們也把所有分支的結束狀態都連接到大規則的結束狀態上。如下所示:

clip_image009

圖4.3

4 :重複

對於一個重複,我們可以設立兩個狀態。第一個狀態是起始狀態,第二個狀態是結束狀態。當狀態走到結束狀態的時候,如果遇到一個可以讓規則接受的字符串,則再次回到結束狀態。這樣的話就可以用一個狀態圖來表示重複了。於是對於重複,我們可以構造狀態圖如下所示:

clip_image010

圖4.4

5 :可選

爲可選操作建立狀態圖比較簡單。爲了完成可選操作,我們需要在接受一個字符的時 候,如果字符串的前綴被當前規則接受則走當前規則的狀態圖,如果可選規則的後續規則接受了字符串則走後續規則的狀態圖,如果都接受的話就兩個圖都要走。爲 了達到這個目的,我們把規則的狀態圖的起始狀態和結束狀態連接起來,得到了如下狀態圖:

clip_image011

圖4.5

如果重複使用的是0次以上重複,也就是原來的重複加上可選的結果,那麼可以簡單地把圖4.4的Start狀態去掉,讓End狀態同時擁有起始狀態和結束狀態兩個角色,[Start]和[End]則保持原狀。

至此,我們已經將5種構造狀態圖的辦法都對應到了5種構造規則的辦法上了。對於 任意的一個正則表達式,我們僅需要把這個表達式還原成那5種構造的嵌套,然後把每一步構造都對應到一個狀態圖的構造上,就可以將一個正則表達式轉換成一個 ε-NFA了。舉個例子,我們使用正則表達式來表達“一個字符串僅包含偶數個a和偶數個b”,然後把它轉換成ε-NFA。

我們先對這個問題進行分析。如果一個字符串僅包含偶數個a和偶數個b的話,那麼 這個字符串一定是偶數長度的。於是我們可以把這個字符串分割成兩個兩個的字符段。而且這些字符段只有四種:aa、bb、ab和ba。對於aa和bb來說, 無論出現多少次都不會影響字符串中a和b的數量的奇偶性(理由:在一個模2加法系統裏,0是不變項,也就是說對於任何屬於模2加法的數X有X+0 = 0+X = X)。對於ab和ba的話,如果一個字符串的開始和結束是ab或者ba,中間的部分是aa或者bb的任意組合,這個字符串也是具有偶數個a和偶數個b的。 我們現在得到了兩種構造偶數個a和偶數個b的字符串的方法。把串聯、並聯、可選、重複等操作應用在這些字符串上,仍然會得到具有偶數個a和偶數個b的字符 串。於是我們可以把正則表達式書寫成以下形式:

((aa|bb)|((ab|ba)(aa|bb)*(ab|ba)))*

根據上文提到的方法,我們可以把這個正則表達式轉換成以下狀態機:

clip_image013

圖4.6

至此,我們已經得到了把一個正則表達式轉換爲ε-NFA的方法了。但是只得到ε-NFA還是不行的,因爲ε-NFA的不確定性太大了,直接根據ε-NFA跑的話,每一次都會得到大量的臨時狀態集合,會極大地降低效率。因此,我們還需要一個辦法消除一個狀態機的非確定性。

5、消除非確定性

消除ε邊算法

我們見到的有窮狀態自動機一共有三種:ε-NFA、NFA和DFA。現在我們需 要將ε-NFA轉換爲DFA。一個DFA中不可能出現ε邊,所以我們首先要消除ε邊。消除ε邊算法基於一個很簡單的想法:如果狀態A通過ε邊到達狀態B的 話,那麼狀態A無需讀入字符就可以直達狀態B。如果狀態B需要讀入字符x纔可以到達狀態C的話,那麼狀態A讀入x也可以到達狀態C。因爲從A到C的路徑是 A B C,其中A到B不需要讀入字符。

於是我們會得到一個很自然的想法:消除從狀態A出發的ε邊,只需要尋找所有從A 開始僅通過ε邊就可以到達的狀態,並把從這些狀態觸出發的非ε邊複製到A上即可。剩下的工作就是刪除所有的ε邊和一些因爲消除ε邊而變得不可到達的狀態。 爲了更加形象地描述消除ε邊算法,我們從正則表達式(ab|cd)*構造一個ε-NFA,並在此狀態機上應用消除ε邊算法。

正則表達式(ab|cd)*的狀態圖如下所示:

clip_image015

圖5.1

1 :找到所有有效狀態。

有效狀態就是在完成了消除ε邊算法之後仍然存在的狀態。我們可以在開始整個算法 之前就預先計算出所有有效狀態。有效狀態的特點是存在非ε邊的輸入。同時,起始狀態也是一個有效狀態。結束狀態不一定是有效狀態,但是如果存在一個有效狀 態可以僅通過ε邊到達結束狀態的話,那麼這個狀態應該被標記爲結束狀態。因此對一個ε-NFA應用消除ε邊算法產生的NFA可能出現多個結束狀態。不過起 始狀態仍然只有一個。

我們可以把“存在非ε邊的輸入或者起始狀態”這個判斷方法應用在圖5.1每一個狀態上,計算出圖5.1中所有的有效狀態。結果如下圖所示。

clip_image017

圖5.2

所有非有效狀態的標籤都被刪除

如果一個狀態同時具有ε邊和非ε邊輸入的話,那麼這個狀態仍然是有效狀態。因爲所有的有效狀態在下一步的操作中,都會得到新的輸出和新的輸入。

2 :添加所有必要的邊

接下來我們要對所有的有效狀態都應用一個算法。這個算法分成兩步。第一步是尋找 一個狀態的ε閉包,第二步是把這個狀態的ε閉包看成一個整體,把所有從這個閉包中輸出的邊全部複製到當前狀態上。從標記有效狀態的結果我們得到了圖5.1 狀態圖的有效狀態集合是{S/E 3 5 7 9}。我們依次對這些狀態應用上述算法。第一步,計算S/E狀態的ε閉包。所謂一個狀態的ε閉包就是從這個狀態出發,僅通過ε邊就可以到達的所有狀態的集合。 下圖中標記出了狀態S/E的ε閉包:

clip_image019

圖5.3

現在,我們把狀態S/E從狀態S/E的ε閉包中排除出去。因爲從狀態A輸出的非 ε邊都屬於從狀態A的ε閉包中輸出的非ε邊,複製這些邊是沒有任何價值的。接下來就是找到從狀態S/E的ε閉包中輸出的非ε邊。在圖5.3我們可以很容易 地發現,從狀態1和狀態6(見圖5.1的狀態標籤)分別輸出到狀態3和狀態7的標記了a或者b的邊,就是我們所要尋找的邊。接下來我們把這些邊複製到狀態 S/E上,邊的目標狀態仍然保持不變,可以得到下圖:

clip_image021

圖5.4

至此,這個算法在S/E上的應用就結束了,接下來我們分別對剩下的有效狀態{3 5 7 9}分別應用此算法,可以得到下圖:

clip_image023

圖5.5

紅色的邊爲新增加的邊

3 :刪除所有ε邊和無效狀態

這一步操作是消除ε邊算法的最後步驟。我們只需要刪除所有的ε邊和無效狀態就完成了整個消除ε邊算法。現在我們對圖5.5的狀態機應用第三步,得到如下狀態圖:

clip_image024

圖5.6

不過並不是只有新增的邊纔不被刪除。根據定義,所有從有效狀態出發的非ε邊都是不能刪除的邊。

我們通過把消除ε邊算法應用在圖5.1的狀態機上,得到了圖5.6這個DFA。但是並不是所有的消除ε邊算法都可以直接從ε -NFA 直接得到DFA ,這個其實跟正則表達式本身有關。至於什麼正則表達式可以達到這個效果這裏就不深究了。但是因爲有可能產生NFA,所以我們還需要一個算法把NFA轉換成DFA。

從NFA 到DFA

NFA是非確定性的狀態機,DFA是確定性的狀態機。確定性和非確定性的最大區 別就是:從一個狀態讀入一個字符,確定性的狀態機得到一個狀態,而非確定性的狀態機得到一個狀態的集合。如果我們把NFA的起始狀態S看成一個集合{S} 的話,對於一個狀態集合S’,給定一個輸入,就可以用NFA計算出對應的狀態集合T’。因此我們在構造DFA的時候,只需要把起始狀態對應到S’,並且找 到所有可能在NFA同時出現的狀態集合,把這些集合都轉換成DFA的一個狀態,那麼任務就完成了。因爲NFA的狀態是有限的,所以NFA所有狀態的集合的 冪集的元素個數也是有限的,因此使用這個方法構造DFA是完全可能的。

爲了形象地表達出這個算法的過程,我們將構造一個正則表達式,然後給出該正則表達式轉換成NFA的結果,並把構造DFA的算法應用在NFA上。假設一個字符串只有a、b和c三種字符,判斷一個字符串是不是以abc開始並且以cba結束正則表達式如下:

abc(a|b|c)*cba

通過上文的算法,可以得出如下圖所示的NFA:

clip_image026

圖5.7

現在我們開始構造DFA,具體算法如下:

1:把{S}放進隊列L和集合D中。其中S是NFA的起始狀態。隊列L放置的是未被處理的已經創建了的DFA狀態,集合D放置的是已經存在的DFA狀態。根據上文的討論,DFA的每一個狀態都對應着NFA的一些狀態。

2:從隊列L中取出一個狀態,計算從這個狀態輸出的所有邊所接受的字符集的並 集,然後對該集合中的每一個字符尋找接受這個字符的邊,把這些邊的目標狀態的並集T計算出來。如果T∈D則代表當前字符指向了一個已知的DFA狀態。否則 則代表當前字符指向了一個未創建的DFA狀態,這個時候就把T放進L和D中。在這個步驟裏有兩層循環:第一層是遍歷 所有接受的字符的並集,第二層是對每一個可以接受的字符遍歷 所有輸出的邊計算目標DFA狀態所包含的NFA狀態的集合。

3:如果L非空則跳到2。

現在我們開始對圖5.7的狀態機應用DFA構造算法。

首先執行第一步,我們得到:

clip_image028

圖5.8

從上到下分別是隊列L、集合D和DFA的當前狀態。就這樣一直執行該算法直到狀態3進入集合D,我們得到:

clip_image030

圖5.9

現在從隊列L中取出{3},經過分析得到狀態集合{3}接受的字符集合爲{a b c}。{3}讀入a到達{4},讀入b到達{5},讀入c到達{6 7}。因爲{4}、{5}和{6 7}都不屬於D,所以把它們都放入隊列L和集合D:

clip_image032

圖5.10

從隊列中取出4進行計算,我們得到:

clip_image034

圖5.11

顯然,對於狀態{4}的處理並沒有發現新的DFA狀態。於是處理{5}和{6 7},我們可以得到:

clip_image036

圖5.12

在處理狀態{5}的時候沒有發現新的DFA狀態,處理{6 7}在輸入{a c}之後的跳轉也沒有發現新的DFA狀態,但是我們發現了{6 7}輸入b卻得到了新的DFA狀態{5 8}。把算法執行完,我們得到一個DFA:

clip_image038

圖5.13

至此,對圖5.7的狀態機應用DFA構造算法的流程就結束了,我們得到了圖5.13的DFA,其功能與圖5.7的NFA等價。在DFA中,起始狀態是0,結束狀態是4E。凡是包含了NFA的結束狀態的DFA狀態都必須是結束狀態。

6、使用正則表達式構造詞法分析器

判斷一個字符串是否屬於某規則的算法介紹到這裏就結束了。回到我們一開始的問題上,我們需要使用一些規則來吧一個長的字符串斷開成記號,然後計算出每一個記號對應的規則。在解決這個問題之前,我們先考察一下能夠成功地被詞法分析器接受的字符串應該是什麼樣子的。

假設我們現在有規則A、B、C和D,分別對應於四種記號類型,那麼被詞法分析器 接受的字符串就是A、B、C和D的任意組合,也就是(A|B|C|D)*。如果規定了輸入的字符串不能爲空的話,那麼被詞法分析器接受的字符串就是 (A|B|C|D)+。但是單純地使用(A|B|C|D)+作爲一個規則去應用在輸入的字符串的話,我們只能夠判斷字符串是否是詞法分析器能夠接受的字符 串。因此我們需要對這個方法進行修改。

首先按照上文的方法,把每一個記號類型對應的規則的正則表達式轉換成DFA,然後使用並聯的方法將他們組合起來,但是並不使用“重複”。但是這次我們要做一點修改,我們要把新的 DFA 的每一個狀態對應的規則的DFA 狀態集合記住 。

這裏給出一個例子,我們假設需要一個簡單語言的詞法分析器,規則如下:

I:[a-zA-Z_][a-zA-Z_0-9]*

N:[0-9]+

R:[0-9]+.[0-9]+

O:[=>+-*/|&]

按照規則構造出四個DFA並將它們組合起來:

clip_image039

圖6.1

我們構造出了I|N|R|O的DFA,並且標識出了哪些狀態包含了原DFA的結 束狀態。這樣做的一個好處是,當我們把一個字符串放進這樣的一個DFA之後,我們就一直等待整個字符串被接受,或者失敗。如果字符串被接受的話,我們就把 當前的結束狀態記下來。如果失敗的話,我們就把這個狀態機在分析字符串的時候經過的最後一個結束狀態記下來。這個時候,結束狀態所代表的原DFA結束狀態 的相應記號類型就是我們所需要的信息了。如果得不到任何結束狀態的話,輸入的字符串就是不背詞法分析其接受的。

舉個例子,使用上述狀態機分析”123.ABC”。

首先從狀態0開始,依次經過狀態N N N 2,然後宣告失敗。最後一個N(結束狀態)以及當時被接受的字符串”123”被識別,結果爲(N,”123”)。然後從”.ABC”開始,輸入第一個記號 就失敗了,於是”.”被識別爲不可接受字串。最後輸入”ABC”,依次經過狀態0 1 I I,然後字符串結束並且被接受,於是輸出(I,”ABC”)。

爲什麼我們在構造狀態機的時候不使用“重複”呢?因爲在每一個記號被識別出的時候,我們都要做一些額外的工作。如果我們使用“重複”來構造詞法分析器的狀態機,我們將無從知道一個記號被識別出來的確切時間。

算法到這裏基本上就結束了,不過還存在一些小問題需要在細節上解決。一般來說我們給出的一些構成詞法分析器的規則很少有衝突,不過偶爾會出現兩個規則所代表的字符串集合存在交集的情況。有了DFA這個工具,我們可以很輕易地識別出規則衝突。

假如我們的詞法分析器有A和B兩個狀態,那麼我們構造詞法分析器A|B的時候, 將會得到一些包含DFA(A)和DFA(B)的結束狀態的新狀態。我們只需要檢查這些狀態是否具有以下特徵,就可以判斷A和B的關係。我們假設 DFA(A)爲規則A的狀態機,DFA(B)爲規則B的狀態機,DFA(L)爲詞法分析器A|B的狀態機:

1:如果DFA(L)存在一個或多個狀態同時包含了DFA(A)和DFA(B)的結束狀態,那麼A和B所代表的字符串存在交集。

2:如果DFA(L)不存在同時包含了DFA(A)和DFA(B)的結束狀態的狀態,那麼A和B所代表的字符串不存在交集。

3:如果DFA(L)的某些狀態包含了DFA(A)的結束狀態,並且這些狀態都無一例外地包含了DFA(B)的結束狀態的話,那麼A是B的子集。

4:如果DFA(L)的某些狀態包含了DFA(A)的結束狀態,但是這些狀態並沒有無一例外地包含DFA(B)的結束狀態的話,那麼A不是B的子集。

在圖6.1的詞法分析器中,我們可以很清楚地看出I、N、R和O四個規則兩兩之間都不存在交集。我們可以嘗試構造一個衝突的規則,並看一看詞法分析器的DFA是什麼樣子的:

假設詞法分析器包含以下規則:

A:”if”

B:[a-z]+

對A|B構造DFA,我們將會得到如下狀態機:

clip_image040

圖6.2

通過圖6.2我們可以看出,這個狀態圖滿足了上述的條件3:包含了狀態A的結束狀態的狀態都包含了B的結束狀態,因此A是B的子集。顯然”if”是[a-z]+的一個子集。在處理這種有衝突的規則的時候,既可以報錯,也可以根據指定的優先級進行挑選。

7、尾聲

使用DFA的方法完成的可配置詞法分析器的性能是相當好的。筆者前不久曾經做過 實驗,首先使用本文提到的算法開發一個這樣的詞法分析器,然後在一份C++代碼(這份代碼經過多次複製而成件,一共有3.12M)中抽取所有數字、標識符 和註釋,吞吐速度高達46萬記號/秒(筆者的臺式電腦配置是奔騰4的超線程2.99GHz處理器,1G內存),其中抽取出來的記號一共有22萬個。在分析 的過程中,只有10%的時間花在了DFA上,90%的時間花在了處理結果的工作上。DFA本身造成的消耗是很小的。不過詞法分析的性能在很大程度上跟 DFA的實現有很大關係。三個月前筆者也實現過一個同類的程序,但是吞吐速度僅有1.1萬記號/秒。

一般來說,比較高性能的DFA的實現是一張二維的表。行代表字符,列代表DFA 的狀態,單元格代表該狀態經輸入某個字符之後進行轉移的目標狀態。此外還有一張表用來記錄哪些狀態對應哪些規則的結束狀態。筆者的詞法分析器是基於 UTF-16編碼的字符串,一張表一共有65535行顯然是不現實的,因此還有另一張表把字符轉換成字符類。字符類是這樣定義的:假設現在已經存在了 65535行的一張大表,如果在某個字符區間所對應的子表內,任意一列的單元格的數據都一樣的話,那麼這個區間內的所有字符就可以被視爲是等價的,這些字 符就屬於同一個字符類。於是僅需要另外一張65535個單元的表用來把一個字符映射到字符類。這種做法可以大大的壓縮DFA所需要的空間。在筆者的程序 裏,識別字符類的算法被融入了DFA的構造算法中

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章