KMP - 字符串匹配(个人理解 + 模板代码(代码在最下面))

Knuth-Morris-Pratt 算法:

一个人能能走的多远不在于他在顺境时能走的多快,而在于他在逆境时多久能找到曾经的自己。           

                                   ————KMP

在了解 KMP 算法前先去了解一下字符串的一些基础定义:

  1、字符串 S 的 子序列 是从 S 中将若干元素提取出来并不改变相对位置形成的序列,即 S [ p1 ] , S [ p2 ] ……   S [ pk ] ;1 <= p1 < p2 < …… < pk < | S | (字符串 S 的长度);

  2、字符串 S 的 子串 S [ i  .... j ] ,表示 S 串中从 i 到 j 这一段形成的字符串;

  3、后缀 是指从某个位置 i 开始到整个串末尾结束的一个特殊子串;真后缀 指除了 S 本身的 S 的后缀。

    比如 abcabc 中的后缀为 { c, bc, abc, cabc, bcabc, abcabc };

    其中的真后缀为 { c, bc, abc, cabc, bcabc };

  4、前缀 是指从串首开始到某个位置 i 结束的一个特殊子串;真前缀 指除了 S 本身的 S 的前缀。

    比如 abcabc 中的前缀为 { a, ab, abc, abca, abcab, abcabc };

    其中的真前缀为 { a, ab, abc, abca, abcab };

***为了表述方便以下表述的 前/后缀 都为 真前/后缀

**我们设置主串 A 的长度为 n , 模板串 B 的长度为 m;设遍历 A 的指针为 i , 遍历 B 的指针为 j ;

** 这里 KMP  的算法中的字符串是从下标 1 开始的;

  KMP 与 暴力匹配区别:

  对于暴力匹配,每一次失配后 B 串都需要从头开始继续与 A 串匹配,这样的复杂的就接近 O ( n * m ) 而 KMP 的复杂度在最差情况为 O ( n + m ) ;

下面来模拟一下 KMP 算法进行字符串匹配的过程:

  我们需要先了解 KMP 中的 next [ i ] 数组,其中的 next [ i ] 表示模板串 B [ 1 ~ i ] 其中相同前后缀的最长长度;

比如:ababa, 这里相同前后缀的最长长度为 3 , 前缀 "aba" 和后缀 "aba" 相同且长度为 3,这里不能是 "ababa" 因为这里表示的是 真前/后缀 

***相对于暴力匹配中的 i ,j 指针,在 KMP 匹配的过程中,我们只回溯  j 指针,而不去回溯 i 指针;

下面是 KMP 的过程和栗子:

**i ,j 初始化为 0
  1、如果 A [ i + 1 ] == B [ i + 1 ] , i++, j++, 继续匹配;

  2、如果 A [ i + 1 ] != B [ i + 1 ] ;回溯 j 到 next [ j ] ,直到 A [ i + 1 ] == B [ j + 1 ];当 j 回溯到 0 时也不能满足使 A [ i + 1 ] == B [ j + 1 ] 此时 B 串需要移动到 i 后面;

当 j == 0 时,忽略 j ,增加 i ,直到 A [ i + 1 ] == B [ j + 1 ] ;

  3、当 j == m 时,输出为位置,继续匹配;

 

  A (主串): a b a b a b a a b a b a c b (长度为 n )
  i
  B (模串): a b a b a c b (长度为 m )
  j

  next          :  0 0 1 2 3 0 0     // 后面有对 next [ ] 数组的解释

———————————————————————————————————

  A (主串): a b a b a b a a b a b a c b (长度为 n )
                     i
  B (模串): a b a b a c b (长度为 m )
                                  j

此时 i = 5 ,A [ i ] = a ,j = 5 , B [ j ] = a;到目前为止我们知道 A [ 1 ~ 5 ] 和 B [ 1 ~ 5 ] 是两段匹配成功的子串也可以说是这两个子串是相同的; 

但是此时 A [ i + 1 ] = b ,B [ j + 1 ] = c 很明显后面的字符不相同,所以我们在这里失配了,为了让字符串的匹配具有高效性的同时具有准确性,我们则需要使 B 串后移的最少且在已知信息中匹配的最多;

**我们需要求的是 A 子串的后缀集合和 B子串的前缀集合的交集的最大相同长度同时也是 j 指针回溯的位置 ;

