#KMP算法
真実はいつも一つ—KMP
KMP这个难题困扰了好久,为记录这段时间的辛苦,为给以后的我以启发,写下这点见解。
前世今生—前世
说KMP之前,我们先来说下**朴素的模式匹配算法***:在一个大字符串S中找子串T的定位操作。
假设从主串S="goodgoogle"中,找到T=“google”*这个子串的位置,我们通常按照以下步骤进行:
1)从主串S第一位开始,依次匹配,前三个字母都可以匹配成功,但S第四个字母是d而T的是g,此时匹配失败,如下图所示:竖直线表示相等,闪电符表示不等。
2)主串S第二位开始匹配,o与g匹配失败,如下图所示:
3)主串S第三位开始匹配,o与g匹配失败,如下图所示:
4)d与g匹配,依然匹配失败;
5)主串S第五位开始,S与T,6个字母全匹配,匹配成功,如下图所示:
简单的说,对主串的每一个字符作为子串开头,与要匹配的字符串进行匹配。对与代码结构,是利用两个循环,主串做大循环,每个字符开头做T的长度的小循环,直到匹配成功或全部遍历完成为止。
/*返回子串T在主串S中第pos个字符之后的位置,若不存在,则函数返回值为0*/
/*S[0],T[0]存放S,T的长度*/
/*T非空,1<=pos<=StrLength(S)*/
int Index(String S,String T,int pos)
{
int i = pos; //主串S中当前位置的下标
int j = 1; //j用于子串T中当前位置下标值
while(i<=S[0] && j<=T[0])
{
if(S[i] == T[j]) //主串和子串中对应位置相等则继续
{
++i;
++j;
}
else //不相等
{
i = i-j+2; //**i退回**到上次匹配首位的下一位
j = 1; //j依然从第一个位置匹配
}
}
/* 退出循环
1)没找到相同子串,i>S[0]循环条件退出;
2)找到相同子串,j>T[0]循环条件退出。
*/
if(j > T[0])
{
return i-T[0]; //找到相同子串
}
else
{
return 0;
}
}
前世今生—今生
为了能够更好的理解KMP算法,还是从这个算法的研究角度来理解为什么它比朴素算法要好。
如果主串S=“abcdefgab”,要匹配的T=“abcdex”,如果用朴素算法,比较过程如下:
仔细观察发现,对于要匹配的子串T来说,“abcdex” 首字母 “a” 与后面的串 “bcdex” 中任意一个字符都不相等。即是,子串T中的 “a” 不可能与S串的第2列到第5列的字符相等。在上图中,(2)(3)(4)(5)都是多余。我们可以跳过(2)–(5)的步骤,直接进行第(6)步,直接将子串T中的 “a” 与主串S的 “f” 比较。
==但是!但是!但是!!==从第(1)步直接到第(6)步的前提是我们知道 T 串中首字符 “a” 与T 中后面的字符均不相等,T串的 “a” 与 S串后面的 “b”、 “c”、“d”、“e” 也都在(1)之后就可以确定是不相等的,所以(2)(3)(4)(5)没有必要,只保留(1)(6)即可。
那如果T串后面也含有首字符 “a” 的字符怎么办?
先来看下面一个例子,假设 S=“abcabcabc”,T=“abcabx”。对于开始的判断,前五个字符完全相等,第6个字符不等。
根据刚才的经验,T的首字符 “a” 与 T 的第二位字符 “b”, 第三位字符 “c” 均不等,所以不需要做判断;
按照朴素算法的思维,直接比较T中首字符 “a” 与主串S的第四位 “a” :
而在第一步 ① 比较时,T中第四位的 “a” 与第五位的 “b” 已经与主串 S 中的相应位置比较过了,是相等的,因此可以断定,T 的首字符 “a” 、第二位的字符 “b” 与 S 的第四位字符和第五位字符也不需要比较了,肯定是相等的-----之前就判断过了。
也就是说,对于在子串中所有与首字符相等的字符,也是可以省略一部分不必要的判断步骤。
对比这两个例子,会发现在朴素的模式匹配算法中,主串的 i 值是不断的回溯来完成的。而经过分析发现,这种回溯其实是可以不需要的,KMP模式匹配算法就是为了让这没必要的回溯发生。
既然 i 值不回溯,即是主串中的指针不往回倒,那为了继续匹配子串,需要子串做出相应的移动,也即是 j 发生移动,j 往前走还是往后移动?移动到哪个位置?
j 当然是往后移动啦( j 值减小的方向), 因为要让子串T 和主串 S 匹配,当然是 ”从头重新“ 开始比较啊!这就意味着 j 要减少!
j 具体移动的位置怎么计算?想想我们第二个例子,当主串 S 与子串 T 第六个位置发生不匹配时,主串S 的指针 i 依然在 i = 6的位置,j 此时往后移动到了第三个位置。
为什么是第三个位置而不是第二个位置?为什么不是第四个位置?
其实只要我们分析下子串 T 的情况,前三个字符 “abc” 是都不相同的,而原本的子串T=“abcabx” 中,T[1]=T[4] , T[2]=T[5] , 则 T[1]=T[4] =S[4],T[2]=T[5] =S[5],所以S[4]不可能等于T[2]。就像下面这种情况,指针移动到 j=4 的位置,但是前三个字符都不匹配。
同理,移动到第二个位置也是同样的情况;
所以,移动到第三个位置是有道理可循的,如下图所示,我们可以看出在 红框的位置,元素匹配失败,在这种情况下,出现了子串中 前缀 “ab” = 后缀 “ab” ,为保证模式串往前移动的距离大(保证匹配次数少),还不漏掉每一种可能的情况(假如 j 移动到 j=1的位置,则会漏掉某几种结果,可能正确的结果就在漏掉的情况中),我们将前缀移动到后缀的位置,也即是 j 移动到 j=3 的位置,可以理解为,我们知道主串 S 中S[4] = T[1] ,S[5] = T[2], 但是我们不知道 S[6] 是否等于 T[3] ?所以我们重新从 j=3的位置开始比。
这个时候我们可以发现,我们确定 j=3 的位置是根据前后缀来确定的,那我们是不是就可以转而求解子串 T 中的前后缀来确定 j 移动的位置?答案是肯定的。
通过上图可以看出前后缀表示的是当前位置之前的子串中出现的两个完全相等的子串,移动的位置是前缀的后一个元素,但是我们该如何确定子串 T 中前缀的后一个位置呢?
对于一个字符串 T 来说,我们如果知道了前缀的长度,就可以知道该移动的位置。我们根据字符串T 求解下前后缀,过程如下:
通过比较当前位置之前的子串,定义出现的最长子串的长度为前缀长度,例如 j=6 时,最长子串为 “ab” ,长度为 2 ,我们通过得出的前缀长度,可以得出,如果在 j=6处发生不匹配,则指针应该移动到前缀长度 的后一个位置(字符串下标从1开始的情况;下标如果从0开始,则 j 直接移动到前缀长度的位置)。
根据书本上的定义,把求前缀长度的过程表示为 求当前位置的的 next[j] 的过程;
只要理解了为什么移动,移动的具体位置,代码什么的就比较好理解了;
求解 next[j] 的代码如下:
void get_next(String 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值回溯
}
}
大概就这么多吧,目前还只是理解到这个水平,如有其他见解,欢迎讨论。
(个人的了解和例子来自于《大话数据结构》第五章,详细内容可以参考书本)