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

@[toc]
今天是五一假期第一天枪蘑,這里先給大家拜個晚 咳咳!岖免!祝大家五一快樂岳颇,我這里給大家奉上一篇硬核教程。首先聲明颅湘,這篇文章不是教你如何寫正則表達式话侧,而是教你寫一個能執(zhí)行正則表達式的執(zhí)行引擎。 網(wǎng)上教你寫正則表達式的文章闯参、教程很多瞻鹏,但教你寫引擎的并不多。很多人認為我就是用用而已鹿寨,沒必要理解那么深新博,但知道原理是在修煉內功,正則表達式底層原理并不單單是用在這脚草,而是出現(xiàn)在計算機領域的各個角落赫悄。理解原理可以讓你以后寫字符串匹配時正則表達式能夠信手拈來,理解原理也是觸類旁通的基礎馏慨。廢話不多說埂淮,直接開始正式內容。

本文是我個人做的動手實踐性項目写隶,所以未完整支持所有語法倔撞,而且因為是用NFA實現(xiàn)的所以性能比生產(chǎn)級的執(zhí)行引擎差好多。目前源碼已開放至https://github.com/xindoo/regex樟澜,后續(xù)會繼續(xù)更新误窖,歡迎Star、Fork 提PR秩贰。

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

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

前置知識

聲明:本文不是入門級的文章霹俺,所以如果你想看懂后文的內容,需要具備以下的基本知識毒费。

  1. 基本的編程知識丙唧,雖然這里我是用java寫的,但并不要求懂java觅玻,懂其他語法也行想际,基本流程都是類似,就是語法細節(jié)不同溪厘。
  2. 了解正則表達式胡本,知道簡單的正則表達式如何寫。
  3. 基本的數(shù)據(jù)結構知識畸悬,知道有向圖的概念侧甫,知道什么是遞歸和回溯。

有限狀態(tài)機

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

比如我今年18歲,我現(xiàn)在就是處于18歲的狀態(tài)须板,如果時間過了一年碰镜,我就變成19歲的狀態(tài)了,再過一年就20了习瑰。當然我20歲時時光倒流2年我又可以回到18歲的狀態(tài)绪颖。這里我們就可以把我的年齡狀態(tài)和時間流逝之間的關系用一個自動機表示出來,如下甜奄。


在這里插入圖片描述

每個圈代表一個節(jié)點表示一種狀態(tài)柠横,每條有向邊表示一個狀態(tài)到另一個狀態(tài)的轉移條件。上圖中狀態(tài)是我的年齡课兄,邊表示時間正向或者逆向流逝牍氛。

有了狀態(tài)機之后,我們就可以用狀態(tài)機來描述特定的模式烟阐,比如上圖就是年齡隨時間增長的模式搬俊。如果有人說我今年18歲紊扬,1年后就20歲了。照著上面的狀態(tài)機我們來算下唉擂,1年后你才19歲餐屎,你這不是瞎說嗎! 沒錯玩祟,狀態(tài)機可以來判定某些內容是否符合你狀態(tài)機描述的模式了腹缩。喲,一不小心就快扯到正則表達式上了空扎。
我們這里再引入兩種特殊的狀態(tài):起始態(tài)接受態(tài)(終止態(tài))藏鹊,見名知意,不用我過多介紹了吧转锈,起始態(tài)和終止態(tài)的符號如下盘寡。

在這里插入圖片描述

我們拿狀態(tài)機來做個簡單的字符串匹配。比如我們有個字符串“zsx”撮慨,要判斷其他字符串是否和"zxs"是一致的宴抚,我們可以為"zxs"先建立一個自動機,如下甫煞。
在這里插入圖片描述

對于任意一個其他的字符串菇曲,我們從起始態(tài)0開始,如果下一個字符能匹配到0后邊的邊上就往后走抚吠,匹配不上就停止常潮,一直重復,如果走到終止態(tài)3說明這個字符串和”zxs“一樣楷力。任意字符串都可以轉化成上述的狀態(tài)機喊式,其實到這里你就知道如何實現(xiàn)一個只支持字符串匹配的正則表達式引擎了,如果想支持更多的正則語義萧朝,我們要做的更多岔留。

狀態(tài)機下的正則表達式

我們再來引入一條特殊的邊,學名叫\epsilon閉包(emm检柬!看到這些符號我就回想起上學時被數(shù)學支配的恐懼)献联,其實就是一條不需要任何條件就能轉移狀態(tài)的邊。

在這里插入圖片描述

沒錯何址,就只這條紅邊本邊了里逆,它在正則表達式狀態(tài)機中起著非常重要的連接作用,可以不依賴其他條件直接跳轉狀態(tài)用爪,也就是說在上圖中你可以直接從1到2原押。
有了 閉包的加持,我們就可以開始學如何畫正則表達式文法對應的狀態(tài)機了偎血。

