先来一个导读,关于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"是好后缀,尽管在模式串中没有另外一个与好后缀匹配的子串,但是我们如果模式串移动到好后缀的后面,就会错过模式串和主串可以匹配的情况。
BM算法的代码实现
哈希表的代码构建过程
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;
}
}
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算法代码实现中的关键变量。
大家载坚持一下看完好后缀规则的实现
我们把 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; //公共后缀子串也是模式串的前缀子串
}
}
//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 算法的性能分析及优化
引用《数据结构与算法之美》