正则引擎入门——基于虚拟机的正则匹配(二)

前言

  整篇文章是对作者Russ Cox的文章Regular Expression Matching: the Virtual Machine Approach的翻译,在我看来,该文章是入门正则引擎的较好的文章之一,读者在阅读之前,最好有一定的正则表达式的基础。翻译内容仅代表作者观点。侵删
  该作者所有的文章的网址在此:https://swtch.com/~rsc/regexp/

正文

非递归回溯实现

  递归的回溯实现形式会持续运行一个线程直到它终止,并且以颠倒的顺序运行被创建的线程(新的最先运行)。等待运行得线程并没有被明确的编码,相反的,它们被间接地以pcsp的值的方式存储在了C的调用栈中。如果同一时刻有过多的线程等待运行,C的调用栈可能会溢出,造成难以调试的错误。这种问题最常出现在处理重复如 .* 的时候,它会在每一个字符之后都创建一个新的线程(正如上边的例子中的a+所做的那样)。这会在多线程的程序中造成很大的困扰,因为它们常常对栈的大小有所限制,同时也没有对于栈溢出的硬件检查。
  我们可以显式的维护一个线程栈代替C的调用栈来避免栈溢出。开始之前,我们定义一个Thread结构体和一个简单的构造函数:

struct Thread 
{
    Inst *pc;
    char *sp;
};

Thread thread(Inst *pc, char *sp);

  然后虚拟机会重复地把线程从ready列表中取出并让它运行到结束。如果一个线程匹配成功了,那么我们可以提前结束:剩下的线程没有必要再运行了。如果所有线程在结束前都没有匹配成功,那匹配失败了。我们对等待运行的线程数量设置一个简单的限制,当到达这个最大值时,程序会报错。

int backtrackingvm(Inst *prog, char *input)
{
    enum { MAXTHREAD = 1000 };
    Thread ready[MAXTHREAD];
    int nready;
    Inst *pc;
    char *sp;

    /* 队列初始化线程 */
    ready[0] = thread(prog, input);
    nready = 1;

    /* 根据在栈中存储的顺序执行各个线程 */
    while(nready > 0)
    {
        --nready;  /* 将状态 pop 出来执行下一个线程  */
        pc = ready[nready].pc;
        sp = ready[nready].sp;
        for(;;)
        {
            switch(pc->opcode)
            {
                case Char:
                    if(*sp != pc->c)
                        goto Dead;
                    pc++;
                    sp++;
                    continue;
                case Match:
                    return 1;
                case Jmp:
                    pc = pc->x;
                    continue;
                case Split:
                    if(nready >= MAXTHREAD)
                    {
                        fprintf(stderr, "regexp overflow");
                        return -1;
                    }
                    /* 新线程入队 */
                    ready[nready++] = thread(pc->y, sp);
                    pc = pc->x;  /* 继续当前线程 */
                    continue;
            }
        }
    Dead:;
    }
    return 0;
}

  这个实现与recursiverecursiveloop的性能表现是一致的,它只不过没有使用C的栈而已。让我们比较一下两者的Split

/* recursiveloop */
case Split:
    if(recursiveloop(pc->x, sp))
        return 1;
    pc = pc->y;
    continue;


/* backtrackingvm */
case Split:
    if(nready >= MAXTHREAD)
    {
        fprintf(stderr, "regexp overflow");
        return -1;
    }
    /* queue new thread */
    ready[nready++] = thread(pc->y, sp);
    pc = pc->x;  /* continue current thread */
    continue;

  两者都展现出了回溯,但是backtrackingvm的代码是显式实现的,而recursiveloop的代码是隐式实现的:在递归之后将PC和SP都保存起来这样在当前线程失败之后还可以尝试其他线程。显式的维护线程栈为增加溢出检查提供了可能性。

Thompson的实现

  将正则匹配看做是在虚拟机中运行线程,我们可以提供Ken Thompson算法的另一个展现形式,其中一个与我们在上一篇文章中展示的Thompson的PDP-11自动机代码很接近。
  Thompson注意到回溯需要程序多次扫描输入的字符串的某个部分。为了避免这种情况,他创建了一个虚拟机,在该虚拟机下运行的线程均处于单步模式(lock step):它们都处理字符串的第一个字符,之后都处理第二个,这样继续下去。这是可行的因为新创建的线程绝不会向后查看字符串,所以它们能够和现存的线程一同处于单步状态。

struct Thread
{
    Inst *pc;
};
Thread thread(Inst *pc);

  在我们的工程中,Thompson的实现方式如下:

