End

数据结构与算法之美-12 字符串匹配 BF RK BM KMP Trie AC

本文地址


目录

32 | 字符串匹配基础(上):如何借助哈希算法实现高效字符串匹配?

编程语言提供的字符串查找函数,底层依赖的就是字符串匹配算法,比如 Java 中的 indexOf(),Python 中的 find() 等。

字符串匹配算法有很多,常见的有如下几种:

  • 单模式串匹配算法:一个串跟一个串进行匹配,如 BFRKBMKMP
  • 多模式串匹配算法:在一个串中同时查找多个串,如 Trie 树AC 自动机

主串和模式串:在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串

BF 算法

BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。

假设主串的长度为 n,模式串的长度为 m,因为是在主串中查找模式串,所以 n > m

作为最简单、最暴力的字符串匹配算法,BF 算法的思想用一句话来概括就是:在主串中,检查起始位置分别是 0、1、2....n-m 且长度为 m 的 n - m + 1 个子串,看有没有跟模式串匹配的。

在极端情况下,每次都比对 m 个字符,要比对 n - m + 1 次,所以,这种算法的最坏情况时间复杂度是 O(n * m)

尽管理论上的时间复杂度很高,但在实际的开发中,BF 算法却是一个比较常用的字符串匹配算法:

  • 原因一:时间复杂度并没有想想中的那么大
    • 大部分情况下,模式串和主串的长度都不会太长,所以 n * m 的值并不会很大
    • 每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候,就可以就停止了,不需要把 m 个字符都比对一下
    • 所以,尽管理论上的最坏情况时间复杂度是 O(n*m),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多
  • 原因二:简单
    • 朴素字符串匹配算法思想简单,代码实现也非常简单。简单意味着不容易出错,如果有 bug 也容易暴露和修复
    • 在工程中,在满足性能要求的前提下,简单是首选。这也是我们常说的 KISS(Keep it Simple and Stupid)设计原则

所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。

RK 算法

RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。

RK 算法其实就是上面 BF 算法的升级版。

RK 算法的思路是这样的:

  • 我们通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小
  • 如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题)
  • 因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了

哈希算法的设计

不过,通过哈希算法计算子串的哈希值的时候,需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。有没有方法可以提高哈希算法计算子串哈希值的效率呢?

这就需要哈希算法设计的非常有技巧了。假设要匹配的字符串的字符集中只包含 K 个字符,我们可以用一个 K 进制数来表示一个子串,将这个 K 进制数转化成十进制数,作为子串的哈希值。

比如假设要处理的字符串只包含 a~z 这 26 个小写字母,那就用二十六进制来表示一个字符串。我们把 a~z 这 26 个字符映射到 0~25 这 26 个数字,a 就表示 0,b 就表示 1,以此类推,z 表示 25。

在十进制的表示法中,一个数字的值是通过下面的方式计算出来的。对应到二十六进制,一个包含 a 到 z 这 26 个字符的字符串,计算哈希的时候,只需要把进位从 10 改成 26 即可。

哈希算法的规律

这种哈希算法有一个特点:在主串中,相邻两个子串的哈希值的计算公式有一定关系。

假设 i 表示子串在主串中的起始位置,子串的长度为 m

规律是:相邻两个子串 s[i-1]s[i],对应的哈希值计算公式有交集,也就是说,可以使用 s[i-1] 的哈希值很快的计算出 s[i] 的哈希值。

注意:下图中最后一行少了一个左括号

另外,26^(m-1) 这部分的计算,可以通过查表的方法来提高效率。我们事先计算好 260、261、262……26(m-1),并且存储在一个长度为 m 的数组中,公式中的次方就对应数组的下标。当需要计算 26 的 x 次方的时候,就可以从数组的下标为 x 的位置取值,直接使用,省去了计算的时间。

时间复杂度分析

整个 RK 算法包含两部分:

  • 计算子串哈希值:根据上面的分析,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以时间复杂度是 O(n)
    • 注意:实际代码实现上,在扫描的过程中,一旦发现字串的哈希值和模式串的哈希值一样,就不继续计算后面字串的哈希了
  • 模式串哈希值与子串哈希值之间的比较:每次比较的时间复杂度是 O(1),总需比较 n - m + 1 次,所以时间复杂度是 O(n)

所以,RK 算法整体的时间复杂度就是 O(n)

引入散列冲突

这里还有一个问题:如果模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,如果超过了计算机中整型数据可以表示的范围,那该如何解决呢?

上面设计的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样的。

而为了能将无限的哈希值落在有限的整型数据范围内,就必须允许哈希冲突(鸽巢原理)。这个时候哈希算法该如何设计呢?

哈希算法的设计方法有很多

  • 可以把字符串中每个字母对应的数字相加,最后得到的作为哈希值。这样哈希值的数据范围就相对要小很多了。不过,这种哈希算法的哈希冲突概率也是挺高的。
  • 可以将每一个字母从小到大对应一个素数,而不是 1,2,3……这样的自然数,这样冲突的概率就会降低一些。

那现在新的问题来了,当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。

解决方法很简单。当我们发现一个子串的哈希值跟模式串的哈希值相等的时候,我们只需要再对比一下子串和模式串本身就好了。当然,如果子串的哈希值与模式串的哈希值不相等,那对应的子串和模式串肯定也是不匹配的,就不需要比对子串和模式串本身了。

哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致 RK 算法的时间复杂度退化,极端情况下,时间复杂度就会退化成 O(n*m)。

总结

