字符串模式匹配:KMP算法讲解

一、模式匹配问题的定义

字符串的模式匹配问题指的是:给定一个字符串 S 和一个模式串 P ,搜索模式串 P S 中第一次出现的位置。假设字符串 “ BCABAABAABCACAABC ” 为被搜索的字符串 S ,字符串 “ ABAABC ” 为要匹配的模式串 P ,则 P S 中第一次出现的位置为 5 ,如下表格所示:

《字符串模式匹配:KMP算法讲解》

二、KMP算法原理

让我们一步一步地观察模式串 P 是如何匹配主串 S 的:
1、主串第0个字符与模式串第0个字符匹配,发现 B A 不同,则模式串向后移动一位:

《字符串模式匹配:KMP算法讲解》

2.、主串第1个字符与模式串第0个字符匹配,发现 C A 不同,则模式串向后移动一位:

《字符串模式匹配:KMP算法讲解》

3、主串第2个字符与模式串第0个字符匹配,发现 A A 相同,匹配成功,则模式串继续向后匹配:

《字符串模式匹配:KMP算法讲解》

此后,主串的第3个字符与模式串第1个字符匹配成功,主串第4个字符与模式串第2个字符匹配成功……直到主串的第7个字符和模式串的第5个字符匹配失败,此时最自然的想法就是把模式串再后移一位,然后从模式串的第0个字符开始重新匹配:

《字符串模式匹配:KMP算法讲解》

但是我们知道,主串的第3个字符肯定与模式串的第1个字符相同,而与模式串的第0个字符不同,因此把模式串后移一位是不可能匹配成功的,这是从模式串自身之前的匹配结果推理得到的,所以我们不需要浪费时间去进行一个已知的不可能成功的匹配,而应该把模式串移动到可能匹配成功的新位置,这就要考虑模式串自身的特点了。

根据肉眼我们可以看出,我们应该把模式串向后移动3位,即移动到主串的第5个字符的位置处开始重新匹配:

《字符串模式匹配:KMP算法讲解》

为什么要移动3位?这是因为在之前匹配成功的模式串子串 “ ABAAB ” 中(注意不包括匹配失败的字符 ‘ C ’),其前缀和后缀相同的最长子串的长度为2(即子串” AB ”),而匹配成功的模式串子串 “ ABAAB ” 的长度为5,因此应该把模式串向后移动 52=3 位再重新匹配。

补充说明一下字符串前缀和后缀的概念:
字符串”AB”的前缀是 { “A” }, 后缀是 { “B” },前后缀集合中没有相同的子串,因此前后缀相同的最长子串的长度为0;
字符串”ABA”的前缀是 { “A”, “AB” }, 后缀是 { “BA”, “A” },前后缀集合中相同的子串为 “A”,因此前后缀相同的最长子串的长度为1;
字符串”ABAA”的前缀是 { “A”, “AB”, “ABA” }, 后缀是 { “BAA”, “AA”, “A” },前后缀集合中相同的子串为 “A”,因此前后缀相同的最长子串的长度为1;
字符串”ABAAB”的前缀是 { “A”, “AB”, “ABA”, “ABAA” }, 后缀是 { “BAAB”, “AAB”, “AB”, “B” },前后缀集合中相同的子串为 “AB”,因此前后缀相同的最长子串的长度为2;
由此我们可以知道,字符串的前缀指的是除了该字符串的最后一个字符外,从第0个字符开始的依次连续的字符子串的集合;字符串的后缀指的是除了该字符串的第0个字符外,从第1个字符开始的到最后一个字符的依次连续的字符子串的集合。

再回到上面的例子,匹配成功的模式串子串的长度为5,如果该成功子串的前后缀集合中没有相同的子串,则我们只需要把模式串向后移动5位再重新匹配,但是因为 “ ABAAB ” 的前后缀相同的最长子串的长度为2(” AB ”),因此应该向后移动 52=3 位,这样才能保证匹配到的是模式串在主串中第一次出现的位置,并且保证不会漏掉任何成功的匹配。
还有一点需要注意的是,重新匹配指的不是重新从模式串的第0个字符开始匹配,而是从主串匹配失败的那个字符的位置开始匹配,比如说在上面的例子中,主串匹配失败的字符是第7个字符 ‘A’,则把模式串向后移动3位后:

