目录
在处理字符串的过程中,经常会遇到这样一种需求:在一段文本中查找特定的子串。这个过程被称为字符串模式匹配。例如在"hello world"中寻找"world"与"word",可以发现"world"的起始位置在整个字符串的第7位,而"word"则不在整个主串里。
KMP(Knuth-Morris-Pratt)算法是一种 高效的字符串匹配算法,用于在 主串(text) 中查找 模式串(pattern) 的出现位置。它的核心思想是 利用已匹配的信息 进行跳跃式比较,从而避免重复匹配,减少不必要的回溯。对于初学者而言,KMP 算法往往较为晦涩难懂。本文将从多个角度深入解析 KMP 算法,帮助初学者更直观地理解其原理。
模式串匹配的暴力解法
这里首先给出模式匹配的暴力解法。设两个指针i和j,分别指向主串当前待比较字符位置和模式串当前待比较字符位置。假设主串为"abacabab",模式串为"abab",下面使用一张图来描述暴力算法的解题过程:
图1 暴力算法的解题过程(前三轮)
这里可以很清楚地看出暴力算法的解题过程和思路:由于需要在主串中找出与模式串完全相同的子串,那么子串的第一个字符和模式串必然是相同的。所以算法将每一个字符与模式串的第一个字符比较,如果相同再比较后续字符。具体代码如下:
int Index(string S,string T){ //S为主串,T为模式串
int i=1,j=1; //字符串第一个字符从1下标开始存储
while(i<=S.length && j<=T.length){
if(S.ch[i]==T.ch[j])
++i; ++j; //继续比较后继字符
else
i=i-j+2; j=1; //指针后退重新开始匹配
}
if(j>T.length) return i-T.length;
else return 0;
}
暴力解法存在的问题
假设主串的长度为n,模式串长度为m,则最多要进行n-m+1轮次匹配,每轮匹配中最多要进行m次比较,时间复杂度为O(nm)。那么是否存在一种方法,能够使时间复杂度降为O(n+m)呢?
KMP算法
算法的基本思想
在前面的暴力解法中,我们可以发现一个很显著的问题,即暴力解法进行了很多次非必要的比较,且对于某特定轮次的匹配,其并没有使用到前几轮次的任何信息,相当于是一次独立的过程。为了说明这个问题,这里假设主串为"abcabcabd",模式串为"abcabd"。
一轮比对后,指针位置如下:
图2 一轮比对后字符串情况
本轮比对之后按照暴力算法,i将会退回第二位,j退回第一位,但是用肉眼观察这是完全没有必要的。因为在主串中,如果需要找到和模式串一样的子串,那么子串的前几位和模式串的前几位一定是相同的。用肉眼观察得知,在已经扫描过的主串里,后两位的字符串"ab"和模式串的前两位"ab"相同,所以只需要将模式串“移动”至两个"ab"重合的位置,就能够省略掉非必要的操作。
图2 移动后字符串情况
可以看到这样对齐之后,一下子就找出了匹配子串。为了更好地说明这个思想,现在再给出一个的例子。设主串为"aaaab",子串为"aaab"。当扫描至第四位时,会发现模式串与当前子串不相同。按照之前的逻辑,我们需要找出已扫描过的主串中,有没有后几位与模式串的前几位相同的字符串,如果有,就将他们“对齐”。肉眼观察得知字符串"a"与"aa"都满足这个条件,现在将他们按照之前的方法对齐。
图3 不同对齐方法情况
可以发现,以单个字符"a"作为对齐标准时,匹配结果明显存在偏差,而使用"aa"进行对齐则能得到正确的结果。这说明了在寻找"前几位"与"后几位"相同的字符串过程中,一定要找到最长的那个,否则会漏答案。
到现在,如果你已经理解了上面的方法,那么你就懂了KMP算法的基本思想。所谓“前几位”字符串,称为“前缀”,即除最后一个字符外,字符串的所有头部子串。而“后几位”字符串,称为“后缀”,即除了第一个字符外,字符串的所有尾部子串。前缀与后缀的最长相等长度,称为“部分匹配值”(这个长度将会决定模式串向右滑动的距离,下面会作解释)。当前字符串对应的每个头部子串的部分匹配值的表称为PM表,示例如下:
图4 PM表示例
而上面方法提到的“对齐”或者说“移动”,在程序中其实是j指针的回退与i指针的静止共同作用的结果。
图5 回退位置示意
从上面这个例子可以看出,j指针回退的位置为3。这个3是咋来的呢?在前面已经比对过的长度为3的字符子串"aaa"中,我们需要达到两个长度为2的字符串"aa"对齐的效果,则需要将模式串右移3-2=1位,此时j指针回退的位置为原始j的位置-右滑位数,即4-1=3,而这个位置将会被存储在所谓的next数组里。给出定义如下:当模式串第j位字符与子串不符时,j指针需要回退到next[j]位置,即j=next[j]。约定next数组的第一个元素为next[1]。next数组与PM表的关系推导如下:
next[j] = j - 右滑位数 = j - [ ( j - 1)-PM[ j - 1] ]=PM[ j - 1 ]+1
所以可以看出,next数组等于PM表向右移动一位并且+1。
综上,KMP算法的执行流程可以概括如下:
- 计算模式串的next数组。
- 比较模式串与主串,如果遇到字符不等,将j指针回退至next[j](模式串右滑),继续比较。
- 若j超出模式串长度,匹配成功,返回子串位置。
这里还有一个需要注意的问题:在扫描主串时,直到遇到与模式串不同的字符之前,当前匹配的主串和模式串是完全一致的。因此,j
回退的位数仅取决于模式串,而与主串无关。所以next数组的计算仅基于模式串。
KMP算法的执行代码如下:
int KMP(String S,String T,int next[]){
int i=1,j=1;
while(i<=S.length&&j<=T.length){
i£(j==0||S.ch[i]==T.ch[j]) //注意j=0的判定条件
++i; ++j; //继续比较后继字符
else
j=next[j]; //j指针回退
if(j>T.length)
return i-T.length;
else
return 0;
}
}
next数组求解
知道了KMP算法的求解过程后,关键问题来了:前面说到无论是PM表还是next数组,都是用眼睛看出来的,那么实际在程序中应该怎么求解next数组呢?首先根据前面的讨论,可以得出如果next数组从1开始计数,则其值将会等于PM表右移一格再+1。所以求解next数组本质上是求解PM表,即求每个字符串对应的部分匹配值。为了方便理解,现给出模式串"aaaa"的next数组求解过程。初始状态重置如下:
其中j指针指示当前最长前缀的最后一个字符(其实就是pm值),i指针指向当前待计算next值的字符。这里next[1]默认为0,而next[2]=PM[1]+1=1(PM[1]=0,因为单字符的部分匹配值必为0)。
接下来进行一次判定,即T[i]是否等于T[j]。可以看到两者是相等的,所以PM[2]=1,next[3]=PM[2]+1=2。又由于需要进行下一次判定,所以i指针与j指针同时+1(先自增再赋值)。
再进行一次判定,显然T[i]==T[j]成立,则PM[3]=PM[2]+1=2,j自增,下一位的next置为PM[3]+1=3。
所以可以看到,按照i+1,j+1这样的移动规律,如果满足T[i]==T[j],则
PM[i]=PM[i-1]+1
next[i+1]=PM[i]+1
现在问题来了,如果说T[i]!=T[j],那么next数组该如何计算呢?这里以"abacabab"字符串为例。假设现在i=8,j=4,此时发现字符'c'与'b'对不上了,即前缀'abac'不等于后缀'abab'。
在这里由于'abac'与'abab'不同,所以我们需要比对次长的前后缀有无相同的,即比较"aba"-"bab"、"ab"-"ab"、"a"-"b"。
这里观察可以发现,这样比对与将第八位的'b'替换到第四位的'c',然后寻找前半截字符串"abab"的部分匹配值的过程是完全相同的。这是因为在前面的比对过程中,已经确定了前面的"aba"与后面的"aba"是完全一样的。这里可以下定一个结论:寻找第八位b的部分匹配值,等价于寻找等效字符串"abab"的部分匹配值。
再思考一下指针的变化逻辑。首先,i指针指向当前需要计算 next值的字符,因此它的值保持不变。而j指针表示当前最长相同前后缀的最后一个字符,这里的最长前缀指的是等效字符串"abab"的最长匹配前缀。通过观察可以发现,j应该跳转到位置2。
因为j指向的是最长的前缀的最后一个字符,也就是最长前缀的长度,也就是PM值。用眼睛看可以知道"abab"的最长前缀长度为2。
事后我们知道这个2其实是next[4](next[j]),那么为什么j要回退到next[4]的位置呢?回顾 next
数组的定义:当模式串第j位字符与主串不匹配时,j需要回退到next[j]位置,即j=next[j]。这一回退操作意味着,模式串在指针j之前的部分(即前缀)将匹配主串中已扫描部分的后缀。在这里已扫描的子串为"aba",j回退的示意图如下:
换句话说,j回退后的位置,正是已扫描子串"aba"的最长匹配前缀的最后一位加1,即next[j]。此时再进行一轮比对,如果 T[i]=T[j],则有next[i]=j+1,表示i位置的最长匹配前后缀长度更新为j+1。
综上,当匹配过程中出现T[i]!=T[j],则j回退到next[j](继承上一次匹配中相同子串的最大前缀长度),并且再次比对T[i]与T[j](等效字符串替换,即"abab"),如果相同,next[i]=j+1。
在b站上看到一个很有意思的评论,希望能够帮助大家理解kmp算法:一个人能能走的多远不在于他在顺境时能走的多快,而在于他在逆境时多久能找到曾经的自己。
next数组求解的代码如下(结合前面所给的流程图理解):
void get_next(String T,int next[]){
int i=1,j=0; //初始化指针
next[1]=0; //初始化next[1]
while(i<r.length){
if(j==0||T.ch[i]==T.ch[j]){
++i; ++j;
next[i]=j; //若比对后相等,则next[i]重置为当前最大前缀长度+1
}
else
j=next[j]; //否则令j回退至next[j]
}
}
next数组优化——nextval数组
这里给出主串"aaaaab"与模式串"aaaab",给出模式串的next数组如下:
当i=4,j=4时字符串失配,此时j回退至3,又失配;回退至2,又失配...用肉眼看其实i=4,j=4时应该直接退回j=1才对。这又出现什么问题了呢?其实很明显可以看出,模式串中T[1]=T[2]=T[3],由于回退时出现T[j]=T[next[j]],而主串S[i]已经不等于T[j]了,那么自然也不等于T[next[j]]。所以只需要对next数组进行优化,当T[j]=T[next[j]]时,再将next[j]重置为next[next[j]]即可。示例如下:
KMP算法注意点
整篇文章都是将next数组的第一个元素储存在next[1]中,这是大多数教材的做法。如果next数组从0开始,则next[0]=-1,next[1]=0,相当于直接将PM表向右移了一位而没有+1。