RK 算法是借助哈希算法对 BF 算法进行改造,即对每个子串分别求哈希值,然后拿子串的哈希值与模式串的哈希值比较,减少了比较的时间。

理想情况下,RK 算法的时间复杂度是 O(n),跟 BF 算法相比,效率提高了很多。不过这样的效率取决于哈希算法的设计方法,如果存在冲突的情况下,时间复杂度可能会退化。极端情况下,哈希算法大量冲突,时间复杂度就退化为 O(n*m)

33 | 字符串匹配基础(中):如何实现文本编辑器中的查找功能?

文本编辑器中的查找替换功能是怎么实现的呢?

如果用 BF 算法,在内容很长时性能问题非常严重;而 RK 算法需要用到哈希算法,设计一个可以应对各种类型字符的哈希算法并不简单。

对于工业级的软件开发来说,我们希望算法尽可能的高效,并且在极端情况下,性能也不要退化的太严重。那么,对于查找功能是重要功能的文本编辑器来说,它们的查找功能都是用什么算法来实现的呢?

今天要学习的 BM(Boyer-Moore)算法就是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的 KMP 算法的 3 到 4 倍。

BM 算法的原理很复杂,比较难懂,学起来比较烧脑,希望做好打硬仗的准备。

平常基本不可能会自己去实现一个 BM 算法,顶多就用个 BF 算法。不过 BM 算算法号称最高效的,比如 grep 命令就是用它实现的,所以很有必要了解一下。

BM 算法的核心思想

我们把模式串和主串的匹配过程,看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时,BF 算法和 RK 算法的做法是,模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。

然而,在上面这个例子里,主串中的 c 在模式串中是不存在的,所以,在模式串向后滑动过程中,只要 c 与模式串有重合,肯定就无法匹配。所以,我们可以一次性把模式串往后滑动到 c 的后面。通过这样一次性往后滑动好几位,匹配的效率就提高了。

今天要讲的 BM 算法,本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。

BM 算法原理分析

BM 算法包含两部分:

  • 坏字符规则 bad character rule
  • 好后缀规则 good suffix shift

坏字符规则

前面讲的算法,在匹配的过程中,我们都是按模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的。而 BM 算法的匹配顺序,是按照模式串下标从大到小的顺序,倒着匹配的。

也即从模式串的末尾往前倒着匹配,当发现某个字符没法匹配的时候,我们把主串中这个没有匹配的字符叫作 坏字符

谨记:坏字符指的是主串中的字符

我们拿坏字符 c 在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符 c 与模式串中的任何字符都不可能匹配。这个时候,我们可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。

这时我们发现

  • 模式串中最后一个字符 d,还是无法跟主串中的 a 匹配
  • 但是主串中的坏字符 a 在模式串中是存在的,模式串中下标是 0 的位置也是字符 a

这种情况下,我们可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。

第一次不匹配的时候,我们滑动了三位,第二次不匹配的时候,我们将模式串后移两位,那具体滑动多少位,有没有规律呢?

当发生不匹配的时候:

  • 坏字符对应的模式串中的字符的下标记作 si
    • 第一次和第二次不匹配的时候,指的都是模式串中的字符 d 的下标,所以 si = 2
  • 坏字符在模式串中的下标记作 xi,如果不存在,记作 -1
    • 第一次不匹配的时候,因为坏字符 c 在模式串中不存在,所以 xi = -1
    • 第二次不匹配的时候,因为坏字符 a 在模式串中不存在,所以指的都是模式串中的字符 a 的下标,所以 xi = 0
  • 那模式串往后移动的位数就等于:si - xi
    • 第一次不匹配的时候,往后移动 2 - (-1) = 3
    • 第二次不匹配的时候,往后移动 2 - 0 = 2

注意,这里说的下标,都是指字符在模式串的下标

注意:如果坏字符在模式串里多处出现,在计算 xi 的时候,需要选择最靠后的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。

利用坏字符规则,BM 算法在最好情况下的时间复杂度是 O(n/m)。比如,主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的模式串和主串的时候,BM 算法非常高效。

不过,单纯使用坏字符规则还是不够的,因为根据 si - xi 计算出来的移动位数,有可能是负数。这就会导致不但不会向后滑动模式串,还有可能倒退。所以,BM 算法还需要用到好后缀规则

负数案例:比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa
- 坏字符是主串中的第一个 a
- 坏字符所对应的模式串中的字符是 b,其下标是 0,所以 `si = 0`
- 坏字符 a 在模式串中的下标是 3 (最靠后的那个)
- 所以需要往后移动 `0 - 3 = -3` 位

好后缀规则

好后缀规则实际上跟坏字符规则的思路很类似。当模式串滑动到下图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。

这个时候该如何滑动模式串呢?当然,我们还 可以 利用坏字符规则来计算模式串的滑动位数,不过,我们 也可以 使用好后缀处理规则。两种规则如何选择稍后会讲,现在我们来看,好后缀规则是怎么工作的?

我们把已经匹配的 bc 叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置。

如果在模式串中找不到另一个等于{u}的子串,我们就直接将模式串滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况。

不过,这样做是否有点太过头呢?我们来看下面这个例子。这里面 bc 是好后缀,尽管在模式串中没有另外一个相匹配的子串{u*},但是如果我们将模式串移动到好后缀的后面,那就会错过模式串和主串可以匹配的情况。

如果好后缀在模式串中不存在可匹配的子串

  • 在一步一步往后滑动模式串的过程中,只要主串中的{u}与模式串 完全重合,那肯定就无法完全匹配
  • 但是当模式串滑动到前缀与主串中{u}后缀部分重合,且重合的部分相等的时候,就有可能会存在完全匹配的情况

