KMP算法(一)(与暴力算法对比及主要思想)
书接上文
在(一)中我们讲到了,KMP算法与暴力算法对比的优点就在于特殊前缀表(这节我们称为next数组)的存在使得我们在匹配失败时,不需要再一个一个从头开始,而是根据特殊前缀表直接匹配对应位置,节省时间。
这节我们主要来讲解前缀表构建的思路及如何代码实现。
以下讲解较为细致,大佬找知识点可按目录选看,如果是没有基础小白建议一步步看,理解会更透彻。
文章目录
前缀表与next数组
对于讲解前缀表,大多数教材会选择直接介绍next数组,next数组可以算的上是一个经过一步特殊操作的前缀表,两种的创建以下都会提到
构建思路
前缀表的定义在(一)中有,不在赘述。
本节不在写表头,以前缀值代表前后缀公共元素个数的最大值
前缀表作为一个表,在代码表示时,我们很容易想到用一个数组来表示,所以以下讲解时,会有一行多余的数据代表数组索引。
下面我们看一个简单的前缀表
字符串用例:ABABD

前缀表

前缀值的获取(前几位直观获取,举例讲解)
对于任意一个字符串,我们很容易得到它的第一个前缀字符(数组索引为0)对应的前缀值一定为0(因为第一个前缀字符只有一个字符A没有前后缀,所以也不存在公共元素)


而对于第二位(数组索引为1),我们理所应当的将其与第一位进行比较,如果第二位与第一位相同,则第二位对应的前缀值为1,否则前缀值为0。(第二位字符所组成的前缀字符串AB,它的前缀即为第一位字符,后缀即为第二位字符,若相同,则前后缀有一个公共元素,否则没有为零)


到了第三位(数组索引为2),这里我们就不能这么直观的观察出前缀值了。
观察前缀表
第三位与前面组成了一个三位字符串ABA对应数组索引为012。因为字符串长度为3,所以字符串前后缀最大长度只能为2,这里我们就会思考,最大长度的前缀与最大长度的后缀是否相同,从而得出公共长度的最大值呢。这里其实不需要我们思考,上一步前缀值已经告诉了我们答案。

上一步中我们得到第二位的前缀值为零,那么它能给我们的信息是否只有它的前缀后缀公共元素最大值为0呢,肯定是不止这些的。在第三步中,我们曾尝试判断最大前缀与最大后缀是否匹配,但我们却忽略了它们的组成。第三位相较于第二位字符多了一位,导致了最大前后缀的长度都增加了。但是它的最大前后缀的起点并没有变,当我们比较最大前后缀时,比较的第一个字符同第二步一样(即第三位中最大长度前缀AB与最大长度后缀BA在比较时依旧要先比较AB中的A和BA中B是否相同),所以这就使得了这一步比较我们可以直接从第二步得到答案,第二位B前缀值为0,即A与B未匹配,所以我们可以直接判断第三位中最大长度前缀AB与最大长度后缀BA不匹配,所以第三位所对应的前缀值小于2。此时我们从第二步得到结论后,仅需要在让次之长度(1)的前后缀做对比就行了,所以首位A与末位A匹配成功,得到前后缀公共元素的最大值为1,即第三位的前缀值为1


在第三位的分析之上我们再来讨论第四位(数组索引为3),很明显最大长度的前后缀依旧同第三位一样,同第三位分析过程,我们可以快速得到,三位长度的前后缀不是公共元素,接下来是两位长度的前后缀,这里我们再一次运用第三位的思想,在第三位中,由于我们通过第二位的前缀值很快做出判断得到第三位的前缀值,同理,在第四位中我们来分析第三位的前缀值又表示了些什么。
经过判断我们得到第三位的最大长度前后缀公共元素为1,换句话说由于长度为1,所以我们可以说第三位与第一位匹配成功,当我们找寻第四位前后缀公共元素时,我们由已知结论排除了原有的最大长度3,当我们试图匹配长度为2的前后缀时,我们发现判断起点再一次回到了第三位的判断,而第三位判断时,给出了我们第三位与第一位匹配成功的结论,所以我们可以直接继续进行长度为2的前后缀公共元素的讨论,即:第四位作为第三位的后一位有机会不与第一位比较,而是与排在第一位后一位的第二位做比较。所以第四位与第二位比较得到结果称为判断前缀值的关键,如果相等则我门应该得到前缀值为2(对于此例确实如此),如果不相等我们在另寻它法先不做讨论。这样看求第四位的前缀值关键是我们由第三位的前缀值1得到了第三位与第一位相同,可直接比较第四位与第二位。
前缀值的获取(总结结论,结论推导获取)
到了这里,我们应该对前缀值有一个更深层次的认识,前缀值不仅仅代表着对应位置字符串前后缀公共元素长度的最大值,因为是前后缀的公共元素,它还可以表示该位置与从字符串首位起固定位置的字符相同,即指向自身与其相同的元素的位置。但是如上所说,相同的字符并不一定会有前缀值指引,需强调“前后缀的公共元素”,即未参与过“前后缀的公共元素”的字符的前缀值自然不会有所指向。


