從0到1打造正規表示式執行引擎(一)

xindoo發表於2020-07-01

我這裡給大家奉上一篇硬核教程。首先宣告,這篇文章不是教你如何寫正規表示式,而是教你寫一個能執行正規表示式的執行引擎。 網上教你寫正規表示式的文章、教程很多,但教你寫引擎的並不多。很多人認為我就是用用而已,沒必要理解那麼深,但知道原理是在修煉內功,正規表示式底層原理並不單單是用在這,而是出現在計算機領域的各個角落。理解原理可以讓你以後寫字串匹配時正規表示式能夠信手拈來,理解原理也是觸類旁通的基礎。廢話不多說,直接開始正式內容。

本文是我個人做的動手實踐性專案,所以未完整支援所有語法,而且因為是用NFA實現的所以效能比生產級的執行引擎差好多。目前原始碼已開放至https://github.com/xindoo/regex,後續會繼續更新,歡迎Star、Fork 提PR。

目前支援的正則語義如下:

  • 基本語法: . ? * + () |
  • 字符集合: []
  • 特殊型別符號: d D s S w W

前置知識

宣告:本文不是入門級的文章,所以如果你想看懂後文的內容,需要具備以下的基本知識。

  1. 基本的程式設計知識,雖然這裡我是用java寫的,但並不要求懂java,懂其他語法也行,基本流程都是類似,就是語法細節不同。
  2. 瞭解正規表示式,知道簡單的正規表示式如何寫。
  3. 基本的資料結構知識,知道有向圖的概念,知道什麼是遞迴和回溯。

有限狀態機

有限狀態機(Finite-state machine),也被稱為有限狀態自動機(finite-state automation),是表示有限個狀態以及在這些狀態之間的轉移和動作等行為的數學計算模型(From 維基百科 狀態機) 。 聽起來晦澀難懂,我用大白話描述一遍,狀態機其實就是用圖把狀態和狀態之間的關係描述出來,狀態機中的一個狀態可以在某些給定條件下變成另外一種狀態。舉個很簡單的例子你就懂了。

比如我今年18歲,我現在就是處於18歲的狀態,如果時間過了一年,我就變成19歲的狀態了,再過一年就20了。當然我20歲時時光倒流2年我又可以回到18歲的狀態。這裡我們就可以把我的年齡狀態和時間流逝之間的關係用一個自動機表示出來,如下。
在這裡插入圖片描述
每個圈代表一個節點表示一種狀態,每條有向邊表示一個狀態到另一個狀態的轉移條件。上圖中狀態是我的年齡,邊表示時間正向或者逆向流逝。

有了狀態機之後,我們就可以用狀態機來描述特定的模式,比如上圖就是年齡隨時間增長的模式。如果有人說我今年18歲,1年後就20歲了。照著上面的狀態機我們來算下,1年後你才19歲,你這不是瞎說嗎! 沒錯,狀態機可以來判定某些內容是否符合你狀態機描述的模式了。喲,一不小心就快扯到正規表示式上了。
我們這裡再引入兩種特殊的狀態:起始態接受態(終止態),見名知意,不用我過多介紹了吧,起始態和終止態的符號如下。
在這裡插入圖片描述
我們拿狀態機來做個簡單的字串匹配。比如我們有個字串“zsx”,要判斷其他字串是否和"zxs"是一致的,我們可以為"zxs"先建立一個自動機,如下。
在這裡插入圖片描述
對於任意一個其他的字串,我們從起始態0開始,如果下一個字元能匹配到0後邊的邊上就往後走,匹配不上就停止,一直重複,如果走到終止態3說明這個字串和”zxs“一樣。任意字串都可以轉化成上述的狀態機,其實到這裡你就知道如何實現一個只支援字串匹配的正規表示式引擎了,如果想支援更多的正則語義,我們要做的更多。

狀態機下的正規表示式