而 A 子串的后缀集合和 B子串的前缀集合的交集其实也可以看组是 B 子串和前缀集合与它本身的后缀集合的交集,这也是为什么我们只需要去求 B 子串的 next [ ] 数组;

举个栗子:

  这里 A 匹配的子串为 "ababa" ,B 的子串为 "ababa" 我们需要求此时 A 的子串 "ababa" 的后缀和 B 的子串 "ababa"  的前缀中相同集合的最大长度;我们可以发现已经匹配过的子串一定是相同的,所以我们只需要去求 B 的子串 "ababa" 的相同的前后缀的最大长度。

 

%%%为什么是 A 子串的后缀集合和 B子串的前缀集合的交集的最大相同长度呢?(这里说的是我自己的理解,理解不了很正常我本来就是个菜鸡,知道上面的就行了)

我认为这里也是一种字符串的匹配,不过这里被预处理了也就是建立 next [ ] 的过程;这里相当于我们已经知道 A 子串的后缀 "aba" 与 B 子串的前缀 "aba" 相同,那我们已经知道了相同为什么还要去再匹配一边呢;所以我们就跳过这一段的匹配。

———————————————————————————————————— 

  A (主串): a b a b a b a a b a b a c b (长度为 n )
                     i
  B (模串): a b a b a c b (长度为 m )
                            j

 失配之后我们并不改变 i 指针的位置先只改变 j 指针的位置,我们是判断 A [ i + 1 ] 与 B [ j + 1 ] 是否失配;而失配的时候 j = 5,所以改变后 j 指针的位置就是 next [ 5 ]  = 3;
 
  A (主串): a b a b a b a a b a b a c b (长度为 n )
                           i
  B (模串): a b a b a c b (长度为 m )
                                  j
这里我们继续匹配,此时 i = 7,A [ 7 ] = a,j = 5,B [ 5 ] = a, 我们来判断一下 A [ i + 1 ] 与 B [ j + 1 ] ;
 发现 A [ i + 1 ] = a 而 B [ j + 1 ] = c ,那么此时失配了,改变后的 j 指针位置变成 next [ 5 ] = 3;

  A (主串): a b a b a b a a b a b a c b (长度为 n )
                           i
  B (模串): a b a b a c b (长度为 m )
                            j
此时 i = 7,A [ 7 ] = a,j = 3,B [ 3 ] = a,我们来判断一下 A [ i + 1 ] 与 B [ j + 1 ] ,A [ i + 1 ] = a 而 B [ j + 1 ] = b,我们发现又失配了,于是改变后的 j 指针位置变成 next [ 3 ] = 1;

  A (主串): a b a b a b a a b a b a c b (长度为 n )
                           i
  B (模串): a b a b a c b (长度为 m )
                      j
发现还是和上面一样,这里我们又失配了,于是 j 指针的位置变成了 next [ 1 ] = 0;

  A (主串): a b a b a b a a b a b a c b (长度为 n )
                           i
  B (模串): a b a b a c b (长度为 m )
       j
现在 i = 7 ,j = 0;但是此时我们不能把 j 指针往左移了,这里我们要去将 i 指针往右移动去找到
A [ i + 1 ] == B [ j + 1 ]  的情况;不过这里我们各个好可以发现此时 A [ i + 1 ] = A [ 8 ] = a ,B [ j + 1 ] = B [ 1 ] = a;所以我们现在可以直接往后继续匹配;

  A (主串): a b a b a b a a b a b a c b (长度为 n )
                                                i
  B (模串): a b a b a c b (长度为 m )
                                        j
这里我省略了一步一步的过程(不会画动图,理解一下QAQ);
我们发现现在 j == m ,这就代表现在我们已经匹配成功了,然后出这里的 i - n 表示匹配成功后字符串的第一个字符的下标;
**如果还需要去知道还有哪些位置能成功匹配,在匹配成功后 j = next [ m ] 然后继续匹配;
这里附上匹配时候的代码:
 1 for(int i = 0, j = 0; i < m; i++)// i 和 j 指针都从 0 开始
                      //这里主串是 s , 模式串是 p
