BM(Boyer-Moore)算法是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的KMP性能的三到四倍。在本文的描述中,我们将被匹配的字符串叫做主串,拿去主动和被人匹配的较短的串叫做模式串。BM(Boyer-Moore)算法的本质(KMP其实也是这样)是寻找当模式串的与主串中字符匹配时,怎样将模式串往后多滑动几位。对BM算法的原理主要包括两部分:坏字符规则和好后缀规则。下面先介绍这两个规则。
1.坏字符规则
与传统的BK算法和RF算法按照模式串下标从小到大的顺序匹配不同,BM算法是按照模式串从大到小与主串进行匹配。从模式串的末尾往前倒着匹配,如果遇到和主串不能匹配的字符,我们就将主串中这个字符叫做坏字符。坏字符规则的操作顺序如下:
- 匹配中过程找到坏字符,将坏字符与模式串中不匹配的对应模式串下标记为si
- 在模式串中查找,看能否找到和坏字符相匹配的字符,若能够找到。则将模式串中与坏字符不匹配的字符在模式串中最后出现的坐标记为xi(强调最后出现的原因是为了保证模式串在后移的过程中不会滑动过多),若不能找到,则将xi置为-1;
- 最终模式串需要后移的位数等于si-xi
从上述坏字符规则的描述中,我们可以得知若采用坏字符规则,算法的时间复杂度是o(n/m),n表示主串的长度,m表示模式串的长度。坏规则的过程可以用代码来描述:
class Solution{
private final int SIZE = 256; //散列表的大小,假设主串和模式串中包含的字符都在256个ASCII码所涵盖的字符中
public int[] generateOurHashTable(char[]b, int m, int[]ourHashTable){ //b数组表示模式串,m表示模式串的长度
for(int i=0; i<SIZE; i++){
ourHashTable[i] = -1; //某个值为-1,表示的是在模式串中无法找到相对应的字符
}
for(int i=0; i<m; i++){
//操作的含义是将模式串中某个字符对应的下标是该字符对应整型值(int)b[i]的ourHashTable[ourHashTable]的值置为模式串数组的下标
ourHashTable[(int)b[i]] = i;
}
return ourHashTable;
};
//仅仅使用坏字符规则的BM算法
public int BM(char[] mainString, int n, char[] subString, int m){
if(n<=m) return -1;
int[] ourHashTable = new int[SIZE];
generateOurHashTable(subString, m, ourHashTable);
int i = 0;
while(i<n-m){
for(int j=m-1; j>=0; j--){
if(mianString[i+j] != subString[j]) //找到了坏字符
break;
}
if(j==-1){
return i; //模式串与主串完全的完成了匹配,返回最终找到的与主串匹配的字符所在位置
}
i = i +( j -ourHashTable[j]); //相当于将模式串向后移了j -ourHashTable[j]个位置
}
return -1;
};
}
其实,坏字符规则存在一个比较大的问题,就是会出现si的值比xi的值小的时候出现,如对于模式串bcadc,如果模式串是在si=2的位置上与主串中的字符c匹配不了,则xi=4,此时的si-xi为一个负数。为了解决这个问题,前人又想出了好后缀规则,相比于坏字符规则,好后缀规则更加复杂。
2. 好后缀规则
好后缀指的就是模式串在从前往后与主串匹配的过程中在找到坏字符时能与主串匹配的子串,我们将其记为u,好后缀规则的处理流程如下(在这个过程中没有考虑BM算法中实际上使用了坏字符规则和好后缀规则中返回的需要向后移动的最大值):
- 按上述过程找打到好后缀u
- 考察好后缀在模式串是否有相匹配的子串,如果有,将模式串移动到该子串与主串相匹配的子串正好匹配的位置,判断模式串能否和主串匹配,若能,则查找成功,若不能则继续步骤1;
- 若2中在模式串中没有找到与好后缀相匹配的子串,则还需要将模式串的前缀子串与好后缀的后缀子串进行比较(这个过程后缀子串需要从长到短与前缀子串中长度相等的子串进行比较,找到能匹配的最长的),如果有重合,则将模式串移至其前缀子串与主串中好后缀的后缀子串正好匹配的位置,继续进行匹配;如果没有重合的子串,则直接将模式串移至主串中的好后缀后面,继续执行步骤1
从上面对好后缀规则的处理流程中我们可以发现,其最核心的两个内容是:
- 在模式串中查找与好后缀匹配的另一个子串
- 在好后缀的后缀子串中,查找能与模式串的前缀子串匹配的后缀子串
为了实现这两个内容,可以用暴力的方法去找,但是为了保证效率,可以首先对模式串进行预处理,因为其实好后缀也是模式串的后缀子串。所以预处理(可以理解为使用缓存)的主要思路是:
- 为了应对核心内容1:预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置,引入suffix数组:suffix数组的下标表示的是后缀子串的长度,下标对应的数组存储的是在该模式串与好后缀匹配的子串的起始下标值。当模式串中与多个子串与后缀子串匹配时,suffix中存储的是最后一个能匹配的子串的下标值(防止模式串向后滑动过多)
- 为了应对核心内容2:引入boolean类型的prefix数组,用于记录某个后缀子串能否与模式串的前缀子串相匹配
计算两个数组suffix和prefix的过程非常巧妙:
- 我们拿下标从 0 到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。
- 如果公共后缀子串的长度是 k,那我们就记录 suffix[k]=j(j 表示公共后缀子串的起始下标)。如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,我们就记录 prefix[k]=true。
其具体的代码如下:
class Solution{
public void genetateSuffixAndPrefix(char []subString, int m, int[] suffix, char[] prefix){
for(int i=0; i<m; i++){ //初始化两个数组
suffix[i] = -1;
prefix[i] = false;
}
for(int i=0; i<m-1; i++){
int j=i;
int k=0; //记录两个子串匹配的长度
while(j>=0 && suString[j] == subString[m-1-k]){ //求与subString[0,m-1]的公共子串,同样是一个前缀子串从一个位置向前,后缀子串也从最后的位置向前进行匹配的过程
k++;
suffix[k] = j; //j表示在模式串中与后缀子串匹配的起始位置
j--;
}
if(j==-1){ //表示前缀子串完全可以和后缀子串匹配
prefix[k] = true;
}
}
};
}
在计算完suffix数组和prefix数组过后,在已知坏字符的下标是j(即j 表示坏字符对应的模式串中的字符下标)的情况下,使用这两个数组的过程如下:
- 假设好后缀的长度是 k。我们先拿好后缀,在 suffix 数组中查找其匹配的子串。如果 suffix[k]不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j-suffix[k]+1 位。
- 如果 suffix[k]等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。好后缀的后缀子串 subString[r, m-1](其中,m表示模式串的长度,r 取值从 j+2 到 m-1)的长度 k=m-r,如果 prefix[k]等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。
- 如果经过1, 2两步过后都没有找到可以匹配好后缀及其后缀子串的前缀子串,我们就将整个模式串后移 m 位。
到这里,好后缀规则的执行流程全部介绍结束
3. 使用坏字符和好后缀规则的完整BM算法
为了最大限度的将模式串向后移(这也是BM算法的目标),将坏字符规则和好后缀规则产生的最大值作为向后移动的步数,完整的BM程序代码如下:
class Solution{
private final int SIZE=256;
public int[] generateOurHashTable(char[]subString, int m, int[]ourHashTable){ //b数组表示模式串,m表示模式串的长度
for(int i=0; i<SIZE; i++){
ourHashTable[i] = -1; //某个值为-1,表示的是在模式串中无法找到相对应的字符
}
for(int i=0; i<m; i++){
//操作的含义是将模式串中某个字符对应的下标是该字符对应整型值(int)b[i]的ourHashTable[ourHashTable]的值置为模式串数组的下标
ourHashTable[(int)b[i]] = i;
}
return ourHashTable;
};
public void genetateSuffixAndPrefix(char []subString, int m, int[] suffix, char[] prefix){
for(int i=0; i<m; i++){ //初始化两个数组
suffix[i] = -1;
prefix[i] = false;
}
for(int i=0; i<m-1; i++){
int j=i;
int k=0; //记录两个子串匹配的长度
while(j>=0 && suString[j] == subString[m-1-k]){ //求与subString[0,m-1]的公共子串,同样是一个前缀子串从一个位置向前,后缀子串也从最后的位置向前进行匹配的过程
k++;
suffix[k] = j; //j表示在模式串中与后缀子串匹配的起始位置
j--;
}
if(j==-1){ //表示前缀子串完全可以和后缀子串匹配
prefix[k] = true;
}
}
};
public int BM(char[]mainString, int n, char[]suString, int m){
int[] ourHashTable = new int[SIZE] ;
generateOurHashTable(subString, m, ourHashTable);
int[]suffix = new int[m];
boolean[]prefix = new boolean[m];
generateSuffixAndPrefix(subString, m, suffix, prefix);
int i = 0; //表示主串与模式串匹配的第一个字符
while(i<=n-m){
int j;
for(j =m-1; j>0; j--){
if(mainString[i+j]!=subString[j]){ //找到坏字符
break;
}
}
if(j == -1) { //找到了匹配的字符串
return i;
}
int x = j-ourHashTable[mainString[i+j]];//坏字符规则计算的向后移动的距离
int y = 0;
y = moveByGoodSuffix(j, m, suffix, prefix);
i = i + Math.max(x, y); //这其实也规避了x为负数的情况
}
};
public int moveByGoodSuffix(int j, int m, int[]suffix, boolean prefix){
int k = m-1-j; //好后缀长度
if(suffix[k] != -1) //监测好后串在模式串中是否有与其匹配的子串
return j-suffix[K]+1;
else{
for(int r=j+2; r<=m-1; r++){ //好后串子串与模式串前串子串匹配的过程是一个循环,目的是找到最长匹配子串
if(prefix[m-r]){ //其中m-r表示好后缀子串长度
return r;
}
}
return m;
}
};
}
- BM算法的时间复杂度分析非常复杂,在此不详细介绍,有论文对此做了专门的讨论,其时间复杂度为o(3n)到o(5n)之间
- BM算法的内存消耗主要在ourHashTable数组(大小取决于字符集大小)和suffix、prefix数组(大小取决于模式串长度大小,模式串较短时可忽略不计)上,即其空间复杂度主要取决于坏字符规则中需要使用的hash字符数组上。