苦肝三日,我终于带了这份超易理解的 KMP 攻略,就连我没上过幼儿园的奶奶都差点看懂了。看完本文,你将清楚 kmp 的每一个细节,将收获对 kmp 思想的理解,将能够轻松写出 kmp 代码,最主要的是不会再忘记。
本文从字符串匹配的一般方法讲起,引出暴力匹配的缺点——主串和模式串都要回退。围绕解决这个问题,我们先想到的是仅模式串回退,那模式串怎么回退,回退到什么地方合适呢?假设模式串走到下标 j 时匹配失败,第一个方案就是从 0 开始到 j-1,每个位置都回退过去试一下,然后在能匹配的里面选个大的回退。但这样很耗时,我们期望着有没有人能够告诉我,我该回退到哪里,一步到位,这就引出了 next 数组。
上面模式串回退的两种方案,就跟游戏里撤退过程中找一个安全的草丛走一样。最笨的探草方法,就是每个草都探一下,然后选一个安全的,但这比较浪费时间啊。我们前进的时候是不是经过了一些草丛,我们知道那些安全的草,这就可以用来规划我们的撤退路线,失败的时候直接按照记忆撤退就好。
本文上下文联系较为紧密,环环相扣,建议从上往下开始阅读。
1、字符串匹配的概念
字符串匹配,有主串和模式串两个角色参与,通常来说,主串是长的,模式串是短的。
要做什么呢?就是在长的里面寻找短的,主串里面找模式串。
主串
模式串
2、字符串匹配的一般过程
我们要从暴力匹配开始,这很有必要,在这一过程中我们会引出很多关键信息。
下文中,我们使用 i 来表示主串的下标,j 来表示模式串的下标。
暴力匹配的第一轮
在开始匹配的初始状态,主串的下标 i = 0,模式串的下标 j = 0,从头开始,目前没有一个字符进行过匹配。
第一轮开始匹配前的初始状态
现在匹配第 i = 0,j = 0 的字符,匹配成功了,所以 i,j 都向后移动一位,i = 1,j = 1。
第一位匹配成功后的情况
现在匹配第 i = 1,j = 1 的字符,又匹配成功,此时已经有两个字符匹配成功了,i,j 都继续向后移动一位,i = 2,j = 2。
第二位匹配成功后的情况
继续这个过程,i = 2,j = 2 时也能匹配成功,双方都后移 i = 3,j = 3,当前总共匹配成功了三个字符。
第三位匹配成功后的情况
现在要匹配 i = 3,j = 3 上的字符了,很显然 a != b,匹配失败。
这说明从 i = 0 开始的主串没有办法和模式串匹配,所以我们这次让主串下标从 1 开始,模式串仍然要从头开始,下标的变化是
主串 i: 3——>1
模式串 j:3——>0
我们一起看看第二轮匹配的情况吧。
暴力匹配的第二轮
主串 i = 0 的位置被置为了灰色,表示该字符已经不参与匹配了,因为我们刚刚试过了,从它开始的字符串无法和模式串匹配成功。
第二轮开始匹配前的状态
第二轮从 i = 1,j = 0 开始,b != a,此时就宣告这一轮失败了,头都对不上,尾就没必要看了。
所以进入第三轮匹配,主串从下标 2 开始,模式串依然从 0 开始,第三轮匹配的过程就不继续演示了,总之它失败了。
我们下面直接看第四轮。
暴力匹配的第四轮
第四轮主串的初始位置为 i = 3,模式串的初始位置还是 j = 0。
第四轮开始匹配前的状态
逐位匹配,每次匹配成功后,j 和 j 都后移,一路成功,直到 i = 8,j = 5 时,发现匹配失败了,此前已经匹配成功了五个字符。
第六个字符匹配失败了
按照之前的规律,我们应该让 i = 4, j = 0,然后重新开始第五轮匹配。
但是主字符串可不是个任劳任怨的家伙,它不干了:“大爷我不想动了,每次都让我往回跑,我现在已经到下标 8 了,你却让我回退到 4,累死了!模式串你自己动吧,真男人从不回头!”
3、仅模式串回退
各位朋友,目前我们有什么已知信息呢? 1、主串不想回退了,它当前保持 i = 8,下次变化就是 i = 9 了; 2、主串从 i = 0, 1, 2, 3 这几个下标开始的匹配都尝试过了,无法匹配成功;但是 i = 4, 5, 6, 7 这几个位置开始的字符串没有进行过尝试,必须得想办法知道这几个位置能不能匹配成功; 3、仅模式串回退,看模式串有什么办法试探出 i = 4, 5, 6, 7 这几个位置开始的主串能不能成功了。
“好好好!主串你是大爷,我是打工仔,这个牛马我当了!”
怎么当?
不妨每个位置都回退试一下吧。
模式串回退到 j = 0
此时 i = 8, j = 0,b !=a,通过比较,我们能确定从 i = 8 开始的主串无法和模式串匹配成功。
模式串回退到 j = 0 开始匹配前
模式串回退到 j = 1
现在模式串回退到 j = 1 了,主串不动。
模式串回退到 j = 1 开始匹配前
那我们是直接从 j = 1 开始比较吗?不行的,一个模式串要完全匹配,它的头部必然也是要匹配才行。那先考虑 j = 0 的位置是否匹配吧。
但因为 i = 8 不能往前移动,那 j 也不能往前移动,因为移动后,i 和 j 的相对位置关系就变了。所以匹配前面的字符的时候,我们用模式串[j - x] 和 主串[i - x],x = 1,2,3,... 这样的形式比较,边界条件是 j-x >= 0。通过这样一个操作,虽然 i 没有回退,但也比较到了 i = 7 这个位置,这次匹配相当于是从 i = 7 开始进行的了,比较下来就能知道从 i = 7 开始的主串是否能和模式串成功匹配。
如图,j < 1 部分的模式串头部匹配成功了,用蓝色表示,从而和 j >=1 的主要流程匹配成功时的绿色背景做区分。
后续的匹配就跟我们最开始的动作一样的,如果匹配成功,i, j 都向后移动。不继续演示了,后面的流程都是重复的。
模式串回退到 j = 1,j < 1 部分的的模式串头部能匹配成功
模式串回到 j = 2
按照刚刚的思路,先比较模式串头部是否能匹配成功,但我们发现不能正常匹配,j = 2 及之后的比较就没必要进行了,这波说明了从 i = 6 开始的主串也不能和模式串匹配成功。
模式串回退到 j = 2,j < 2 部分的模式串头部不能匹配成功
模式串回到 j = 3
模式串回退到 j = 3 开始匹配前
还是先比较 j < 3 之前的模式串头部,我们发现这次头部匹配成功了。
模式串回退到 j = 3,j < 3 部分的模式串头部能匹配成功
后续接着进行主流程的匹配就行了,而且操作下去,会发现从 i = 5 开的主串能和模式串完全匹配,至此也就找到了答案。
但我们继续往下看看,看看回退到 j = 4 的情况。
模式串回到 j = 4
我们发现模式串回退到 j = 4,模式串头部匹配失败了,说明从 i = 4 开的主串不能和模式串完全匹配。
模式串回退到 j = 4,j < 4 部分的模式串头部不能匹配成功
现在在看一下我们的已知信息。
i = 8 虽然固定不动,但通过将模式串回退到不同的位置处,我们也能确定从 i = 4, 5, 6, 7 这几个位置开始的模式串能否匹配成功,不过要挨个试。 在回退后,我们的匹配过程可以拆分成两步: 1、比较 j 之前的模式串头部是否能成功匹配。这一步成功后才继续进行第二步。 2、比较 j 以及之后的模式串。
模式串位置 j 的含义
刚刚我们作了尝试,发现将模式串回退到 j = 0,1,2,3,4 后,有时候模式串 j 之前的头部能匹配成功,有时候模式串的头部又不能匹配成功。这种连头部都不能匹配成功的回退有什么意义吗?
浪费时间罢了,我们能不能避开这样的回退呢?不妨先搞清楚模式串位置 j 的含义吧,在这基础上在弄清回退的含义。
让我们找到最开始进行匹配的图。
此时 j = 0,还未开始匹配,也就是没有字符串匹配成功。
添加图片注释,不超过 140 字(可选)
当第一个字符匹配成功后,j 后移一位。此时 j = 1,已经匹配成功了一个字符。
添加图片注释,不超过 140 字(可选)
当 j = 2 时,已经有 2 两个字符成功匹配;
当 j = 3 时,已经有 3 个字符成功匹配;
......
当 j = 6 时,已经有 6 个字符成功匹配了。
添加图片注释,不超过 140 字(可选)
有什么想法吗?
模式串的数字 j 其实代表一种匹配程度的状态,代表位置 j 之前已经有 j 个字符匹配成功了。
模式串的数字 j 其实代表一种匹配程度的状态,代表位置 j 之前已经有 j 个字符匹配成功了!
那回退意味着什么?
回到位置 j,意味着回到状态 j,意味着要让模式串有 j 个字符能成功匹配。
哪 j 个字符呢?那不就是 j 之前的 j 个字符嘛(别忘了下标从 0 开始的)。
还是晕,不如把刚刚 j = 5 时匹配失败那张图拿过来吧,此时这轮匹配走不下去了,需要回退 j。
添加图片注释,不超过 140 字(可选)
再强调一次,回退到位置 j,代表着回到有 j 个字符匹配成功的状态。
这时候模式串中有个聪明的小弟 a 说话了,“大哥,你看最前面那个家伙和我长得一模一样,我在这能匹配成功,它肯定也行啊,我的长度是 1,代表着有一个字符能匹配成功,所以回退到 j = 1 吧”。
咦?回退到位置 1,有 1 个字符匹配成功,好像符合要求。
模式串一尝试,确实能保证 j 之前有一个字符匹配成功,这样一来,模式串再和主串比较时,只需要比较 j>=1 部分的字符了,前面的 j 个已经确定能匹配成功了。
回退到 j = 1,有一个字符匹配成功
这时候,另一个小弟 ab 也说话了,“大哥,我也发现了,最前面有个家伙 ab 和我一模一样,我现在能匹配成功,那它肯定也可以,我的长度是 2,所以我们能保证 2 个字符匹配成功,回退到 j = 2吧”。
模式串一尝试,靠,j = 2 前面的两个字符居然没匹配成功,小老弟,怎么回事?
添加图片注释,不超过 140 字(可选)
其实小弟不是有意的,问题出在这个小弟 ab 自身上,小弟 ab 和匹配失败的位置 j = 5 没有紧邻啊,这是必须的,不然位置对不齐。从图里应该很直观的看到吧,回退到 j = 2 后,“前面的家伙 ab”和“小弟 ab”错位了。
这就是为什么教材上说,需要用 j 之前子串的前缀和后缀来比较,“小弟 ab”并不是 j<5 部分子串的后缀,所以它出问题了。
顺便说一下前缀和后缀。对于字符串 ababa,它的前缀不能包含最后一个字符,有 a, ab, aba, abab;它的后缀不能包含第一个字符,有 a, ba, aba, baba。
针对我们上图 j = 5 时匹配失败的场景,必须要求“小弟”是绿色部分的后缀,而“前面的家伙”是绿色部分的前缀。
你可能有疑问,为什么是绿色的部分呢?
因为绿色部分的字符都是已经完全和主串匹配上了的,这里的每一个小弟都能保证和主串匹配,那当发现“前面的家伙”和某个小弟长一样,前面的自然也能和主串匹配。
“我悟了,大哥,只有贴着红色块的小弟才是好小弟。回退到 j = 3 吧,我的长度是 3, 而且我紧挨着红色块是后缀!最前面有个家伙 aba 和我一样,当它移到我的位置肯定能匹配上,能保证有 3 个字符被匹配”,小弟 aba 惊喜的说道。
好小弟 aba,紧挨红色块是后缀,长度是 3,回退到 j = 3,没毛病。能满足回退到位置 j,代表着回到有 j 字符匹配成功的状态。
回退到 j = 3,有 3 个字符匹配成功
那现在回到 j =1 和 j = 3 都能满足有 j 个字符匹配成功,我们要回退到哪里呢?
你居然问我这个问题?肯定是要多的啊,本着少回退、少比较的原则,我们自然选择回退到 j = 3,那可是已经有 3 个字符匹配成功不用再比较了啊,谁 tm 喜欢多干活!
好勒,现在我们知道了,当模式串在位置 j 匹配失败时,要进行回退,要回退到最大的那个位置,回到最大匹配程度的状态。
那得先找到最大的那个位置吧,我们刚刚是在绿色部分找“最长的后缀小弟”,要求小弟在最前面有个一模一样的家伙(前缀),专业的说法就是寻找相等前、后缀的最大长度。
对于模式串,我们找出每个位置之前的子串的最大相等前后缀长度,把它们先记录到一个表里,等用的时候再查就能做到快速回退,这个表就是 next 数组。
我已经迫不及待的想求 next 数组了,但缓一缓,先梳理一下整个匹配的过程,假设我们现在已经有了 next 数组吧,我们该怎么做呢?
有点急,所以直接上代码吧。
/*
* 主串是 haystack,模式串是 needle,leetcode 上直接复制过来的
*/
int strStr(string haystack, string needle) {
/*
* 下面两行计算 next 数组,现在不关注
*/
int next[needle.size()];
getNext(needle, next);
int j = 0; // 模式串下标 j 初始为 0
// 主串下标 i 初始也为 0
for (int i = 0; i < haystack.size(); ++i) {
/*
* 当某个位置不匹配时,模式串需要回退
* j == 0 时都匹配不成功,那么主串下标 i 就需要后移了,所以有 j>0
*/
while (j > 0 && haystack[i] != needle[j]) {
j = next[j];
}
/*
* 匹配成功时,主串和模式串都后移,主串下标 i 在 for 循环头部后移
*/
if (haystack[i] == needle[j]) {
j++;
}
/*
* 模式串下标为 needle.size(),意味着已经有 needle.size() 个字符匹配成功了
* 那就是全部匹配成功了
*/
if (j == needle.size()) {
return (i - needle.size() + 1);
}
}
return -1;
}
注释的应该还算详细,但还是强调一下:
- j = next[j]; 回退操作是放在一个 while 循环里的。这是因为回退到位置 j,只是保证了 j 之前的部分是完全和主串匹配的,但第 j 个字符可没有保证,如果 needle[j] 还是不能和主串匹配,那就需要继续回退;
- j == needle.size(),表示 needle.size() 个字符已经匹配成功了,也就是全部匹配成功,返回这次匹配时主串的起始位置。
如果整个主串都遍历结束了,j 还是没有等于 needle.size(),此时的最后的 j 是否有意义呢?
没有,但匹配过程中 j 值最大的一次有意义,它表示主串能包含的模式串最长前缀。比如下面的模式串在匹配某个主串的过程中,出现的 j 最大值是 3,那就表示该主串最多能包含该模式串的前 3 个字符。换句话说,如果用模式串的不同前缀来和主串做匹配,那能匹配成功的最长前缀是 3。
留意一下这个说法,我们计算 next 数组时会用到。
添加图片注释,不超过 140 字(可选)
4、next 数组的计算
终于要计算 next 数组了,现在我们再强调一下几个重要信息,这是我们的立足点。
- 模式串的位置 j 代表一种匹配程度的状态,代表模式串已经有 j 个字符匹配成功了;
- next[j] 的作用是模式串在位置 j 匹配失败时,j 可以回退到 next[j],也即 j = next[j],回退到 next[j] 代表回退到模式串有 next[j] 个字符匹配成功的状态。
- next[j] 的值表示模式串在下标 j 之前的部分,最长的相等前、后缀长度是 next[j],求 next[j] 也是通过求最长相等前、后缀来进行的。
根据第 3 点,现在我们要做的是求相等前后缀的最大长度。对于模式串 ababaa,假设我们现在要求 next[5],那我们需要考虑的是 [0, 4] 间的字符串。
j = [0, 4] 部分的字符串
前缀不能包含最后一个字母只能使用如下的字符。
j = [0, 4] 部分的最长前缀
后缀不能包含第一个字符,使用的字符如下。
j = [0, 4] 部分的最长后缀
我们的做法是用最大长度的前缀串来和最大长度的后缀串做匹配,如果匹配成功了,该最大长度就是我们的 next[j] 值;但如果匹配不成功,那过程中出现的最大匹配长度就是要寻找的 next[j] 的值了,这个最大匹配长度既是前缀的长度也是后缀的长度。
等等,前面这段话怎么有些眼熟?
添加图片注释,不超过 140 字(可选)
我们是不是把上图那段中的主串换成后缀串就能求 next 数组了!所以 next 的求法,就是把子串的最长前缀串当做模式串,最长后缀串当做主串来做匹配。
好,下面我们来操作一下,对于模式串 ababaa。
求 next[0]:
j = 0 之前没有子串,前后缀不存在,所以能匹配的前后缀长度显然是 0,next[0] = 0。
求 next[1]:
j = 1 之前就一个字符 a,前缀和后缀长度都是 0,所以 next[1] = 0。
添加图片注释,不超过 140 字(可选)
求 next[2]:
j = 2 之前子字符串的前缀只有 a,后缀为 b,匹配失败所以 next[2] = 0。(前缀串做模式串时的下标用 jj,为了与 j 区分。)
添加图片注释,不超过 140 字(可选)
求 next[3]:
j = 3 之前子字符串的最长前缀为 ab,最长后缀为 ba。我们直接用它们做匹配,最后发现只能匹配上一个字符,所以 next[3] = 1;
添加图片注释,不超过 140 字(可选)
求 next[4]:
j = 4 之前子字符串的最长前缀为 aba,最长后缀为 bab。直接用它们做匹配,最后发现能匹配上两个字符,所以 next[3] = 2;
添加图片注释,不超过 140 字(可选)
求 next[5]:
j = 5 之前子字符串的最长前缀为 abab,最长后缀为 baba。直接用它们做匹配,最后发现能匹配上三个字符,所以 next[5] = 3;
添加图片注释,不超过 140 字(可选)
至此 next 数组全部求解完毕。这个例子证明了求模式串的数组 next[j] 的值过程,就是用 j 之前的最长前缀子串做模式串,最长后缀子串做主串的 kmp 匹配过程。
好,做 kmp 过程的代码我们在上一节就写出来了,现在又有何难。
void getNext(string needle, int* next) {
int size = needle.size();
next[0] = 0; // 0 之前没有子串,自然没有长度相等的前、后缀
if (size == 1) return; // 模式串就一个字符的话,没必要进行后续步骤
next[1] = 0; // 下标 1 前面就一个字符,前缀和后缀长度都为 0
/*
* slow 用来表示前缀串的下标;也是内部 kmp 的模式串下标
*/
int slow = 0;
/*
* fast 表示后置串的下标,为什么从 1 开始呢,因为后缀串不能包含第一个字符;也是内部 kmp 的主串下标
*/
for (int fast = 1; fast < size - 1; ++fast) {
/*
* 当主串和模式串对应位置字符不相等时,需要进行回退
* 模式串下标 slow 进行回退,回退到 next[slow]
* 直到 slow == 0 为止
*/
while (slow > 0 && needle[fast] != needle[slow]) {
slow = next[slow];
}
/*
* 如果主串和模式串对应位置相等,那么 fast 和 slow 都 +1
* fast 的 +1 在 while 循环的头里
*/
if (needle[fast] == needle[slow]) {
slow++;
}
/*
* 还记得 slow 是什么吗,slow 是前缀串(模式串)的下标,表示已经有 slow 个字符匹配成功了
* 匹配成功了?那就意味着最长相等前后缀长度是 slow
* 更具体就是说在下标 [0, fast] 的子串里,最长相等前后缀的长度是 slow
* 这不就是 next[fast+1] 的含义吗
*/
next[fast + 1] = slow;
}
}
虽然代码里已经加了详细的注释,但这里还是再强调几个地方:
- 求 next 数组,其实就是用模式串不同位置之前的最长前缀做模式串,最长后缀做主串的又一次字符匹配,我们的代码也是秉承这一思想。slow 代表的区间是 [0, slow], 表示前缀串的下标,同时也是模式串的下标,模式串的下标还表示已经匹配成功了 slow 个字符;fast 表示的区间是 [1, fast],是后缀串的下标,同时也是主串的下标。
- slow = next[slow] 这一步回退,没有太多描述,因为我们在前面已经多次强调,next[slow] 表示在第 slow 个字符匹配失败时,模式串下标应该回退的位置,同时也意味着下标 slow 之前的子串里最长相等前、后缀的长度为 next[slow]。
- next[fast+1]= slow。还记得我们前面的表述吗,next[j] 的值是下标 j 之前的字符里,最长相等前后缀的长度。那这里换一下说法就是“next[fast + 1] 的值是下标 fast+1 之前的字符里,最长相等前后缀的长度”,而此时前缀串(模式串)的下标是 slow,代表已经成功匹配了 slow 个字符,相应的前缀串长度是 slow,所以相等前、后缀的长度自然也是 slow。
确实有点绕,我们再回顾一下关于 next 数组和模式串的信息,这其实就是一切的依据。
- 模式串走到下标 j,表是已经有 j 个字符匹配成功了;
- next[j] 表示 j 之前的字符里,最长相等前后缀的长度是 next[j];
- 当模式串走到下标 j 匹配失败时,应该回退到 next[j]。
最后,给出完整的全部代码。
void getNext(string needle, int* next) {
int size = needle.size();
next[0] = 0; // 0 之前没有子串,自然没有长度相等的前、后缀
if (size == 1) return; // 模式串就一个字符的话,没必要进行后续步骤
next[1] = 0; // 下标 1 前面就一个字符,前缀和后缀长度都为 0
/*
* slow 用来表示前缀串的下标;也是内部匹配的模式串下标
*/
int slow = 0;
/*
* fast 表示后置串的下标,为什么从 1 开始呢,因为后缀串不能包含第一个字符;也是内部匹配的主串下标
*/
for (int fast = 1; fast < size - 1; ++fast) {
/*
* 当主串和模式串对应位置字符不相等时,需要进行回退
* 模式串下标 slow 进行回退,回退到 next[slow]
* 直到 slow == 0 为止
*/
while (slow > 0 && needle[fast] != needle[slow]) {
slow = next[slow];
}
/*
* 如果主串和模式串对应位置相等,那么 fast 和 slow 都 +1
* fast 的 +1 在 while 循环的头里
*/
if (needle[fast] == needle[slow]) {
slow++;
}
/*
* 还记得 slow 是什么吗,slow 是前缀串(模式串)的下标,表示已经有 slow 个前缀字符匹配成功了
* 匹配成功了?那就意味着最长相等前后缀长度是 slow
* 更具体就是说在下标 [0, fast] 的子串里,最长相等前后缀的长度是 slow
* 这不就是 next[fast+1] 的含义吗
*/
next[fast + 1] = slow;
}
}
/*
* 主串是 haystack,模式串是 needle,leetcode 上直接复制过来的
*/
int strStr(string haystack, string needle) {
/*
* 下面两行计算 next 数组,现在不关注
*/
int next[needle.size()];
getNext(needle, next);
int j = 0; // 模式串下标 j 初始为 0
// 主串下标 i 初始也为 0
for (int i = 0; i < haystack.size(); ++i) {
/*
* j == 0 时都匹配不成功,那么主串下标 i 就需要后移了;
* 不匹配时,模式串回退
*/
while (j > 0 && haystack[i] != needle[j]) {
j = next[j];
}
/*
* 匹配成功时,主串和模式串都后移,主串下标 i 在 for 循环头部后移
*/
if (haystack[i] == needle[j]) {
j++;
}
/*
* 模式串下标为 needle.size(),意味着已经有 needle.size() 个字符匹配成功了
* 那就是全部匹配成功了
*/
if (j == needle.size()) {
return (i - needle.size() + 1);
}
}
return -1;
}
可 AC。
添加图片注释,不超过 140 字(可选)