算法#20--正则表达式匹配原理

本文不会介绍正则表达式的语法,重点介绍正则表达式匹配原理,算法实现。相信大家也都知道正则表达式应用强大之处,这里也不再介绍其应用范围。

1. 正则引擎

我们可以将前面KMP算法,看作一台由模式字符串构造的能够扫描文本的有限状态自动机。对于正则表达式,我们要将这个思想推而广之。

KMP的有限状态自动机会根据文本中的字符改变自身的状态。当且仅当自动机达到停止状态时它找到一个匹配。算法本身就是模拟这种自动机,这种自动机的运行很容易模拟的原因是因为它是确定性的:每种状态的转换都完全由文本中的字符所确定。

而正则表达式需要一种更加抽象的自动机(引擎),非确定有限状态自动机(NFA)。正则引擎大体上可分为不同的两类:DFA和NFA,而NFA又基本上可以分为传统型NFA和POSIX NFA。

DFA–Deterministic finite automaton 确定型有穷自动机

NFA–Non-deterministic finite automaton 非确定型有穷自动机

  • Traditional NFA
  • POSIX NFA

2. 引擎区别

  • DFA:

DFA 引擎在线性时状态下执行,因为它们不要求回溯(并因此它们永远不测试相同的字符两次)。DFA 引擎还可以确保匹配最长的可能的字符串。但是,因为 DFA 引擎只包含有限的状态,所以它不能匹配具有反向引用的模式;并且因为它不构造显示扩展,所以它不可以捕获子表达式。

  • NFA:

传统的 NFA 引擎运行所谓的“贪婪的”匹配回溯算法,以指定顺序测试正则表达式的所有可能的扩展并接受第一个匹配项。因为传统的 NFA 构造正则表达式的特定扩展以获得成功的匹配,所以它可以捕获子表达式匹配和匹配的反向引用。但是,因为传统的 NFA 回溯,所以它可以访问完全相同的状态多次(如果通过不同的路径到达该状态)。

因此,在最坏情况下,它的执行速度可能非常慢。因为传统的 NFA 接受它找到的第一个匹配,所以它还可能会导致其他(可能更长)匹配未被发现。

NFA最重要的部分:回溯(backtracking)。回溯就像是在道路的每个分岔口留下一小堆面包屑。如果走了死路,就可以照原路返回,直到遇见面包屑标示的尚未尝试过的道路。如果那条路也走不通,你可以继续返回,找到下一堆面包屑,如此重复,直到找到出路,或者走完所有没有尝试过的路。

  • POSIX NFA:

POSIX NFA 引擎与传统的 NFA 引擎类似,不同的一点在于:在它们可以确保已找到了可能的最长的匹配之前,它们将继续回溯。因此,POSIX NFA 引擎的速度慢于传统的 NFA 引擎;并且在使用 POSIX NFA 时,您恐怕不会愿意在更改回溯搜索的顺序的情况下来支持较短的匹配搜索,而非较长的匹配搜索。

DFA与NFA对比:

  • DFA对于文本串里的每一个字符只需扫描一次,比较快,但特性较少。

NFA要翻来覆去吃字符、吐字符,速度慢,但是特性丰富,所以反而应用广泛。
当今主要的正则表达式引擎,如Perl、Ruby、Python的re模块、Java和.NET的regex库,都是NFA的。

  • 只有NFA支持lazy、backtracking、backreference,NFA缺省应用greedy模式,NFA可能会陷入递归险境导致性能极差。

DFA只包含有穷状态,匹对相配过程中无法捕获子表达式(分组)的匹对相配结果,因此也无法支持backreference。

DFA不能支持捕获括号和反向引用。

POSIX NFA会继续尝试backtracking,以试图像DFA相同找到最长左子正则式。因此POSIX NFA速度更慢。

  • NFA是最左子式匹配,而DFA是最长左子式匹配。

  • NFA的编译过程通常要快一些,需要的内存也更少一些。

对于“正常”情况下的简单文本匹配测试,两种引擎的速度差不多。一般来说,DFA的速度与正则表达式无关,而NFA中两者直接相关。

  • 对正则表达式依赖性较量强的操作系统(大量应用正则做搜索匹对相配),最好完全把握NFA->DFA算法,充分理解所应用的正则表达式引擎的思想和特性。

3. 匹配过程

首先构造NFA,如下图:

它是一个有向图,边表示了引擎匹配时的运行轨迹。从起始状态0开始,到达1的位置(也就是“((”后),它有两种选择,可以走2,也可以走6,…直到最后的接受状态。

得到有向图后,匹配实现就简单多了。这里用到了有向图的多点可达性问题–DirectedDFS算法。

  1. 首先我们查找所有从状态0通过ε-转换可达的顶点(状态)来初始化集合。对于集合的每个顶点,检查它是否可能与第一个输入字符相匹配。检查之后,就得到了NFA在匹配第一个字符之后可能到达的其他顶点。这里还需要向该集合中加入所有从该集合中的任意状态通过ε-转换可以到达的顶点。

  2. 有了这个匹配第一个字符之后可能到达的所有顶点的集合,ε-转换有向图中的多点可达性问题的答案就是可能匹配第二个输入字符的顶点集合。

  3. 重复这个过程直到文本结束,得到两种结果:最后的集合含有可接受的顶点;不含有。

注释,什么是ε-转换。

4. NFA的构造

将正则表达式转化为NFA的过程在某种程度上类似于Dijkstra的双栈算法对表达式求值的过程。

构造规则:

逻辑很容易理解,请参考如下代码和轨迹图:


5. 代码实现

附上DirectedDFS和Digraph类.


public class NFA 
{ 
    private Digraph graph;     // digraph of epsilon transitions
    private String regexp;     // regular expression
    private int m;             // number of characters in regular expression

    /**
     * Initializes the NFA from the specified regular expression.
     *
     * @param  regexp the regular expression
     */
    public NFA(String regexp) 
    {
        this.regexp = regexp;
        m = regexp.length();
        Stack ops = new Stack(); 
        graph = new Digraph(m+1); 
        for (int i = 0; i < m; i++) 
        { 
            int lp = i; 
            if (regexp.charAt(i) == '(' || regexp.charAt(i) == '|') 
            {
                ops.push(i); 
            }
            else if (regexp.charAt(i) == ')') 
            {
                int or = ops.pop(); 

                // 2-way or operator
                if (regexp.charAt(or) == '|') 
                { 
                    lp = ops.pop();
                    graph.addEdge(lp, or+1);
                    graph.addEdge(or, i);
                }
                else if (regexp.charAt(or) == '(')
                {
                    lp = or;
                }
                else assert false;
            } 

            // closure operator (uses 1-character lookahead)
            if (i < m-1 && regexp.charAt(i+1) == '*') 
            { 
                graph.addEdge(lp, i+1); 
                graph.addEdge(i+1, lp); 
            } 
            if (regexp.charAt(i) == '(' || regexp.charAt(i) == '*' || regexp.charAt(i) == ')') 
            {
                graph.addEdge(i, i+1);
            }
        }
        if (ops.size() != 0)
        {    
            throw new IllegalArgumentException("Invalid regular expression");        
        }
    } 

    /**
     * Returns true if the text is matched by the regular expression.
     * 
     * @param  txt the text
     * @return {@code true} if the text is matched by the regular expression,
     *         {@code false} otherwise
     */
    public boolean recognizes(String txt) 
    {
        DirectedDFS dfs = new DirectedDFS(graph, 0);
        Bag pc = new Bag();
        for (int v = 0; v < graph.V(); v++)
        {
            if (dfs.marked(v)) pc.add(v);
        }

        // Compute possible NFA states for txt[i+1]
        for (int i = 0; i < txt.length(); i++) 
        {
            if (txt.charAt(i) == '*' || txt.charAt(i) == '|' || txt.charAt(i) == '(' || txt.charAt(i) == ')')
            {
                throw new IllegalArgumentException("text contains the metacharacter '" + txt.charAt(i) + "'");
            }

            Bag match = new Bag();
            for (int v : pc) 
            {
                if (v == m) 
                {
                    continue;
                }
                if ((regexp.charAt(v) == txt.charAt(i)) || regexp.charAt(v) == '.')
                {
                    match.add(v+1); 
                }
            }
            dfs = new DirectedDFS(graph, match); 
            pc = new Bag();
            for (int v = 0; v < graph.V(); v++)
            {
                if (dfs.marked(v)) pc.add(v);
            }

            // optimization if no states reachable
            if (pc.size() == 0) 
            {
                return false;
            }
        }

        // check for accept state
        for (int v : pc)
        {
            if (v == m) return true;
        }
        return false;
    }

    /**
     * Unit tests the {@code NFA} data type.
     *
     * @param args the command-line arguments
     */
    public static void main(String[] args) 
    {
        String regexp = "(" + "(A*B|AC)D" + ")";
        String txt = "AABD";
        NFA nfa = new NFA(regexp);
        System.out.println(nfa.recognizes(txt));
    }
} 

你可能感兴趣的:(算法,算法空间)