所以,针对这种情况,我们不仅要看好后缀在模式串中是否有另一个匹配的子串,我们还要考察好后缀的后缀子串是否存在跟模式串的前缀子串匹配的。

所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。
所谓某个字符串 s 的前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab。

我们从好后缀 {u} 的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是 {v},然后将模式串滑动到主串中 {v} 的位置。

坏字符和好后缀的基本原理都讲完了,我现在回答一下前面那个问题。当模式串和主串中的某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的位数呢?

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

BM 算法代码实现

坏字符规则

当遇到坏字符时,要计算往后移动的位数 si - xi,其中 xi 的计算是重点,如何查找坏字符在模式串中出现的位置呢?

  • 方法一:拿坏字符在模式串中顺序遍历查找,这种方式比较低效,时间复杂度为 O(m)
  • 方法二:先将模式串中的每个字符及其下标都存到散列表中,然后就可以以 O(1) 的时间复杂度找到坏字符在模式串的位置下标了

假设字符串的字符集为 256,每个字符长度是 1 字节,那么我们就可以用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置

将上面的过程翻译成代码,就是下面这个样子。

private static final int SIZE = 256; // 全局变量或成员变量

// b 是模式串,m 是模式串的长度,bc 是散列表
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 值,计算的结果表示字符在散列表 bc 中的下标
    bc[ascii] = i; // 存储的是这个字符在模式串 b 中出现的位置
  }
}

先不考虑好后缀规则,仅用坏字符规则,并且不考虑 si - xi 计算得到的移动位数可能会出现负数的情况,此时 BM 算法代码的大框架如下:

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; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
    }
    i = i + (j - bc[(int)a[i+j]]); // 这里等同于将模式串往后滑动 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[i] = -1;
    prefix[i] = false;
  }
  for (int i = 0; i < m - 1; ++i) { // b[0, i]
    int j = i;
    int k = 0; // 公共后缀子串长度
    while (j >= 0 && b[j] == b[m-1-k]) { // 与b[0, m-1]求公共后缀子串
      --j;
      ++k;
      suffix[k] = j+1; //j+1表示公共后缀子串在b[0, i]中的起始下标
    }
    if (j == -1) prefix[k] = true; //如果公共后缀子串也是模式串的前缀子串
  }
}

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

假设好后缀的长度是 k。我们先拿好后缀,在 suffix 数组中查找其匹配的子串。如果 suffix[k] 不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j - suffix[k] + 1 位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k] 等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。我们可以用下面这条规则来处理。

好后缀的后缀子串 b[r, m-1](其中,r 取值从 j+2 到 m-1)的长度 k=m-r,如果 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 算法的性能分析

空间复杂度分析

整个算法用到了额外的 3 个数组,其中 bc 数组的大小跟字符集大小有关,suffix 数组和 prefix 数组的大小跟模式串长度 m有关。

如果处理字符集很大的字符串匹配问题,bc 数组对内存的消耗就会比较多。因为好后缀和坏字符规则是独立的,如果我们运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免 bc 数组过多的内存消耗。

时间复杂度分析

实际上,前面讲的 BM 算法是个初级版本。为了让你能更容易理解,有些复杂的优化我没有讲。基于我目前讲的这个版本,在极端情况下,预处理计算 suffix 数组、prefix 数组的性能会比较差。

比如模式串是 aaaaaaa 这种包含很多重复的字符的模式串,预处理的时间复杂度就是 O(m^2)。当然,大部分情况下,时间复杂度不会这么差。关于如何优化这种极端情况下的时间复杂度退化,如果感兴趣,你可以自己研究一下。

实际上,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。你可以自己阅读看看。

内容小结

今天,我们讲了一种比较复杂的字符串匹配算法,BM 算法。尽管复杂、难懂,但匹配的效率却很高,在实际的软件开发中,特别是一些文本编辑器中,应用比较多。如果一遍看不懂的话,你就多看几遍。

BM 算法核心思想是,利用模式串本身的特点,在模式串中某个字符与主串不能匹配的时候,将模式串往后多滑动几位,以此来减少不必要的字符比较,提高匹配的效率。BM 算法构建的规则有两类,坏字符规则和好后缀规则。好后缀规则可以独立于坏字符规则使用。因为坏字符规则的实现比较耗内存,为了节省内存,我们可以只用好后缀规则来实现 BM 算法。

34 | 字符串匹配基础(下):如何借助 BM 算法轻松理解 KMP 算法?

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

尽管在实际的开发中,我们几乎不大可能自己亲手实现一个 KMP 算法。但是,学习这个算法的思想,作为让你开拓眼界、锻炼下逻辑思维,也是极好的,所以我觉得有必要拿出来给你讲一讲。不过,KMP 算法是出了名的不好懂。我会尽力把它讲清楚,但是你自己也要多动动脑子。

实际上,KMP 算法跟 BM 算法的本质是一样的。上一节,我们讲了好后缀和坏字符规则,今天,我们就看下,如何借助上一节 BM 算法的讲解思路,让你能更好地理解 KMP 算法。

KMP 算法基本原理

KMP 算法是根据三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字来命名的,算法的全称是 Knuth Morris Pratt 算法,简称为 KMP 算法。

KMP 算法的核心思想,跟上一节讲的 BM 算法非常相近。我们假设主串是 a,模式串是 b。在模式串与主串匹配的过程中,当遇到不可匹配的字符的时候,我们希望找到一些规律,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况。

