字符串匹配问题的KMP算法

其实在此之前我已经看过好几回KMP教程了,各种讲解都挺详细的。但是由于平时实在用不上,导致一段时间不看就忘了,大概的思想还是记得的。昨天下午手撸了一面草稿纸,从匹配过程到next数组的建立,然后动手写code,然后找了一道模板题提交上去发现自己写的KMP是没有问题的。遂想记录一篇博客,把KMP算法的思路给记录下来,以后再遇到KMP,应该也能在一段时间内推导出来。


 

问题描述

首先KMP算法解决的是字符串匹配问题,给定模式串pattern与字符串s,需要找到s中是否有子串与pattern完全相同。那么显然这里pattern的长度一定是小于s的长度的(不然一定匹配不上,也就没意义了)。呢么我们假设s长度为n,pattern长度为m,n>m。最基本的办法是暴力求解,从第一个字符开始,看看长度m的子串能否匹配上pattern。这里的匹配过程自然是逐个字符匹配了。如果不行,就从第二个字符开始看子串能否匹配……直到匹配为止。

算法自然很简单,但是问题在于,如果是下图这种情况,每一次都是在最后一个字符才发现匹配不同,这样的话每个字符都要匹配m次才能知道是否匹配成功,所以时间复杂度为O(n*m)。

我们需要看看这种匹配算法的问题在哪里,此时指针i在string的第6个字符处,但是匹配失败后,i需要回溯到第2个字符处,同样j需要回溯到第1个字符处。这里我们可以想象成两张字条,我们把pattern字条向前推了一位开始比较。

但是实际上,我们在前面比较过程中,所有的字符都是一样的'a',但是在下一次比较时,我们仍然是让a之间一个个比对。在我们能看到这样的字符串的时候能知道,这样的比对显然是无意义的。但是这一信息程序并不知道,所以程序在将pattern纸条向前推1位后,只能让两个指针向前回溯,一个个字符进行比较,匹配到后面的位置。

 

所以,导致了暴力字符串匹配时间复杂度高的原因是:程序并没有有效利用字符串的内容信息,导致了指针进行了无意义的回溯前移。


KMP算法思路

这时可以引入KMP算法的思路了,如果像上图一样,在pattern这张字条往前移之后,能让程序知道前面的字符一定是匹配成功的,那程序就没必要将指针进行回溯,直接从蓝色指针处进行匹配即可。在这里可以视为pattern纸条移动了一位,所以pattern的指针也仅仅回溯了一位。那么最差的情况是什么呢,显然是指针回溯到头部,如下图的情况,此时我们可以视为pattern纸条向前移动了三位。

在实际的程序运行时,字符串是不会移动的,仅仅是指针移动,在这里纸条推动的步数是指针移动的步数。那么最大的问题是如何确定纸条前移(指针回溯)的位置。

如果我们能知道此时纸条前移(指针回溯)到哪里最好,只需要把纸条前移(指针回溯)到相应位置进行比对即可,假设我们真的能够知道pattern的指针移动到哪里最好,那么就能实现KMP算法的基本思路框架:

 1 while(i < s.length() && j < pattern.length()){
 2     if(s[i] == pattern[j])
 3     {
 4         i++;
 5         j++;
 6     }
 7     else if(j != 0)
 8         moveBack(j);
 9     else
10         i++;
11 }

 

也就是说,如果匹配两个字符匹配上,或者pattern无法再回溯了,指针i才前移。但是从代码,以及前面的思路可以看出来,指针i是不会回溯的。这也是KMP算法的精髓:string的指针i一定不会回溯,只会向前移。否则也就不是KMP算法了。


next数组

其实这个回溯的位置就是KMP算法中著名的next数组,该数组也有非常多的应用。该数组的含义是,当pattern的指针j在匹配失败时,应该回溯到的位置,所以该数组一定是根据pattern构建的。在知道这一点后,其实KMP算法已经可以写出来了。只要匹配失败后,指针j就一直向前回溯即可。

 1 int kmp(string pattern, string  s){
 2     int i = 0, j = 0;
 3     while(i < s.length() && j < pattern.length()){
 4         if(s[i] == pattern[j])
 5         {
 6             i++;j++;
 7         }
 8         else if(j != 0)
 9             j = match[j]; //c++貌似有个next相关的库函数?所以就不起名next了。
10         else
11             i++;
12     }
13     if(j == pattern.length())
14         return i - j;    //这里是string中第一个匹配的字符的位置
15     return -1;           //匹配失败的符号
16 }

 

