正則表達式匹配可以更快更簡單 (but is slow in Java, Perl, PHP, Python, Ruby, ...)

source: https://swtch.com/~rsc/regexp/regexp1.html
translated by trav, [email protected]

引言

下圖是兩種正則匹配算法的對比圖,其中左邊的是許多語言都作爲標準使用的算法,而右邊的算法則鮮爲人知,它是多個版本的awk和grep程序所使用的算法。這兩種算法有着驚人的不同表現:
grep5p
注意到Perl需要大約60秒的時間來匹配長度爲29的字符串,而Thompson NFA算法只需要20微秒,兩者相差了上百萬倍。不僅如此,兩者的差距還在繼續增長,Thompson NFA算法處理長度爲100的字符串只需要不到200微秒,而Perl則需要超過10^15年。(Perl語言只是諸多語言的一個典例,其他包括Python、PHP或Ruby等更多語言的表現都是如此)
這可能非常令人難以置信,也許你在使用Perl的時候並沒有發現它表現得如此糟糕。實際上,Perl在大多數情況下已經足夠快了,但是當我們拿出喪心病狂的正則表達式來測試Perl的時候,它可以變得異常的慢。相比之下,Thompson NFA算法並感覺不到任何不適。看到這樣的對比,你很可能會產生疑問:“爲什麼Perl不使用Thompson NFA算法呢?”其實Perl可以這樣做,也應該這樣做,這就是本篇文章要探討的。
在歷史上,正則表達式是一個展示“好的理論產生好的程序”的好例子。計算機理論學家們原本只是將正則表達式作爲驗證理論的計算模型,但是Ken Thompson在他爲CTSS編寫的QED文本編輯器裏實現了正則表達式,並且把它帶入了程序員的世界。Dennis Ritchie同樣也在他爲GE-TSS編寫的QED裏實現了正則表達式。Thompson和Ritchie在他們合作製造的Unix裏也不忘攜帶着正則表達式。正則表達式作爲Unix系統的關鍵成員,存在於ed, sed, grep, egrep, awk和lex這些著名的工具中。
在今天,正則表達式同樣也是一個展示“忽視理論能做出多差的程序”的典例。今天大多數實現正則表達式的算法比起許多已存在30多年的Unix工具不知道要慢幾個數量級。
本文回顧了一些很好的理論:正則表達式有限自動機以及Thompson於19世紀60年代中期提出的正則表達式搜索算法,並且理論聯繫實際,描述了一個少於400行的C語言的Thompson算法的實現,這個版本就是上圖與Perl進行比較的版本。本文最後討論瞭如何在現實世界中將理論轉化爲實踐。

正則表達式

正則表達式描述了一組符合某種特定形式的字符串,當一個字符串滿足這種特定形式,我們就稱其匹配:

  1. 最簡單的正則表達式是單個字符,字符可以自我匹配,其中有六個操作符*+?()|需要添加反斜槓\進行轉義才能匹配。
  2. 兩個正則表達式可以合併或連接來組成一個新的正則表達式:若e1匹配s,e2匹配t,則e1|e2匹配s或t,則e1e2匹配st。
  3. * + ?是重複操作符:e1*匹配0個或多個e1e1+匹配1個或多個e1e1?匹配0個或1個e1
  4. 操作符的優先級爲 * > + > ? > 連接 > 合併,其中括號的優先級最高,例如ab|cd等價於(ab)|(cd)ab*等價於a(b*)

以上描述的規則是傳統的Unix egrep正則表達式規則的最小子集,這個子集已經能夠描述所有的正則表達式,當然現在出現了許多新的操作符,這些新的操作符同樣能被上述子集描述。本文內容只涉及上述操作符。

有限自動機