这里我们给出一个较长的字符串的前缀表,让大家直观的感受下

对于上述所说未参与过“前后缀的公共元素”的字符,我们也给出举例

所以,根据以上例子我们可以得到一个结论:
对于某一位置的前缀值,我们可以将该位置元素与前一位的前缀值对应位置的下一位(前一位元素p的前缀值对应位置的元素q与其相同,即p=q)进行匹配,以此来判断前缀值或者前后最大长度继续增加还是就此中断。
到这里,应该会有读者发现这个数组索引笔者贯穿始终,甚至影响理解与观感笔者都未将其删除,这是因为因为在代码实现中,我们的真值表就是以数组的形式所形成,口头解释可以便于我们理解,但理解的同时我们也要适应机器语言,不仅如此数组索引在这里还有着神奇的作用帮助我们简化了结论,由于数组索引是从0开始,所以导致数组索引总是比字符位(第3位字符对应数组索引为2)少1,而上述结论中“前一位的前缀值对应位置的下一位”我们可以更改为 “前一位的前缀值作为数组索引对应的值”。
结论更新:
对于某一位置的前缀值,我们可以将该位置元素与前一位的前缀值作为数组索引对应的元素进行匹配,以此来判断前缀值或者前后最大长度继续增加还是就此中断。

我么已经得到了如何求解前缀值的有效结论,下面我们先不继续更新,我们来讨论结论中的“继续增加与就此中断”。
匹配结果对前缀的影响
增加:
观察一组前缀表(字符串:AAABBA)

我们可以看到,最大长度前缀字符串在与最大长度后缀字符串匹配成功之后,到下一位开始匹配时,因为字符串长度+1,所以前后缀长度上限+1,对于前一位的与后缀匹配成功的最大长度前缀字符串仅表现为向后延伸了一个字符,而后缀字符串也仅表现为增加了一个末位字符。所以前后缀上限+1,要想使得最大长度的前后缀+1,仅需要增加的末位字符与前缀延伸的字符相同即可。
即:当遇到连续匹配成功的字符时,前缀值会持续增长,而根据定义,每向后增加一位字符时,前缀值只能增加一。
匹配失败后怎样回溯
中断:
我们考虑了连续匹配成功的情况,那要是连续被中断了呢?
前面我们考虑了在前几位匹配成功时只需要根据前缀值找到对应元素进行匹配,若匹配成功则前缀值+1,那么要是匹配失败呢?观察举例(字符串AAABAABDA)


我们观察第六行,当末位的A匹配第三位的B时匹配失败,此时末位的A保持不变,我们寻找下一个匹配对象,当B匹配失败时,代表我们前缀值必定会小于匹配成功时继续递增的前缀值,此时我们思考是否要顺次降位判断长度-1时,前后缀是否相同呢(递增的前后缀公共元素长度的最大值已经不成立,我们是否需要判断长度-1的前后缀是否是公共元素呢)如果直接判断,若判断不匹配,那我们岂不是就要再判断再次降位后的结果,以至于我可能对这一个前缀值需要一直判断到是否与首位匹配,如此耗时,这不是我们所需要的。这是我们思考,我们为什么会将末位的A与第三位的B进行比较,而不是从最大长度前缀后缀一次匹配下来呢,没错,又是前缀值。
当末位的A与第三位的B匹配失败时,我们所剩余的可判断长度让A与B称为了等价地位。