那么关键问题就成了回溯到什么位置是最佳的,也就是next数组应该如何求解。下图为字符串匹配的过程,在这个时候,能够看出来前面的ab是可以成功匹配的,只需要从第三个字符h匹配即可。

从图中可以看出ab是字符串pattern的前缀,同时也是string中i位置之前的子串的后缀。为了使pattern与string能匹配,一定有指针i之前的字符与指针j之前的字符相同。而j之前的为pattern的前缀,i之前的位子串的后缀。同时,在需要回溯的条件下,i之前的子串与j之前的子串是已经匹配过的,也就是i之前子串的后缀也是回溯前pattern的j之前子串的后缀。

所以j回溯的位置一定是和前缀和后缀有关的。为了使回溯后,i与j之前的字符能匹配上,回溯的位置一定要满足前缀与原位置后缀相同。同时,为了使j移动的距离最少,这个后缀长度应该尽可能长。所以这个next[j]表示的含义应该是,该位置之前串的一个最长子串,其既是该串的前缀,也是该串的后缀,其作为前缀的下一个位置(子串不能与原字符串相同,否则肯定前缀后缀相同,也就没有意义了)。这样说可能有点绕,以下图为例,对应9位置,其之前串为ababcabab,那么显然作为最长前缀与最长后缀相同的子串为abab,那么next[9]应该是前缀abab的下一个位置,即next[9]=4,并且我们并不需要知道9位置对应元素是什么。

需要注意的是,前缀与后缀可能有相交的地方,例如下图,8位置之前最长前缀与后缀应该为ababab,而不是abab。

接下来问题来了,怎么求next数组呢。每个位置都向前用循环来找前缀后缀相同的情况显然不是个很好的办法,这里可以观察next[i]与next[i-1]的关系

对于位置10而言,已知位置9的next[9] = 4,若位置9的字符为pattern[4], 即为c,那么显然ababc能构成新的最长前缀与后缀,所以next[10]=5。

 

否则ababc一定不是最长前缀,那么往前找next[4]=2,所以开始比对pattern[2]是否与位置9的字符相同。若都为a,那么新的最长前缀应该是aba,next[10]=3。(2的下一个位置)。

否则,仍然接着往前找,next[2]=0,比对pattern[0]是否与pattern[9]相同,相同则next[10]=1,不同时,由于无法接着往前找了,next[10]=0。

关于为什么上述方法得到的一定是最长的呢?在后续更新的时候会用图示的方式解释。

根据约定,next[0]=-1。根据如上的方式,就能从0开始构造next数组。其可以用如下的函数构造:

 1 void build(string pattern){
 2     for(int i = 1; i < pattern.length(); i++){
 3         int j = match[i-1];
 4         while(j >= 0){
 5             if(pattern[j] == pattern[i-1]){
 6                 match[i] = j + 1;
 7                 break;
 8             }
 9             else
10                 j = match[j];
11         }
12         if(j == -1)
13             match[i] = 0;
14     }
15 }

 

那么综上,在得到next数组之后,就能通过KMP算法进行字符串的匹配,其最坏情况下的时间复杂度为O(n+m)。至于为什么我这里就不做详细的分析了(我也不知道)。


总结

下面是一个KMP算法的demo,也是我昨天撸出来的,一次就撸正确的代码。

 1 #include<string>
 2 #include<iostream>
 3 using namespace std;
 4 
 5 int match[100] = {-1};
 6 
 7 void build(string pattern){
 8     for(int i = 1; i < pattern.length(); i++){
 9         int j = match[i-1];
10         while(j >= 0){
11             if(pattern[j] == pattern[i-1]){
12                 match[i] = j + 1;
13                 break;
14             }
15             else
16                 j = match[j];
17         }
18         if(j == -1)
19             match[i] = 0;
20     }
21 }
22 
23 int kmp(string pattern, string  s){
24     int i = 0, j = 0;
25     while(i < s.length() && j < pattern.length()){
26         if(s[i] == pattern[j])
27         {
28             i++;j++;
29         }
30         else if(match[j] != -1)
31             j = match[j];
32         else
33             i++;
34     }
35     if(j == pattern.length())
36         return i - j;
37     return -1;
38 }
39 
40 int main(){
41     string pattern = "ababcababcababc";
42     string s = "ababcababdababcababcababcababc";
43     build(pattern);
44     cout << kmp(pattern, s);
45 }

 