int thompsonvm(Inst *prog, char *input)
{
    int len;
    ThreadList *clist, *nlist;
    Inst *pc;
    char *sp;

    len = proglen(prog);  /* # of instructions */
    clist = threadlist(len);
    nlist = threadlist(len);

    addthread(clist, thread(prog));
    for(sp=input; *sp; sp++)
    {
        for(i=0; i<clist.n; i++)
        {
            pc = clist.t[i].pc;
            switch(pc->opcode)
            {
                case Char:
                    if(*sp != pc->c)
                        break;
                    addthread(nlist, thread(pc+1));
                    break;
             case Match:
                    return 1;
                case Jmp:
                    addthread(clist, thread(pc->x));
                    break;
                case Split:
                    addthread(clist, thread(pc->x));
                    addthread(clist, thread(pc->y));
                 break;
            }
        }
        swap(clist, nlist);
        clear(nlist);
    }
}

  设想正则表达式的程序一共有n条指令,由于线程的唯一状态便是程序计数(PC),因此最多只有n个不同的线程会出现在clistnlist。如果addthread在遇到相同(pc相等)的线程时不会将该线程加入列表,那ThreadLists只需要n个线程的存储空间就可以了,消除了栈溢出的可能性。
  列表中最多只有n个线程也会为处理每一个字符节约大量的时间。假设addthread的时间复杂度为O(1),那处理一个字符的最坏情况也才O(n),因此处理整个字符串的复杂度为O(mn)。这比本质上没有具体时限的回溯要好很多。(这也消除了上文所提到的无限循环。)
  严格的说,回溯的虚拟机实现没有理由不使用相同的技巧,确保在遇到相同(具有相同的pc和sp)的线程时不把它放入队列中。这样做会需要处理nm个可能的线程:每一个对应一个pc和sp数据对。
  使用一个20字节的正则表达式去匹配一个百万字节的文本是常见的。在这种情况下,n最多为40,但是nm可以高达4千万。(按照如今的标准,一个百万字节的文本是很小的)Thompson实现带来的好处在于,由于所有的线程都处于单步模式,因此在任何一个给定的点上只会有n个可能的线程。这种实现通过使匹配独立于文本长度来减少了需要存储的数据量。

局部匹配

  将正则匹配看作是编译后的字节码可以让添加新的特性更加的简单,如添加局部匹配(submatch tracking),我们只需定义新的字节码并实现它们就可以了。
  为了增加这一特性,我们需要在线程状态中增加一个保存字符指针的数组。新的字节码的指令save i会将当前的字符指针保存到当前线程的指针数组的第i个位置。正则表达式(e)会存储e所匹配的字符串,为了编译它,我们会将save指令放到e的代码的周围。对于第k个括号对,我们使用第2k个位置存储开始位置,使用第2k+1个位置保存结束位置。
  例如,我们比较a+b+(a+)(b+)编译后的代码:

a+b+              (a+)(b+)

                    0   save 2
0   char a          1   char a
1   split 0, 2      2   split 1, 3
                    3   save 3
                    4   save 4
2   char b          5   char b
3   split 2, 4      6   split 5, 7
                    7   save 5
4   match           8   match

  如果我们想找到整个匹配的边界,我们可以使用save 0save 1来包括整个字节码段。
  在recursiveloop中实现save指令是很直接的(saved[pc->i]=sp),除了在匹配失败时需要将所有的记录都清除掉。这样就将成功的线程与失败的线程隔离开来。

int recursiveloop(Inst *pc, char *sp, char **saved)
{
    char *old;

    for(;;)
    {
        switch(pc->opcode)
        {
            case Char:
                if(*sp != pc->c)
                    return 0;
                pc++;
                sp++;
                break;
         case Match:
                return 1;
            case Jmp:
             pc = pc->x;
             break;
         case Split:
              if(recursiveloop(pc->x, sp, saved))
                 return 1;
             pc = pc->y;
             break;
            case Save:
                old = saved[pc->i];
                saved[pc->i] = sp;
                if(recursiveloop(pc+1, sp, saved))
                    return 1;
                /* restore old if failed */
                saved[pc->i] = old;
                return 0;
        }
    }
}

  注意到case Save有一个不可避免的递归调用,正如case Split中的那样,Save中的递归比Split中的递归更难用循环代替,将Save写入backtrackingvm中也需要更多的努力。正因为如此,众多的程序员愿意保留递归,即便这样会有栈溢出的可能性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值