一看就懂的字符串匹配算法 之 BM算法

先来一个导读,关于BM算法开始有了难度,大家一定要静下心,咬着呀也得给我读下去,这样你才能有收获。

我们在文本编辑器中,我们经常用到查找及替换功能。比如说,在Word文件中,通过查找及替换功能,可以把某一个单词统一替换成另一个单词。对于文本编辑器这种软件来说,查找及替换是其核心功能,我们希望使用的字符串匹配算法尽可能地高效。之前讨论过RK算法,时间复杂度为O(n),其实已经很高效了,现在来介绍一个新的字符串匹配算法,BM(Boyer-Moore)算法。

BM算法的核心思想

BM算法是一个非常高效的字符串匹配算法,不过BM算法的原理非常复杂,理解起来可能有点困难,大家花费半小时静下心去好好研究一下。

模式串和主串的匹配过程可以看成模式串在主串中不停的向后滑动。当遇到不匹配的字符时,BF算法和RK算法是将模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。

主串中的字符c在模式串中没有对应的字符,就肯定无法匹配。因此,我们可以一次性把模式串往后多滑动几位,把模式串移动到字符c的后面,如下图。

 

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

BM算法的原理分析

BM算法的具体实现原理包含两个部分:坏字符规则(bad character rule)和好后缀规则(good suffix rule)。

1.坏字符规则

我们知道在BF算法中的模式串和主串之间的匹配是按照下标从小到大的顺序进行的,而BM算法的匹配顺序却是比较特殊,他是按照模式串下标从大到小的顺序倒叙进行的。如下图。

从模式串的末尾往前倒着匹配,当发现某个字符无法匹配的时候,我们就把这个无法匹配的字符称为“坏”字符。注意,坏字符指的是主串中的字符,而不是模式串中的字符。如下图。

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

我们将模式串滑动到c之后,就会发现,模式串中的最后一个字符d,还是无法与主串中的字符a相互匹配。此时,我们是否能将模式串滑动到主串中坏字符a(主串中第三个a)的后面?

其实是不可以的,因为坏字符a在模式串中存在,也就是下标为0的位置存储的就是字符a。因此,我们可以将模式串往后滑动两位,让模式串中的a与主串中的第三个a上下对齐,然后再从模式串的末尾字符开始匹配。

 

 现在大家可能会发现一个问题,那就是,第一次我们移动模式串的时候,是移动了三位,但是第二次的时候就是移动了两位,那么对于移动的具体次数,有没有规律呢?

当模式串与主串不匹配时,我们把坏字符对应的模式串中的字符在模式串中的下标记作为si。如果坏字符在模式串中存在,那么我们把坏字符在模式串中的下标记作xi,如果坏字符在模式串中不存在,那么我们把xi记作-1。那么,模式串往后滑动的位数就等于si-xi。

 这里还要说明的是,如果坏字符在模式串中出现多次,那么在计算xi的时候,我们选择模式串中最靠后的哪个坏字符的下标作为xi的值。这样就不会因为模式窜滑动过多,而导致本来可能匹配的情况被忽略。

利用坏字符规则,BM算法在最好情况下的时间复杂度非常底,是O(n/m)。例如,主串是aaabaaabaaabaaab,模式串是aaaa,每当模式串与主串不匹配时(坏字符是b),我们就可以将模式串直接往后滑动4位,因此,匹配具有类似特点的主串与模式串的时候,BM算法是高效的。

不过,单纯使用坏字符规则还不够,因为根据si-xi计算出来的滑动位数又可能是负数,如主串是aaaaaaaaaaaa,模式串为baaa。针对这种情况,就还需要另外一种规则,好后缀规则。

2.好后缀规则

好后缀规则与坏字符规则非常类似,当模式串滑动到如下图所示的位置时,模式串与主串有两个字符是匹配的,倒数第三个字符不匹配。

先来看看好后缀规则怎么工作的。

我们把已经匹配的"bc"称为好后缀,记作{u}.我们用他在模式串中进行查找,如果找到另一个与好后缀{u}匹配的子串{u*},那么我们就将模式串滑动到子串{u*}与好后缀{u}上下对齐的位置。如下图。

 

 如果在模式串中找不到好后缀{u}匹配的另外的子串,就直接将模式串滑动到好后缀{u}的后面。

 不过大家想没想过这个问题,当模式串中不存在与好后缀{u}匹配的子串时,我们直接将模式串滑动到好后缀{u}后面,是不是有点过头,看下图一个例子,其中"bc"是好后缀,尽管在模式串中没有另外一个与好后缀匹配的子串,但是我们如果模式串移动到好后缀的后面,就会错过模式串和主串可以匹配的情况。

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

 

所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还
要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。
所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串 就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a, ab。我们从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设 BM是{v},然后将模式串滑动到如图所示的位置。
当模式串和主串中的 某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的 位数?
我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往 后滑动的位数。这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的往 后滑动的位数,有可能是负数的情况。

 

BM算法的代码实现

“坏字符规则”本身不难理解。当遇到坏字符时,要计算往后移动的位数 si-xi,其中 xi 的 计算是重点,我们如何求得 xi 呢?或者说,如何查找坏字符在模式串中出现的位置呢?如果我们拿坏符在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性 能。有没有更加高效的方式呢?我们之前学的散列表,这里可以派上用场了。我们可以将模 式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下 标了。
关于这个散列表,我们只实现一种最简单的情况,假设字符串的字符集不是很大,每个字符 长度是 1 字节,我们用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组 的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。

哈希表的代码构建过程

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

//b为模式串,m为模式串长度,bc为哈希表
pivate 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[ascii] = i; 
   } 
}
掌握了坏字符规则之后,我们先把 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算法代码实现中的关键变量。

大家载坚持一下看完好后缀规则的实现

我们先简单回顾一下,前面讲过好后缀的处理规则中最核心的内容:
在模式串中,查找跟好后缀匹配的另一个子串;
在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;
在不考虑效率的情况下,这两个操作都可以用很“暴力”的匹配查找方式解决。但是,如果 想要 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 数组的计算过程,用代码实现出来,就是下面这个样子: 

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;
      intk = 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;//表示主串与模式串匹配的第一个字符
   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算法,在极端情况下,预处理计算 suffix 数组、prefix 数组 的性能会比较差。
比如模式串是 aaaaaaa 这种包含很多重复的字符的模式串,预处理的时间复杂度就是 O(m^2)。当然,大部分情况下,时间复杂度不会这么差。

 

引用《数据结构与算法之美》

 

 

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值