还记得我们上一节讲到的好后缀和坏字符吗?这里我们可以类比一下,在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀

当遇到坏字符的时候,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。这个比较的过程能否更高效了呢?可以不用一个字符一个字符地比较了吗?

KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?

我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v},长度是 k。我们把模式串一次性往后滑动 j-k 位,相当于,每次遇到坏字符的时候,我们就把 j 更新为 k,i 不变,然后继续比较。

为了表述起来方便,我把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串;对应的前缀子串,叫作最长可匹配前缀子串

如何来求好前缀的最长可匹配前缀和后缀子串呢?我发现,这个问题其实不涉及主串,只需要通过模式串本身就能求解。所以,我就在想,能不能事先预处理计算好,在模式串和主串匹配的过程中,直接拿过来就用呢?

类似 BM 算法中的 bc、suffix、prefix 数组,KMP 算法也可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为 next 数组,很多书中还给这个数组起了一个名字,叫失效函数(failure function)。

数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。这句话有点拗口,我举了一个例子,你一看应该就懂了。

用当前缀的后缀去匹配当前前缀,取最长可匹配后缀的末尾字符位置。例如 abab 它的后缀可以有b和ab与abab匹配,取最长后缀ab,它的末尾字符位置就是1

有了 next 数组,我们很容易就可以实现 KMP 算法了。我先假设 next 数组已经计算好了,先给出 KMP 算法的框架代码。

// 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;
}

KMP 算法的失效函数

KMP 算法的基本原理讲完了,我们现在来看最复杂的部分,也就是 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[r, i-1] 肯定是 b[0, i-1] 的可匹配后缀子串,但不一定是最长可匹配后缀子串。所以,既然 b[0, i-1] 最长可匹配后缀子串对应的模式串的前缀子串的下一个字符并不等于 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] 的最长可匹配后缀子串。

前面我已经给出 KMP 算法的框架代码了,现在我把这部分的代码也写出来了。这两部分代码合在一起,就是整个 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 算法的原理和实现我们就讲完了,我们现在来分析一下 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)

内容小结

KMP 算法讲完了,不知道你理解了没有?如果没有,建议多看几遍,自己多思考思考。KMP 算法和上一节讲的 BM 算法的本质非常类似,都是根据规律在遇到坏字符的时候,把模式串往后多滑动几位。

BM 算法有两个规则,坏字符和好后缀。KMP 算法借鉴 BM 算法的思想,可以总结成好前缀规则。这里面最难懂的就是 next 数组的计算。如果用最笨的方法来计算,确实不难,但是效率会比较低。所以,我讲了一种类似动态规划的方法,按照下标 i 从小到大,依次计算 next[i],并且 next[i] 的计算通过前面已经计算出来的 next[0]next[1],……,next[i-1] 来推导。

KMP 算法的时间复杂度是 O(n+m),不过它的分析过程稍微需要一点技巧,不那么直观,你只要看懂就好了,并不需要掌握,在我们平常的开发中,很少会有这么难分析的代码。

至此,我们把经典的单模式串匹配算法全部讲完了,它们分别是 BF 算法、RK 算法、BM 算法和 KMP 算法,关于这些算法,你觉得什么地方最难理解呢?

35 | Trie树:如何实现搜索引擎的搜索关键词提示功能?

像 Google、百度这样的搜索引擎,它们的关键词提示功能非常全面和精准,肯定做了很多优化,但万变不离其宗,底层最基本的原理就是今天要讲的这种数据结构:Trie 树

什么是 Trie 树

Trie 树,也叫字典树前缀树,是一种专门处理字符串匹配树形数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

当然,这样的问题可以有多种解决方法,比如散列表、红黑树,或者前面几节讲到的字符串匹配算法,但是,Trie 树在这个问题的解决上,有它特有的优点。不仅如此,Trie 树能解决的问题也不限于此,我们一会儿慢慢分析。

Trie 树案例

现在,我们先来看下,Trie 树到底长什么样子。

假如我们有 6 个字符串,它们分别是:how,hi,her,hello,so,see。我们希望在里面多次查找某个字符串是否存在,如果每次查找,都是拿要查找的字符串跟这 6 个字符串依次进行字符串匹配,那效率就比较低,有没有更高效的方法呢?

这个时候,我们就可以先对这 6 个字符串做一下预处理,组织成 Trie 树的结构,之后每次查找,都是在 Trie 树中进行匹配查找。Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。最后构造出来的就是下面这个图中的样子。

其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。

例如,如果树中存储了字符串 he,则上图的 e 就应该是红色节点。即红色节点表示的是完整存储,从根节点到红色节点的字符串,在树中是实实在在存储有的。

Trie 树构造过程

为了让你更容易理解 Trie 树是怎么构造出来的,我画了一个 Trie 树构造的分解过程。构造过程的每一步,都相当于往 Trie 树中插入一个字符串。当所有字符串都插入完成之后,Trie 树就构造好了。

Trie 树查找过程

当我们在 Trie 树中查找一个字符串的时候,比如查找字符串 her,那我们将要查找的字符串分割成单个的字符 h,e,r,然后从 Trie 树的根节点开始匹配。如图所示,绿色的路径就是在 Trie 树中匹配的路径。

如果我们要查找的是字符串 he 呢?我们还用上面同样的方法,从根节点开始,沿着某条路径来匹配,如图所示,绿色的路径,是字符串 he 匹配的路径。但是,路径的最后一个节点 e 并不是红色的。也就是说,he 是某个字符串的前缀子串,但并不能完全匹配任何字符串。