所以我们的末位A来到第三位B的位置上继续判断,同理,前一位的前缀值对应位置的下一位与之匹配,由例得,B的前一位(A)的前缀值(1)对应位置(第一位)的下一位(第二位)为A,所以此时末位A匹配成功,但是末位A基于B的位置上进行的匹配所以,前缀值增加也是基于B的位置,此时前一位前缀值为1,所以末位前缀值再此基础上+1得到末位A的前缀值为2。
接下来展示后面几位A所对应的情况应该与现在讨论的这个A一致


如果再次基础上还是匹配失败,我们可以故技重施,再次找到其前一位前缀值对应位置的元素进行等价。
综上,当我们连续匹配后匹配失败时,无论哪个位置我们都可以通过前缀值不断的找到等价位置代入,直到在某个合适的等价位置求得前缀值位置。
到了这里我们对前缀表所有的值分析完毕,得出以下结论(这里称为前缀表结论):
前缀表结论
·前缀表的第一个字符的前缀值一定为0
·对于某个位置的前缀值,我们可以通过匹配该位置元素与其前一位前缀值所对应位置的元素来得到前缀值。如果匹配成功,则前缀值+1。如果匹配失败,我们可通过前缀值得到的等价位置换位匹配。
对于next数组,我们上面提到过,其数组索引神奇的帮我们避开了“前一位”的影响,使得我们的结论更加逻辑简洁。这里给出next数组与前缀表的关系。上面我们给出了数组索引,所以我们不妨建立一个字符数组c来存放前缀表中的字符,next数组来存放前缀值。不过需要注意的是这里我们前缀值整体后移一位,首位补-1,即如图示(字符串AABAAAABD)

由上我们也可以得到结论(这里称为next数组结论)
next数组结论
·next[0]=-1,next[1]=0恒成立;
·对于next的值,因为在原理上它为前缀值的后移,所以我们通过在求得前缀值后,先递增一位在赋值,即将某一位置的前缀值赋给后一位的next值,所以对于next值,它只取决与前一位元素匹配情况,而与本身无关。这样我们在某个字符匹配成功时,先递增前缀值然后再将值赋给下一位的next值,而匹配失败时,我们可以直接通过它的next值(前一位的前缀值)寻找等价位置进行回溯。
代码实现
前缀表的代码实现
public static void prefix_table(char pattern[],int prefix[],int n)
{
prefix[0]=0;//存放字符的数组
int len=0;//len为前缀值,首位为0
int i=1;
while (i<n)//结束条件为全部的前缀值确定
{
if (len==0||pattern[i]==pattern[len])
{
len++;
prefix[i]=len;
i++;
}else {
len = prefix[len - 1];//前一位前缀值作为数组索引找到等价位置进行回溯匹配
}
}
}
next数组的代码实现
public static void next_array(char pattern[],int next[],int n)
{
next[0]=-1;//前缀值后移后首位补1
next[1]=0;//后移后,确定首位0后移
int len=-1;//前缀值初始也后移为-1
int i=0;
while (i<n)
{
if (len==-1||pattern[i]==pattern[len])
{
len++;
i++;//整体前缀值后移操作:先递增再赋值
next[i]=len;
}else {
len = next[len];//每位next值为前一位的数组值,所以回溯时,不在需要提前一位
}
}
}
KMP算法代码实现
这里以next数组这个特殊的前缀表来实现,具体原因可以参考(一)
package KMP;
public class kmp {
//先写一个代码求出前缀表
public static void next_array(char pattern[],int next[],int n)
{
next[0]=-1;
next[1]=0;
int len=-1;
int i=0;
while (i<n-1)
{
if (len==-1||pattern[i]==pattern[len])
{
len++;
i++;
next[i]=len;
}else {
len = next[len];
}
}
}
public static void main(String[] args) {
String str1="aabbabcdababdad";
String str2="ababd";
char t[] = str2.toCharArray();
int n=t.length;
int next[]=new int[n];
next_array(t,next,n);
int index =kmp_find(str1,str2,next);
System.out.println("index:"+index);
}
public static int kmp_find(String str1,String str2,int next[])
{
char s1[]=str1.toCharArray();
char s2[]=str2.toCharArray();
int s1_len=s1.length;
int s2_len=s2.length;
int i=0,j=0;
while (i<s1_len && j<s2_len)
{
if(s1[i]==s2[j]){
i++;
j++;
}else
{
if (next[j]==-1)
i++;
else
j=next[j];
}
}
if (j==s2_len)
return i-j;
else
return -1;
}
}
934