我們再來引入一條特殊的邊,學名叫$\epsilon$閉包(emm!看到這些符號我就回想起上學時被數學支配的恐懼),其實就是一條不需要任何條件就能轉移狀態的邊。
在這裡插入圖片描述
沒錯,就只這條紅邊本邊了,它在正規表示式狀態機中起著非常重要的連線作用,可以不依賴其他條件直接跳轉狀態,也就是說在上圖中你可以直接從1到2。
有了 $\epsilon$閉包的加持,我們就可以開始學如何畫正規表示式文法對應的狀態機了。

串聯匹配

首先來看下純字元匹配的自動機,其實上面已經給過一個"zxs"的例子了,這裡再貼一下,其實就是簡單地用字串在一起而已,如果還有其他字元,就繼續往後串。
在這裡插入圖片描述
兩個表示式如何傳在一起,也很簡單,加入我們已經有兩個表示式A B對應的狀態機,我們只需要將其用$\epsilon$串一起就行了。
在這裡插入圖片描述

並連匹配 (正規表示式中的 |)

正規表示式中的| 標識二選一都可以,比如A|B A能匹配 B也能匹配,那麼A|B就可以表示為下面這樣的狀態圖。
在這裡插入圖片描述
從0狀態走A或B都可以到1狀態,完美的詮釋了A|B語義。

重複匹配(正規表示式中的 ? + *)

正規表示式裡有4中表示重複的方式,分別是:

  1. ?重複0-1次
    • 重複1次以上
    • 重複0次以上
  2. {n,m} 重複n到m次

我來分別畫下這4種方式如何在狀態機裡表示。

重複0-1次 ?

在這裡插入圖片描述
0狀態可以通過E也可以依賴$\epsilon$直接跳過E到達1狀態,實現E的0次匹配。

重複1次以上

在這裡插入圖片描述
0到1後可以再通過$\epsilon$跳回來,就可以實現E的1次以上匹配了。

重複0次以上

在這裡插入圖片描述
仔細看其實就是? +的結合。

匹配指定次數

在這裡插入圖片描述
這種建圖方式簡單粗暴,但問題就是如果n和m很大的話,最後生成的狀態圖也會很大。其實可以把指定次數的匹配做成一條特殊的邊,可以極大減小圖的大小。

特殊符號(正規表示式中的 . d s……)

正規表示式中還支援很多某類的字元,比如.表示任意非換行符,d標識數字,[]可以指定字符集…… ,其實這些都和圖的形態無關,只是某調特殊的邊而已,自己實現的時候可以選擇具體的實現方式,比如後面程式碼中我用了策略模式來實現不同的匹配策略,簡化了正則引擎的程式碼。

子表示式(正規表示式 () )

子表達可以Tompson演算法,其實就是用遞迴去生成()中的子圖,然後把子圖拼接到當前圖上面。(什麼Tompson說的那麼高大上,不就是遞迴嗎!)

練習題

來聯絡畫下 a(a|b)* 的狀態圖,這裡我也給出我畫的,你可以參考下。
在這裡插入圖片描述

程式碼實現

建圖

看完上文之後相信你一直知道如果將一個正規表示式轉化為狀態機的方法了,這裡我們要將理論轉化為程式碼。首先我們要將圖轉化為程式碼標識,我用State表示一個節點,其中用了Map<MatchStrategy, List<State>> next表示其後繼節點,next中有個key-value就是一條邊,MatchStrategy用來描述邊的資訊。

public class State {
    private static int idCnt = 0;
    private int id;
    private int stateType;

    public State() {
        this.id = idCnt++;
    }

    Map<MatchStrategy, List<State>> next = new HashMap<>();

    public void addNext(MatchStrategy path, State state) {
        List<State> list = next.get(path);
        if (list == null) {
            list = new ArrayList<>();
            next.put(path, list);
        }
        list.add(state);
    }
    protected void setStateType() {
        stateType = 1;
    }
    protected boolean isEndState() {
        return stateType == 1;
    }
}

NFAGraph表示一個完整的圖,其中封裝了對圖的操作,比如其中就實現了上文中圖串 並連和重複的操作(注意我沒有實現{})。

