知识点十八:字符串匹配算法(Ⅱ)—— KMP/BM算法

前言

文本编辑器中的查找替换功能,大家都不陌生。比如,我们在 Word 中把一个单词统一替换成另一个,用的就是这个功能。用上一节讲的 BF 算法和 RK 算法,可以实现这个功能,但是在某些极端情况下,BF 算法性能会退化的比较严重,而 RK 算法需要用到哈希算法,而设计一个可以应对各种类型字符的哈希算法并不简单。对于工业级的软件开发来说,我们希望算法尽可能的高效,并且在极端情况下,性能也不要退化的太严重。那么,对于查找功能是重要功能的软件来说,比如一些文本编辑器,它们的查找功能都是用哪种算法来实现的呢?有没有比 BF 算法和 RK 算法更加高效的字符串匹配算法呢?

BM算法的核心思想

BM(Boyer-Moore)算法,是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的KMP 算法的 3 到 4 倍。不过,BM 算法的原理有点复杂,比较难懂,学起来会比较烧脑。

我们知道,模式串和主串的匹配过程,其实就是模式串在主串中不停地往后滑动,比较模式串和子串是否一一对应的过程。当遇到不匹配的字符时,BF 算法和 RK 算法的做法是,模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。
在这里插入图片描述
不过,我们可以发现,这个例子里,主串中的 c,在模式串中是不存在的,所以,模式串向后滑动的过程中,只要 c 与模式串有重合,肯定无法匹配。所以,我们可以一次性把模式串往后多滑动几位,把模式串移动到 c 的后面。
在这里插入图片描述
由此现象展开,我们可以思考下,当遇到不匹配的字符时,有什么固定的规律,可以将模式串往后多滑动几位呢?这样一次性往后滑动好几位,那匹配的效率岂不是就提高了?没错,BM 算法本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位

BM算法原理分析

BM 算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。

1.坏字符规则

BF算法和RK算法在匹配的过程中,都是按模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的。这种匹配顺序比较符合我们的思维习惯。
在这里插入图片描述
而 BM 算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的
在这里插入图片描述
我们从模式串的末尾往前倒着匹配主串中的子串,当发现某个字符没法匹配的时候,我们把这个没有匹配的字符叫作坏字符(主串中的字符)。
在这里插入图片描述
我们拿坏字符 c 在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符 c 与模式串中的任何字符都不匹配。这个时候,我们可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。
在这里插入图片描述
这个时候,我们发现,模式串中最后一个字符 d,还是无法跟主串中的 a 匹配,这个时候,还能将模式串直接往后滑动三位吗?答案是不行的。因为这个时候,坏字符 a 在模式串中是存在的,模式串中下标是 0 的位置也是字符 a。这种情况下,我们可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。
在这里插入图片描述
第一次不匹配的时候,我们向后滑动了三位,第二次不匹配的时候,我们将模式串后移了两位,那具体该滑动多少位,到底有没有规律呢?

当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作 si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi。如果不存在,我们就把 xi 记作 -1。那么,模式串往后移动的位数就等于 si-xi。(注意:这里说的下标,都是指的字符在模式串中的下标)。
在这里插入图片描述
此外,如果坏字符在模式串里多处出现,那在计算 xi 的时候,选择最靠后的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过

利用“坏字符规则”,BM 算法在最好情况下的时间复杂度非常低,是 O(n/m),其中n是主串的长度,m是模式串的长度。比如,当主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的模式串和主串的时候,BM 算法非常高效。不过,单纯使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动位数,有可能是负数,比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa。此时计算出来的si = 0,xi>=1,si-xi<0,此时不但不会向后滑动模式串,还有可能倒退。所以,BM 算法还需要用到“好后缀规则”。

2.好后缀规则