按这里的意思,红色节点表示能完全匹配一个字符串。也就是说如果 e 是红色节点,则说明树中存储了字符串 he;如果 e 不是红色节点,则说明树中没有存储字符串 he。

Trie 树的设计

知道了 Trie 树长什么样子,我们现在来看下,如何用代码来实现一个 Trie 树。

从刚刚 Trie 树的介绍来看,Trie 树主要有两个操作

  • 将字符串集合构造成 Trie 树,这个过程分解开来的话,就是一个将字符串插入到 Trie 树的过程
  • 在 Trie 树中查询一个字符串

如何存储子节点的指针

二叉树中,一个节点的左右子节点是通过两个指针来存储的。而 Trie 树是一个多叉树,我们怎么存储一个节点的所有子节点的指针呢?

最经典的存储方式,是借助散列表的思想:通过一个下标与字符一一映射的数组,来存储子节点的指针

假设字符串中只有从 a 到 z 这 26 个小写字母

  • 我们在数组中下标为 0 的位置存储指向子节点 a 的指针,在下标为 1 的位置存储指向子节点 b 的指针
  • 以此类推,下标为 25 的位置,存储的是指向的子节点 z 的指针
  • 如果某个字符的子节点不存在,我们就在对应的下标的位置存储 /

当我们在 Trie 树中查找字符串的时候,我们就可以通过字符的 ASCII 码减去字符 a 的 ASCII 码,迅速找到匹配的子节点的指针。

比如,d 的 ASCII 码减去 a 的 ASCII 码就是 3,那子节点 d 的指针就存储在数组中下标为 3 的位置中。

Trie 树代码实现

public class Trie {
  private TrieNode root = new TrieNode('/'); // 存储无意义字符

  // 往Trie树中插入一个字符串
  public void insert(char[] text) {
    TrieNode p = root;
    for (int i = 0; i < text.length; ++i) {
      int index = text[i] - 'a'; // 查找字符 text[i] 在结点 p 的 子节点指针数组 children 中的位置(下标)
      if (p.children[index] == null) { //children 数组定义时长度足够大,所以插入时不需要担心会越界
        TrieNode newNode = new TrieNode(text[i]);
        p.children[index] = newNode; //插入一个新的字符
      }
      p = p.children[index]; //以 p 为操作中心,每操作完一层,就将 p 下移一层
    }
    p.isEndingChar = true; // 只有最后一个字符才会被标记为 结束字符
  }

  // 在Trie树中查找一个字符串
  public boolean find(char[] pattern) {
    TrieNode p = root;
    for (int i = 0; i < pattern.length; ++i) {
      int index = pattern[i] - 'a';
      if (p.children[index] == null) {
        return false; // 不存在 pattern
      }
      p = p.children[index]; // 下移一层
    }
    return p.isEndingChar; // 如果为 false,只能判定 pattern 是一个前缀,而不是完整的字符串,也就是不完全匹配
  }

  public class TrieNode {
    public char data; //每个结点真正的数据部分只是一个字符(char),在 Java 中只需要占用 2 个字节(16位)的存储空间
    public TrieNode[] children = new TrieNode[26]; //但是每个结点都需要一个大数组来存储所有子节点的指针,所以很浪费内存
    public boolean isEndingChar = false; //标记当前字符是否为 结束字符(也即上面说的红色节点)
    public TrieNode(char data) {
      this.data = data;
    }
  }
}

Trie 树时间复杂度

构建 Trie 树的过程,需要扫描所有的字符串,时间复杂度是 O(n)(n 表示所有字符串的长度和)。

一旦构建成功之后,每次查询时,如果要查询的字符串长度是 k,那我们只需要比对大约 k 个节点就能完成查询操,跟原本那组字符串的长度个数没有任何关系。因此,构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。

所以,如果要在一组字符串中频繁地查询某些字符串,用 Trie 树会非常高效。

Trie 树空间复杂度

关于 Trie 树,有这样一种说法:Trie 树是非常耗内存的,用的是一种空间换时间的思路。这是什么原因呢?

Trie 树需要用数组来存储一个节点的子节点的指针,如果字符串中包含从 a 到 z 这 26 个字符:

  • 每个结点真正的数据部分只是一个字符(char),在 Java 中只需要占用 2 个字节(16位)的存储空间
  • 但是每个节点都要存储一个长度为 26 的数组,并且每个数组元素要存储一个 n 字节指针
  • 即便一个节点只有很少的子节点,比如 3、4 个,我们也要维护一个长度为 26 的数组
  • 如果字符串中不仅包含小写字母,还包含大写字母、数字、甚至是中文,那需要的存储空间就更多了
  • Trie 树的本质是避免重复存储一组字符串的相同前缀子串,如果重复的前缀子串不多,Trie 树不但不能节省内存,还有可能会浪费更多的内存

Trie 树这个内存问题,是否有解决办法呢?

我们可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。用哪种数据结构呢?我们的选择其实有很多,比如有序数组、跳表、散列表、红黑树等。

假设我们用有序数组,数组中的指针按照所指向的子节点中的字符的大小顺序排列。查询的时候,我们可以通过二分查找的方法,快速查找到某个字符应该匹配的子节点的指针。但是,在往 Trie 树中插入一个字符串的时候,我们为了维护数组中数据的有序性,就会稍微慢了点。

替换成其他数据结构的思路是类似的:

  • 有序数组查找方便,但是维护难
  • 跳表查找方便,但是容易退化,且耗空间
  • 散列表容易散列冲突,且耗空间
  • 红黑树需要一定的数据量,不然就是杀鸡用牛刀