另一種描述字符串特徵的方式就是有限自動機,有限自動機有時又被稱爲狀態機。以下是描述a(bb)+a的有限自動機:
grep1
有限自動機由兩部分組成,一部分是圖中由圓圈表示的狀態,另一個部分是連接狀態的箭頭與箭頭上的字符。當有限自動機讀入一串字符串時,它會從一個狀態轉入另一個狀態。這種狀態機有兩種特殊的狀態:開始狀態s0和終止狀態s4。終止狀態由雙圓圈表示,開始狀態由箭頭頭部指出。
狀態機每次從字符串輸入流中讀取一個字符,並根據箭頭方向從一個狀態轉移到另一個狀態。假設讀入字符串abbbba,那麼狀態轉移的過程如下:
grep2
狀態機的結束狀態爲s4,是終結狀態,因此這個字符串被匹配了。如果狀態機最終結束的狀態不是終結狀態,那麼就不匹配。如果在字符串匹配過程中,出現了意外的字符導致狀態不能繼續轉移,那麼也是不匹配,此時狀態機會提早結束了事。
我們上述討論的是確定的有限自動機(DFA),其特徵就是對於某個特定的輸入,只有至多一個確定的轉移狀態。我們也可以製作出不確定的有限自動機(NFA),其特徵是對某個特定的輸入,其轉移狀態可能有多個。下圖給出一個與之前DFA等價的NFA:
grep3
在狀態s2時,讀入一個字符b,其轉移狀態可能爲s1,也可能爲s3,所以這就是不確定自動機。因爲自動機無法預測未來,因此它不知道轉移到哪種狀態纔有最正確的選擇。在這種情況下,一個有趣的事情就是如何讓自動機總是做出正確的選擇,或者說是每次都猜對答案。總之,這樣的自動機就被稱爲不確定的有限自動機。
有時候,在NFA中設置無字符的箭頭是一件好事。在一個NFA中,一個狀態在任何時候都可以順着無字符的箭頭轉移到另一個狀態。例如下圖等價的NFA:
grep4
狀態s3指向s1的無符號箭頭能夠更清晰更簡單的描述a(bb)+a

正則表達式轉爲NFA

正則表達式與NFA是完全等價的,一個正則表達式一定有對應的NFA,反之亦然。歷史上有非常多種將正則表達式轉化爲NFA的方法,我們這裏描述的方法是由Thompson在1968年的CACM論文中提出的。
一個最終的NFA是由多個部分的局部NFA組合而成的,局部的NFA沒有狀態轉移,而是由一個或多個空箭頭表示。最終我們會把這些局部的NFA通過他們的空箭頭連接起來。

  • 單字符正則表達式對應的NFA:
    grep5
  • 表示連接關係的NFA:
    grep6
  • 表示合併關係的NFA:
    grep7
  • e?對應的NFA:
    grep8
  • e*對應的NFA:
    grep9
  • e+對應的NFA:
    grep10

細數上面的對應關係可以發現,我們需要爲每個操作符都新建一個NFA,其中不包括括號操作符,因此一個完整的NFA包含的狀態數至多與原正則表達式的長度相等。
你可以在這些局部的NFA中發現許多無符號的箭頭,正如我們上一節末尾所說的,爲NFA設置一些無符號的箭頭是合理的(我們的算法就是這樣乾的),同時這些無符號的箭頭會幫助我們閱讀和理解,並且讓我們的C語言代碼更簡潔。

正則表達式搜索算法

