引言
KMP算法是一种著名的字符串模式匹配算法,这篇文章的重点在于如何理解KMP算法中求next和nextval数组的代码,并且建立一个直观感性的认知,帮助读者理解为什么这些代码能够找到最长相等前后缀和避免无效匹配。
本文是满满的干货,若有条件最好跟着文中思路自己写一写,以快速建立一个较为深刻的理解
在正式开始之前,还需要对一些概念做一些说明。
概念说明
前缀
前缀是一个字符串除了最后一个字符外以第一个字符为开头的所有子串,例如字符串ababa的前缀有a,ab,aba,abab,而bab不是前缀(没有以第一个字符开头)
后缀
与前缀相似,后缀是一个字符串除了第一个字符外以最后一个字符为结尾的所有子串,例如ababa的后缀有a,ba,aba,baba,可以看到都是以最后一个字符a结尾的
相等前后缀与最长相等前后缀
相等前后缀,顾名思义,即同一个字符串的完全相同的前缀和后缀,以上述字符串ababa为例,
a和aba都是相等前后缀,而最长相等前后缀就是相等前后缀中长度最长的那个,即aba
KMP算法简介
KMP算法是对暴力匹配算法BF算法的改进,在匹配失败时,主串指针不需回溯,模式串指针不必回溯到开头,以减少不必要的匹配,提高效率。
原理是利用已经成功匹配的子串的信息,使得下一次尝试匹配时模式串可以尽量移动到真正需要判断的位置,而问题是我们怎么知道把模式串放到哪才是有效位置呢?下面我们来看一个例子
若S为主串:abcababca,T为模式串:abcabx
则利用BF算法的过程如下:
注:此处图片引用自图解KMP算法,如侵删
我们可以看到,当a和x匹配失败时,前面的abcab子串已经匹配成功了。
(1)首先,如果我们知道a不等于后面的b和c,那么步骤②和③都是可以省略的,是无效判断,因为他们一定不相等。
(2)另外,如果我们知道对于abcab子串,前面的ab等于后面的ab,那么步骤④和⑤也同样可以省略,因为他们一定相等。
综上,对于一个子串来讲,如果我们知道这个子串前后的不重叠与重叠关系,就能省去很多不必要的判断,从而减少时间开销,提高效率,这就是KMP的思想
那么我们如何知道子串的重叠关系呢?显而易见就是找到该子串的最长相等前后缀,相等的前后缀对应上述(2),而前缀与不匹配的后缀的关系对应上述(1)
在KMP算法中,找寻子串的最长相等前后缀的过程就是确定next和nextval(next的修正值)数组值的过程,下面我将会从直观的角度介绍我对next,nextval数组的理解
KMP中next,nextval的理解
next
始终记得目的:寻找模式串的最长相等前后缀,此时与主串无关
代码
typedef struct
{
char str[MAX_LEN];
int length;
} String;
void get_next(String s_pattern, int next[])
{
int len = 0;//记录当前长度(之前成功匹配的信息),标志前缀的最右位置
next[0] = 0;//第一个字符没有相等的前后缀
int i = 1;//用i遍历模式串,从前往后寻找最长相等前后缀,标志后缀最右位置
while (i < s_pattern.length)
{
if (s_pattern.str[i] == s_pattern.str[len])
{
len++;
next[i] = len;//刚开始都是从最短开始试,只不过后面用到了前面成功匹配的信息(len)
i++;//寻找下一个位置的最长相等前后缀
}
else
{
if(len==0)//没有相等的前后缀
{
next[i] = 0;
i++;//判断下一个位置
}
else
{
len = next[len - 1];//回溯到上一次匹配成功相等前后缀的位置,降低要求,尝试以更短的前后缀匹配是否相等
}
}
}
}
代码解释:假设从左往右遍历字符串,下标从0开始,判断的是下标[0,i]子串的最长相等前后缀
(1)用len记录当前已经寻找到相等前后缀的长度,同时标志前缀的最右位置(之后在例子里会解释),同时,用i遍历字符串,记录所有位置的next值,同时标志后缀的最右位置。初始化next[0]=0,因为只有一个字符的字符串一定没有相等的前后缀
(2)用i遍历字符串,对于每一个位置做如下判断:
a)如果i位置字符等于len位置:
len(已找到的相等前后缀长度)+1,i位置的next值就是len值,然后把i++以判断下一个位置,注意,这里的len是保留下来的,既代表了之前成功匹配的相等前后缀长度,又可用于后续赋值
b)如果i位置字符不等于len位置:
如果此时len=0,代表在之前没有成功匹配的相等前后缀,当前位置又不等,那么这个位置的最长相等前后缀长度自然就是0
重点是len!=0的情况,这种情况下在之前有成功匹配的相等前后缀,只是当前位置不等,类似于aba和abc在c位置匹配的情况,此时我们只需要把len回溯到上一个成功匹配的位置即可,降低要求重新尝试匹配
看到这里,也许你对上述加粗的文字很不理解,下面我们直接看一个例子
例子
下面我们来考虑确定字符串aaaba的next值(最好有一张纸自己画画看),我们来走一遍next代码
(1)初始化len=0,next[0]=0,i=1
(2)判断i=1位置的next值:此时len=0,a=a——>len++,next[1]=1,i++,直观理解:此时子串为aa,判断的实际为前缀a和后缀a
(3)i=2位置,此时len=1,a=a——>len++,next[2]=2,i++,到这你可能会疑惑:不是找最长相等前后缀吗,怎么判断两个字符相等就能判断前后缀相等呢?
这就是我要说的重点:len标志前缀的最右位置,i标志后缀的最右位置,比较len和i位置的字符实际上就相当于比较了分别以len和i为边界的前缀和后缀,这一步的核心是用到了迭代的思想。
让我们结合例子直观理解:此时子串为aaa,因为(2)中已经判断前缀a=后缀a,这个信息随着len=1传递给(3)(迭代),所以我们只需判断此时i位置=len位置的字符,那么判断的实际为前缀aa和后缀aa,成功匹配后len为2,即最长相等前后缀(aa)的长度,再次传递给下一步(迭代)
(4)i=3位置,此时len=2,len位置的a!=i位置的b——>len=next[len-1]=1,我们来看看这一步到底干了啥:此时判断的子串为aaab,结合(3),我们首先判断的应该是前缀aaa是否等于后缀aab,显然是不等的,之后把len回溯到1,也就是第二个a的位置,此时i不变。再次进循环确定next[i]时,len=1,判断的实际是前缀aa是否等于后缀ab(由len确定比较的前后缀长度)(对长度的要求太高了,缩短比较的前后缀长度后再次尝试匹配),也不等,len=next[len-1]=0,再次进循环,前缀a仍然不等于后缀b,此时len=0,next[3]=0,i++
(5)此时i=4,len=0,i位置的b=len位置的a,len++,next[4]=1,i++后退出循环。此时你可能又有疑惑:根据(3),此时匹配的不只是前缀a和后缀a吗,怎么确定不会有更长的前后缀能匹配呢?
让我们来看看现在所有其他“可能”匹配的前后缀
前缀:aa,aaa,aaab
后缀:ba,aba,aaba
让我们再来回想一下len是怎么回到0的:回看(4)
a)前缀aaa!=后缀aab,那么aaax一定!=aaby(x,y为任意字符),对应上述前缀aaab!=后缀aaba
b)前缀aa!=后缀ab,那么aax一定!=aby,对应上述aaa!=aba
c)前缀a!=后缀b,那么ax一定!=by,对应上述aa!=ba
我们可以很直观的看到,len=0中已经包含了其他前后缀一定不可能匹配的信息,所以我们才能直接next[4]=1,这同样也体现了算法的核心:迭代
注:len若回溯到非0值,则在想要增加前后缀长度时分析同上,想要减少长度时分析同(4)
不难理解,此结论对于其他任意字符串也同样成立
总结
从上述内容不难看出,求next值的算法核心是迭代(后一步利用了前面的信息或结论,而不是从头算起),通过len变量实现
一方面,len记录了之前已经成功匹配的相等前后缀长度,使得下一次只要判断相应位置的字符相等就相当于判断了整个前缀和后缀相等,避免了不必要的比较
另一方面,在len进行回溯后判断下一个位置next值时,len也包含了更长的前后缀不可能相等的信息,同样避免了不必要的比较
nextval
nextval即next的修正值,是对next数组的改进,它不只利用了之前已经成功匹配的信息,还利用了当前位置不匹配的信息,使得在某些情况下可以进一步避免不必要的比较
下面我们直接来看例子,来体会nextval在某些情况下的优势
例子
我们来考虑进行主串为aaaba,模式串为aaaa的KMP过程,此时next数组已经计算好了
i | next[i] |
0 | 0 |
1 | 1 |
2 | 2 |
3 | 3 |
下面进行KMP
i为主串索引,j为模式串索引(均从0开始),s_main为主串,s_pattern为模式串
前3个a正常匹配,第四次,i位置的b!=j位置的a,此时i不变,j=next[ j - 1 ]=2,再次进循环,j位置的a!=i位置的b,j=1,不等,j=0,仍不等,最后发现 i 这个位置匹配不了,i++。
我们可以发现由于模式串的前几个都是a,是相同的,所以当j=3时的a!=b时,就能判断出来前面的字符也一定不等于b,那这几次的比较就完全可以避免了。nextval数组的作用就是使得模式串指针在回溯时可以一步到位,不需进行上述多余的匹配
上述思路即:
若s_main[ i ]!=s_pattern[ j ]且s_pattern[ j ]==s_pattern[next[ j - 1 ]]时不断将 j 回溯,直到 j 在回溯前后所对应的字符是不同的为止(或者j回溯到0)
代码
注意,nextval与next一样,都只与模式串有关,s为模式串
此处代码下标均从1开始
void get_nextval(String s, int next_val[])//下标均从1开始
{
int i = 1, j = 0;
next_val[1] = 0;
while (i <= s.length)
{
// 这里和next数组有差别,判断是否s.str[i+1]==s.str[j+1]
if (j == 0 || s.str[i] == s.str[j])// 如果相等或者回溯到了头位置
{
i++;
j++;
// 判断s.str[i+1]是否等于s.str[j+1]
// 如果s.str[i+1]!=s.str[j+1]但是s.str[i] == s.str[j],这和next数组就一样了
if (s.str[i] != s.str[j])
{
next_val[i] = j;//相当于get_next中的j++,next[i]=j,i++
}
// 如果s.str[i+1]等于s.str[j+1],说明在回溯时应是无效回溯
else
{
next_val[i] = next_val[j];
/*因为最开始就是这样,所以往后迭代时无效的nextval[j]值会一直传递,这样的一步就相当于是直接回到了有效位置*/
}
}
else//此处与next一样
{
j = next_val[j];
}
}
}
代码解释:
是反着判断的,若s.str[ i ]=s.str[ j ]就说明在KMP中对模式串作回溯时有回溯后字符相同(无效回溯)的风险,而++后接下来的判断就是判断到底是不是会造成无效回溯:若相等说明都相同,是无效回溯,不相等说明回溯后是不相同的,是有效的
总结
nextval是对next的进一步改进,在模式串含有多个相同字符时可以节省大量时间
注:在计算nextval时仍然是通过一次一次迭代算出来的,而优点在于kmp中回溯使用时可以一步到位,避免不必要的比较
目录