串聯(lián)匹配

首先來看下純字符匹配的自動機诸衔,其實上面已經(jīng)給過一個"zxs"的例子了盯漂,這里再貼一下,其實就是簡單地用字符串在一起而已笨农,如果還有其他字符宠能,就繼續(xù)往后串。

在這里插入圖片描述

兩個表達式如何傳在一起磁餐,也很簡單,加入我們已經(jīng)有兩個表達式A B對應的狀態(tài)機阿弃,我們只需要將其用串一起就行了诊霹。
在這里插入圖片描述

并連匹配 (正則表達式中的 |)

正則表達式中的| 標識二選一都可以,比如A|B A能匹配 B也能匹配渣淳,那么A|B就可以表示為下面這樣的狀態(tài)圖脾还。

在這里插入圖片描述

從0狀態(tài)走A或B都可以到1狀態(tài),完美的詮釋了A|B語義入愧。

重復匹配(正則表達式中的 ? + *)

正則表達式里有4中表示重復的方式鄙漏,分別是:

  1. ?重復0-1次
    • 重復1次以上
    • 重復0次以上
  2. {n,m} 重復n到m次
    我來分別畫下這4種方式如何在狀態(tài)機里表示棺蛛。

重復0-1次 怔蚌?

在這里插入圖片描述

0狀態(tài)可以通過E也可以依賴直接跳過E到達1狀態(tài),實現(xiàn)E的0次匹配旁赊。

重復1次以上

在這里插入圖片描述

0到1后可以再通過跳回來桦踊,就可以實現(xiàn)E的1次以上匹配了。

重復0次以上

在這里插入圖片描述

仔細看其實就是? +的結合终畅。

匹配指定次數(shù)

在這里插入圖片描述

這種建圖方式簡單粗暴籍胯,但問題就是如果n和m很大的話,最后生成的狀態(tài)圖也會很大离福。其實可以把指定次數(shù)的匹配做成一條特殊的邊杖狼,可以極大減小圖的大小。

特殊符號(正則表達式中的 . \d \s……)

正則表達式中還支持很多某類的字符妖爷,比如.表示任意非換行符蝶涩,\d標識數(shù)字,[]可以指定字符集…… 絮识,其實這些都和圖的形態(tài)無關子寓,只是某調特殊的邊而已,自己實現(xiàn)的時候可以選擇具體的實現(xiàn)方式笋除,比如后面代碼中我用了策略模式來實現(xiàn)不同的匹配策略斜友,簡化了正則引擎的代碼。

子表達式(正則表達式 () )

子表達可以Tompson算法垃它,其實就是用遞歸去生成()中的子圖鲜屏,然后把子圖拼接到當前圖上面烹看。(什么Tompson說的那么高大上,不就是遞歸嗎B迨贰)

練習題

來聯(lián)系畫下 a(a|b)* 的狀態(tài)圖惯殊,這里我也給出我畫的,你可以參考下也殖。

在這里插入圖片描述

代碼實現(xiàn)

建圖

看完上文之后相信你一直知道如果將一個正則表達式轉化為狀態(tài)機的方法了土思,這里我們要將理論轉化為代碼。首先我們要將圖轉化為代碼標識忆嗜,我用State表示一個節(jié)點己儒,其中用了Map<MatchStrategy, List<State>> next表示其后繼節(jié)點,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表示一個完整的圖,其中封裝了對圖的操作绩卤,比如其中就實現(xiàn)了上文中圖串 并連和重復的操作(注意我沒有實現(xiàn){})途样。

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;
    }

}

整個建圖的過程就是依照輸入的字符建立邊和節(jié)點之間的關系,并完成圖的拼接濒憋。

   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;
            }
        }
    }

這里我用了設計模式中的策略模式將不同的匹配規(guī)則封裝到不同的MatchStrategy類里何暇,目前我實現(xiàn)了. \d \D \s \S \w \w,具體細節(jié)請參考代碼凛驮。這么設計的好處就是簡化了匹配策略的添加赖晶,比如如果我想加一個\x 只匹配16進制字符,我只需要加個策略類就好了辐烂,不必改很多代碼遏插。

匹配

其實匹配的過程就出從起始態(tài)開始,用輸入作為邊纠修,一直往后走胳嘲,如果能走到終止態(tài)就說明可以匹配,代碼主要依賴于遞歸和回溯扣草,代碼如下了牛。

    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;
    }

下集預告

還有下集?沒錯辰妙,雖然到這里已經(jīng)是實現(xiàn)了一個基本的正則表達式引擎鹰祸,但距離可用在生產(chǎn)環(huán)境還差很遠,預告如下密浑。

功能完善化