現在我們有了匹配正則表達式的方法:首先將正則表達式轉化爲NFA,然後將待匹配字符串作爲輸入,運行NFA,查看結果。在面對多種狀態轉移的選擇時,我們需要NFA有做出正確選擇的能力,因此我們必須尋找一種可靠的方式來模擬NFA的猜測過程。
grep11
一種方式就是:嘗試其中一個選項,如果這條路走不通,那就選擇另一條路。例如,考慮abab|abbb的NFA在匹配字符串abbb的過程:
grep12
在step 0中,我們必須選擇往上走還是往下走,往上走匹配abab,往下走匹配abbb。在圖中,NFA嘗試往上走,結果在step 3失敗了,於是便回溯到了step 0,嘗試往下走,從step 4到step 8完成了匹配。這種回溯的方式可以通過簡單的遞歸實現。但是,我們容易發現,當一個不匹配的字符串輸入時,自動機將會嘗試所有的可能。在這個例子中,NFA僅僅嘗試了兩條路,但是在更糟糕的情況中,自動機將會做出大量的嘗試,這就導致了自動機變得異常緩慢。
另一個更高效但更復雜的方式就是同步地進行嘗試。這種方式允許自動機一次可以進入多種狀態,當讀入一個字符時,自動機會同時轉移到所有可能的狀態。
grep13
如圖所示,在匹配字符串的過程中,自動機會同時嘗試兩條路徑,在step 3時,僅剩下一條亦然匹配成功的路徑。這種多狀態並存的方式可以在同一時間嘗試兩種可能,也僅僅讀取輸入的字符串一次。即使在最壞的情況下,NFA也許會同時嘗試所有狀態,但是這也僅僅花費O(1)的時間,因此任意長的字符串也能在O(N)的時間內解決。這種方式把回溯花費的指數時間降爲線性時間,是一種巨大的提升。效率的提升在於,這種方式嘗試的是所有可能的狀態,而不是所有可能的路徑。在一個有N個結點的自動機裏,每一步最多有N個轉移狀態,但是卻有2^n條路。

實現

Thompson在1968年的論文裏介紹了這種多狀態並存的方式,在他的實現方案中,NFA的狀態被表示爲一組機器碼序列,下一步可能的轉移狀態被表示爲一組函數調用。實質上,Thompson是將正則表達式編寫爲一組聰明的機器碼。四十年之後,計算機已經變得非常快了,他那種機器碼的方式已經不在需要了。在接下來的章節中,我們會看到一個不到400行的C語言版本的代碼。source code

實現NFA

第一步就是將正則表達式轉化爲等價NFA。在代碼中,狀態的數據結構如下:

struct State
{
    int c;
    State *out;
    State *out1;
    int lastlist;
};

狀態有三種表示,具體依賴與c的值:
grep14
(lastlist字段在運行時使用,我們會在下一節介紹)
依照Thompson的論文,接下去需要將中綴的正則表達式轉化爲後綴的正則表達式,再從後綴的正則表達式轉化爲NFA。在其後綴表達式中,新增加一個操作符dot(.),用來表示連接。函數re2post將一箇中綴表達式a(bb)+a轉化爲等價的後綴表達式abb.+.a.。(在這裏,需要區分現實中正則表達式的操作符dot(.)。在現實中,我們會使用dot(.)來表示某個字符,但是在這裏我們用來表示連接。在實際工作中,我們也許直接使用中綴表達式來轉爲NFA,但是,這種後綴表達式的版本也十分便捷,並且也是最符合Thompson的論文的。)
當程序掃描後綴表達式時,它會維護一個棧,棧中會存儲NFA片段。普通的字符會產生一個新的NFA片段,然後入棧。遇到操作符則會將棧中的片段pop出,然後組合成一個新的片段再入棧。例如,在處理abb之後,棧中的內容是a,b,b的片段,遇到了dot(.)之後,就會彈出兩個b片段,然後組合成一個bb片段重新壓入棧中。NFA片段的結構如下:

struct Frag
{
    State *start;
    Ptrlist *out;
};

其中start指針指向片段的開始狀態,out指針指向一個指針鏈表,指針鏈表中指針分別指向其他的State*指針。當然指針鏈表中的指針現在還沒有指向具體的內容,我們稱其爲懸掛的指針,這些指針形象地表示了NFA片段中的空箭頭。
一些輔助函數如下:

Ptrlist *list1(State **outp);
Ptrlist *append(Ptrlist *l1, Ptrlist *l2);
void patch(Ptrlist *l, State *s);

list1函數創建一個新只包含outp指針的指針鏈表。append函數將兩個鏈表連接起來。patch函數爲上面提到的懸掛的指針賦值,讓l中懸掛的指針指向狀態s:它會設置l鏈表中所有的指針指向s。
有了這些工具,我們的程序只是通過一個簡單的循環體來依次處理每個後綴表達式中的字符。最終,棧裏只剩下最後一個片段,將這個片段與matchstate進行patch,說明最終的狀態爲終結狀態,即完成了轉化。

