• Regular Expressions


    正则表达式

    Regular Expressions

    在文本中查找子字符串只是寻找一个单一的字符串,但经常的我们可能不知道这个字符串的完整信息,或是寻找的是吻合某种模式的一些字符串,即所谓 模式匹配(Pattern Matching)

    正则表达式(Regular Expressions) 就是用来描述模式的,表示符合某种模式的字符串的集合(可能是无限的),它有下面几种基本操作:

    regexp-1

    连接、或、闭包和括号,都不难理解。其中闭包表示若干个自身连接,可以是零个。

    为了便于表示实际的模式,一般还会有些额外的缩略写法:

    regexp-2

    通配符(.)可以表示任意字符,包含在方括号中的一系列字符(- 可表示范围)表示这些字符中的任意一个,加号表示至少连接一次的特殊闭包,花括号可以指定次数(也可以用 - 指定允许的次数范围)。还有问号表示连接一次或零次的闭包,^ 表示补集,构造正则的元字符可以加上反斜杠来转义等等。

    正则表达式很强大,可以描述很多模式,但不易读,也就不容易调试,要小心使用。而且,也具有一定的局限性,像没法描述 A 和 B 数量一样的字符串。

    REs And NFAs

    其实,正则表达式和确定型有穷自动机间存在着二元性(duality),即 Kleene 定理所说:对任意 DFA 存在着描述同样字符串集合的正则表达式,对任意正则表达式存在着识别同样字符串集合的 DFA。例子:

    duality

    实现模式匹配的第一次尝试是模仿 KMP 算法,从正则表达式构造 DFA,用输入文本来转移状态,能到终结状态就表示匹配成功。同样的,好处是在输入流中没有回退,也有线性时间的性能保证。但是,这方法并不可行,因为正则表达式对应的 DFA 的状态数目可能是指数级的。

    于是乎,我们来了解下非确定型有限状态自动机(Nondeterministic finite state automata,NFA),状态间的转移是不确定的。正则表达式匹配的 NFA:

    • 正则表达式用括号括起。
    • 正则每个符号对应 NFA 一个状态,再加个接受状态。
    • 接受空串 (epsilon),不扫描下个字符而直接改变状态(下图红线),不确定性所在。
    • 扫描字符,匹配转移到一下个状态(下图黑线)。
    • 在扫描完全部文本字符后,如果有 任一 转移序列到达接受状态,则匹配成功。

    NFA

    因为 NFA 接受空串,不扫描字符也能改变状态,所以同一输入在 NFA 会有很多条路可走。只要有一条走到了接受状态,那就匹配成功。如果不匹配的话,那也要走完所有可能的路。所以,下面我们要解决如何系统地考虑所有可能的转移序列。

    NFA-simulation

    首先我们这样来表示 NFA:用整数 0 到 M(正则长度)来标号状态(像上图),用数组 re 来存储正则表达式,用有向图来存储空转移((epsilon-transitions),上图红线)。

    至于怎么模拟 NFA 输入文本运行,感觉类似广搜,维护每一步所有可能走到的状态,下一步再拓展这些状态,要是文本流结束那步的状态里包含接受状态,就表示匹配成功。例图:

    nfa-simulation

    每一步可以走到的状态(reachable),可以用课程之前介绍过的深搜来获得,需要的时间和 E + V 成正比。

    public class NFA {
        private char[] re;    // match transitions
        private Digraph G;    // epsilon transition digraph
        private int M;        // number of states
    
        public NFA(String regexp) {
            M = regexp.length();
            re = regexp.toCharArray();    
            G = buildEpsilonTransitionDigraph();
        }
    
        public boolean recognizes(String txt) {
            // states reachable from start by epsilon transitions
            Bag<Integer> pc = new Bag<Integer>();
            DirectedDFS dfs = new DirectedDFS(G, 0);
            for (int v = 0; v < G.V(); v++)
                if (dfs.marked(v)) pc.add(v);
    
            for (int i = 0; i < txt.length(); i++) {
                // states reachable after scanning past txt.charAt(i)
                Bag<Integer> match = new Bag<Integer>();
                for (int v : pc) {
                    if (v == M) continue;
                    // 匹配时直接加上下一个状态
                    if ((re[v] == txt.charAt(i)) || re[v] == '.')
                        match.add(v + 1);
                }
    
                // follow epsilon transitions
                dfs = new DirectedDFS(G, match);    // 拓展上一步的所有状态
                pc = new Bag<Integer>();
                for (int v = 0; v < G.V(); v++)
                    if (dfs.marked(v)) pc.add(v);
            }
    
            // accept if can end in state M
            for (int v : pc)
                if (v == M) return true;
            return false;
        }
    
        public Digraph buildEpsilonTransitionDigraph() {
            // 见下一小节
        }
    }
    

    最坏情况下,在长度为 N 的文本中寻找长度为 M 的模式,需要的时间和 MN 成正比。对于文本中的每个字符,我们都要对 (epsilon) 转移有向图来次深搜,而下面要介绍的构造转移图的方法可以保证其边数不超过 3M,即意味着深搜时间和 M 成正比,所以总共是正比于 MN。

    NFA-construction

    现在我们再来具体说说如何构造 NFA。

    States

    NFA 的状态数为 M + 1,用 0 - M 间的整数来表示,最后是接受状态,没什么好说的。

    states

    Concatnation

    针对连接操作,将表示字符的状态指向下一个状态,即字符匹配时的情况,不用显式构造,见上一节代码。

    match

    Parentheses

    括号主要是为了下面的闭包和或操作服务的,它自己就直接加条 (epsilon) 转移,表示可以直接到下一状态。

    parentheses

    Closure

    闭包分为下面两种情况,也很好理解,实现下面再说。

    closure-1

    例子里只有第一种情况。

    closure-2

    Or

    或操作两边应有括号括起才合情合理,所以就这一种情况。

    or-1

    例子再来两条空转移。

    or-2

    为了实现后面两种跨度较大的转移,我们需要借助栈这一数据结构。扫描正则表达式构造 NFA 时,碰到左括号和或操作符就压入栈(状态编号),碰到右括号就弹出栈顶状态号,如果状态对应的是或操作符,那或操作要加的两条空转移的三个状态号现在都可以知道,因为左括号状态肯定在栈顶现在。而闭包的话,只要向前看一个字符就好,具体看代码。

    private Digraph buildEpsilonTrnsitionDigraph() {
        Digraph G = new Digraph(M + 1);
        Stack<Integer> ops = new Stack<Integer>();
        for (int i = 0; i < M; i++) {
            int lp = i;    // 左括号或当前扫描字符所在状态标号
            // left parentheses and |
            if (re[i] == '(' || re[i] == '|') ops.push(i);
            else if (re[i] == ')') {
                int or = ops.pop();
                if (re[or] == '|') {
                    lp = ops.pop();
                    G.addEdge(lp, or + 1);
                    G.addEdge(or, i);
                }
                else lp = or;    // 不是或操作就是左括号,更新为闭包服务
            }
            // closure
            // needs 1-character lookahead
            if (i < M - 1 && re[i + 1] == '*') {
                G.addEdge(lp, i + 1);
                G.addEdge(i + 1, lp);
            }
            // metasymbols
            if (re[i] == '(' || re[i] == '*' || re[i] == ')')
                G.addEdge(i, i + 1);
        }
        return G;
    }
    

    上面的构造过程,需要的时间和空间都只是正比于 M 的级别。因为,从上面添加边的过程可以看出,对于长度为 M 的正则表达式,每个字符我们最多只有加三条空转移边和两个对栈的操作。

    Applications

    课程举了挺多例子,这里不做展开。正则表达式模式匹配的起源是 Unix 的命令 grep (Generalized regular expression print,另说 Global search a regular expression and print),而现在正则表达式则已经被内置于许多现代编程系统之中,像 Perl、Python 和 Javascript。

  • 相关阅读:
    《软件工程》-第三章随笔
    《软件工程》-第二章随笔
    《软件工程》-第一章随笔
    软件工程——理论、方法与实践③
    软件工程——理论、方法与实践②
    软件工程——理论、方法与实践①
    爬虫之SCRAPY
    爬虫之 App 爬取
    flask bootstrap
    爬虫之协程,selenium
  • 原文地址:https://www.cnblogs.com/mingyueanyao/p/9495243.html
Copyright © 2020-2023  润新知