本身上面的引擎對正則語義支持不是很完善蛙婴,后續(xù)我會繼續(xù)完善代碼,有興趣可以收藏下源碼https://github.com/xindoo/regex尔破,但應該不會出一篇新博客了街图,因為原理性的東西都在這里浇衬,剩下的就是只是一些編碼工作 。

DFA引擎

上文只是實現(xiàn)了NFA引擎餐济,NFA的引擎建圖時間復雜度是O(n)耘擂,但匹配一個長度為m的字符串時因為涉及到大量的遞歸和回溯,最壞時間復雜度是O(mn)絮姆。與之對比DFA引擎的建圖時間復雜度O(n^2)醉冤,但匹配時沒有回溯,所以匹配復雜度只有O(m)篙悯,性能差距還是挺大的蚁阳。

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

正則引擎優(yōu)化

首先DFA引擎是可以繼續(xù)優(yōu)化的匿垄,使用Hopcroft算法可以近一步將DFA圖壓縮移宅,更少的狀態(tài)節(jié)點更少的轉移邊可以實現(xiàn)更好的性能。其次椿疗,目前生產(chǎn)級的正則引擎很多都不是單純用NFA或者DFA實現(xiàn)的漏峰,而是二者的結合,不同正則表達式下用不同的引擎可以達到更好的綜合性能届榄,簡單說NFA圖小但要回溯浅乔,DFA不需要回溯但有些情況圖會特別大。敬請期待我后續(xù)博文铝条。

本文由博客一文多發(fā)平臺 OpenWrite 發(fā)布靖苇!

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市班缰,隨后出現(xiàn)的幾起案子贤壁,更是在濱河造成了極大的恐慌,老刑警劉巖埠忘,帶你破解...
    沈念sama閱讀 211,639評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件脾拆,死亡現(xiàn)場離奇詭異,居然都是意外死亡莹妒,警方通過查閱死者的電腦和手機名船,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,277評論 3 385
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來旨怠,“玉大人渠驼,你說我怎么就攤上這事〖澹” “怎么了渴邦?”我有些...
    開封第一講書人閱讀 157,221評論 0 348
  • 文/不壞的土叔 我叫張陵疯趟,是天一觀的道長。 經(jīng)常有香客問我谋梭,道長信峻,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,474評論 1 283
  • 正文 為了忘掉前任瓮床,我火速辦了婚禮盹舞,結果婚禮上乡小,老公的妹妹穿的比我還像新娘晃痴。我一直安慰自己,他們只是感情好寺鸥,可當我...
    茶點故事閱讀 65,570評論 6 386
  • 文/花漫 我一把揭開白布丑掺。 她就那樣靜靜地躺著获印,像睡著了一般。 火紅的嫁衣襯著肌膚如雪街州。 梳的紋絲不亂的頭發(fā)上兼丰,一...
    開封第一講書人閱讀 49,816評論 1 290
  • 那天,我揣著相機與錄音唆缴,去河邊找鬼鳍征。 笑死,一個胖子當著我的面吹牛面徽,可吹牛的內容都是我干的艳丛。 我是一名探鬼主播,決...
    沈念sama閱讀 38,957評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼趟紊,長吁一口氣:“原來是場噩夢啊……” “哼氮双!你這毒婦竟也來了?” 一聲冷哼從身側響起霎匈,我...
    開封第一講書人閱讀 37,718評論 0 266
  • 序言:老撾萬榮一對情侶失蹤眶蕉,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后唧躲,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體造挽,經(jīng)...
    沈念sama閱讀 44,176評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,511評論 2 327
  • 正文 我和宋清朗相戀三年弄痹,在試婚紗的時候發(fā)現(xiàn)自己被綠了饭入。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,646評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡肛真,死狀恐怖谐丢,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤乾忱,帶...
    沈念sama閱讀 34,322評論 4 330
  • 正文 年R本政府宣布讥珍,位于F島的核電站,受9級特大地震影響窄瘟,放射性物質發(fā)生泄漏衷佃。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,934評論 3 313
  • 文/蒙蒙 一蹄葱、第九天 我趴在偏房一處隱蔽的房頂上張望氏义。 院中可真熱鬧,春花似錦图云、人聲如沸惯悠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,755評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽克婶。三九已至,卻和暖如春丹泉,著一層夾襖步出監(jiān)牢的瞬間情萤,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,987評論 1 266
  • 我被黑心中介騙來泰國打工嘀掸, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留紫岩,地道東北人规惰。 一個月前我還...
    沈念sama閱讀 46,358評論 2 360
  • 正文 我出身青樓睬塌,卻偏偏與公主長得像,于是被迫代替她去往敵國和親歇万。 傳聞我的和親對象是個殘疾皇子揩晴,可洞房花燭夜當晚...
    茶點故事閱讀 43,514評論 2 348