好后缀规则实际上跟坏字符规则的思路很类似。当模式串滑动到下图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。
在这里插入图片描述
这个时候该如何滑动模式串呢?当然,我们还是可以利用“坏字符规则 ”来计算模式串的滑动位数,不过,我们也可以使用“好后缀规则”处理。现在我们来看,“好后缀规则”是怎么工作的?
我们把主串中和模式串已经匹配的 bc 叫作好后缀,记作{u}。我们拿它在模式串中进行查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置。
在这里插入图片描述
如果在模式串中找不到另一个等于{u}的子串,我们就直接将模式串滑动到主串中{u}的后面,因为在这之前的任何一次往后滑动,都不会出现匹配主串中{u}的情况。不过,仔细想想,这样做是否有点太过头呢?我们来看下面这个例子。这里面 bc 是好后缀,尽管在模式串中没有另外一个相匹配的子串{u*},但是如果我们将模式串直接移动到好后缀的后面,如下图所示,那就会错过模式串和主串可以匹配的情况。
在这里插入图片描述
实际上,如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步往后滑动模式串的过程中,只要主串中的{u}与模式串有重合,那肯定就无法完全匹配。但是当模式串滑动到前缀与主串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。
在这里插入图片描述
所以,针对这种情况,我们不仅要考察好后缀在模式串中是否有另一个匹配的子串,还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab。我们从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是{v},然后将模式串滑动到如下图所示的位置。
在这里插入图片描述
现在,坏字符和好后缀的基本原理都讲完了,那么问题来了,当模式串和主串中的某个字符不匹配(出现“坏字符”)的时候,应该选择用“好后缀规则”还是“坏字符规则”,来计算模式串往后滑动的位数?

我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。实际上,这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的模式串往后滑动的位数有可能是负数的情况。

BM算法的代码实现

学习完了基本原理,我们再来看,如何实现 BM 算法?
“坏字符规则”本身不难理解。当遇到坏字符时,要计算往后移动的位数 si-xi,其中 xi 的计算是重点,我们如何求得 xi 呢?或者说,如何查找坏字符在模式串中出现的位置呢?
如果我们拿坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能。有没有更加高效的方式呢?我们可以利用散列表的快速查找性质,将模式串中的每个字符及其下标都存到散列表中,这样就可以快速找到坏字符在模式串的位置下标了。举个最简单的例子,假设字符串的字符集不是很大,每个字符长度是 1 字节,我们用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。
在这里插入图片描述
上面的过程翻译成代码,就是下面这个样子。其中,变量 b 是模式串,m 是模式串的长度,bc 表示散列表。

private static final int SIZE = 256; // 全局变量或成员变量
private void generateBC(char[] b, int m, int[] bc) {
  for (int i = 0; i < SIZE; ++i) {
    bc[i] = -1; // 初始化bc
  }
  for (int i = 0; i < m; ++i) {
    int ascii = (int)b[i]; // 计算b[i]的ASCII值,也就是计算模式串中每个字符对应的ASCII值
    bc[ascii] = i; // 在散列表bc中记录每个字符在模式串中出现的位置
  }
}

掌握了坏字符规则之后,我们先把 BM 算法代码的大框架写好,先不考虑好后缀规则,仅用坏字符规则,并且不考虑 si-xi 计算得到的移动位数可能会出现负数的情况。

public int bm(char[] a, int n, char[] b, int m) {
  int[] bc = new int[SIZE]; // 记录模式串中每个字符最后出现的位置
  generateBC(b, m, bc); // 构建坏字符哈希表
  int i = 0; // i表示主串与模式串对齐的第一个字符
  while (i <= n - m) {
    int j;
    for (j = m - 1; j >= 0; --j) { // 模式串从后往前匹配
      if (a[i+j] != b[j]) break; // 坏字符对应模式串中的下标是j
    }
    if (j < 0) {
      return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
    }
    // 这里等同于将模式串往后滑动j-bc[(int)a[i+j]]位
    i = i + (j - bc[(int)a[i+j]]); 
  }
  return -1;
}

结合着下图,代码应该会更好理解些。
在这里插入图片描述
至此,我们已经实现了包含坏字符规则的框架代码,只剩下往框架代码中填充好后缀规则了。先简单回顾一下,前面讲过好后缀的处理规则中最核心的内容:

  • 在模式串中,查找跟好后缀匹配的另一个子串;
  • 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;

在不考虑效率的情况下,这两个操作都可以用很“暴力”的匹配查找方式解决。但是,如果想要 BM 算法的效率很高,这部分就不能太低效。如何来做呢?

