突破该死的无回溯模式匹配算法
关键字:无回溯模式匹配
本文是我研究张乃孝的《算法与数据结构-C语言描述(第二版)》中第三章“字符串”的无回溯模式匹配算法(P77)时的一些心得。这个算法只有简单的几行,却让我看了差不多两个晚上才看懂。为了让后来人不至于跟我一样花同样或甚至更多的时间在理解它上面(当然肯定还有不少比我厉害的牛人,你们就不用看了^_^)。特写下此心得。请在阅读以前,先让自己的心静下来,先要看懂算法的思想,不要拘泥于细节,然后根据思想并结合例子彻底搞懂。张乃孝先生本章的“小结”最后一段中说:“认真理解无回溯的模式匹配算法需要费一定的时间。不过一旦真正弄懂了这个算法,将会极大地增强你的学习兴趣。”所以请千万不要急功近利。当然,也可以根据你的实际情况,跳过有关段落。我尽量用最浅显易懂地方式为大家解说(大家会发现大部分内容来自书上,但经过我的精心整理^_^),大家可以结合书本来看,也可以完全抛开书本来看。
一、对朴素模式匹配的简单回顾
为了说明思想,只考虑极为简单的一般情况:
假设主串为:t=t(1) t(2) … t(m-1) … t(n-1),模式为:p=p(0) p(1) … p(m-1),这里的x(i)表示下标为i的字符(后同)。第一次匹配(图1):
j=m-1
t(0) t(1) … t(m-1) … t(n-1)
| | |
p(0) p(1) … p(m-1)
i=m-1
图1
这里的“|”表示比较上下对应的字符,j、i分别为控制变量即指向当前比较的字符(后同)。假设匹配失败,则j回到1,i回到0,开始下一次匹配(图2):
j=m
t(0) t(1) t(2) … t(m-1) t(m) … t(n-1)
| | | |
p(0) p(1) … p(m-2) p(m-1)
i=m-1
图2
如果仍然匹配失败,则继续下去,直到匹配成功或找不到这样的子串为止。可以看出,朴素模式匹配过程当中,如果匹配不成功需要进行下一次匹配,则必须回溯控制变量。
二、无回溯模式匹配
(一)、next数组的意义
搞懂next数组的意义是至关重要的第一点,这里我只解释next数组的意义,其他的请你先放一边,“分治法”不是我们常用的分析方法吗:)
在p与任意的t匹配时,若发现p(i)!=t(j),则意味着p(0),p(1),…,p(i-1)已与t中对应的字符进行过比较,而且是相等的,如图3所示:
j
t(0) … t(j-i+1) t(j-i) … t(j-1) t(j) … t(n-1)
= = !=
p(0) … p(i-1) p(i)
i
图3
这里的“=”表示上下对应的字符相等,“!=”表示上下对应的字符不相等。否则,便轮不到p(i)与t(j)比较。因此图4与图3等价:
j
t(0) … t(j-i+1) p(0) … p(i-1) t(j) … t(n-1)
= = !=
p(0) … p(i-1) p(i)
i
图4
接下来有点难理解。如图5所示:
{------后缀----} j
t(0) … t(j-i+1) p(0) … p(i-k) … p(i-1) t(j) …
= = |
p(0) … p(k-1) p(k) …
{------前缀----} i=k(小于图4中的i)
图5
把p右移i-k位(先不用管k是什么,移了再说),我们希望用p(k)与t(j)继续比较(也先不要问为什么可以这样做和为什么要这样做,接下来就是解释),这意味着t(j)和p(k)以前的比较工作都已经完成,即相当于p(0)…p(i-1)的一个前缀p(0)…p(k-1)与它的一个长度相同的后缀p(i-k)…p(i-1)已经相等。这就是k的意义:如果p(i)!=t(j)(图4),我们就直接把p右移i-k位(图5),用p(k)与t(j)继续比较,这里我们假设p(0)…p(k-1)与p(i-k)…p(i-1)已经相等了。我们把这样的k存储在对应于p(i)(图4中的p(i))的数组元素next[i]里,所以next数组每个元素的意义均为如此。也就是说,对于不同的p(i),0<=i<=m-1有不同的next[i]值也即不同的k值,使我们一旦发现p(i)!=t(j)便可以把p右移i-k位(有一些特殊情况,请先忽略),直接用p(k)与t(j)比较。可以看到,此时t的控制变量j并没有回溯,而k
现在来回答为什么要这么做,假设这样的k存在的话,j就不用回溯了,而且i也不用每次都回溯到0,大大减少了比较次数。
下面回答为什么可以这样做。前面我们是假设p(0)…p(k-1)与p(i-k)…p(i-1)已经相等了(所以才可以右移p)。然后我们再假设p(0)…p(k-1)与p(i-k)…p(i-1)是p(0)…p(i-1)中相等的而且是长度最大的前缀与后缀即这个k值是最大的(不算p(0)…p(i-1)与其本身),这样当p右移量小于i-k时,用不着比较,因为此时用长度大于k的前后缀对齐,比较结果肯定不相等(因为根据假设,k代表了相等的而且长度最大的前后缀);而当右移量大于i-k时,又可能错过成功匹配的机会。所以,右移量等于i-k是最合适的即这样的k值是最合适的。
下面,在假设已经求出了next数组的前提下,简单介绍无回溯模式匹配算法,完后,再介绍怎么求next数组(这是最难点)。
(二)、无回溯模式匹配算法
看懂本算法是至关重要的第二点。假设已经求出了next数组,请参照书本78页的“算法3.6”,为了不看书本也能看懂本算法,添加以下定义:
struct SeqString { /*字符串顺序存储*/
int MAXNUM; /*最大字符个数*/
int n; /*串的长度,n<=MAXNUM*/
char *c; /*指针,指向字符串的首地址*/
};
typedef struct SeqString * PSeqString;
下面是算法正文(除了注释,后面还有解说):
1 int pMatch(PseqString t,PSeqString p,int * next)
2 /*求p所指的串在t所指的串中第一次出现时,*/
3 /*p所指串的第一个元素在t所指的串中序号*/
4 /*变量next是数组next的第一个元素next[0]的地址*/
5 {
6 int i,j; /*i,j即为上面图中的i,j*/
7 i=0;j=0; /*初始化*/
8 while(i<(p->n)&&j<(t->n)) /*反复比较*/
9 {
10 if(i==-1||p->c[i]==t->c[j]) /*先不要管为什么要比较i==-1*/
11 {
12 i++;j++; /*继续匹配下一字符*/
13 }
14 else i=next[i]; /*j不变,i后退*/
15 }
16 if(i>=(p->n)) return(j-(p->n)+1); /*匹配成功,返回序号*/
17 else return 0; /*匹配失败*/
18 }
几点说明:
1、给p->n和t->n加上括号完全是为了可读性好一点,去掉没有任何影响。
2、不考虑第10行中的i==-1条件,很明显,条件p->c[i]==t->c[j]表示对应字符是否相等,如果相等,则继续比较下一个字符;如果不相等,进行i=next[i],表示i后退到next[i](即上文所说的k值,图5),也就相当于把p右移了i-k位,继续比较p->c[i]==t->c[j](这里的i==k)。
3、第10行中之所以比较i==-1是因为next[i]有可能等于-1(14行,先不管为什么),从而使i==-1。下面回答为什么next[i]有可能等于-1。见图4,当p(i)!=t(j)时,完全有可能这个时候的i==0,如果i==0,下一步应该用p(i)与t(j+1)进行比较,而不是右移i-k后比较p(i)与t(j)(实际上根本不存在这样的k值,即使令k==0也会造成死循环,请联系上文中阐述的k值的意义)。这就是上文所说的特殊情况。解决办法就是用k=-1来代表这种情况,所以算法中要检测i==-1。如果等于-1,第12行让i增至0,让j=j+1刚好满足p(i)与t(j+1)进行比较,从而巧妙地解决了这一问题。在其他情况时,k>=0。其中k==0代表p(0)…p(i-1)中相等的最大长度的前后缀长度为0(注意:这里的相等的前后缀不包括p(0)…p(i-1)与其本身,前文也有说明),也就是说,不存在相等的前后缀。这个时候用p(0)与t(j)比较是必要的而且不会造成死循环。
(三)、如何求next数组
知道怎么求next数组是至关重要的第三点,也是本算法中的难中之最。我就是卡在这个地方而苦思冥想了两个晚上。
在图5中我们假设了p(0)…p(i-1)的前缀p(0)…p(k-1)已经与其后缀p(i-k)…p(i-1)相等。见下图:
p(0) … p(i-k) … p(i-1) …
= =
p(0) … p(k-1)
图6
我们的目的是要求出对于每一个i(0<=i<=m-1)值,p的相等的且长度最大的前后缀长度,使得当我们每次碰到p(i)!=t(j)时可以利用它来把p右移i-k。根据假设,当p(i)!=t(j),p的相等的且长度最大的前后缀长度为k,即next[i]=k,如图6所示。那么当i增1即i=i+1时呢,这个k值又如何计算?也就是说换了是p(i+1)!=t(j)的话,如何求得p(0)…p(i)的相等的且长度最大的前后缀?经典的地方到了,请看下图:
i
p(0) … p(i-k) … p(i-1) p(i) …
= = |
p(0) … p(k-1) p(k)
k
图7
也许有人已经观察出一点什么来了。如果p(0)…p(i-1)中相等的且最大长度的前后缀长度为k,那么p(0)…p(i)中相等的且最大长度的前后缀长度不超过k+1,当p(i)==p(k)时取得k+1。即当p(i)==p(k)时,next[i+1]=k+1。如果p(i)!=p(k)呢,我们图7中的所有i换成j,所有k换成i及其上部的所有p换成t,见下图:
j
t(0) … t(j-i) … t(j-1) t(j) …
= = !=
p(0) … p(i-1) p(i)
i
图8
跟图3对比一下,其本质是一样的,图8表示的还是一个模式匹配问题。(针对图7)只不过这个时候p成了主串,既然这样,请把t彻底忘记,因为p(i)!=p(k),则可以按照类似的处理方法,我们假设p(0)…p(k-1)中相等的且最大长度的前后缀长度为k'。则next[k]=k'。可以把图7中的p(0)…p(k)右移i-k'。我们把这里的next[k]仍赋给k以便进一步递归下去(仍然处理的是图7但k变得越来越小)。接着又要轮到比较p(i)和新的p(k),分相等和不相等两种情况。相等则仍然是next[i+1]=k+1,不相等则又要假设k'。不相等的情况达到一定的次数,终归可以达到k==1甚至k==0的情况。k==0时,是特殊情况,next[i+1]==-1。当k==1时,则next[i+1]==0(可以看作-1+1)。从上面的分析可知,这是不断地递归和假设。而当k==1或者k==0时next[i+1]的值是已知的,从而可以把所有的假设值都求出来。这样,已知next[i]可以求出next[i+1]。从而所有的next元素都可以求出来。算法如下:
1 makeNext(PSeqString p,int * next)
2 {
3 int i=0,k=-1;
4 next[0]=-1; /*初始化*/
5 while(i<(p->n)-1) /*计算next[i+1]*/
6 {
7 while(k>=0&&p->c[i]!=p->c[k]) /*p(i)!=p(k)*/
8 {k=next[k];} /*递归*/
9 i++;k++;
10 next[i]=k; /*有待改进*/
11 }
12 }
几点说明:
1、第7行表明,一旦p(i)!=p(k)则不断地递归假设,可以看出由于k2、第9行表明,一旦p(i)==p(k),则next[i+1]=k+1。
3、在p(0)!=t(j)这样的特殊情况时,用-1标记即next[0]=-1,同时很巧妙地跟其他情况统一起来,比如求p(1)!=t(j)的情况时,next[1]=-1+1=0以及p(i)==p(k)时,next[i+1]=next[i]+1。
4、本算法尚可进一步改进:按上述算法求出k后,则当发现p(i)!=t(j)时,要用p(k)去跟t(j)比较,如果p(k)==p(i),用p(k)与t(j)比较也必不相等。由此可预先进行这个比较。把第10行改为:
if(p->c[i]==p->c[k])next[i]=next[k];
else next[i]=k;