《字符串模式匹配:KMP算法讲解》

应该从模式串的第2个字符 ‘A’ 处重新开始和主串的第7个字符匹配,而不是从模式串的第0个字符 ‘A’ 处重新开始和主串的第5个字符匹配!经过上面的解释我们可以得到一张表:

《字符串模式匹配:KMP算法讲解》

注意上面中的第5行,匹配成功的模式子串前后缀相同的最长子串的长度恰好就是将模式串移动后,开始重新匹配的字符的下标!(比如上面的例子,重新匹配是从模式串的第2个字符 ‘A’ 处重新开始和主串的第7个字符匹配,而刚好先前匹配成功的模式子串的前后缀相同的最长子串的长度就是2!)将上面的第5行提取出来,就得到了教科书里面说的神奇的next数组

《字符串模式匹配:KMP算法讲解》

表格中把第0个字符对应的next数组的值设置为-1,是为了方便后面求next数组。

next数组代码求解

下面我们来对next数组进行递推归纳求解:
第一步: j=0 时, next[j]=1
归纳步:已知 next[j] 的值,求 next[j+1] 。假设 next[j]=k ,这就意味着在模式串 p0p1...pj...pn j 个字符之前的模式子串 p0p1...pj1 拥有长度为 k 的前后缀相同的最长子串,即存在 p0p1...pk1=pjkpjk+1...pj1

此时如果有 pk=pj ,则存在 p0p1...pk1pk=pjkpjk+1...pj1pj ,这就意味着在模式串的第 j+1 个字符之前的模式子串 p0p1...pj 拥有长度为 k+1 的前后缀相同的最长子串,所以 next[j+1]=k+1=next[j]+1 。我们以上面的模式串 ABAABC 为例来说明这个问题:

《字符串模式匹配:KMP算法讲解》

假设我们现在已知 next[4]=1 ,求 next[5] 。因为 next[4]=1 ,这就意味着在模式串 ABAABC 4 个字符 ‘ B ’ 之前的模式子串 ABAA 拥有长度为 1 的前后缀相同的最长子串 “ A ” ,此时由表格可知 pj=pk=B ,也就是说在模式串 ABAABC 5 个字符 ‘ C ’ 之前的模式子串 ABAAB 拥有长度为 2 的前后缀相同的最长子串 “ AB ” ,所以 next[5]=next[4]+1=2

如果 pkpj ,这就意味着在模式串的第 j+1 个字符之前的模式子串 p0p1...pj 并不拥有长度为 k+1 的前后缀相同的最长子串,但是却可能存在长度小于 k+1 的前后缀相同的最长子串,因此我们要利用next数组往回找:判断 pj=pnext[k] 是否成立(即判断 pj=pnext[next[j]] 是否成立),如果成立,则 next[j+1]=next[k]+1=next[next[j]]+1 ,否则继续回溯,判断 pj=pnext[next[k]] 是否成立……直到找到满足要求的 pk ,则 next[j+1]=next[k]+1 ,如果找不到,则 next[j+1]=0 。下面我分别用两个例子来说明这两种情况:
首先考虑模式串 DABCDABDE

《字符串模式匹配:KMP算法讲解》

已知 next[7]=3 ,求 next[8] 。此时 j=7,k=3 ,显然 pk=Cpj=D ,因此模式串第8个字符 ‘ E ’ 之前的模式子串 DABCDABD 无法拥有长度为 k+1=4 的前后缀相同的最长子串(因为 DABCDABD ),此时我们回溯,因为 next[k]=next[3]=0 ,我们比较 p0=pj 是否成立,显然 p0=p7=D ,所以模式串第8个字符 ‘ E ’ 之前的模式子串 DABCDABD 拥有长度为 next[k]+1=0+1=1 的前后缀相同的最长子串,因此 next[8]=next[k]+1=next[next[j]]+1=1

我们再来考虑模式串 ABCDABDE

《字符串模式匹配:KMP算法讲解》