因为好后缀也是模式串本身的后缀子串,所以,我们可以在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。这个预处理过程比较有技巧,很不好懂,建议多看几遍消化一下。那么,如何表示模式串中不同的后缀子串呢?因为后缀子串的最后一个字符的位置是固定的,下标为 m-1,我们只需要记录长度就可以了。通过长度,我们可以确定一个唯一的后缀子串
在这里插入图片描述
现在,我们要引入一个最关键的变量 suffix 数组。suffix 数组的下标 k,表示后缀子串的长度,下标对应的数组值存储的是,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值
在这里插入图片描述
但是,如果模式串中有多个(大于 1 个)子串跟后缀子串{u}匹配,那 suffix 数组中该存储哪一个子串的起始位置呢?为了避免模式串往后滑动得过头了,我们肯定要存储模式串中最靠后的那个子串的起始位置,也就是下标最大的那个子串的起始位置。不过,仅仅是选最靠后的子串片段来存储仍是不够的。因为好后缀的处理规则中指出,我们不仅要在模式串中,查找跟好后缀匹配的另一个子串,还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串

因此,如果我们只记录刚刚定义的 suffix 数组,实际上,只能处理规则的前半部分,也就是,在模式串中查找跟好后缀匹配的另一个子串。所以,除了 suffix 数组之外,我们还需要另外一个 boolean 类型的 prefix 数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串
在这里插入图片描述
现在,我们来看下,如何来计算并填充这两个数组的值?这个计算过程其实非常巧妙。
我们拿模式串中下标从 0 到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是 k,那我们就记录 suffix[k]=j,其中 j 表示公共后缀子串的起始下标。如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,我们就记录 prefix[k]=true。
在这里插入图片描述
把 suffix 数组和 prefix 数组的计算过程,用代码实现出来,就是下面这个样子:

// b表示模式串,m表示长度,suffix,prefix数组事先申请好了
private void generateGS(char[] b, int m, int[] suffix, boolean[] prefix) {
  for (int i = 0; i < m; ++i) { // 初始化suffix 数组和 prefix 数组
    suffix[i] = -1;
    prefix[i] = false;
  }
  for (int i = 0; i < m - 1; ++i) { // b[0, i]
    int j = i;
    int k = 0; // k表示公共后缀子串的长度
    while (j >= 0 && b[j] == b[m-1-k]) { // b[0, i]与b[0, m-1]求公共后缀子串
      --j;
      ++k;
      suffix[k] = j+1; //由于前面先进行了--j,所以j+1表示公共后缀子串在b[0, i]中的起始下标
    }
    if (j == -1) prefix[k] = true; //由于前面先进行了--j,所以 j=-1 时表示公共后缀子串也是模式串的前缀子串
  }
}

有了这两个数组之后,我们再来看,在模式串跟主串匹配的过程中,遇到不能匹配的字符时,如何根据好后缀规则,计算模式串往后滑动的位数?

假设好后缀的长度是 k。我们先拿好后缀,在 suffix 数组中查找其匹配的子串。如果存在另一个与好后缀匹配的子串,即 suffix[k] 不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j-suffix[k]+1 位(j 表示坏字符对应的模式串中的字符下标)。
在这里插入图片描述
如果 suffix[k] 等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。定义好后缀的后缀子串为 b[r, m-1],其长度 k=m-r(其中,r 取值从 j+2 到 m-1,j 表示坏字符对应的模式串中的字符下标,从j+2起才开始构成后缀子串)。如果 prefix[k] 等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。
在这里插入图片描述
如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移 m 位。
在这里插入图片描述
至此,好后缀规则的代码实现我们也讲完了。我们把好后缀规则加到前面的代码框架里,就可以得到 BM 算法的完整版代码实现。

// a,b表示主串和模式串;n,m表示主串和模式串的长度。
public int bm(char[] a, int n, char[] b, int m) {
  int[] bc = new int[SIZE]; // 记录模式串中每个字符最后出现的位置
  generateBC(b, m, bc); // 构建坏字符哈希表
  int[] suffix = new int[m];
  boolean[] prefix = new boolean[m];
  generateGS(b, m, suffix, prefix);
  int i = 0; // j表示主串与模式串匹配的第一个字符
  while (i <= n - m) {
    int j;
    for (j = m - 1; j >= 0; --j) { // 模式串从后往前匹配
      if (a[i+j] != b[j]) break; // 坏字符对应模式串中的下标是j
    }
    if (j < 0) {
      return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
    }
    int x = j - bc[(int)a[i+j]];
    int y = 0;
    if (j < m-1) { // 如果有好后缀的话
      y = moveByGS(j, m, suffix, prefix);
    }
    i = i + Math.max(x, y);
  }
  return -1;
}

