从0到1打造正则表达式执行引擎(一)

我这里给你们奉上一篇硬核教程。首先声明,这篇文章不是教你如何写正则表达式,而是教你写一个能执行正则表达式的执行引擎。 网上教你写正则表达式的文章、教程不少,但教你写引擎的并很少。不少人认为我就是用用而已,不必理解那么深,但知道原理是在修炼内功,正则表达式底层原理并不仅仅是用在这,而是出如今计算机领域的各个角落。理解原理可让你之后写字符串匹配时正则表达式可以信手拈来,理解原理也是举一反三的基础。废话很少说,直接开始正式内容。 java

本文是我我的作的动手实践性项目,因此未完整支持全部语法,并且由于是用NFA实现的因此性能比生产级的执行引擎差好多。目前源码已开放至https://github.com/xindoo/regex,后续会继续更新,欢迎Star、Fork 提PR。 git

目前支持的正则语义以下:github

  • 基本语法: . ? * + () |
  • 字符集合: []
  • 特殊类型符号: 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“同样。任意字符串均可以转化成上述的状态机,其实到这里你就知道如何实现一个只支持字符串匹配的正则表达式引擎了,若是想支持更多的正则语义,咱们要作的更多。segmentfault

状态机下的正则表达式

咱们再来引入一条特殊的边,学名叫$\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不须要回溯但有些状况图会特别大。敬请期待我后续博文。

相关文章
相关标签/搜索