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


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

本文是我個人做的動手實踐性項目,所以未完整支持所有語法,而且因爲是用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> 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();
            String edge = 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 '[' : {
                    edge = getCharSetMatch(reader);
                    break;
                }
                case '^' : {
                    break;
                }
                case '$' : {
                    break;
                }
                case '.' : {
                    edge = ".";
                    break;
                }
                // 處理特殊佔位符
                case '\\' : {
                    char nextCh = reader.next();
                    switch (nextCh) {
                        case 'd': {
                            edge = "\\d";
                            break;
                        }
                        case 'D': {
                            edge = "\\D";
                            break;
                        }
                        case 'w': {
                            edge = "\\w";
                            break;
                        }
                        case 'W': {
                            edge = "\\W";
                            break;
                        }
                        case 's': {
                            edge = "\\s";
                            break;
                        }
                        case 'S': {
                            edge = "\\S";
                            break;
                        }
                        // 轉義後的字符匹配
                        default:{
                            edge = String.valueOf(nextCh);
                            break;
                        }
                    }
                    break;
                }

                default : {  // 處理普通字符
                    edge = String.valueOf(ch);
                    break;
                }
            }
            if (edge != null) {
                NFAState start = new NFAState();
                NFAState end = new NFAState();
                start.addNext(edge, end);
                NFAGraph newNFAGraph = new NFAGraph(start, end);
                checkRepeat(reader, newNFAGraph);
                if (nfaGraph == null) {
                    nfaGraph = newNFAGraph;
                } else {
                    nfaGraph.addSeriesGraph(newNFAGraph);
                }
            }
        }
        return nfaGraph;
    }

這裏我用了設計模式中的策略模式將不同的匹配規則封裝到不同的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引擎

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

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

正則引擎優化

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

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