实际上,Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。

实现起来要判断每一个节点是否有兄弟节点,再用递归的方式一层层往上缩,出口就是有兄弟节点的地方。

虽然这样可以节省空间,但却增加了编码难度。

Trie 树与散列表、红黑树的比较

实际上,字符串的匹配问题,笼统上讲,其实就是数据的查找问题。对于支持动态数据高效操作的数据结构,我们前面已经讲过好多了,比如散列表、红黑树、跳表等等。实际上,这些数据结构也可以实现在一组字符串中查找字符串的功能。我们选了两种数据结构,散列表和红黑树,跟 Trie 树比较一下,看看它们各自的优缺点和应用场景。

在刚刚讲的这个场景,在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有极其严苛的要求。

  • 第一,字符串中包含的字符集不能太大,否则存储空间可能就会浪费很多,即便可以优化,但也要付出牺牲查询、插入效率的代价
  • 第二,字符串的前缀重合比较多,不然空间消耗会变大很多
  • 第三,需要自己从零开始实现一个 Trie 树,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做
    • 一个 Trie 树的开源库:Apache Commons,里面有关于Trie的实现
  • 第四,通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,对缓存不友好,性能上会打个折扣

综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。

实际上,Trie 树不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie 树比较适合的是查找前缀匹配的字符串,也就是类似开篇问题的那种场景。

如何实现搜索关键词的提示功能

我们假设关键词库由用户的热门搜索关键词组成,我们将这个词库构建成一个 Trie 树。当用户输入其中某个单词的时候,把这个词作为一个前缀子串在 Trie 树中匹配。

假设词库里只有 hello、her、hi、how、so、see 这 6 个关键词。当用户输入了字母 h 的时候,我们就把以 h 为前缀的 hello、her、hi、how 展示在搜索提示框内。当用户继续键入字母 e 的时候,我们就把以 he 为前缀的 hello、her 展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。

实际上,搜索引擎的搜索关键词提示功能远非这么简单,如果再稍微深入一点,你就会想到,上面的解决办法遇到下面几个问题:

  • 上面讲的思路是针对英文的搜索关键词提示,对于更加复杂的中文来说,词库中的数据又该如何构建成 Trie 树呢?
  • 如果词库中有很多关键词,在搜索提示的时候,用户输入关键词,作为前缀在 Trie 树中可以匹配的关键词也有很多,如何选择展示哪些内容呢?
  • 像 Google 这样的搜索引擎,用户单词拼写错误的情况下,还是可以使用正确的拼写来做关键词提示,这个又是怎么做到的呢?

这些问题,我们会在实战篇里具体来讲解。

实际上,Trie 树的这个应用可以扩展到更加广泛的一个应用上,就是自动输入补全,比如输入法自动补全功能、IDE 代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等。

内容小结

今天我们讲了一种特殊的树,Trie 树。Trie 树是一种解决字符串快速匹配问题的数据结构。如果用来构建 Trie 树的这一组字符串中,前缀重复的情况不是很多,那 Trie 树这种数据结构总体上来讲是比较费内存的,是一种空间换时间的解决问题思路。

尽管比较耗费内存,但是对内存不敏感或者内存消耗在接受范围内的情况下,在 Trie 树中做字符串匹配还是非常高效的,时间复杂度是 O(k),k 表示要匹配的字符串的长度。

但是,Trie 树的优势并不在于做动态集合数据的查找,因为,这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie 树的优势是查找前缀匹配的字符串,比如搜索引擎中的关键词提示功能这个场景,就比较适合用它来解决,也是 Trie 树比较经典的应用场景。

36 | AC自动机:如何用多模式串匹配实现敏感词过滤功能?

很多支持用户发表文本内容的网站,大都会有敏感词过滤功能,用来过滤掉用户输入的一些淫秽、反动、谩骂等内容,这个功能是怎么实现的呢?

实际上,这些功能最基本的原理就是字符串匹配算法,也就是通过维护一个敏感词字典,当用户输入一段文字内容之后,通过字符串匹配算法,来查找用户输入的这段文字,是否包含敏感词。如果有,就把它替代掉。

我们前面讲过好几种字符串匹配算法了,它们都可以处理这个问题。但是,对于访问量巨大的网站来说,我们对敏感词过滤系统的性能要求就要很高。那如何才能实现一个高性能的敏感词过滤系统呢?这就要用到今天的多模式串匹配算法

敏感词过滤功能基础架构

前面几节讲的字符串匹配算法可以分为两类:

  • 单模式串匹配算法:BF、RK、BM、KMP
    • 在一个主串中查找一个模式串
  • 多模式串匹配算法:Trie 树
    • 在一个主串中查找多个模式串
    • 主串:每次要进行匹配查找的字符串,例如上一节中的用户当前输入的内容,这一节中的用户要发表的内容
    • 模式串:构建成 Trie 树的多个字符串,例如上一节中的热门搜索关键词词库,这一节中的敏感词词库

实际上,单模式串匹配算法也能完成多模式串的匹配工作。例如开篇的思考题,我们可以针对每个敏感词,逐个通过单模式串匹配算法与用户输入的文字内容进行匹配。但是,如果敏感词很多,并且用户输入的内容很长,这种处理思路就非常低效。