2 { 3 while(j && s[i + 1] != p[j + 1])//此时如果失配且 j 指针不为 0 时
                          //我们通过 nxet 数组去跳转 j 的位置
4 j = ne[j]; 5 if(s[i + 1] == p[j + 1]) 6 j++; 7 if(j == m)//当 j 指针到达模板串最后的位置时匹配结束 8 { 9 printf("%d ", i - n + 1); 10 //返回即相同字符串的区间的第一个下标 11 j = ne[j];//结束后继续运行,这里需要跳转 j 的位置 12 } 13 }

 

然后再来说说如果去得到 next [ ] 数组:(其实 next [ ] 的求法和上面的匹配过程特别像)

先说一下过程:

  1、如果匹配:next [ i ] = j + 1, j 是 B 串的前缀指针,也就是当前字符串匹配之前的最长公共前后缀的长度,如果这里匹配成功了,需要 + 1; 

  2、如果不匹配:回溯 j 指针,j = next [ j ] ,直到匹配成功;

next [ i ] 表示 s [ 1 ~ i ] 中他的前缀和后缀相同集合的最大长度;

 

  B :a  b  c  a  b  e  a  b  c  a  b  c

    i

  j

下标:   1  2  3  4  5  6  7  8  9 10 11 12

此时 i = 1,j = 0 ;

同样的这里我们用两个指针去匹配;因为这里只考虑真前/后缀所以 "a" 没有前缀和后缀,因而 next [ 1 ] = 0;

如果 B [ i + 1 ] 与 B [ j + 1 ] 不匹配,同上面的字符串匹配一样我们通过右移 i 指针去找到 B [ i + 1 ] == B [ j + 1 ] 的状态; 

 

  B :a  b  c  a  b  e  a  b  c  a  b  c

       i

  j

下标:   1  2  3  4  5  6  7  8  9 10 11 12

此时 i = 3 , j = 0, 这时 i 指针已经移动了 2 个单位长度,但是 j 却一直等于 0 说明没有与后缀匹配的前缀所以这里的 next [ 2 ~ 3 ] = 0 ;

不过这时 i 指针和 j 指针满足 B [ i + 1 ] == B [ j + 1 ] 的状态所以这里我们开始匹配相同前后缀的长度;

 

  B :a  b  c  a  b  e  a  b  c  a  b  c

               i

             j

下标:   1  2  3  4  5  6  7  8  9 10 11 12

此时我们在 i = 5,j = 2,这时 i 指针已经移动了 2 个单位长度,j 也跟着一起移动了 2 个单纯长度,i = 4 时,j = 1 我们发现此时有 1 个单位长度的前缀匹配成功,所以此时的 next [ 4 ] = 1;同理 next [ 5 ] = 2;

让我们来继续匹配;

——————————————————————————————————

  B :a  b  c  a  b  e  a  b  c  a  b  c

                                      i

                         j

 下标:   1  2  3  4  5  6  7  8  9 10 11 12

此时 i = 11 ,j = 5 ,我们发现 B [ i + 1 ] = B [ 12 ] = c , B [ j + 1 ] = B [ 6 ] = e,此时失配了而我们为了让匹配的次数更少我们用字符串匹配时候用到过的方法;

** j = next [ j ] ;同样是为了不去重复比较已知的相同的前后缀,而这里的 next [ j ] 也就是 next [ 5 ] 我们发现其实在前面已经处理过了,所以我们可以直接向左变跳转。

求 next [ ] 数组的代码:

1 for(int i = 1, j = 0; i < n; i++)//因为已知 next[1] = 0 所以从 i = 1 开始处理而不是 0 
2     {
3         while(j && p[i + 1] != p[j + 1]) // j 指针的跳转
4             j = ne[j];
5         if(p[i + 1] == p[j + 1])//相同匹配,长度 +1
6             j++;
7         ne[i + 1] = j;//记录当下相同的前后缀长度
8     }

这里再放一遍字符串匹配的代码

 1 for(int i = 0, j = 0; i < m; i++)
 2     {
 3         while(j && s[i + 1] != p[j + 1])
 4             j = ne[j];
 5         if(s[i + 1] == p[j + 1])
 6             j++;
 7         if(j == n)
 8         {
 9             printf("%d ", i - n + 2);
10             //返回即相同字符串的区间的第一个下标(从1开始)
11             j = ne[j];
12         }
13     }

我们发现两坨代码其实很相似,如果认为过于抽象那就背板子吧。

posted @ 2022-07-09 16:12  aYi_7  阅读(162)  评论(0)    收藏  举报