已知 next[6]=2 ,求 next[7] 。此时 j=6,k=2 ,显然 pk=Cpj=D ,因此模式串第7个字符 ‘ E ’ 之前的模式子串 ABCDABD 无法拥有长度为 k+1=3 的前后缀相同的最长子串(因为 ABCABD ),此时我们回溯,因为 next[k]=next[2]=0 ,我们比较 p0=pj 是否成立,显然 p0=Apj=D ,因此模式串第7个字符 ‘ E ’ 之前的模式子串 ABCDABD 无法拥有长度为 next[k]+1=1 的前后缀相同的最长子串(因为 AD ),我们再回溯,此时 next[next[k]]=next[0]=1 ,说明找不到满足要求的 pk 了,因此置 next[7]=0

为什么这样递归就能求解得到next数组呢?这里我想引用v_JULY_v在他的博客 http://blog.csdn.net/v_july_v/article/details/7041827 里面写的解释:

这又归根到next数组的含义。我们拿前缀 p0 pk-1 pk 去跟后缀pj-k pj-1 pj匹配,如果pk 跟pj 失配,下一步就是用p[next[k]] 去跟pj 继续匹配,如果p[ next[k] ]跟pj还是不匹配,则需要寻找长度更短的相同前缀后缀,即下一步用p[ next[ next[k] ] ]去跟pj匹配。此过程相当于模式串的自我匹配,所以不断的递归k = next[k],直到要么找到长度更短的相同前缀后缀,要么没有长度更短的相同前缀后缀。如下图所示:《字符串模式匹配:KMP算法讲解》
所以,因最终在前缀ABC中没有找到D,故E的next值为0。

因此,我们可以写出求next数组的java代码了:

public void get_next(String pattern, int[] next) {
  int j = 0, k = -1;
  next[0] = -1;

  while (j < pattern.length() - 1) {
    // 计算next[j + 1],此时在上一轮循环中k的值应该被更新成next[j]的值了
    if (k == -1 || pattern.charAt(j) == pattern.charAt(k)) {
      // 如果pattern[k] == pattern[j], 则next[j + 1] = k + 1
      // 如果k == -1,则next[j + 1] = k + 1 = 0
      next[j + 1] = k + 1;
      // 更新j的值以计算下一个next[j + 1],同时把k的值更新为next[j]
      j++;
      k = next[j];
    } else {
      // 否则,递归找到满足条件的k
      k = next[k];
    }
  }
}

理解上述代码的关键是要知道每一次新的 while 循环都是计算 next[j + 1] 的值,且新一轮循环的 jk 的值要在上一轮循环的时候更新!很多数据结构教材都把上面代码中 while 循环里面的 if 语句中的代码简化成如下形式:

if (k == -1 || pattern.charAt(j) == pattern.charAt(k)) {
  ++j;
  ++k;
  next[j] = k;
}

这种写法我个人认为非常难懂,很难把每个代码语句与上面的推理归纳过程对应起来。但是在计算下面的优化next数组时,我们必须要用 ++k 去代替 k = next[j] !

优化next数组

上面的next数组虽然看似已经完美,但事实上仍有缺陷。考虑这样的例子:用模式串 “ AAAAB ” 去匹配主串 “ AAABAAAAB ”。我们可以得到模式串的next数组为:

《字符串模式匹配:KMP算法讲解》

这样我们开始匹配,第一步匹配时模式串的第3个字符 ‘ A ’ 匹配失败:

《字符串模式匹配:KMP算法讲解》

则根据 next[3]=2 的值将模式串向右移动,从第2个字符开始重新匹配:

《字符串模式匹配:KMP算法讲解》

但是事实上从模式串自身我们已经知道这样的匹配肯定会失败,这是因为 p3=p2=pnext[3]=A ,因此这种移动事实上又造成了明知失败还要去匹配的情况。为了避免这种情况,我们在构建next数组时,假如 pj=pk=pnext[j] 成立,还应该注意当 pj+1=pk+1=pnext[j]+1 时,我们应该再次回溯使 next[j+1]=next[k+1]=next[next[j]+1] ,这样,上述的例子中的next数组就变成了:

《字符串模式匹配:KMP算法讲解》

