1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法,简称BM算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)的时间复杂度。在实践中,比KMP算法的实际效能高。 "文本串"与"模式串"头部对齐,从尾部开始比较.BM算法大量使用在文本编辑器中
在BM算法的思想中,在模式串与主串匹配是从后往前开始匹配在匹配的过程中,当遇到不可匹配的字符的时候,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况。
BM算法定义了两个规则:
-
坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,
移动的位数 = 坏字符在对应模式串中的位置 - 坏字符在模式串中上次出现的位置。此外,如果"坏字符"不包含在模式串之中,则最右出现位置为-1。
-
好后缀规则:当字符失配时,
后移位数 = 好后缀对应在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。
每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。
坏字符规则思想
1 坏字符没出现在模式串中,这时可以把模式串移动到坏字符的下一个字符,继续比较
2 坏字符出现在模式串中,这时可以把模式串第一个出现的坏字符和母串的坏字符对齐,
好后缀规则思想
好后缀规则移动模式串,shift(好后缀)分为三种情况:
1 模式串中有子串匹配上好后缀,此时移动模式串,让该子串和好后缀对齐即可,如果超过一个子串匹配上好后缀,则选择最靠左边的子串对齐。
2 模式串中没有子串匹配上后后缀,此时需要寻找模式串的一个最长前缀,并让该前缀等于好后缀的后缀,寻找到该前缀后,让该前缀和好后缀对齐即可。
3 模式串中没有子串匹配上后后缀,并且在模式串中找不到最长前缀,让该前缀等于好后缀的后缀。此时,直接移动模式到好后缀的下一个字符。
举例说明:
给定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,现要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。
1 ,"文本串"与"模式串"头部对齐,从尾部开始比较。"S"与"E"不匹配。这时,“S"就被称为"坏字符”,即不匹配的字符,它对应着模式串的第6位。且"S"不包含在模式串"EXAMPLE"之中(相当于最右出现位置是-1),这意味着可以把模式串后移6-(-1)=7位,从而直接移到"S"的后一位。
2 继续从尾部开始比较发现"P"与"E"不匹配,所以"P"是"坏字符";P此坏字符在模式串"EXAMPLE"中出现。位置为4 所以将模式串后移6-4=2位,两个"P"对齐。
3 继续比较 “MPLE”、“PLE”、“LE”、“E"都是好后缀。发现最大好后缀为MPLE,即所有尾部匹配的字符串。与模式串匹配。这时我们需要从 模式串"EXAMPLE” 中 找到最后次好后缀出现的位置,“MPLE” 在剩下的模式串“EXA”中没有再次出现,接着找"PLE",“LE” 发现都在对应剩下的模式串中没有出现,“E” 则在剩下的模式串“EXAMPL” 中第一个出现即0的位置
所以好后缀E下移动6-0 = 6;
接着关注坏后缀I,I在模式串中没有出现,所以移动2-(-1) = 3;
我们在两者中选最大的移动6
4 继续从尾部开始比较,“P”与“E”不匹配,因此“P”是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2位。因为是最后一位就失配,尚未获得好后缀。
由此可以BM算法利用好后缀与坏字符的思想移动距离是非常大的,比KMP算法要高效
代码实现
代码层面我们需要考虑每次从尾部开始匹配的时候都要寻找坏字符或者好后缀在对应模式串最后次出现的位置。最容易想到的就是lastIndexOf()方法,但是每次lastIndexOf 方法是比较耗费性能的。
对于坏字符:
我们可以使用hashMap记录坏字符最后次出现的位置
//注意我们的BM 算法需要的是上一次出现的位置,即最后次出现的索引位置,如果有相同的字符则取最后一个的索引位置
while (j < len -1){
int finalJ = j;
matchMap.compute(String.valueOf(patternString.charAt(j++)) ,(k, v)-> finalJ);
}
对于好后缀我们需要找到该好后缀在模式串最后次出现的位置,将对应的好后缀字符串写入hashMap中,后面直接进行获取
String lastStr;
//这里从len - 2开始是因为最后一个字符已经在上面坏字符统计过了
int lastIndex = len - 2; int skipIndex = 1; boolean isSkip;
label: for (int m = len-2;m>=0;){
for (int n = m-skipIndex;n>= 0;n--){
isSkip = false;
if (patternString.charAt(n) == patternString.charAt(m)){
//截取对应的后缀子串
if (patternString.substring(n, n + len - m).equals(lastStr = patternString.substring(lastIndex))){
matchMap.put(lastStr,n);
skipIndex = m - n;//找到一个对应前缀,后面肯定都是不符合下一个对应的前缀,所以下次从该前缀开始逆遍历,
lastIndex = --m;
isSkip = true;//由于是找最后一次出现的前缀,逆序下找到一个就跳出
}
}
if (n == 0) break label;//已经找完
if (isSkip) continue label;
}
m--;
}
获取对应坏字符与好后缀最后次出现位置代码
private static HashMap<String,Integer> getMatchIndexMap(String patternString){
int j = 0; int len =patternString.length();
HashMap<String,Integer> matchMap = new HashMap<>();
//注意我们的BM 算法需要的是上一次出现的位置,即最后次出现的索引位置,如果有相同的字符则取最后一个的索引位置
while (j < len -1){
int finalJ = j;
matchMap.compute(String.valueOf(patternString.charAt(j++)) ,(k, v)-> finalJ);
}
String lastStr;
int lastIndex = len - 2; int skipIndex = 1; boolean isSkip;
label: for (int m = len-2;m>=0;){
for (int n = m-skipIndex;n>= 0;n--){
isSkip = false;
if (patternString.charAt(n) == patternString.charAt(m)){
if (patternString.substring(n, n + len - m).equals(lastStr = patternString.substring(lastIndex))){
matchMap.put(lastStr,n);
skipIndex = m - n;//找到一个对应前缀,后面肯定都是不符合下一个对应的前缀,所以下次从该前缀开始逆遍历,
lastIndex = --m;
isSkip = true;//由于是找最后一次出现的前缀,逆序下找到一个就跳出
}
}
if (n == 0) break label;//已经找完
if (isSkip) continue label;
}
m--;
}
return matchMap;
}
BM算法核心代码
public static int bMAlMatch(String textString, String patternString){
if (textString.length() < patternString.length()) return -1;
int patternLen = patternString.length();
int textLen = textString.length();
int i = patternLen -1 ,j = i;
//将对应好后缀与坏字符最后次在模式串出现的位置写入集合
HashMap<String, Integer> matchMap = getMatchIndexMap(patternString);
while (i < textLen){
char badCharacter;
int oldShiftLen = patternLen - j -1;//原来好的后缀最大长度,同时也记录了i 和 j 的前移动次数
if ((badCharacter = textString.charAt(i)) != patternString.charAt(j)){
//int badIndex = patternString.lastIndexOf(badCharacter); //这里效率会低下,所以我们使用hashMap的哈希表来优化下,避免每次都去查找
int badIndex = matchMap.getOrDefault(String.valueOf(badCharacter),-1);
int moveIndex = j - badIndex;
j += oldShiftLen;
i += oldShiftLen; //这两个注意,如果发生匹配失败,i 和 j 的索引还需要还原回去
int goodIndex = 0,lastShiftIndex = j;
if (oldShiftLen > 0 ){ //存在好的后缀
//检查每个后缀字符串在模式串上次出现的位置,即寻找模式串的一个最长前缀, 目的是让该对应的前缀移动到好后缀的位置
while (oldShiftLen >= 0){
goodIndex = matchMap.getOrDefault(patternString.substring(j - oldShiftLen+1),-1);
//好后缀在就无需再次找了,因为后面都是好后缀长度比当前好后缀短的
if (goodIndex > -1 ){
//获得好后缀对应的索引位置
lastShiftIndex = j - oldShiftLen+1 ;
break;
}
oldShiftLen--;
}
moveIndex = Math.max(moveIndex,lastShiftIndex - goodIndex);
}
i = i + moveIndex ;
}
else {
i--;
j--;
}
//判断首字符与母串对应字符是否相等,为什么要加此条件?因为索引比模式串长度小1,当索引为0的时候 j =0 对应的字符还没有和母串匹配就被返回,这是不正确的
if (j == 0 && patternString.charAt(0) == textString.charAt(i)) {
return i ;
}
}
return -1;
}