用 Java 实现一个正则表达式引擎

实现一个正则表达式需要几步?

就三步:

  • 分析正则表达式并构建出NFA
  • 根据NFA得出DFA
  • 根据DFA匹配字符串
    当然,这只是最基本的,但是可以了解到正则表达式的实现原理,这篇文章实现三个最基本的正则操作:
  • 连接 abc 匹配 abc
  • 或 ab|cd 匹配 ab或cd
  • 重复 a* 匹配 任意多个a

(功能较完备的正则引擎:https://github.com/Qzhangqi/Regex)

第一步 (分析正则表达式并构建出NFA )

例子:ab(c|d)*
把正则中的每个字母都表示成两个节点用一条边相连,如表示 a

节点和图的数据结构

class Node {
    int id; //节点的 id, 生成新节点时自动生成, 自增长
    Map<Character, List<Node>> nextNodes; //Character 表示边上的字符(转换条件)
    boolean isEnd   = false; //是否是结束节点
}

class Graph {
    Node start; //图的开始节点
    Node end;  //图的结束节点
}

当读入一个a时这样处理

Node start = new Node();
Node end = new Node();
start.addNextNode('a', end);
graph = new Graph(start, end);

ab 两个字母首尾相连这样表示

Graph seriesGraph(Graph graph1, Graph graph2) {
     graph1.end.addNextNode(' ', graph2.start);
     graph1.end = graph2.end;
     return graph1;
}

(c|d) 括号内的就整体处理和算术一样 | 号这样表示

void parallelGraph(Graph graph1, Graph graph2) {
        Node start = new Node();
        Node end   = new Node();
        start.addNextNode(' ', graph1.start);
        start.addNextNode(' ', graph2.start);
        graph1.end.addNextNode(' ', end);
        graph2.end.addNextNode(' ', end);
        graph1.start = start;
        graph1.end = end;
    }

(c|d)* *号加一条从尾到首的空转移

void repeatGraph(Graph graph) {
        graph.end.addNextNode(' ', graph.start);
}

再把ab和(c|d)*连起来,最后结果是这样的


好了,这就一个NFA(非确定有限状态机)了, 你肯定也想到了,匹配字符串时就是将字符一个一个读入,然后根据读入的字符在 1 2 3 4 5 ....这些状态之间转换,然后判断是否到了结束状态就可以得出是否匹配成功,但是NFA和字符串进行匹配效率太低,原因有二:

  • 空边无用,需要消除,空边是构造NFA时起辅助作用的,匹配时就不再需要了
  • 转移状态非确定,需要合并相同边,考虑这样一种情况:

这在读入 a 时是转换到 2 状态,还是 3 状态,如果随机进入一个状态,到后面不匹配还需要回溯,影响性能。

第二步(根据NFA得出DFA )

例子是一个这样的正则表达式:a(a|b|c)*cba,使用上一步的办法构造出来的NFA是这样的


a(a|b|c)*cba

当把它转换成一个DFA时,我们决定不用 Node 这样的数据结构来储存这张图了,而是用一张表

class StateTable {
    State[][] stateTable; //状态表
    Map<Character, Integer> mapx; //转换条件到数组横坐标的映射
// 当你要实现一个像 \w 这样的通配字符时,可以用整形储存转换条件,然后再加一个映射关系
    Map<State, Integer> mapy; //状态到数组纵坐标的映射
}
class State {
    List<Integer> id; //状态的 id, 状态将有多个 id
    boolean isEnd; //是否是结束态
}

拿上图中的前四个点举个例

a b c
{0} {9} null null null
{9} null null null {7}
{7} null null null {1,3,5}
{1} {2} null null null

开始转换了

先把表的列确定下来,在读入正则时根据不同的字符填充 mapx

a b c

然后对起始点 bfs 但是这里的 bfs 有点不一样,只有空边的下一个节点才会添加入 bfs 队列,而非空边的下一节点直接加入 stateTable 状态表,这样就消除了空边
ps:这里给出的代码都是伪代码,是帮助理解的,和真正程序里的代码是有区别的,比如没有记录一个 State 是否是结束状态,在实现时要注意

    //startNode      起始点
    void start(Node startNode) {
        State state = new State();
        state.nodes.add(startNode);
        addline(state);
    }
    //添加一行
    void addline(State state) {
        for (Node node : state.nodes) {
            bfs(node, state);
        }
    }

    void bfs(Node snode, State state) {
        ArrayList<Node> bfsNodes = new ArrayList<>(); // bfs队列
        bfsNodes.add(snode);
        while (!bfsNodes.isEmpty()) {
            Node node = bfsNodes.remove(0);
            for (int i : node.nextNodes.keySet()) {
                for (Node node0 : node.nextNodes.get(i)) {
                    //上两行不用管,知道这里 node0 是 snode 的下一个节点
                    // i 是 snode 到 node0 的转换条件
                    if (!node0.look) {
                        node0.look = true;
                        if ((char)i != ' ')
                            //                    列号    行号       状态id
                            stateTable.addState((char)i, state, node0.getId());
                        else
                            bfsNodes.add(node0);
                    }
                }
            }
        }
    }

处理完头节点,这个表就这样了

a b c
{0} {9} null null

把刚刚添加的这一行中所有的 State 添加进一个队列 (添加过的不重复添加),从队列中一个一个取出 State 进行处理,直到队列空

void start(Node startNode) {
        State state = new State();
        state.nodes.add(startNode);
        ArrayList<State> states = new ArrayList<>(); //状态队列
        states.add(state);
        while (!states.isEmpty()) {
            State state1 = states.pollFirst();
            addline(state1);
            //这个函数会根据 state1(行号 mapy中映射成 y 坐标)
            //去查 stateTable 将这一行中的所有状态添加进 states
            //添加时还会和 stateTable 中的所有 行号 比较,有了的就不添加了
            stateTable.add(state1, states);
        }
    }

完成后 stateTable 就是这个样子, 这就是 DFA (确定有限状态机)


a(a|b|c)*cba

第三步(根据DFA匹配字符串)

这步比较简单了,直接看代码

    public boolean match(String matched) {
        //头一行的行号
        State state = stateTable.getFirstLine();
        for (int i = 0; i < matched.length ; i++) {
            char ch = matched.charAt(i);
            //根据 行号 列号 获得状态
            state = stateTable.get(state, ch);
        }
        return state.isEnd;
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,780评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,424评论 1 305
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,397评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,576评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,997评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,945评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,107评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,850评论 0 208
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,625评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,804评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,285评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,613评论 3 261
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,291评论 3 242
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,164评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,963评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,096评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,886评论 2 278