// j表示坏字符对应的模式串中的字符下标; m表示模式串长度
private int moveByGS(int j, int m, int[] suffix, boolean[] prefix) {
  int k = m - 1 - j; // 好后缀长度
  if (suffix[k] != -1) return j - suffix[k] +1;
  for (int r = j+2; r <= m-1; ++r) {
    if (prefix[m-r] == true) {
      return r;
    }
  }
  return m;
}

BM算法的性能分析及优化

先来分析 BM 算法的内存消耗。整个算法用到了额外的 3 个数组,其中 bc 数组的大小跟字符集大小有关,suffix 数组和 prefix 数组的大小跟模式串长度 m 有关。如果我们处理字符集很大的字符串匹配问题,bc 数组对内存的消耗就会比较多。因为好后缀和坏字符规则是独立的,如果我们运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免 bc 数组过多的内存消耗。不过,单纯使用好后缀规则的 BM 算法效率就会下降一些了。

接着,我们从时间复杂度的角度来分析BM算法的执行效率。实际上,前面讲的 BM 算法只是个初级版本,没有涉及到一些复杂的优化。基于目前讲的这个版本,在极端情况下,预处理计算 suffix 数组、prefix 数组的性能会比较差。比如,当模式串是 aaaaaaa 这种包含很多重复的字符的模式串,预处理的时间复杂度就是 O(m2),m为模式串长度。当然,大部分情况下,时间复杂度不会这么差。

BM 算法的时间复杂度分析起来是非常复杂,这篇论文“A new proof of the linearity of the Boyer-Moore string searching algorithm”证明了在最坏情况下,BM 算法的比较次数上限是 5n。这篇论文“Tight bounds on the complexity of the Boyer-Moore string matching algorithm”证明了在最坏情况下,BM 算法的比较次数上限是 3n。

KMP算法

前面讲的BM 算法,尽管很复杂,也不好理解,但却是工程中非常常用的一种高效字符串匹配算法。有统计说,它是最高效、最常用的字符串匹配算法。不过,在所有的字符串匹配算法里,要说最知名的一种的话,那非 KMP 算法莫属。很多时候,提到字符串匹配,我们首先想到的就是 KMP 算法。

KMP 算法是根据三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字来命名的,算法的全称是 Knuth Morris Pratt 算法,简称为 KMP 算法。KMP 算法的核心思想,跟 BM 算法非常相近。我们假设主串是 a,模式串是 b。在模式串与主串匹配的过程中,当遇到不可匹配的字符的时候,我们希望找到一些规律,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况。

这里我们可以类比一下BM算法中的坏字符和好后缀的定义,在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀
在这里插入图片描述
当遇到坏字符的时候,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。这个比较的过程能否更高效呢?是否可以不用一个字符一个字符地比较呢?
在这里插入图片描述
KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动多位呢?
我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v},长度是 k。我们把模式串一次性往后滑动 j-k 位,相当于,每次遇到坏字符的时候,我们就把 j 更新为 k,i 不变,然后继续比较。
在这里插入图片描述
我们把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串;对应的前缀子串,叫作最长可匹配前缀子串
在这里插入图片描述
那么,如何求好前缀的最长可匹配前缀和后缀子串呢?
实际上,这个问题不涉及主串,只需要通过模式串本身就能求解。类似 BM 算法中的 bc、suffix、prefix 数组,KMP 算法也可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为 next 数组,很多书中还给这个数组起了一个名字,叫失效函数(failure function)。数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标
在这里插入图片描述
有了 next 数组,我们很容易就可以实现 KMP 算法了。先假设 next 数组已经计算好了,先给出 KMP 算法借助 next 数组匹配的框架代码。

