一直打算写KMP算法的笔记,但是对这个算法的推导过程着实有点恐惧,但是又不能停到这里不往下去学,我也不想跳过,所以决定硬着头皮认真写下去,总有一天会写完的。
KMP算法的作用用是尽可能简单的方法比较两个字符串,普通的字符串匹配算法有很多步都是多余的。
先看一下普通的字符串匹配算法(也叫朴素的匹配算法):
//返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回0
//T非空,1≤pos≤StrLength(s)
int Index(char *S, char *T, int pos)
{
int i = pos; //i用于主串S中当前位置下标,若pos不为1
//则从pos位置开始匹配
int j = 1; //j用于子串T中当前位置下标值
while(i <= S[0] && j <= T[0]) //若i小于S的长度且j小于T的长度时循环
{
if(S[i] == T[j]) //两字母相等则继续
{
++i;
++j;
}
else //指针后退重新开始匹配
{
i = i-j+2; //i退回到上次匹配首位的下一位,
//i = i-(j-1)+1=i-j+2,减去循环
//的次数(j从1开始,所以循环了j-1次),
//加1回到上次匹配首位的下一位。
j = 1; //j退回到子串T的首位
}
}
if(j > T[0])
return i-T[0];
else
return 0;
}
普通的字符串匹配算法每次匹配到不相同的字符时子串都要回溯到主串上次匹配首位的下一位,这样匹配的效率太低了。
KMP算法主要就是要掌握next数组和nextval数组的推导,next数组和nextval数组的推导过程都能看懂,但是要让人看懂,很有必要花大量文字描述推导的过程,所以当我看到这里的时候,感觉很头大,当时也是我缺乏耐心,心情浮躁,推导过程虽多,但简单易懂,只要认真看,肯定能看懂;静不下心来看推导过程是一个问题,强行向将文字翻译成代码是我第二个耐心耗光的原因,这也是我钻牛角尖了,这算法是算法大师写出来经过了好多年的流传,怎么可能让我一下子想出来,现在想想我想通过文字推导过程转换为简单易懂的代码就是个笑话,也许以后可以,但是现在肯定做不到。所以要看懂代码最方便的方法就是找几个例子带到代码中体验一下算法的逻辑,这不难,就是要有耐心,要静下心来慢慢理解。
那next数组和nextval数组是干什么的呢?
举例说明:主串S=“abcdefgab”,子串T=“abcdex”,普通的字符串匹配过程是:
显然,对于子串T来说,“abcdex"首字母"a"与后面的串"bcdex"中任意一个字符都不相等,所以过程①比较完后,过程②③④⑤的判断都是多余的。那如果子串后面的字符有的和前面的字符相等呢?
再看个例子:主串S=“abcabeabc”,子串T=“abcabx”。
上面的推导也都是根据朴素匹配算法推导的,和第一个推导类似,②③是多余的,T的首位"a”,与第四位的"a"相等,第二位的"b"与第五位的"b"相等。而在①时,第四位的"a"与第五位的"b"已经与主串S中的相应位置比较过了,是相等的,因此可以断定,T的首字符"a"、第二位的字符"b"与S的第四位字符和第五位字符也不需要比较了,肯定也是相等的——之前比较过了,所以④⑤两个步骤也可以省略。
上面的两个推导,在①中,i=6,在②③④⑤过程中,i值是2、3、4、5,到了⑥,i值才又回到了6,即在朴素的模式匹配算法中,主串的i值是不断地回溯的,经分析,这种回溯其实可以不需要。
既然i值不回溯,那么要考虑的变化就是j值了,j值的变化与主串没什么关系,关键就取决于T串的结构中是否有重复的问题。
所以next数组就用来保存T串各个位置的j值的变化。注意是j值的变化,j值和j值的变化是不一样的。
这里给几个例子,但不描述详细的推导过程:
由上述几个例子可知,当前字符之前的串如果前缀和后缀有一个字符相等,next[j]就等于2,前缀和后缀有两个字符相等,next[j]就等于3,n个相等next[j]就是n+1。
这里还得再不厌其烦的介绍下前缀和后缀,比如,对于串T=“abcab”,其前缀和后缀相等的字符就是"ab"。再比如串T=“ababaaa”,其前缀和后缀相等的字符就是"a"。了解了这,判断当前字符之前的串的前缀和后缀相等字符的数量就很容易了。
光有next数组的值还不能完全排除多余的过程,还得需要nextval数组的值,这在后面再说,先来看看求next数组的代码:
//通过计算返回子串T的next数组
void get_next(char *T, int *next)
{
int i,j;
i=1;
j=0;
next[1]=0;
while(i<T[0]) //此处T[0]表示串T的长度
{
if(j==0 || T[i]==T[j]) //T[i]表示后缀的单个字符,
//T[j]表示前缀的单个字符
{
++i;
++j;
next[i] = j;
}
else
j = next[j]; //若字符不相同,则j值回溯
}
}
理解代码一个好方法就是将例子带到程序中,可以将上面几个已经求好next数组值的字符串带到程序中自己在纸上写出next数组值,看看是不是一样。
再来看看将next数组应用到朴素匹配算法中的代码:
//返回子串T在主串中第pos个字符之后的位置。若不存在,则函数返回值为0
//T非空,1≤pos≤StrLength(S)
int Index_KMP(char *S, char *T, int pos)
{
int i = pos; //i用于主串S当前位置下标值,若pos
//不为1,则从pos位置开始匹配
int j = 1; //j表示子串T中当前位置下标值
int next[255]; //定义next数组
get_next(T,next); //对串T作分析,得到next数组
while(i <= S[0] && j <= T[0]) //若i小于S的长度且j小于
//T的长度时,循环继续
{
if(j==0 || S[i] == T[j]) //两字母相等则继续,与朴素算法
//相比,增加了j==0判断
{
++i;
++j;
}
else //指针后退重新开始匹配
{
j = next[j]; //j退回合适的位置,i值不变
}
}
if(j > T[0])
return i-T[0];
else
return 0;
}
上面提到next数组不能完全排除多余的过程,所以还有nextval数组,先看一下next数组不能排除多余过程的例子:
主串S=“aaaabcde”,子串T=“aaaaax”,其next数组值分别为012345,在开始时,当i=5、j=5时,"b"与"a"不相等,如下图①,因此j=next[5]=4,如下图②,此时"b"与j=4处的"a"依然不等,j=next[4]=3,如下图的③,后面④⑤也是一样,直到j=next[1]=0时,此时i++、j++,得到i=6、j=1,如下图的⑥。
可以发现,②③④⑤步骤都是多余的,T串的第二、三、四、五位置的字符都与首位的“a”相等,所以可以用首位next[1]的值取取代与它相等的字符后后续next[j]的值。
//通过计算返回子串T的next数组
void get_nextval(char *T, int *nextval)
{
int i,j;
i=1;
j=0;
nextval[1]=0;
while(i<T[0]) //此处T[0]表示串T的长度
{
if(j==0 || T[i]==T[j]) //T[i]表示后缀的单个字符,
//T[j]表示前缀的单个字符
{
++i;
++j;
if(T[i] != T[j]) //若当前字符与前缀字符不同
nextval[i] = j; //则当前的j为nextval在i位置的值
else
nextval[i] = nextval[j]; //如果与前缀字符相同,
//则将前缀字符的nextval值
//赋值给nextval在i位置的值
}
else
j = nextval[j]; //若字符不相同,则j值回溯
}
}