然而,多模式匹配算法在处理这个问题时,只需要扫描一遍主串,就能在主串中一次性查找多个模式串是否存在。

  • 首先对敏感词字典进行预处理,构建成 Trie 树结构
  • 如果敏感词字典动态更新了,比如删除、添加了一个敏感词,只需要动态更新一下 Trie 树就可以了
  • 当用户输入一段文本内容后
    • 我们把用户输入的内容作为主串,从第一个字符开始,在 Trie 树中匹配
    • 当匹配到 Trie 树的叶子节点,或者中途遇到不匹配字符的时候,我们将主串的开始匹配位置后移一位
    • 然后重新在 Trie 树中匹配

基于 Trie 树的这种处理方法,有点类似单模式串匹配的 BF 算法。单模式串匹配算法中,对 BF 算法进行改进的核心思想是,让匹配失败时尽可能将模式串往后多滑动几位。借鉴单模式串的优化改进方法,能否对多模式串 Trie 树进行改进呢?这就要用到 AC 自动机算法了。

AC 自动机

AC 自动机算法,全称是 Aho-Corasick 算法。

Trie 树跟 AC 自动机之间的关系,就像单模式串匹配算法中 BF 跟 KMP 之间的关系一样,只不过前者针对的是多模式串而已。所以,AC 自动机实际上就是在 Trie 树之上,加了类似 KMP 的 next 数组,只不过此处的 next 数组是构建在树上罢了。

public class AcNode {
  public char data;
  public AcNode[] children = new AcNode[26]; // 字符集只包含 a~z 这 26 个字符
  public boolean isEndingChar = false; // 是否为结尾字符
  public int length = -1; // 当为结尾字符时,记录模式串长度
  public AcNode fail; // 失败指针
  public AcNode(char data) {
    this.data = data;
  }
}

AC 自动机的构建,包含两个操作:

  • 将多个模式串构建成 Trie 树
  • 在 Trie 树上构建失败指针(相当于 KMP 中的失效函数 next 数组)

关于如何构建 Trie 树,我们上一节已经讲过了。所以,这里我们就重点看下,构建好 Trie 树之后,如何在它之上构建失败指针?

我用一个例子给你讲解。这里有 4 个模式串,分别是 c,bc,bcd,abcd;主串是 abcd。

Trie 树中的每一个节点都有一个失败指针,它的作用和构建过程跟 KMP 算法中的 next 数组极其相似。所以要想看懂这节内容,必须先理解 KMP 算法中 next 数组的构建过程。

假设我们沿 Trie 树走到 p 节点(也就是下图中的紫色节点),那 p 的失败指针就是从 root 走到紫色节点形成的字符串 abc,跟所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串。

这里的最长可匹配后缀子串,我稍微解释一下:

  • 字符串 abc 的后缀子串有两个 bc,c,我们拿它们与其他模式串匹配,如果某个后缀子串可以匹配某个模式串的前缀,那我们就把这个后缀子串叫作可匹配后缀子串
  • 我们从可匹配后缀子串中,找出最长的一个,就是刚刚讲到的最长可匹配后缀子串
  • 我们将 p 节点的失败指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点,就是下图中箭头指向的节点

计算每个节点的失败指针这个过程看起来有些复杂。其实,如果我们把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。

我们可以像 KMP 算法那样,当我们要求某个节点的失败指针的时候,我们通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说,我们可以逐层依次来求解每个节点的失败指针。所以,失败指针的构建过程,是一个按层遍历树的过程。

首先 root 的失败指针为 NULL,也就是指向自己。当我们已经求得某个节点 p 的失败指针之后,如何寻找它的子节点的失败指针呢?

我们假设节点 p 的失败指针指向节点 q,我们看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到。如果找到了节点 q 的一个子节点 qc,对应的字符跟节点 pc 对应的字符相同,则将节点 pc 的失败指针指向节点 qc。

如果节点 q 中没有子节点的字符等于节点 pc 包含的字符,则令 q=q->fail(fail 表示失败指针,这里有没有很像 KMP 算法里求 next 的过程?),继续上面的查找,直到 q 是 root 为止,如果还没有找到相同字符的子节点,就让节点 pc 的失败指针指向 root。

我将构建失败指针的代码贴在这里,你可以对照着讲解一块看下,应该更容易理解。这里面,构建 Trie 树的代码我并没有贴出来,你可以参看上一节的代码,自己实现。

public void buildFailurePointer() {
  Queue<AcNode> queue = new LinkedList<>();
  root.fail = null;
  queue.add(root);
  while (!queue.isEmpty()) {
    AcNode p = queue.remove();
    for (int i = 0; i < 26; ++i) {
      AcNode pc = p.children[i];
      if (pc == null) continue;
      if (p == root) {
        pc.fail = root;
      } else {
        AcNode q = p.fail;
        while (q != null) {
          AcNode qc = q.children[pc.data - 'a'];
          if (qc != null) {
            pc.fail = qc;
            break;
          }
          q = q.fail;
        }
        if (q == null) {
          pc.fail = root;
        }
      }
      queue.add(pc);
    }
  }
}

通过按层来计算每个节点的子节点的失效指针,刚刚举的那个例子,最后构建完成之后的 AC 自动机就是下面这个样子:

AC 自动机到此就构建完成了。我们现在来看下,如何在 AC 自动机上匹配主串

我们还是拿之前的例子来讲解。在匹配过程中,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。

  • 如果 p 指向的节点有一个等于 b[i] 的子节点 x,我们就更新 p 指向 x,这个时候我们需要通过失败指针,检测一系列失败指针为结尾的路径是否是模式串。这一句不好理解,你可以结合代码看。处理完之后,我们将 i 加一,继续这两个过程;
  • 如果 p 指向的节点没有等于 b[i] 的子节点,那失败指针就派上用场了,我们让 p=p->fail,然后继续这 2 个过程。