// a, b分别是主串和模式串;n, m分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
  int[] next = getNexts(b, m);
  int j = 0;
  for (int i = 0; i < n; ++i) {
    while (j > 0 && a[i] != b[j]) { // 一直找到a[i]和b[j]
      j = next[j - 1] + 1;
    }
    if (a[i] == b[j]) {
      ++j;
    }
    if (j == m) { // 找到匹配模式串的了
      return i - m + 1;
    }
  }
  return -1;
}

失效函数(next 数组)计算方法

我们可以用非常笨的方法,比如要计算下面这个模式串 b 的 next[4],我们就把 b[0, 4] 的所有后缀子串,从长到短找出来,依次看看,是否能跟模式串的前缀子串匹配。
在这里插入图片描述
显然,这个方法可以计算得到 next 数组,但是效率非常低。有没有更加高效的方法呢?我们按照下标从小到大,依次计算 next 数组的值。当我们要计算 next[i] 的时候,前面的 next[0],next[1],……,next[i-1] 应该已经计算出来了。利用已经计算出来的 next 值,我们是否可以快速推导出 next[i] 的值呢?

如果 next[i-1]=k-1,也就是说,子串 b[0, k-1] 是 b[0, i-1] 的最长可匹配前缀子串。如果子串 b[0, k-1] 的下一个字符 b[k],与 b[0, i-1] 的下一个字符 b[i] 匹配,那子串 b[0, k] 就是 b[0, i] 的最长可匹配前缀子串,即next[i] 等于 k。
在这里插入图片描述
但是,如果 b[0, k-1] 的下一字符 b[k] 跟 b[0, i-1] 的下一个字符 b[i] 不相等呢?这个时候就不能简单地通过 next[i-1] 得到 next[i] 了。这个时候该怎么办呢?

假设 b[0, i] 的最长可匹配后缀子串是 b[r, i]。如果我们把最后一个字符 b[i] 去掉,那 b[r, i-1] 肯定是 b[0, i-1] 的可匹配后缀子串,但不一定是最长可匹配后缀子串。例如:假设模式串的好前缀为 “abxabcabxabx”,其最长可匹配后缀子串为 “abx”,去掉最后一个字符 ‘x’ 后,虽然 “ab” 还是好前缀的可匹配后缀子串,但 “abxab” 才是最长可匹配后缀子串。

既然 b[0, i-1] 的最长可匹配后缀子串对应的模式串的前缀子串 b[0, k-1] 的下一个字符 b[k] 并不等于 b[i],那么我们就可以考察 b[0, i-1] 的次长可匹配后缀子串 b[x, i-1] 对应的可匹配前缀子串 b[0, i-1-x] 的下一个字符 b[i-x] 是否等于 b[i]。如果等于,那 b[x, i] 就是 b[0, i] 的最长可匹配后缀子串。
在这里插入图片描述
可是,如何求得 b[0, i-1] 的次长可匹配后缀子串呢?次长可匹配后缀子串肯定被包含在最长可匹配后缀子串中,而最长可匹配后缀子串又对应最长可匹配前缀子串 b[0, y]。于是,查找 b[0, i-1] 的次长可匹配后缀子串,这个问题就变成,查找 b[0, y] 的最长匹配后缀子串的问题了。
在这里插入图片描述
按照这个思路,我们可以考察完所有的 b[0, i-1] 的可匹配后缀子串 b[y, i-1],直到找到一个可匹配的后缀子串,它对应的前缀子串的下一个字符等于 b[i],那这个 b[y, i] 就是 b[0, i] 的最长可匹配后缀子串。

现在把计算 next 数组这部分的代码也写出来。和前面的部分代码合在一起,就是整个 KMP 算法的代码实现。

// b表示模式串,m表示模式串的长度
private static int[] getNexts(char[] b, int m) {
  int[] next = new int[m];
  next[0] = -1;
  int k = -1;
  for (int i = 1; i < m; ++i) {
    while (k != -1 && b[k + 1] != b[i]) {
      k = next[k];
    }
    if (b[k + 1] == b[i]) {
      ++k;
    }
    next[i] = k;
  }
  return next;
}

KMP 算法复杂度分析

空间复杂度很容易分析,KMP 算法只需要一个额外的 next 数组,数组的大小跟模式串相同。所以空间复杂度是 O(m),m 表示模式串的长度