public class NFAGraph {
    public State start;
    public State end;
    public NFAGraph(State start, State end) {
        this.start = start;
        this.end = end;
    }

    // |
    public void addParallelGraph(NFAGraph NFAGraph) {
        State newStart = new State();
        State newEnd = new State();
        MatchStrategy path = new EpsilonMatchStrategy();
        newStart.addNext(path, this.start);
        newStart.addNext(path, NFAGraph.start);
        this.end.addNext(path, newEnd);
        NFAGraph.end.addNext(path, newEnd);
        this.start = newStart;
        this.end = newEnd;
    }

    //
    public void addSeriesGraph(NFAGraph NFAGraph) {
        MatchStrategy path = new EpsilonMatchStrategy();
        this.end.addNext(path, NFAGraph.start);
        this.end = NFAGraph.end;
    }

    // * 重複0-n次
    public void repeatStar() {
        repeatPlus();
        addSToE(); // 重複0
    }

    // ? 重複0次哦
    public void addSToE() {
        MatchStrategy path = new EpsilonMatchStrategy();
        start.addNext(path, end);
    }

    // + 重複1-n次
    public void repeatPlus() {
        State newStart = new State();
        State newEnd = new State();
        MatchStrategy path = new EpsilonMatchStrategy();
        newStart.addNext(path, this.start);
        end.addNext(path, newEnd);
        end.addNext(path, start);
        this.start = newStart;
        this.end = newEnd;
    }

}

整個建圖的過程就是依照輸入的字元建立邊和節點之間的關係,並完成圖的拼接。

   private static NFAGraph regex2nfa(String regex) {
        Reader reader = new Reader(regex);
        NFAGraph NFAGraph = null;
        while (reader.hasNext()) {
            char ch = reader.next();
            MatchStrategy matchStrategy = null;
            switch (ch) {
                // 子表示式特殊處理
                case '(' : {
                    String subRegex = reader.getSubRegex(reader);
                    NFAGraph newNFAGraph = regex2nfa(subRegex);
                    checkRepeat(reader, newNFAGraph);
                    if (NFAGraph == null) {
                        NFAGraph = newNFAGraph;
                    } else {
                        NFAGraph.addSeriesGraph(newNFAGraph);
                    }
                    break;
                }
                // 或表示式特殊處理
                case '|' : {
                    String remainRegex = reader.getRemainRegex(reader);
                    NFAGraph newNFAGraph = regex2nfa(remainRegex);
                    if (NFAGraph == null) {
                        NFAGraph = newNFAGraph;
                    } else {
                        NFAGraph.addParallelGraph(newNFAGraph);
                    }
                    break;
                }
                case '[' : {
                    matchStrategy = getCharSetMatch(reader);
                    break;
                }
                case '^' : {
                    break;
                }
                case '$' : {
                    break;
                }
                case '.' : {
                    matchStrategy = new DotMatchStrategy();
                    break;
                }
                // 處理特殊佔位符
                case '\\' : {
                    char nextCh = reader.next();
                    switch (nextCh) {
                        case 'd': {
                            matchStrategy = new DigitalMatchStrategy(false);
                            break;
                        }
                        case 'D': {
                            matchStrategy = new DigitalMatchStrategy(true);
                            break;
                        }
                        case 'w': {
                            matchStrategy = new WMatchStrategy(false);
                            break;
                        }
                        case 'W': {
                            matchStrategy = new WMatchStrategy(true);
                            break;
                        }
                        case 's': {
                            matchStrategy = new SpaceMatchStrategy(false);
                            break;
                        }
                        case 'S': {
                            matchStrategy = new SpaceMatchStrategy(true);
                            break;
                        }
                        // 轉義後的字元匹配
                        default:{
                            matchStrategy = new CharMatchStrategy(nextCh);
                            break;
                        }
                    }
                    break;
                }

                default : {  // 處理普通字元
                    matchStrategy = new CharMatchStrategy(ch);
                    break;
                }
            }

            // 表明有某類單字元的匹配
            if (matchStrategy != null) {
                State start = new State();
                State end = new State();
                start.addNext(matchStrategy, end);
                NFAGraph newNFAGraph = new NFAGraph(start, end);
                checkRepeat(reader, newNFAGraph);
                if (NFAGraph == null) {
                    NFAGraph = newNFAGraph;
                } else {
                    NFAGraph.addSeriesGraph(newNFAGraph);
                }
            }
        }
        return NFAGraph;
    }

    private static void checkRepeat(Reader reader, NFAGraph newNFAGraph) {
        char nextCh = reader.peak();
        switch (nextCh) {
            case '*': {
                newNFAGraph.repeatStar();
                reader.next();
                break;
            } case '+': {
                newNFAGraph.repeatPlus();
                reader.next();
                break;
            } case '?' : {
                newNFAGraph.addSToE();
                reader.next();
                break;
            } case '{' : {
                //
                break;
            }  default : {
                return;
            }
        }
    }