注意,这个时候,对于出现了 pj+1=pk+1=pnext[j]+1 这个情况的字符(如例子中的字符 ‘ A ’),其next值就不再表示已匹配成功的模式子串前后缀相同的最长子串的长度了,而仅仅表示模式串移动后,开始重新匹配的字符的下标!但是,对于没有出现这个情况的字符(如例子中的字符 ‘ B ’),其next值仍然表示已匹配成功的模式子串前后缀相同的最长子串的长度!但是我们知道next值的求解是递推得到的,例子中字符 ‘ B ’ 的前一个字符 ‘ A ’ 的next值为 1 ,如果是通过 k=next[j],next[j+1]=k+1 这个方法来求字符 ‘ B ’ 的next值,则字符 ‘ B ’ 的next值会是 1+1=0 而不是 3 。所以我们在代码中必须要用 ++k 去代替原来的 k = next[j]。(至于为什么这样就能解决问题,本人才疏学浅,实在是很难解释,这个只能意会了orz) 因此我们将上面求next数组的代码修改如下:

public void get_next(String pattern, int[] next) {
  int j = 0, k = -1;
  next[0] = -1;

  while (j < pattern.length() - 1) {
    if (k == -1 || pattern.charAt(j) == pattern.charAt(k)) {
      if (pattern.charAt(j + 1) != pattern.charAt(k + 1)) { next[j + 1] = k + 1; }
      else { next[j + 1] = next[k + 1]; }
      j++;
      k++;
    } else {
      k = next[k];
    }
  }
}

或者可以直接采用教科书中的简洁的代码:

public void get_next(String target, int[] next) {
  int j = 0, k = -1;
  next[0] = -1;

  while (j < target.length() - 1) {
    if (k == -1 || target.charAt(j) == target.charAt(k)) {
      ++j;
      ++k;
      if (target.charAt(j) != target.charAt(k)) { next[j] = k; }
      else { next[j] = next[k]; }
    } else {
      k = next[k];
    }
  }
}

经过优化之后,我们就把明知失败还要匹配的情况排除掉了,接下来我将把完整的字符串KMP模式匹配的java代码写出来。

KMP模式匹配完整代码

我将以 LintCode 13 字符串查找 这一题为例,展示KMP的应用:

class Solution {
    /** * Returns a index to the first occurrence of target in source, * or -1 if target is not part of source. * @param source string to be scanned. * @param target string containing the sequence of characters to match. */
  public int strStr(String source, String target) {
    // 对一些特殊清空字符串的判断
    if (source == null || target == null) { return -1; }
    int tLen = target.length();
    int sLen = source.length();
    if (tLen > sLen) { return -1; }
    if (tLen == sLen) {
       if (source.equals(target)) { return 0; }
       else { return -1; }
    }
    if (tLen == 0) { return 0; }

    // KMP算法匹配
    int[] next = new int[tLen];
    get_next(target, next);

    int i = 0, j = 0; 
    while (i < sLen && j < tLen) {
      if (source.charAt(i) != target.charAt(j)) {
        if (next[j] == -1) {
          j = 0;
          ++i;
        } else {
          j = next[j];
        }
          continue;
      }
      ++i;
      ++j;
     }

     if (j == tLen) { return i - tLen; }
     else { return -1; }
    }

  public void get_next(String pattern, int[] next) {
    int j = 0, k = -1;
    next[0] = -1;

    while (j < pattern.length() - 1) {
      if (k == -1 || pattern.charAt(j) == pattern.charAt(k)) {
        if (pattern.charAt(j + 1) != pattern.charAt(k + 1)) { next[j + 1] = k + 1; }
        else { next[j + 1] = next[k + 1]; }
        j++;
        k++;
      } else {
        k = next[k];
      }
    }
  }
}

以上就是我对KMP算法的学习总结,很多地方阐述得不够清楚,今后如果能更深入地理解,我将会及时修改这篇博客。有什么不妥之处也请大家不吝赐教!

参考文献:
1. http://blog.csdn.net/v_july_v/article/details/7041827 v_JULY_v 从头到尾彻底理解KMP(2014年8月22日版)
2. 《数据结构(C语言版)》严蔚敏、吴伟民 清华大学出版社
3. http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html 阮一峰 字符串匹配的KMP算法

    原文作者:KMP算法
    原文地址: https://blog.csdn.net/Jack_CJZ/article/details/77418198
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