至于时间复杂度,KMP 算法包含两部分,第一部分是构建 next 数组,第二部分才是借助 next 数组匹配。所以,我们要分别从这两部分来进行分析。

先来分析第一部分的时间复杂度。计算 next 数组的代码中,第一层 for 循环中 i 从 1 到 m-1,也就是说,内部的代码被执行了 m-1 次。for 循环内部代码有一个 while 循环,如果我们能知道每次 for 循环、while 循环平均执行的次数,假设是 k,那时间复杂度就是 O(k*m)。但是,while 循环执行的次数不怎么好统计,我们可以找一些参照变量,i 和 k。i 从 1 开始一直增加到 m,而 k 并不是每次 for 循环都会增加,所以,k 累积增加的值肯定小于 m。而 while 循环里 k=next[k],实际上是在减小 k 的值,k 累积都没有增加超过 m,所以 while 循环里面 k=next[k] 总的执行次数也不可能超过 m。因此,next 数组计算的时间复杂度是 O(m)。

再来分析第二部分的时间复杂度。分析的方法是类似的,i 从 0 循环增长到 n-1,j 的增长量不可能超过 i,所以肯定小于 n。而 while 循环中的那条语句 j=next[j-1]+1,不会让 j 增长的,那有没有可能让 j 不变呢?也没有可能。因为 next[j-1] 的值肯定小于 j-1,所以 while 循环中的这条语句实际上也是在让 j 的值减少。而 j 总共增长的量都不会超过 n,那减少的量也不可能超过 n,所以 while 循环中的这条语句总的执行次数也不会超过 n,所以这部分的时间复杂度是 O(n)。

所以,综合两部分的时间复杂度,KMP 算法的时间复杂度就是 O(m+n),n, m分别是主串和模式串的长度

小结

一、BM算法
1.核心思想:利用模式串本身的特点,在模式串中某个字符与主串不能匹配的时候,将模式串往后多滑动几位,以此来减少不必要的字符比较,提高匹配的效率。
2.算法原理:“坏字符规则”和“好后缀规则”

  1. “坏字符规则” 核心内容
    (1)坏字符:模式串从后往前倒着和主串进行匹配,当发现某个字符没法匹配的时候,我们把这个没有匹配的字符叫作坏字符(主串中的字符)。
    (2)在模式串中,查找是否存在和坏字符相同的字符,通过坏字符对应的模式串中的字符下标,以及和坏字符相同的字符在模式串中的下标,计算模式串往后移动的位数。
    (3)如果坏字符在模式串里多处出现,记录最靠后的那个在模式串中的字符下标,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。
    (4)利用坏字符规则,BM 算法在最好情况下的时间复杂度非常低,是 O(n/m)。
    (5)不足:单纯使用坏字符规则,计算出来的移动位数可能是负值。
  2. “好后缀规则” 核心内容
    (1)好后缀:在模式串和主串匹配过程中,模式串和待匹配的子串的公共后缀子串。
    (2)在模式串中,查找跟好后缀匹配的另一个子串;
    (3)在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;

3.实现:由于坏字符规则的实现比较耗内存,好后缀规则可以独立于坏字符规则使用,为了节省内存,可以只用好后缀规则来实现 BM 算法。

二、KMP算法
1.核心思想:与BM算法非常类似,都是根据规律在遇到坏字符的时候,把模式串往后多滑动几位。
2.算法原理:“好前缀规则”,计算失效函数(next 数组),进而利用 next 数组加速匹配。
3.时间复杂度:O(m+n),n, m分别是主串和模式串的长度。
4.空间复杂度:O(m),m 表示模式串的长度。

参考

《数据结构与算法之美》
王争
前Google工程师

分享一个不错的BM算法图解文档:http://www.cs.jhu.edu/~langmea/resources/lecture_notes/boyer_moore.pdf

推荐这个知乎上的问题,对理解next会比较有帮助:
https://www.zhihu.com/question/21923021

七月在线创始人兼CEO July先生讲解KMP算法的博文:https://blog.csdn.net/v_july_v/article/details/7041827

发布了34 篇原创文章 · 获赞 1 · 访问量 465

猜你喜欢

转载自blog.csdn.net/Mr_SCX/article/details/103955913