前言:
在学习数据结构中的串结构时,总是绕不开模式匹配算法,而普通的模式匹配算法(对主串中所有的元素逐个作为子串开头进行遍历)的效率又显得十分低下,于是三位计算机领域的前辈,D.E.Knuth,J.H.Morris和V.R.Pratt发明了一种新的、简洁得多的模式匹配算法,也就是我们今天要谈到的KMP模式匹配算法。
KMP模式匹配算法解析:
这个部分我想依照我自己理解这一算法时的思路,来一步一步地从构建这种算法的思维,到实现这种算法的程序本身的结构对KMP模式算法进行解析。
1.想法的诱因:
(图中蓝色为主串S,绿色为子串T,下同)
遇到上图中的情况时,若按照普通的模式匹配算法,则会大致经历如下几个过程:
第一步:
第二步:
第三步:
第四步:
这是在普通的模式匹配算法下计算机处理模式匹配问题的方式,特点就是每匹配完一次之后,需要在主串中进行回溯(即比较完第6位的x与b后,主串下一次匹配时比较的第一个字符前移至了第二位的b),但在作为人类的我们看来,这样的回溯,也就是上述的四步比较,其实都是无意义的。
而这种“无意义”的产生,要从已匹配成功的子串T中的部分进行分析。
2.对于匹配成功部分的分析:
下面我们单独将子串T匹配成功的部分拿出来:
1 2 3 4 5
a b c d a
观察它的结构,我们大致可以发现,除第一位的a之外(之后我们会单独给第一位的元素设定一些操作),它是由两个主要部分组成的:
1 2 3 4 5
a b c d a
为了更加清晰地理解上面串的划分方式,我们先定义一个概念——前后缀
前缀:从串的第一个字符开始向后延伸,但不能包含最后一个字符,其间所有的子串都可以成为前缀,如对于上述的串,a,ab,abc,abcd都是其前缀;
后缀:从串的最后一个字符向前延伸,但不能包含第一个字符,相应的也就是a,da,cda,bcda
定义了前后缀之后,划分串的方式便可以较为清楚地表述了。
我们先看后面的第二个部分,也就是5位上的a:
部分2:
5位上的字符a;
特征:是当前串中最长的相同前后缀。
(关于最长的相同前后缀,我们目前先可以用人工方法进行判断——在所有前后缀中由长到短逐一进行比较,直到出现第一个完全相同的前后缀,显然,a,ab,abc,abcd与a,da,cda,bcda中,能够完全匹配的,只有a这一项)
其余的部分,则自动归入第一部分:
部分1:
2、3、4位上的字符b、c、d;
关于这两个部分,分别可以得到两条结论:
结论一:
对于部分一中的字符,如果再将第一位的a与主串S中相应位置字符进行比较,结果绝大多数情况下是无法匹配(如上述的bcd均不等于a)。即使有个别情况,在某一位出现了a,在接着比较之后的位时,最终也必然会出现不匹配的情况:
结论二:
对于部分二中的字符,当匹配进行到T串中的第一个字符与该最长后缀中的第一个字符对齐时,之后直到上一次匹配失败的位置(相对于S串而言)之前的位置,无需再比较,也可以知道会是完全匹配的。
为了更加直观地叙述这两条结论,我们再定义一个概念——位置指针i、j
i —— S串中当前进行比较的位置坐标
j —— T串中当前进行比较的位置坐标
上面提出的两条结论,总结起来,也就是想要表达这样一个意思:指针 i 不会回溯!
当略过了我们所说的不需要再进行比较的位置后,会发现在下一次匹配时,主串S中所需要比较的第一个字符位置永远不会比上一次匹配失败时的 i 更靠前,即下一次的匹配应该直接进行如下的比较:
这也就是所谓的KMP模式匹配算法的匹配方式。
当然,有舍才有得,KMP算法一次性略过了那么多个待匹配的位置,必然要在其他方面付出一些代价,这种代价,也就表现在对于 j 指针回溯位置的求取上。
由上面的匹配过程,我们可以发现,在某次匹配中,若在子串T内任意位置 k 处匹配不成功,则 j 指针会回溯到 k 位置之前的某个位置,再进行比较。观察具体实例,我们也很容易猜想,这个回溯到的位置,其实就是T串中相应的最长前缀末位字符的后一位。
如果我们将最长后前缀长度定义为相似度,那么回溯位置next[k]则可表示为相似度+1.
3.next数组求取方法的代码验证:
下面结合实际代码来证实这个猜想:
/*计算子串各位对应的next值*/
void get_next(const char t[], int* next)
{
int j, k; //j~子串位置指针
j = 1;
k = 0;
next[1] = 0;
while (j < t[0]) //t[0]存储的是串长
{
if (k == 0 || t[j] == t[k])
{
++j;
++k;
next[j] = k;
}
else
{
k = next[k];
}
}
}
//注:该代码块中默认字符串的首个字符位置为1,即t[0]中存储的不是串中的实际内容,而是串长
光看代码,初学者应该都会像我前两天一样,觉得云里雾里。那么,我们不妨针对之前所给出的串,先来对这段代码进行逐步执行:
j=1
特别定义了next[1]=0,即当第一位不匹配时,回溯位置暂时设置为“0”,这样做的意义在接下来的匹配算法的主函数中会有所体现(会使得主串S的位置指针i直接后移一位)。
j=2
在第二位匹配失败时,前面匹配成功部分的相似度=0(只有唯一字符a,无法构成前后缀),k=1,j=2,t[1] = t[2]不成立,经else循环,k=next[k]=next[1]=0,再经由if语句得到next[2]=1
j=3
在第三位匹配失败时,前面匹配成功部分“ab”的相似度=0,故执行else语句,k=next[k]=next[2]=1,再次循环,t[1]=t[3]判断仍然不成立,依旧进入else分支,k=next[k]=next[1]=0,再次循环,k==0条件成立,next[3]=1。
j=4
同j=3情况,在三次经过else分支后,最终next[4]=1。
j=5
next[5]=1,方法不再赘述。
j=6
此时,前面成功匹配部分为“abcda”,相似度=1,第一次循环时满足t[1] = t[5],next[6]=2
显然,测试的结果符合我们的预期,next[j]的求取公式即为:
但是,如果说让计算机按照我们之前说过的人工寻找最长相同前后缀的方法来逐一判别的话,效率并不高,因此KMP模式匹配算法最核心,也最精巧的地方,在于其对于j的回溯位置next[k]的求取方式。
接下来,将用类似于递归的方法,来解释next[j]数组中的各项为什么可以通过上述精炼的代码求出来。
4.KMP算法求取next数组的原理(为什么会有k = next[k]语句):
首先,我们要明确两个基本原则:
1.第 j 位对应的回溯位为 next[j] = 相似度 + 1,也即刚刚验证过的计算公式;
2.next[j + 1] <= next[j] + 1;
对于原则2,可以先做一个简单的理解,即若对于第j位之前的部分,相同前后缀最大长度(相似度)= next[j] - 1,再往后取一位到j + 1位,相同前后缀最大长度最多只能增加1,也就是相似度最大为next[j],next[j + 1]最多为next[j] + 1
再定义两个初始条件:
1.next[1] = 0;(i++,j=1)
2.next[2] = 1;(j=1)
之后,我们要解决的问题就转变成了:已知next[1]~next[j],求next[j+1] (j ≥ 2)
下面定义k = next[j],需要比较T[k] 与 T[j]:
首先考虑最优的情况,即t[j] = t[k],那么next[j+1] = next[j] + 1 = k + 1;
再考虑次之的情况,t[j] ≠ t[k],此时最长相同前后缀对应的串必定包含在1 ~ k - 1位之间
在这里仍然采用反证法来证明这一结论:
假设存在q > k,使得 1 ~ q 位与 j - q + 1 ~ j 位完全相同,那么就会有 1 ~ q - 1 位与 j - q + 1 ~ j - 1 位完全相同,那么在1 ~ j -1位中,最长相同前后缀就应该是 1 ~ q - 1 位,相似度 = q - 1 > k - 1,与next[j]=k相矛盾!
这里的证明即说明了基本原则2的正确性。
于是乎,我们再回归主线,求取1 ~ j 位中的最长相同前后缀。现在,经过了上述推导,我们检查的范围缩小到了 1 ~ k - 1 位。而next[k]是已知的,即1 ~ k - 1 位中最长相同前后缀也是唯一确定的,于是我们发现,问题又回到了最初的分支:
只不过比较的对象变成了T[next[k]] 与 T[j],即将k转化为了next[k]
直到现在,一直令我们困扰的语句k = next[k],其来源终于变得清晰起来。
接下来会一遍又一遍地比较t[j]与t[k],将最长相同前后缀出现的范围逐步缩小,直到出现t[j]=t[k]或回溯到j=1,才能最终求解出当前位置的next值。
下面是实现KMP模式匹配算法的代码:
/*KPM模式匹配————返回子串t在主串s中第position位之后第一次出现的位置,若不存在,返回0*/
int KMP_match(const char s[], const char t[], int position)
{
int i = position; //i~主串位置指针
int j = 1; //子串t由第1位开始匹配
int next[MAX_LENGTH]; //定义存储next值的数组
get_next(t, next); //获取next值
while (i <= s[0] - t[0] + 1 && j <= t[0])
{
if (j == 0 || s[i] == t[j]) //增加了j==0的判断
{
++i;
++j;
}
else
{
j = next[j]; //j指针进行回溯
}
}
if (j > t[0]) //匹配到了则返回相应位置
{
return i - t[0];
}
else
{
return 0;
}
}
可以看到,当遇到 j=0 的情况时,该分支会让S串指针i后移一位,再重新将T串指针移回第一位:
KMP算法的改进:
看似已经减少了很大一部分无意义步骤的KMP算法,是不是已经最优了呢?让我们来看一下下面的情况:
没错,这就是我们之前用KMP算法简化后,第一步与第二步的判断。但是,仔细观察一下待匹配串,是不是会产生一个疑问:S串第6位的x与T串第2位b之间的比较是否是必要的?
如果对于之前这个例子,感觉是否需要省略这一步影响并不是特别大的话,那么下面这个例子就可以让人很直观地感受到KMP算法存在的不足:
S串 a a a a a b c ……
T串 a a a a a a x
第一次从头开始匹配,发现在第六位的b与a不相同,根据之前的KMP算法,T串匹配部分“aaaaa”相似度为4,即next[6] = 5,下一次匹配开始于T串的第五位:
S串 a a a a a b c ……
T串 a a a a a a x
结果仍然是a与b不相同,next[5] = 4,下次从T串的第四位开始匹配:
S串 a a a a a b c ……
T串 a a a a a a x
之后不断重复这样的模式,直到第一个a与b比较完,next[1] = 0,才能让S串指针i后移一位。站在我们人类的角度上来看,前面的5个a与第六位不匹配的a字符相同,既然第六位的a已经无法匹配,那么拿前面5位的a来比较,结果必然也是不匹配。因此这样不必要的操作,在遍历子串结构,确定next值时就应该要设法避免。
究其根源,这样的问题来源于当前不匹配位 k 与next[k]位上的字符相同,故而我们对于每一位的next值可以进行一些调整,让符合上述条件的位上的next值发生一些变化:
/*计算子串各位对应的next值*/
void get_next(const char t[], int* next)
{
int j, k; //j~子串位置指针
j = 1;
k = 0;
next[1] = 0;
while (j < t[0]) //t[0]存储的是串长
{
if (k == 0 || t[j] == t[k])
{
++j;
++k;
if(t[j] == t[k])
{
next[j] = next[k]; //当不匹配位上字符与原来的next对应位置上的字符相同时,在当前next位置的基础上再取一次next值
}
else
{
next[j] = k;
}
}
else
{
k = next[k];
}
}
}
//注:该代码块中默认字符串的首个字符位置为1,即t[0]中存储的不是串中的实际内容,而是串长
这样一来,原先的next值数组和现在定义的新的next值数组就会存在着一些差别:
T串 a a a a a a x
原next值 0 1 2 3 4 5 6
新next值 0 0 0 0 0 0 6
这种加入了对于当前不匹配位上字符的判断的模式,也就是KMP算法的改进。