网上有很多KMP的算法,其中回溯的位置、next数组构造的方式各有不同,例如有的next[1]=0,有的回溯时j=next[j]+1等等……总之大家在应用时不必纠结,哪种用的顺手就用就好。但是KMP的内核一定要把握住,即:string的指针i一定不能回溯,只能向前移动。另外构造next数组的含义是指针j回溯的位置,其是一个最长子串的下一位置,且该子串既是前缀,也是后缀。在构造next[i]时要根据next[i-1]来推导。把握了这几点的话,哪怕很久不看KMP,从头再推一遍代码,应该也不难。


后续更新

这里大概是讲为啥next[i]一定≤next[i-1]+1。以及一道洛谷的模板题,当时做的时候有个点还没想明白。后续有啥再加上吧。

一鼓作气写完吧。

1.为什么next数组的构造是正确的

首先我们要找的是最长的公共前缀后缀,第一步有next[i]≤next[i-1]+1。那我们只需要证明当next[i] > next[i-1]+1时不成立即可。

如图next[i-1]=x。如果next[i] > next[i-1] + 1,也就是说next[i]=k>x+1>x。则一定有pattern[0....k-1]这一子串,既是pattern[0....i-1]的前缀,也是pattern[0....i-1]的后缀。即图中红色框的部分。同时,黑色框的pattern[1...x-1]既是pattern[1....i-2]的前缀,也是pattern[1....i-2]的后缀。

可以将下一个pattern向右平移,直到红色部分上下对应,此时应该上下字符应该完全相同。即一定有pattern[y] = pattern[0](假设右边的红色部分第一个字符从y开始), pattern[y+1] = pattern[1],一直到pattern[i-1]  = pattern[k-1]。那么此时,由于pattern[y...i-1]与pattern[0...k-1]匹配,则pattern[y...i-2]与pattern[0...k-2]匹配。故pattern[0...k-2]既是pattern[0....i-2]的前缀,也是pattern[0....i-2]的后缀。故next[i-1]=k-1>x的,这与next[i-1]=x矛盾。故由反证法,可以得到,next[i]≤next[i-1]+1一定成立。所以第一步直接比对pattern[i-1]与patterrn[next[i-1]],若相等,一定能得到最长前缀为pattern[0...next[i-1]],那next[i]也就在前缀的下一个位置了。

 

 

这个地方很绕,我刚刚推的时候都绕晕了= =。建议自己在图上画一画。

如果pattern[x] != pattern[i-1],那么就要向前找next[x]了。假设next[x]=y吧,那么为什么要这样找呢?可以继续从图中看出来,pattern[0...y-1]既是pattern[0...x-1]的前缀,也是后缀;它同时还是pattern[i-x-1....i-2]的前缀以及后缀。

在pattern[i-1] != pattern[x]的条件下,为了能匹配最多的前缀,那么下一次一定是匹配y位置的。用上述证明同样的反证法可以证明next[i]<=y+1,即y+1到x之间必不存在能匹配的最长前缀后缀。

由此可以证明,我们上节所介绍的next数组的构造方法是正确的。

2.为什么匹配失败j要移动至next[j]的位置

如图。黄色为next[j]所在的位置,红色为j之前串的最长前缀与后缀。如果在此匹配失配,则必须把j移动到next[j]处,如图。

如果移动的步数更小都能匹配上,即j>next[j],如图。即相当于纸条没有移动那么长的距离,而是少移动了。那么此时为了匹配成功,蓝色部分必须相同,而这就代表对于j位置来说,它前面的子串有更长的公共前缀后缀。这与next[j]表示它之前串的最大公共前缀后缀是矛盾的,故纸条(指针)肯定不能移动更少的距离了。