关于匹配的这部分,文字描述不如代码看得清楚,所以我把代码贴了出来,非常简短,并且添加了详细的注释,你可以对照着看下。这段代码输出的就是,在主串中每个可以匹配的模式串出现的位置。

public void match(char[] text) { // text是主串
  int n = text.length;
  AcNode p = root;
  for (int i = 0; i < n; ++i) {
    int idx = text[i] - 'a';
    while (p.children[idx] == null && p != root) {
      p = p.fail; // 失败指针发挥作用的地方
    }
    p = p.children[idx];
    if (p == null) p = root; // 如果没有匹配的,从root开始重新匹配
    AcNode tmp = p;
    while (tmp != root) { // 打印出可以匹配的模式串
      if (tmp.isEndingChar == true) {
        int pos = i-tmp.length+1;
        System.out.println("匹配起始下标" + pos + "; 长度" + tmp.length);
      }
      tmp = tmp.fail;
    }
  }
}

AC 自动机的内容讲完了,关于开篇的问题,你应该能解答了吧?实际上,我上面贴出来的代码,已经是一个敏感词过滤的原型代码了。它可以找到所有敏感词出现的位置(在用户输入的文本中的起始下标)。你只需要稍加改造,再遍历一遍文本内容(主串),就可以将文本中的所有敏感词替换成***

AC 自动机性能分析

AC 自动机实现的敏感词过滤系统,是否比单模式串匹配方法更高效呢?

首先,我们需要将敏感词构建成 AC 自动机,包括构建 Trie 树以及构建失败指针。

我们上一节讲过,Trie 树构建的时间复杂度是 O(m*len),其中 len 表示敏感词的平均长度,m 表示敏感词的个数。那构建失败指针的时间复杂度是多少呢?我这里给出一个不是很精确的上界。

假设 Trie 树中总的节点个数是 k,每个节点构建失败指针的时候,(你可以看下代码)最耗时的环节是 while 循环中的 q = q->fail,每运行一次这个语句,q 指向节点的深度都会减少 1,而树的高度最高也不会超过 len,所以每个节点构建失败指针的时间复杂度是 O(len)。整个失败指针的构建过程就是 O(k*len)

不过,AC 自动机的构建过程都是预先处理好的,构建好之后,并不会频繁地更新,所以不会影响到敏感词过滤的运行效率。

我们再来看下,用 AC 自动机做匹配的时间复杂度是多少?

跟刚刚构建失败指针的分析类似,for 循环依次遍历主串中的每个字符,for 循环内部最耗时的部分也是 while 循环,而这一部分的时间复杂度也是 O(len),所以总的匹配的时间复杂度就是 O(n*len)。因为敏感词并不会很长,而且这个时间复杂度只是一个非常宽泛的上限,实际情况下,可能近似于 O(n),所以 AC 自动机做敏感词过滤,性能非常高。

你可以会说,从时间复杂度上看,AC 自动机匹配的效率跟 Trie 树一样啊。实际上,因为失效指针可能大部分情况下都指向 root 节点,所以绝大部分情况下,在 AC 自动机上做匹配的效率要远高于刚刚计算出的比较宽泛的时间复杂度。只有在极端情况下,如图所示,AC 自动机的性能才会退化的跟 Trie 树一样。

内容小结

今天我们讲了多模式串匹配算法 AC 自动机。单模式串匹配算法是为了快速在主串中查找一个模式串,而多模式串匹配算法是为了快速在主串中查找多个模式串。

AC 自动机是基于 Trie 树的一种改进算法,它跟 Trie 树的关系,就像单模式串中,KMP 算法与 BF 算法的关系一样。KMP 算法中有一个非常关键的 next 数组,类比到 AC 自动机中就是失败指针。而且,AC 自动机失败指针的构建过程,跟 KMP 算法中计算 next 数组极其相似。所以,要理解 AC 自动机,最好先掌握 KMP 算法,因为 AC 自动机其实就是 KMP 算法在多模式串上的改造。

整个 AC 自动机算法包含两个部分,第一部分是将多个模式串构建成 AC 自动机,第二部分是在 AC 自动机中匹配主串。第一部分又分为两个小的步骤,一个是将模式串构建成 Trie 树,另一个是在 Trie 树上构建失败指针。

字符串匹配算法总结

单模式串匹配:

  • BF:简单场景,主串和模式串都不太长,O(m*n)
  • KP:字符集范围不要太大且模式串不要太长,否则hash值可能冲突,O(n)
  • BM:模式串最好不要太长(因为预处理较重),比如IDE编辑器里的查找场景;预处理O(m*m), 匹配O(n),实现较复杂,需要较多额外空间
  • KMP:适合所有场景,整体实现起来也比 BM 简单,O(n+m),仅需一个 next 数组的O(n)额外空间;但统计意义下似乎 BM 更快
  • Sunday:查资料的时候看到的一种比 BM/KMP 更快,且实现、理解起来都更容易的算法

多模式串匹配:

  • Trie 树: 适合多模式串公共前缀较多的匹配(O(n*k)),或者根据公共前缀进行查找(O(k))的场景,比如搜索框的自动补全提示
  • AC自动机: 适合大量文本中多模式串的精确匹配查找,可以到O(n)

2021-9-19

posted @ 2021-09-19 16:55  白乾涛  阅读(334)  评论(0编辑  收藏  举报