State*
post2nfa(char *postfix)
{
    char *p;
    Frag stack[1000], *stackp, e1, e2, e;
    State *s;

    #define push(s) *stackp++ = s
    #define pop()   *--stackp

    stackp = stack;
    for(p=postfix; *p; p++){
        switch(*p){
        /* compilation cases, described below */
        }
    }
    
    e = pop();
    patch(e.out, &matchstate);
    return e.start;
}

一些片段情況如下:

  • 單個字符:
default:
    s = state(*p, NULL, NULL);
    push(frag(s, list1(&s->out));
    break;

grep15

  • 連接:
case '.':
    e2 = pop();
    e1 = pop();
    patch(e1.out, e2.start);
    push(frag(e1.start, e2.out));
    break;

grep16

  • 合併:
case '|':
    e2 = pop();
    e1 = pop();
    s = state(Split, e1.start, e2.start);
    push(frag(s, append(e1.out, e2.out)));
    break;

grep17

  • ?
case '?':
    e = pop();
    s = state(Split, e.start, NULL);
    push(frag(s, append(e.out, list1(&s->out1))));
    break;

grep18

  • *
case '*':
    e = pop();
    s = state(Split, e.start, NULL);
    patch(e.out, s);
    push(frag(s, list1(&s->out1)));
    break;

grep19

  • +
case '+':
    e = pop();
    s = state(Split, e.start, NULL);
    patch(e.out, s);
    push(frag(e.start, list1(&s->out1)));
    break;

grep20

模擬NFA

現在已經有NFA了,接下去我們需要模擬運行它,模擬的過程需要遍歷狀態集合,也就是狀態鏈表:

struct List
{
    State **s;
    int n;
};

模擬過程需要兩個鏈表:clist是當前的狀態集合,nlist是下一步可能轉移的狀態集合。循環體首先將clist初始化爲只包含開始狀態,然後每次循環向前走一步。(C語言沒有集合的概念,因此只能使用鏈表來表示集合)

int
match(State *start, char *s)
{
    List *clist, *nlist, *t;

    /* l1 and l2 are preallocated globals */
    clist = startlist(start, &l1);
    nlist = &l2;
    for(; *s; s++){
        step(clist, *s, nlist);
        t = clist; clist = nlist; nlist = t;    /* swap clist, nlist */
    }
    return ismatch(clist);
}

爲了避免每次循環都要重新分配一個鏈表,match函數在每次循環結束前會交換clist和nlist,並在下一次執行前將nlist初始化。
最後,如果clist中包含終結狀態,則說明輸入字符串匹配。

int
ismatch(List *l)
{
    int i;

    for(i=0; i<l->n; i++)
        if(l->s[i] == matchstate)
            return 1;
    return 0;
}

addstate函數將一個狀態加入到鏈表中,如果這個狀態已經存在於鏈表中,那麼不做任何操作。從頭到尾掃描一遍鏈表是很低效的,取而代之,我們使用一個變量listid表示鏈表的迭代數。當addstate函數將狀態s添加到鏈表中,它同時將s->lastlist賦值爲listid。如果s->lastlist和listid之前已經相同,那麼說明狀態s早就已經被添加入當前鏈表中。如果s是一個split狀態(|?*+操作符都會產生一個split狀態,參見上一節),那麼addstate會同時將s指向的兩個狀態加入到當前鏈表中,而不加入s狀態(可以將split狀態理解爲一個空的狀態)。

void
addstate(List *l, State *s)
{
    if(s == NULL || s->lastlist == listid)
        return;
    s->lastlist = listid;
    if(s->c == Split){
        /* follow unlabeled arrows */
        addstate(l, s->out);
        addstate(l, s->out1);
        return;
    }
    l->s[l->n++] = s;
}

startlist函數創建一個初始化狀態鏈表,其中只包含開始狀態:

List*
startlist(State *s, List *l)
{
    listid++;
    l->n = 0;
    addstate(l, s);
    return l;
}

最後,step函數在接受一個字符後遞進,並使用clist來計算nlist:

void
step(List *clist, int c, List *nlist)
{
    int i;
    State *s;

    listid++;
    nlist->n = 0;
    for(i=0; i<clist->n; i++){
        s = clist->s[i];
        if(s->c == c)
            addstate(nlist, s->out);
    }
}

性能表現

儘管我們在編寫C語言版本的過程中,並沒有考慮任何的性能優化,但是即便如此,我們的算法性能也依舊比那些流行的指數級算法要快得多。通過測試一些主流的語言可以更好的證明這一點。
考慮我們在開篇提出的正則表達式a?nan,對於一個使用回溯進行匹配的算法來說,在處理?操作符時有兩種選擇,對於a?n則一共有2^n種可能,時間複雜度爲O(2^n)。
相比之下,Thompson算法中的狀態鏈表長度至多爲n,若輸入字符串的長度爲m,則時間複雜度爲O(mn)。(這個時間複雜度十分接近線性複雜度,因爲如果我們把正則表達式的n保持爲一個常數,比如25,則對於任意長度爲m的字符串,其匹配時間爲O(25m))
下圖爲不同算法的匹配時間對比:(注意到y軸的間隔不是等距離間隔,而是對數間隔,用對數劃分能夠更好地比較他們的區別)
grep21
從圖中我們可以看出,Perl, PCRE, Python和Ruby全都使用遞歸回溯算法。儘管這裏沒有評估Java,但是Java使用的也是遞歸回溯算法,而PHP使用的也是PCRE庫。

優化:NFA轉爲DFA

DFA的效率比NFA更高,因爲DFA中,在任意時刻你只能處於一個狀態,只有一種選擇,絕不可能有第二種選擇。任何一個NFA都可以轉化爲與之相對應的DFA,每個DFA中的狀態是原來NFA的一組狀態集合。
例如,如下是abab|abbb的NFA:
grep22
其等價DFA:
grep23
可以看到,DFA的狀態都是NFA中狀態的集合。
讀者可以想象,其實Thompson NFA算法就是在處理其等價的DFA,不是嗎?我們知道在匹配算法中,會維護一個clist和nlist,無論是clist還是nlist,不都是一組狀態的集合嗎?因此,Thompson算法實際上是在運行時動態構造了DFA,也就是,只在需要時構造,而不是一次性就講NFA構造爲DFA。
與其在每一步進行時動態地計算下一步該往哪走,下一步可以去往的狀態是哪些,我們可以提早地將這些可能的去向、可能的狀態放在緩衝區中。在這一節中,我們將展示一個不到100行的C語言算法來實現這樣的操作。
source code
爲了實現緩存,我們首先引入新的數據結構來表示DFA的狀態:

struct DState
{
    List l;
    DState *next[256];
    DState *left;
    DState *right;
};

一個Dstate結構保存了List結構的副本(比如clist, nlist的副本),next數組保存了輸入字符對應的下一個轉移狀態,比如d->next[c]就是在處理字符c時,d狀態會轉移的狀態。如果d->next[c]是null,那麼表示對應的轉移狀態還沒有計算,這時我們調用Nextstate函數來計算其轉移狀態。
檢查正則匹配的函數會重複地跟隨d->next[c]的方向轉移,在需要時調用nextstate函數來計算下一個狀態:

int
match(DState *start, char *s)
{
    int c;
    DState *d, *next;
    
    d = start;
    for(; *s; s++){
        c = *s & 0xFF;
        if((next = d->next[c]) == NULL)
            next = nextstate(d, c);
        d = next;
    }
    return ismatch(&d->l);
}

我們使用一個二叉搜索樹來保存Dstate,並將list作爲節點的鍵值。爲了實現這樣一棵二叉搜索樹,首先將list作爲參數輸入,並且對list中的狀態進行排序,然後將其作爲鍵值插入二叉樹中。dstate函數會返回給定list對應的dstate。(這樣做的原因在於,在一些DFA中可能會有迴環,遇到迴環時我們可以直接在二叉樹中找到,並且直接返回)

DState*
dstate(List *l)
{
    int i;
    DState **dp, *d;
    static DState *alldstates;

    qsort(l->s, l->n, sizeof l->s[0], ptrcmp);

    /* look in tree for existing DState */
    dp = &alldstates;
    while((d = *dp) != NULL){
        i = listcmp(l, &d->l);
        if(i < 0)
            dp = &d->left;
        else if(i > 0)
            dp = &d->right;
        else
            return d;
    }
    
    /* allocate, initialize new DState */
    d = malloc(sizeof *d + l->n*sizeof l->s[0]);
    memset(d, 0, sizeof *d);
    d->l.s = (State**)(d+1);
    memmove(d->l.s, l->s, l->n*sizeof l->s[0]);
    d->l.n = l->n;

    /* insert in tree */
    *dp = d;
    return d;
}

nextstate函數調用NFA的step函數,並且返回對應的dstate:

DState*
nextstate(DState *d, int c)
{
    step(&d->l, c, &l1);
    return d->next[c] = dstate(&l1);
}

在最後,DFA的開始狀態也對應與NFA的開始狀態集:

DState*
startdstate(State *start)
{
    return dstate(startlist(start, &l1));
}

(就像NFA中的clist和nlist一樣,這裏的ll也是預先分配空間的list)
我們在運行時動態地構造DFA的狀態,而不是一開始就將NFA對應的整個DFA計算出來。儘管一開始就構造DFA可能會匹配得快一些,不過內存消耗以及啓動時間比較高。
大家可能會擔心這種動態構造DFA的算法會遇到內存瓶頸,不過我們可以在緩存空間過大時直接丟棄整棵二叉樹,並重新構造一顆二叉樹。這種緩存策略的實現並不困難,只需要不到50行額外的內存管理代碼。source code(grep也使用了限制緩存大小的策略,其大小限制爲32個state的大小,這就解釋了爲什麼在n=28的時候,線段發生了一次跳躍,見上圖)
由正則表達式轉化過來的NFA會不斷的計算新的狀態集,由於狀態集會有重複,這讓緩存是值得的。遇到重複的狀態時,可以直接從緩存中取出,而不用做多餘的計算。真實的基於DFA的算法可以做出更多優化,讓算法效率更高。我們將在下一篇文章中討論。

現實世界中的正則表達式

在現實生產環境中,我們遇到的正則表達式會更復雜,不是我們上述提出的方案能夠解決的。在這一節中,我們會討論一些常見的複雜情況,其他更復雜的情況已超出本文範圍。

字符集(character classes):一個字符集,不論是[0-9]還是\w或事dot(.),都只是更精確的表示了|操作。字符集可以被翻譯爲|然後再處理,但是在NFA中引入一個新的操作符會更精確更有效。POSIX將字符集定義爲[[:upper:]],不過其含義取決於上下文,它困難的部分在於搞清楚它的含義,而不是將其編碼爲NFA。

轉移字符:現實開發中,正則表達式需要使用反斜槓\來轉移某些操作符。

可數次重複:很多正則表達式提供一種可數次重複的操作符{n},可以讓字符串重複n次。{n,m}可以重複n到m次,{n,}可以重複n次及以上。遞歸回溯算法可以通過一個循環來實現這個操作符,而NFA或基於DFA的算法就需要顯示地擴展表達式後,才能進行下一次處理,比如e{3}需要先擴展爲eee,e{3,5}擴展爲eeee?e?,e{3,}擴展爲eee+。

提取子串:正則表達式可以用來劃分字符串,比如使用([0-9]+-[0-9]+-[0-9]+) ([0-9]+:[0-9]+)來匹配日期時間的字符串(date time),很多正則表達式引擎可以提取匹配的子串,例如Perl:

if(/([0-9]+-[0-9]+-[0-9]+) ([0-9]+:[0-9]+)/){
    print "date: $1, time: $2\n";
}

如何區分子串的邊界這個問題,被大多數理論學家所忽視。區分子串邊界是最適合使用遞歸回溯算法來解決的問題。然而,Thompson算法也能在不放棄性能的情況下解決問題。Unix regexp(3)庫的第八個版本使用算法就是Thompson算法,然而知道它的人很少。

中間匹配(Unanchored matches):我們在文章中所談的正則表達式都從最左邊開始向右進行匹配,而我們有時需要在字符串中間進行匹配,這可以理解爲是提取子串的特殊情況。比如我們要中間匹配e,那麼就相當於匹配.*(e).*,即字符串中間只要出現e的樣式,就算匹配成功。(hellovello可以匹配.*(love).*)

非貪婪匹配:傳統的正則表達式匹配的都是最長字符串,採用的都是貪婪匹配的方式。Perl語言引入了新的操作符?? *? +?來進行最短字符串匹配。當用(.+?)(.+?)匹配字符串abcd時,第一個(.+?)僅匹配a,第二個匹配bcd(而傳統的貪婪匹配策略第一個匹配abc,第二個匹配d)。由定義可以看出,非貪婪匹配並不影響整個字符串的匹配,而是影響匹配的過程。回溯算法可以先匹配短的,再匹配長的,來實現最短匹配。而使用Thompson算法也可以解決這個問題。

總結

正則表達式表達式本可以更簡單,更快的完成匹配,而基於有限自動機的算法就能夠勝任這項工作,這項技術已然存在幾十年,然而即使到了現在,諸如Perl, PCRE, Python, Ruby, Java等編程語言仍然使用的是基於遞歸的回溯算法,這種算法儘管便於理解、容易編寫,但是運行速度異常緩慢。除了backreferences這樣的操作符,其他可以使用回溯算法解決的問題,都可以使用基於有限自動機的算法解決。

History and References

Michael Rabin and Dana Scott introduced non-deterministic finite automata and the concept of non-determinism in 1959 [7], showing that NFAs can be simulated by (potentially much larger) DFAs in which each DFA state corresponds to a set of NFA states. (They won the Turing Award in 1976 for the introduction of the concept of non-determinism in that paper.)

R. McNaughton and H. Yamada [4] and Ken Thompson [9] are commonly credited with giving the first constructions to convert regular expressions into NFAs, even though neither paper mentions the then-nascent concept of an NFA. McNaughton and Yamada's construction creates a DFA, and Thompson's construction creates IBM 7094 machine code, but reading between the lines one can see latent NFA constructions underlying both. Regular expression to NFA constructions differ only in how they encode the choices that the NFA must make. The approach used above, mimicking Thompson, encodes the choices with explicit choice nodes (the Split nodes above) and unlabeled arrows. An alternative approach, the one most commonly credited to McNaughton and Yamada, is to avoid unlabeled arrows, instead allowing NFA states to have multiple outgoing arrows with the same label. McIlroy [3] gives a particularly elegant implementation of this approach in Haskell.

Thompson's regular expression implementation was for his QED editor running on the CTSS [10] operating system on the IBM 7094. A copy of the editor can be found in archived CTSS sources [5]. L. Peter Deutsch and Butler Lampson [1] developed the first QED, but Thompson's reimplementation was the first to use regular expressions. Dennis Ritchie, author of yet another QED implementation, has documented the early history of the QED editor [8] (Thompson, Ritchie, and Lampson later won Turing awards for work unrelated to QED or finite automata.)

Thompson's paper marked the beginning of a long line of regular expression implementations. Thompson chose not to use his algorithm when implementing the text editor ed, which appeared in First Edition Unix (1971), or in its descendant grep, which first appeared in the Fourth Edition (1973). Instead, these venerable Unix tools used recursive backtracking! Backtracking was justifiable because the regular expression syntax was quite limited: it omitted grouping parentheses and the |, ?, and + operators. Al Aho's egrep, which first appeared in the Seventh Edition (1979), was the first Unix tool to provide the full regular expression syntax, using a precomputed DFA. By the Eighth Edition (1985), egrep computed the DFA on the fly, like the implementation given above.

While writing the text editor sam [6] in the early 1980s, Rob Pike wrote a new regular expression implementation, which Dave Presotto extracted into a library that appeared in the Eighth Edition. Pike's implementation incorporated submatch tracking into an efficient NFA simulation but, like the rest of the Eighth Edition source, was not widely distributed. Pike himself did not realize that his technique was anything new. Henry Spencer reimplemented the Eighth Edition library interface from scratch, but using backtracking, and released his implementation into the public domain. It became very widely used, eventually serving as the basis for the slow regular expression implementations mentioned earlier: Perl, PCRE, Python, and so on. (In his defense, Spencer knew the routines could be slow, and he didn't know that a more efficient algorithm existed. He even warned in the documentation, “Many users have found the speed perfectly adequate, although replacing the insides of egrep with this code would be a mistake.”) Pike's regular expression implementation, extended to support Unicode, was made freely available with sam in late 1992, but the particularly efficient regular expression search algorithm went unnoticed. The code is now available in many forms: as part of sam, as Plan 9's regular expression library, or packaged separately for Unix. Ville Laurikari independently discovered Pike's algorithm in 1999, developing a theoretical foundation as well [2].