如果反过来,指针移动了更大的距离,那么我们并没有计算j=next[j]时能否匹配上。哪怕移动了更大距离时成功匹配,因为我们没有计算j=next[j]的情况,所以并不能说这是最早出现的匹配。有可能j=next[j]时能匹配上。

综上,j=next[j]时,才有可能出现第一次的成功匹配。而j>next[j]时,一定不会出现成功的匹配,这也是为什么KMP算法快速的原因,因为能排除很多不可能匹配的情况, 在匹配失配时快速定位至有可能匹配的位置。

3.洛谷模板题

P3375:

题目描述

给出两个字符串 s1​ 和 s2​,若 s1​ 的区间 [l, r]子串与 s2​ 完全相同,则称 s2​ 在 s1​ 中出现了,其出现位置为 l。
现在请你求出 s2​ 在 s1​ 中所有出现的位置。

定义一个字符串 s 的 border 为 s 的一个非 s 本身的子串 t,满足 t既是 s的前缀,又是 s 的后缀。
对于 s2​,你还需要求出对于其每个前缀 s′ 的最长 border t′ 的长度。

输入格式

第一行为一个字符串,即为 s1​。
第二行为一个字符串,即为 s2​。

输出格式

首先输出若干行,每行一个整数,按从小到大的顺序输出s2​ 在s1​ 中出现的位置。
最后一行输出 ∣s2​∣ 个整数,第 i 个整数表示s2​ 的长度为 i 的前缀的最长 border 长度。

输入输出样例

输入 

ABABABC
ABA
输出 

1
3
0 0 1 

 

 

模板题,本来是没什么好说的。显然border就是next数组了,不过还要计算额外最后一个元素之后的next值(在KMP是用不到的)。唯一需要注意的是要输出所有匹配位置。我一开始没有想明白,在匹配成功后,在下一个位置继续重新KMP,但TLE了。后来才发现这里的逻辑漏洞:当我重新KMP时,string的指针i可能从后面回溯了。因此这与KMP的思想是矛盾了。

正确做法是当KMP成功时,我们假装匹配失败,然后让j回溯,这样就能在i不变的情况下,继续KMP了。

 1 #include<string>
 2 #include<iostream>
 3 using namespace std;
 4 
 5 int match[1000002] = {-1};
 6 
 7 void build(string pattern){
 8     int len = pattern.length();
 9     for(int i = 1; i <= len; i++){
10         int j = match[i-1];
11         while(j >= 0){
12             if(pattern[j] == pattern[i-1]){
13                 match[i] = j + 1;
14                 break;
15             }
16             else
17                 j = match[j];
18         }
19         if(j == -1)
20             match[i] = 0;
21     }
22 }
23 
24 void kmp(string pattern, string  s){
25     int i = 0, j = 0;
26     int len1 = s.length(), len2 = pattern.length();
27     while(i < len1)
28     {
29         if(s[i] == pattern[j])
30         {
31             i++;j++;
32         }
33         else if(match[j] != -1)
34             j = match[j];
35         else
36             i++;
37         if(j == len2)
38         {
39             cout << i-j+1 << endl;
40             j = match[j];
41         }
42     }
43 }
44 
45 int main(){
46     string s1, s2; 
47     cin >> s1 >> s2;
48     int len = s2.length();
49     build(s2);
50     kmp(s2, s1);
51     for(int i = 1; i <= len; i++)
52         cout << match[i] << " ";
53 }

 


写在后面

KMP算法如果思路理清楚还是不难的,难点在于怎么讲清楚,因为有的地方实在太绕了。可能我这篇文讲的也不算清楚,但是基本思路是有的,如果能理清楚思路的话现场推导一下应该花不了太久。

已经是研究生了,多多少少比起大学本科期间也应该干出点事情了。因此想着能不能把当时学过的算法在没有提示的情况下想起来,在草稿纸上算一算。作为弱鸡,写这个专栏并不可能是为了炫技,为了显得自己很牛逼,而是作为自己的一个总结吧。KMP算法写起来确实废话连篇的,后面如果还有的话,应该会简洁一些吧。一篇文写了快一天,也是自己对KMP的一点理解与总结。如果觉得我哪里没说明白或者有问题,欢迎在评论区指正。

posted @ 2020-12-04 11:18  WA_ker  阅读(130)  评论(0编辑  收藏  举报