這裡我用了設計模式中的策略模式將不同的匹配規則封裝到不同的MatchStrategy類裡,目前我實現了. d D s S w w,具體細節請參考程式碼。這麼設計的好處就是簡化了匹配策略的新增,比如如果我想加一個x 只匹配16進位制字元,我只需要加個策略類就好了,不必改很多程式碼。

匹配

其實匹配的過程就出從起始態開始,用輸入作為邊,一直往後走,如果能走到終止態就說明可以匹配,程式碼主要依賴於遞迴和回溯,程式碼如下。

    public boolean isMatch(String text) {
        return isMatch(text, 0, nfaGraph.start);
    }

    private boolean isMatch(String text, int pos, State curState) {
        if (pos == text.length()) {
            if (curState.isEndState()) {
                return true;
            }
            return false;
        }

        for (Map.Entry<MatchStrategy, List<State>> entry : curState.next.entrySet()) {
            MatchStrategy matchStrategy = entry.getKey();
            if (matchStrategy instanceof EpsilonMatchStrategy) {
                for (State nextState : entry.getValue()) {
                    if (isMatch(text, pos, nextState)) {
                        return true;
                    }
                }
            } else {
                if (!matchStrategy.isMatch(text.charAt(pos))) {
                    continue;
                }
                // 遍歷匹配策略
                for (State nextState : entry.getValue()) {
                    if (isMatch(text, pos + 1, nextState)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

下集預告

還有下集?沒錯,雖然到這裡已經是實現了一個基本的正規表示式引擎,但距離可用在生產環境還差很遠,預告如下。

功能完善化

本身上面的引擎對正則語義支援不是很完善,後續我會繼續完善程式碼,有興趣可以收藏下原始碼https://github.com/xindoo/regex,但應該不會出一篇新部落格了,因為原理性的東西都在這裡,剩下的就是隻是一些編碼工作 。

DFA引擎

詳見從0到1打造正規表示式執行引擎(二)

上文只是實現了NFA引擎,NFA的引擎建圖時間複雜度是O(n),但匹配一個長度為m的字串時因為涉及到大量的遞迴和回溯,最壞時間複雜度是O(mn)。與之對比DFA引擎的建圖時間複雜度O(n^2),但匹配時沒有回溯,所以匹配複雜度只有O(m),效能差距還是挺大的。

DFA引擎實現的大體流程是先構造NFA(本文內容),然後用子集構造法將NFA轉化為DFA,預計未來我會出一篇部落格講解細節和具體實現。

正則引擎優化

首先DFA引擎是可以繼續優化的,使用Hopcroft演算法可以近一步將DFA圖壓縮,更少的狀態節點更少的轉移邊可以實現更好的效能。其次,目前生產級的正則引擎很多都不是單純用NFA或者DFA實現的,而是二者的結合,不同正規表示式下用不同的引擎可以達到更好的綜合效能,簡單說NFA圖小但要回溯,DFA不需要回溯但有些情況圖會特別大。敬請期待我後續博文。

相關文章