字符串匹配基础(中)—— BM算法
Ⅰ 前言
文本编辑器的查找替换功能相信大家都不陌生,很多 IDE 像 Eclipse,IntelliJ,包括 Word,都有这个功能,把一个词统一替换成另一个。那这个功能是如何实现的呢?
如果用前一篇文章里的 BF 算法和 RK 算法,当然可以实现这个功能,但是在某些极端情况下,BF 算法性能会退化得比较严重,而 RK 算法需要用到哈希算法,但是设计出一个可以应对各种类型字符的哈希算法并不简单。
对于工业级的软件开发来说,我们希望算法尽可能的高效,并且在极端情况下,性能也不要退化得太严重。那么,对于查找功能是重要功能的软件来说,比如一些文本编辑器,它们的查找功能都是用哪种算法来实现的呢?有没有比 BF 算法和 RK 算法更加高效的字符串匹配算法呢?
这就引出了我们这篇文章要讲的一个算法,BM(Boyer-Moore) 算法。它是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的 KMP 算法的 3 到 4 倍。BM 算法的原理很复杂,比较难懂,我会在王争老师的课程的基础,再加上我的理解,希望能把这个算法讲得更清楚一点。
如果对 BF 算法和 RK 算法还不熟悉的同学,如果有兴趣,可以跳转到下面的链接去看看。
【数据结构与算法】->算法->字符串匹配基础(上)->BF 算法 & RK 算法
Ⅱ BM 算法核心思想
我们把模式串和主串的匹配过程,看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时,BF 算法和 RK 算法的做法是,模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。如下面的这张图👇
在这个例子里,主串中的 c ,在模式串中是不存在的,所以,模式串向后滑动的时候,只要 c 与模式串有重合,肯定无法匹配。所以,我们可以一次性把模式串往后多滑动几位,把模式串移动到 c 的后面。
所以,当遇到不匹配的字符时,有什么固定的规律,可以将模式串往后多滑动几位呢?这样移动地越快,匹配的效率就更高了。
BM 算法本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
Ⅲ BM 算法原理分析
BM 算法包括两部分,分别是 坏字符规则(bad character rule) 和 好后缀规则(good suffix shift)。我们分别来看一看。
1. 坏字符规则
前面讲的 BF 算法和 RK 算法,在匹配的过程中,都是按照模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的。这种匹配顺序比较符合我们的思维习惯,但是 BM 算法的匹配顺序很特别,它是按照模式串下标从大到小,倒着匹配的。
BF 算法👇
BM 算法👇
我们从模式串的末尾倒着匹配,当我们发现某个字符没法匹配的时候,我们把这个字符叫作 坏字符(主串中的字符)。
我们拿坏字符 c 在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符 c 与模式串中的任何字符都不可能匹配。这个时候,我们可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。
这个时候,我们发现,模式串中最后一个字符 d,还是无法和主串中的 a 匹配,这个时候,还能将模式串往后滑动三位吗?答案是不行的。因为这个时候,坏字符 a 在模式串中是存在的,模式串中下标是 0 的位置也是字符 a。这种情况下,我们可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。
第一次匹配的时候,我们滑动了三位。第二次匹配的时候,我们滑动了两位。那具体滑动多少位,是不是可以总结出一个规律来?
当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作 si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi。如果不存在,我们把 xi 记作 -1。那么,模式串往后移动的位数就等于 si-xi。
这里还有一点需要注意,就是如果坏字符在模式串里多次出现,那我们在计算 xi 的时候,选择最靠后的那个,因为这样就不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。
利用坏字符规则,BM 算法在最好情况下的时间复杂度非常低,是 O(n/m) 。比如,主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的模式串和主串的时候,BM 算法非常高效。
不过,单纯使用坏字符规则还不够,因为 si - xi 计算出来的移动位数,有可能是负数,比如主串是 aaaaaaaaaaaaaa,模式串是 baaa。第一次比对, si 也就是坏字符对应的模式串中的字符下标,所以就是 0(b 是坏字符);xi 就是坏字符在模式串中的下标,也就是 3(字符多次出现,取靠后的)。因而 si - xi = -3 。
利用坏字符规则,BM 算法在最好情况下时间复杂度非常低,是 O(n/m)。比如,主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的的模式串和主串的时候,BM 算法非常高效。
不过,单纯使用坏字符规则还是不够的。因为根据 si - xi 计算出来的移动次数,有可能是负数,不但不会向后滑动模式串,还有可能倒退,所以,BM 算法还需要用到 “好后缀规则”。
2. 好后缀规则
好后缀规则实际上跟坏字符规则的思路很类似,比如下面这张图。当模式串滑动到图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。
这个时候该如何滑动字符串呢?当然,我们还可以利用坏字符规则来计算模式串的滑动位数,但是坏字符规则正如我们上面所说不是时时刻刻都有效的,所以我们还需要使用好后缀规则,两个配合使用。
我们把已经匹配好的 b c 叫作好后缀,记作 {u}。我们拿它在模式串中查找,如果找到了另一个跟 {u} 相匹配的子串 {u*},那我们就将模式串滑动到子串 {u*} 与主串中 {u} 对齐的位置。
如果在模式串中找不到另一个等于 {u} 的子串,我们就直接将模式串滑动到主串中 {u} 的后面,因为之前的任何一次往后滑动,都没有匹配主串中 {u} 的情况。
不过,当模式串中不存在等于 {u} 的子串的时候,我们直接将模式串滑动到主串 {u} 的后面,这样做会不会跳过头呢?我们看下面这种情况。这里面 b c 是好后缀,尽管在模式串中没有另外一个相匹配的子串 {u*},但是如果我们将模式串移动到好后缀的后面,那就会错过模式串和主串可以匹配的情况。
如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步往后滑动模式串的过程中,只要主串中 {u} 与模式串有重合,那就肯定无法完全匹配。但是当模式串滑动到前缀与主串中的 {u} 的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。
这个说起来比较复杂,大家看图可能就明白了。
再进一步解释就是,在上一次配比的时候,已经发现了模式串中没有和主串好后缀可以匹配的子串,那模式串就要向后移动。这时候有个情况就是,模式串的中间的一部分和主串的好后缀重合了,那肯定是无法匹配的。因为模式串的头尾肯定和主串的子串不匹配,所以这种情况的重合就意义不大。
但什么情况下模式串的一部分和主串的后缀有重合才有意义呢?就是模式串的前缀子串和好后缀的后缀字串重合,这样如果后面也匹配的话,就是真的匹配了,就像我们上图举的合理滑动的例子。这个逻辑大家看看图仔细想想就可以想通了。
所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。
为了避免歧义,这里我再解释一下什么是前缀后缀子串。比如说有个字符串 s ,它的后缀子串就是最后一个字符和 s 对齐的子串。比如 abc 的后缀子串就是 c,bc。所谓前缀子串,就是起始字符和 s 对齐的子串,比如 abc 的前缀子串就是 a,ab。
我们要从好后缀的后缀子串中,找一个最长的并且能够跟模式串的前缀子串匹配的,假设是 {v},然后将模式串滑动到如图所示的位置👇
这就是好后缀规则的基本原理。
那么模式串和主串中的某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的位数呢?
我们前面说过,坏字符的移动位数可能会出现负数,除了这个情况意外,其他的移动都是安全的。这个安全就是指移动了以后,不会错过正确的字符串匹配。好后缀同样,也是安全的。所以在选择的时候,我们有一个处理原则,就是坏字符规则和好后缀规则的移动位数都进行一个计算,然后取两个数中最大的那个。因为按照我们最基本的思路,要使得每次出现不匹配字符时模式串移动的位数更大,这样查找起来就会更快。
因为好后缀规则不会出现移动位数是负数的情况,所以即使用坏字符规则算出来移动位数是负数,最后取得的移动位数也是正的,模式串会继续向后移动。
Ⅳ BM 算法代码实现
基础的思想和原理相信你看到这里已经明白了,现在我们就来实现 BM 算法。
我们一部分一部分来,先来实现坏字符规则。
1. 坏字符规则
坏字符规则本身并不难理解,当遇到坏字符时,要计算往后移动的位数 si - xi,其中 xi 的计算是重点。那我们如何求得 xi ,也就是坏字符在模式串中出现的位置呢?
如果我们拿坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能。为了追求更高的效率,我们可以用散列表。
对散列表有疑惑的同学可以跳转去看我的这篇文章👇
【数据结构与算法】->数据结构->散列表(上)->散列表的思想&散列冲突的解决
我们可以将模式串中的每个字符及其下标都存到散列表中,这样就可以快速找到坏字符在模式串的位置下标了。
关于这个散列表,我们只实现一种最简单的情况,假设字符串的字符集不是很大,每个字符长度是 1 字节,我们用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。
如果将上面的过程写成代码,就是下面这个样子👇
private static final int SIZE = 256; //ASCII码数
/**
* 借助散列表,存储字符以及其在模式串中的位置
* 如果是同样的字符出现多次,就记录它在模式串中最后出现的位置
* @param patternString 模式串
* @param badChar 坏字符集
*/
private void generateBadChar(char[] patternString, int[] badChar) {
for (int i = 0; i < SIZE; i++) {
badChar[i]