5.4正則表達式
在許多應用中,我們在查找子字符串的時候并沒有查找模式的完整信息。文本編輯器的用戶可能希望僅指定模式的一部分,或是指定某種能夠匹配若干不同單詞的模式,或是指定集中可能任意匹配的不同模式。
和上一節的KMP算法類似,本節也將使用一種能夠在文本中查找模式的抽象自動機來描述三種基本操作,模式匹配算法同樣會構造一個這樣的自動機并且模擬它的運行,這種匹配自動機比KMP算法的DFA更加附加,但不會超出你的想象。你將會看到,我們為模式匹配問題給出的解答和計算機科學中最基礎的問題緊密相連。我們會遇到非確定性這個概念,它在人們對高效算法的追求中起到了重要作用。
5.4.1 使用正則表達式描述模式
我們的重點是模式的描述,它由三種基本操作和作為操作數的字符組成。這里,我們使用語言指代一個字符串的集合(可能是無限的),用模式指代一種語言的詳細說明。
5.4.1.1 連接操作
第一種基本操作就是5.3節使用過的連接操作,當我們寫出AB時,就指定了一種語言{AB}。它含有一個由兩個字符組成的字符串,由A和B連接而成。
5.4.1.2 或操作
第二種基本操作可以在模式中指定多種可能的匹配。如果我們在兩種選擇之間指定了一個或運算符,那么它們就屬于同一種語言,我們使用豎線符號“|”來表示這個操作。例如,A|B指定的語言是{A,B},連接操作的優先級高于或操作,因此AB|BCD指定的語言是{AB,BCD}。
5.4.1.3 閉包操作
第三種基本操作可以將模式的部分重復任意的次數。模式的閉包是由將模式和自身連接任意多次(包括0次)而得到的所有字符串所組成的語言。我們將“*”標記在需要被重復的模式之后,以表示閉包。閉包的優先級高于連接操作,例如,AB *指定的語言由一個A和0個或多個B的字符串自稱,而A *B指定的語言由0個或多個A和一個B的字符串組成。所有文本字符串都有一個空字符串,例如A *
5.4.1.4 括號
我們使用括號來改變默認的優先級順序。例如,C(AC|B)D指定的語言是{CACD,CBD}。(A|C)((B|C))D指定的語言是{ABD,CBD,ACD,CCD},(AB)*指定的語言是將AB連接任務多次得到的所有字符串和空字符串。
5.4.3 正則表達式的實際應用
實際應用表明了正則表達式善于描述與語言有關的內容。因此,正則表達式使用廣泛,這方面的研究也比較深入。
5.4.3.1 子字符串查找
我們的總體目標是開發一種算法,能夠判定給定子字符串是否包含在給定正則表達式描述的字符串集合之中。如果文本包含在模式所描述的語言中,就稱文本和模式相匹配。例如,要在一段文本txt中查找一個子字符串pat,就是檢查txt是否存在于模式“. *pat. *”所描述的語言中。
5.4.3.2 合法性檢查
使用互聯網的時候,時常會碰到正則表達式。你可能希望檢查輸入的格式是否正確。進行這類檢查的一種方法就是用代碼檢查所有可能出現的情況,如果輸入一個美元金額,你可能會檢查第一個字符是否是“$”
5.4.4 非確定有限狀態自動機
我們可以將KMP算法看做一臺由模式字符串構造的能夠掃描文本的有限狀態自動機。對于正則表達式,這個思想需要廣而推之。KMP的有限狀態自動機會根據文本中的字符改變自身的狀態。當且僅當到達了停止狀態才找到了一個匹配。算法本身就是模擬這種自動機,這種自動機的運行很容易模擬的原因是因為它是確定性的:每種狀態的轉換都完全由文本中的字符所決定。要處理正則表達式,需要一種更強大的抽象自動機。因為或操作的存在,自動機無法根據一個字符判斷出模式是否出現;事實上,因為閉包的存在,自動機甚至無法知曉需要檢查多少字符才會出現失敗。為了克服這些困難,我們需要非確定性的自動機:當面對匹配模式的多種可能時,自動機能夠“猜出正確的轉換”,編寫一個程序來構造非確定性有限狀態自動機并且有效模擬它的運行時很簡單的。正則表達式模式匹配程序的總體結構和KMP算法的總體結構幾乎相同。
- 構造和給定正則表達式相對應的非確定有限狀態自動機;
- 模擬NFA在給定文本上的運行軌跡
學習如何構造模式匹配的NFA之前,我們先來看一個示例,它說明了NFA的性質和操作。它所顯示的NFA是用來判斷一段文本是否包含在正則表達式((A*B|AC)D)所描述的語言之中。如這個示例所示,我們所定義的NFA有著以下特點。
- 長度為M的正則表達式中的每個字符在所對應的NFA中都有且只有一個對應的狀態。NFA的起始狀態為0并含有一個(虛擬的)接受狀態M;
- 字母表中的字符所對應的狀態都有一條從它指出的邊,這條邊指向模式中的下一個字符所對應的狀態(圖中黑色的邊)。
- 元字符“(”,“)”、“|”和“*”所對應的狀態至少含有一條指出的邊(圖中紅色的邊),這些邊可能指向其他的任意狀態。
- 有些狀態有多條指出的邊,但一個狀態只能有一條指出的黑色邊。
NFA也是從狀態開始讀取文本中的第一個字符。NFA在狀態的轉換中有時會從文本中讀取字符,從左向右一次一個。但它和DFA有些基本的不同:
- 在圖中,字符對應的結點而不是邊;
- NFA只有讀取了文本中的所有字符后才能識別它,而DFA并不一定需要讀取文本中的全部內容就能夠識別一個模式。
現在的重點是檢查文本和模式是否匹配-----為了達到這個目標,自動機需要讀取所有文本并到達它的接受狀態。在NFA中從一個狀態轉移到另一個狀態的規則和DFA不同---NFA中狀態的轉換有以下兩種方式。
- 如果當前狀態和字母表中的一個字符相對應且文本中的當前字符和該字符匹配,自動機可以掃過文本中的該字符并(由黑色的邊)轉換到下一個狀態。我們稱這種轉換為匹配轉換。
- 自動機可以通過紅色的邊轉換到另一個狀態而不掃描文本中的任何字符。也就是說它所對應的“匹配”是一個空字符串。
5.4.5 模擬NFA的運行
我們可以檢查所有可能的狀態轉換序列,只要存在能夠到達接受狀態的序列,我們就會找到它。
5.4.5.1 自動機的表示
首先,需要能夠表示NFA。選擇很簡單,正則表達式已經給出了所有狀態名(0到M之間的整數,其中M為正則表達式的長度)。用char數組re[]保存正則表達式本身,這個數組也表示了匹配轉換(如果re[i]存在于字母表中,那么就存在一個從i到i+1的匹配轉換)。轉換最自然的表示方法就是有用想吐,它們都是連接0到M之間的各個頂點的有向邊。
5.4.5.2 NFA的模擬與可達性
為了模擬NFA的運行軌跡,我們會記錄自動機在檢查當前輸入字符時候可能遇到的所有狀態集合。我們會查找所有從狀態0通過轉換可達的狀態來初始化這個集合。對于集合中的每個狀態,檢查它是否可能與第一個輸入字符相匹配。檢查并匹配之后就得到了NFA在匹配第一個字符之后可能達到的狀態的集合。匹配了第一個字符之后,轉換有向圖中的多點可達性問題就可能匹配第二個輸入字符的狀態集合。
重復這個過程可能有兩種結果:
- 可能達到的狀態集合中含有接受狀態
- 可能達到的狀態集合中不含有接受狀態
- 第一個結果說明存在某種狀態轉換序列使NFA到達接受狀態,第二種說明對于輸入NFA總會停滯,導致匹配失敗。
5.4.6 構造與正則表達式對應的NFA
5.4.6.1 連接操作
對于NFA,連接操作是最容易實現的了。狀態的匹配轉換和字母表中的字符對應關系就是連接操作的實現。
5.4.6.2 括號
我們將正則表達式字符串中所有左括號的索引壓入棧中。每當我們遇到一個右括號,我們最終都會用后文所述的方法將左括號從棧中彈出。和Dijkstra算法一樣,??梢院茏匀坏奶幚砬短椎睦ㄌ枴?/p>
5.4.6.3 閉包操作
閉包運算符(*)只可能出現在(i)單個字符之后(此時將在該字符和“ *”之間添加相互指向的轉換),或者是右括號之后,此時在對應的左括號(即棧頂元素)和“ *”之間添加相互指向的兩條轉換。
5.4.6.4 “或表達式”
在形如(A|B)的正則表達式中,A和B也是正則表達式。我們的湖里方式是添加兩條轉換。一條從左括號所對應的狀態指向B中第一個字符所對應的狀態,另一條從“|”所對應的狀態指向右括號所對應的狀態。
算法5.9 正則表達式的模式匹配(grep)
public class NFA {
private char[] re; //匹配轉換
private Digraph G; //epsilon轉換
private int M; //狀態數量
public NFA(String regexp) {
//根據給定的正則表達式構造NFA
ArrayDeque<Integer> stack = new ArrayDeque<>();
re = regexp.toCharArray();
this.M = re.length;
G = new Digraph(M + 1);
for (int i = 0; i < M; i++) {
int lp = i;
if (re[i] == '(' || re[i] == '|') {
stack.push(i);
} else if (re[i] == ')') {
int or = stack.pop();
if (re[or] == '|') {
lp = stack.pop();
G.addEdge(lp, or + 1);
G.addEdge(or, i);
}
else lp=or;
}
if (i < M - 1 && re[i + 1] == '*') {
G.addEdge(lp, i + 1);
G.addEdge(i + 1, lp);
}
if (re[i] == '(' || re[i] == '*' || re[i] == ')')
G.addEdge(i, i + 1);
}
}
public boolean recognizes(String txt) {
//NFA能否識別文本txt
Bag<Integer> pc = new Bag<>();
DirectedDFS dfs = new DirectedDFS(G, 0);
for (int v = 0; v < G.getV(); v++) {
if (dfs.marked(v))
pc.add(v);
}
for (int i = 0; i < txt.length(); i++) {
//計算txt[i+1]可能到達的所有NFA狀態
Bag<Integer> match = new Bag<>();
for (int v : pc) {
if (v < M) {
if (re[v] == txt.charAt(i) || re[v] == '.')
match.add(v + 1);
}
}
pc = new Bag<>();
dfs = new DirectedDFS(G, match);
for (int v = 0; v < G.getV(); v++) {
if (dfs.marked(v))
pc.add(v);
}
}
for (int v : pc)
if (v == M) return true;
return false;
}
}