Finally, any discussion of regular expressions would be incomplete without mentioning Jeffrey Friedl's book Mastering Regular Expressions, perhaps the most popular reference among today's programmers. Friedl's book teaches programmers how best to use today's regular expression implementations, but not how best to implement them. What little text it devotes to implementation issues perpetuates the widespread belief that recursive backtracking is the only way to simulate an NFA. Friedl makes it clear that he neither understands nor respects the underlying theory.

Acknowledgements

Lee Feigenbaum, James Grimmelmann, Alex Healy, William Josephson, and Arnold Robbins read drafts of this article and made many helpful suggestions. Rob Pike clarified some of the history surrounding his regular expression implementation. Thanks to all.

References

[1] L. Peter Deutsch and Butler Lampson, “An online editor,” Communications of the ACM 10(12) (December 1967), pp. 793–799. http://doi.acm.org/10.1145/363848.363863
[2] Ville Laurikari, “NFAs with Tagged Transitions, their Conversion to Deterministic Automata and Application to Regular Expressions,” in Proceedings of the Symposium on String Processing and Information Retrieval, September 2000. http://laurikari.net/ville/spire2000-tnfa.ps
[3] M. Douglas McIlroy, “Enumerating the strings of regular languages,” Journal of Functional Programming 14 (2004), pp. 503–518. http://www.cs.dartmouth.edu/~doug/nfa.ps.gz (preprint)
[4] R. McNaughton and H. Yamada, “Regular expressions and state graphs for automata,” IRE Transactions on Electronic Computers EC-9(1) (March 1960), pp. 39–47.
[5] Paul Pierce, “CTSS source listings.” http://www.piercefuller.com/library/ctss.html (Thompson's QED is in the file com5 in the source listings archive and is marked as 0QED)
[6] Rob Pike, “The text editor sam,” Software—Practice & Experience 17(11) (November 1987), pp. 813–845. http://plan9.bell-labs.com/sys/doc/sam/sam.html
[7] Michael Rabin and Dana Scott, “Finite automata and their decision problems,” IBM Journal of Research and Development 3 (1959), pp. 114–125. http://www.research.ibm.com/journal/rd/032/ibmrd0302C.pdf
[8] Dennis Ritchie, “An incomplete history of the QED text editor.” http://plan9.bell-labs.com/~dmr/qed.html
[9] Ken Thompson, “Regular expression search algorithm,” Communications of the ACM 11(6) (June 1968), pp. 419–422. http://doi.acm.org/10.1145/363347.363387 (PDF)
[10] Tom Van Vleck, “The IBM 7094 and CTSS.” http://www.multicians.org/thvv/7094.html

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