字符串匹配算法--BM算法
参考:
《极客时间》
阮一峰老师的BM算法
模式串:要查找的字符串
主串:查找范围
基本概念
Boyer-Moore字符串搜索算法是一种高效的字符串搜索算法,是实用字符串搜索文献的标准基准。于1977年由Robert S. Boyer和J Strother Moore开发。算法预处理要搜索的字符串(模式),但不预处理要搜索的字符串(文本)。因此,它非常适合于模式串比文本短得多的应用程序,或者模式串在多个搜索中存在多次的应用程序。
Boyer-Moore算法使用预处理步骤中收集的信息来跳过文本的部分,这使得常量因子比许多其他字符串搜索算法更低。通常,随着模式长度的增加,算法运行得更快。该算法的关键特征是匹配模式的尾部而不是头部,并以多个字符的跳跃方式沿文本跳跃,而不是搜索文本中的每个字符。参考维基
在BF算法那一篇文章提到,BF算法将模式串向后滑动,每次滑动一位就和主串对应的部分比较,不匹配则继续向后移动一位,直到移动到最后一个子串或者匹配成功为止:
主串 b a d e f c
d e f 不匹配,后移一位
d e f 不匹配,后移一位
d e f 匹配成功,退出算法
而BM算法则采用一定的规则将模式串一次性多滑动几位,减少比较次数,从而提高匹配的效率。
BM算法原理分析
BM算法包括两部分,分别是坏字符规则(The Bad Character Rule)和好后缀规则(The Good Suffix Rule)。
注意:该算法中模式串是从末尾倒着匹配的。
坏字符规则
坏字符规则考虑子串中比较过程失败的字符(假设发生了这样的失败)。找到该字符在模式串中的下一个位置,并将该位置与子串中的不匹配位置进行移位。如果在模式串中没有出现不匹配字符(坏字符),则进行整个模式串的移位,使整个模式串经过不匹配点。参考维基
听着是不是很懵?下面用图来解释。
我们将没有匹配的字符称为坏字符。
拿坏字符c在模式串中查找,发现模式串中并没有出现字符c,此时可以放心地将模式串向后移动3位。
向后移动3位后继续比较,发现模式串的最后一位仍然与子串的最后一位不同,此时不应该直接向后移动3位。直接看两个字符串,我们可以知道,此时只要向后移动两位就可以匹配成功了。那么,我们怎么决定每次移动多少位呢?
坏字符规则的移位规则
当发生不匹配的时候,我们将坏字符对应的模式串中的字符记做si。如果坏字符在模式串中存在,我们将这个坏字符在模式串中的下标记为xi。如果不存在xi记为-1.
即模式串向后移动的位数为:si-xi
注意:
1、这里的下标都是模式串的下标
2、如果坏字符在模式串中多处出现,则xi为按照匹配顺序从右到左第一个出现的位置。(这样不会让模式串滑动过多,导致错过可能的匹配)
坏字符规则在最好情况下的时间复杂度为O(n/m).
好后缀规则
为什么只使用坏字符规则不能比较好地解决匹配问题?
考虑主串为aaaaaaaaa,模式串为baaa,那么,此时si 为0,xi为3,此时si-xi为-3,不但没有向后滑动模式串,还出现了倒退现象。所以需要使用好后缀规则。
从尾部开始匹配,在遇到坏字符之前的部分字符组合称为好后缀。即:
好后缀规则的移位规则
我们将已经匹配的字符串记为{s},如果在模式串中找到一个和它相同的字符串,则记为{s*},找到之后则将模式串的{s*}移动到与{s}相同的位置。
如果找不到该字符串,则说明该后缀在模式串不会再有匹配的机会了,那么直接向后移动m位。(m为模式串的长度)。这种做法其实也会导致一些可能错过的匹配情况。
如图所示:
如果好后缀在模式串中不存在可匹配的子串,那我们在一步一步向后滑动模式串的过程中,只要主串中的{s}与模式串有重合,那肯定就无法完全重合。如果不能理解这一点,我们可以用反证法来解释:
如果主串中的{s}与模式串有重合,那么在好后缀之前的模式串中一定存在可匹配的子串,也就是说假设不成立,也就是说–如果好后缀在模式串中不存在可匹配的子串,那么主串中的{s}不与模式串有重合。
当模式串滑动到前缀与主串中的{s}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能存在完全匹配的情况。
也就是说我们现在应该在好后缀的后缀中找到一个最长的并且能和模式串的前缀子串匹配,假设是{w},此时将模式串移动到{w}的位置即可。
什么时候用好后缀规则和坏字符规则?
每次移位之前分别计算好后缀规则和坏字符规则向后滑动的位数,取两个数中最大的那个数值作为模式串向后移动的位数。
这样可以避免只使用坏字符规则可能出现移动位数为负数的情况。
BM算法代码实现
使用散列表将模式串中的每个字符及其下标都存到散列表中,这样就可以快速找到坏字符在模式串中的位置下标。
-
坏字符规则的核心:
1、使用散列表,数组的下标对应字符的ASCII码值,数组中存储这个字符在模式串中出现的位置。
2、si-xi计算得到的移动位数可能会出现负数的情况。 -
好后缀规则的核心:
1、在模式串中,查找跟好后缀匹配的另一个子串。
2、在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串。
使用非暴力的匹配方式找到好后缀规则中模式串的最优前缀子串,由于好后缀也是模式串的后缀子串,那么我们可以在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。
如何表示模式串中不同的后缀子串:由于后缀子串中的最后一个字符的位置是固定的,下标位m-1,故我们只需要记录长度就可以了。通过长度,我们可以确定一个唯一的后缀子串。
数据结构
- 引入siffix数组,下标表示后缀子串的长度,对应的值是在模式串中跟好后缀{s}相匹配的子串{s*}的起始下标值:
假设
好后缀: bc
模式串: c a b c a b
-------------0 1 2 3 4 5
后缀子串 | 长度 | siffix |
---|---|---|
b | 1 | siffix[1]=2 |
ab | 2 | siffix[2]=1 |
cab | 3 | siffix[3]=0 |
bcab | 4 | siffix[4]=-1 |
abcab | 5 | siffix[5]=-1 |
如果不能理解,可以看这个图:
- 此时要注意,好后缀规则除了要在模式串中,找到跟好后缀匹配的另一个子串,这个子串还要符合最长匹配原则,加入prefix数组来记录模式串的后缀子串是否能匹配模式串的前缀子串。
后缀子串 | 长度 | siffix | prefix |
---|---|---|---|
b | 1 | siffix[1]=2 | prefix[1]=false |
ab | 2 | siffix[2]=1 | prefix[2]=false |
cab | 3 | siffix[3]=0 | prefix[3]=true |
bcab | 4 | siffix[4]=-1 | prefix[4]=false |
abcab | 5 | siffix[5]=-1 | prefix[5]=false |
注意,b ab 都不是模式串的前缀子串。
如何填充siffix、prefix这两个数组的值?
可以这么理解:
当模式串为 c a b c a b的时候,我就是要找c a b c a b的前缀组合即{‘c’, ‘c a’, ‘c a b’ ,‘c a b c’, ‘c a b c a’}与 c a b c a b的后缀组合即{‘a b c a b’,‘b c a b’,‘c a b’,‘a b’,‘b’}求公共最长的元素。
代码如下:
/*
b: 模式串
m:长度
suffix:在模式串中跟好后缀{s}相匹配的子串{s*}的起始下标值
prefix:记录模式串的后缀子串是否能匹配模式串的前缀子串
*/
function generateGS(b , m , suffix,prefix) {
for (let i = 0; i < m ;++i) { // 初始化
suffix[i] = 0;
prefix[i] = 0;
}
// 从前缀的后面开始匹配的好处是:逻辑清晰、减少prefix的赋值出错
for (let i = 0; i < m-1; ++i) {
let j = i;
let k = 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; // 如果公共后缀子串也是模式串的前缀子串
}
}
}
根据siffix、prefix数组确定模式串向后滑动的位数
假设好后缀的长度是k,我们先拿好后缀,在siffix数组中查找其匹配的子串。如果siffix[x]不等于-1(-1表示不存在匹配的子串),那我们就将模式串向后移动j-siffix[x]+1位(j表示坏字符对应的字符下标)。如果siffix[x]等于-1,表示模式串中不存在另一个跟好后缀匹配的子串片段。
如图:
好后缀的后缀子串b[r,m-1](其中,r取值从j+2到m-1)的长度k=m-r。如果prefix[k]等于true,表示长度为k的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移r位。
如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移m位。
完整代码:
const SIZE = 256;
/*
b: 模式串
m:模式串的长度
bc:坏字符对应的散列表
*/
function generateBC(b,m,bc) {
for (let i = 0; i < SIZE; ++i) {
bc[i] = -1; // 初始化bc
}
for (let i = 0; i < m; ++i) {
let acscii = b[i].charCodeAt(); // 将字符的ASCII作为数组的下标,数组存储对应的哈希值
bc[acscii] = i;
}
}
/*
b: 模式串
m:模式串的长度
suffix:在模式串中跟好后缀{s}相匹配的子串{s*}的起始下标值
prefix:记录模式串的后缀子串是否能匹配模式串的前缀子串
*/
function generateGS(b , m , suffix,prefix) {
for (let i = 0; i < m ;++i) { // 初始化
suffix[i] = 0;
prefix[i] = 0;
}
for (let i = 0; i < m-1; ++i) {
let j = i;
let k = 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表示主串和模式串的长度
*/
function bm(a,n,b,m) {
let bc = new Array(SIZE); // 记录模式串中每个字符最后出现的位置
generateBC(b,m,bc);
let suffix = new Array(m);
let prefix = new Array(m);
generateGS(b,m,suffix,prefix);
let i = 0;
while (i <= n-m) { // n-m 为比较次数或者说是移动的最多位数
let j;
for (j = m-1 ;j >= 0; --j) { // 模式串从后往前匹配
if (a[i+j] != b[j]) break; // 坏字符对应模式串的下标是j
}
if (j < 0) {
return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
}
let x = j-bc[a[i+j].charCodeAt()]; // x为坏字符规则应该移动的位数
let y = 0;
if (j < m-1) {
y = moveByGS(j,m,suffix,prefix);
}
i = i + Math.max(x,y);
}
return -1;
}
/*
j:表示坏字符的下标
m:模式串的长度
*/
function moveByGS(j,m,suffix,prefix) {
let k = m - 1 - j;// 好后缀长度
if (suffix[k] != -1) return j - suffix[k] + 1;
for (let r = j + 2; r <= m-1; ++r) {
if (prefix[m-r] == true) {
return r;
}
}
return m;
}
BM算法的性能分析
整个算法用到了额外的三个数组:bc数组、suffix数组、prefix数组。
如果处理字符集比较大的字符串比较问题,也就是说bc数组作为散列表的存储结构对内存的消耗会更多。
由于好后缀规则和坏字符规则是独立的,如果运行的环境对内存比较苛刻,可以只用好后缀规则,不使用坏字符规则,只不过这样BM算法的效率会下降一些。
可以参考其他文章学习,BM及其改进算法性能对比研究
总结
BM算法的核心思想就是,在模式串中某个字符不能和主串想匹配的时候,将模式串往后多滑动几位,以此来避免不必要的字符比较,提高匹配的效率。
BM算法的规则有两类:坏字符规则和好后缀规则。好后缀规则可以独立于坏字符规则使用,因为坏字符规则比较耗内存,为了节省内存,我们可以只用好后缀规则来实现BM算法。