下面引用阮一峰先生在《字符串匹配的Boyer-Moore算法》博文中的图片进行分析讲解
背景
在计算机科学中,Boyer-Moore字符串搜索算法是一种非常高效的字符串搜索算法。它由德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授设计于1977年。此算法仅对搜索目标字符串(关键字)进行预处理,而非被搜索的字符串。
虽然Boyer-Moore算法的执行时间同样线性依赖于被搜索字符串的大小,但是通常仅为其它算法的一小部分:它不需要对被搜索的字符串中的字符进行逐一比较,而会跳过其中某些部分。通常搜索关键字越长,算法速度越快。它的效率来自于这样的事实:对于每一次失败的匹配尝试,算法都能够使用这些信息来排除尽可能多的无法匹配的位置。
坏字符(BM_BC)
下面,我根据Moore教授自己的例子来解释这种算法。
假定字符串为"HERE IS A SIMPLE EXAMPLE",搜索词为"EXAMPLE"。
首先,"字符串"与"搜索词"头部对齐,从尾部开始比较。
这是一个很聪明的想法,因为如果尾部字符不匹配,那么只要一次比较,就可以知道前7个字符(整体上)肯定不是要找的结果。
我们看到,"S"与"E"不匹配。这时,"S"就被称为"坏字符"(bad character),即不匹配的字符。我们还发现,"S"不包含在搜索词"EXAMPLE"之中,这意味着可以把搜索词直接移到"S"的后一位。
依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在搜索词"EXAMPLE"之中。所以,将搜索词后移两位,两个"P"对齐。
我们由此总结出"坏字符规则":
后移位数 = 坏字符的位置 - 搜索词中的上一次出现位置
如果"坏字符"不包含在搜索词之中,则上一次出现位置为 -1。
以"P"为例,它作为"坏字符",出现在搜索词的第6位(从0开始编号),在搜索词中的上一次出现位置为4,所以后移 6 - 4 = 2位。再以前面第二步的"S"为例,它出现在第6位,上一次出现位置是 -1(即未出现),则整个搜索词后移 6 - (-1) = 7位。
依然从尾部开始比较,"E"与"E"匹配。
比较前面一位,"LE"与"LE"匹配。
比较前面一位,"MPLE"与"MPLE"匹配。我们把这种情况称为"好后缀"(good suffix),即所有尾部匹配的字符串。注意,"MPLE"、"PLE"、"LE"、"E"都是好后缀。
比较前一位,发现"I"与"A"不匹配。所以,"I"是"坏字符"。
根据"坏字符规则",此时搜索词应该后移 2 - (-1)= 3 位。问题是,此时有没有更好的移法?
好字符(BM_GS)
回退到上上张图,我们发现,此时存在"好后缀"。所以,可以采用"好后缀规则":
后移位数 = 好后缀的位置 - 搜索词中的上一次出现位置
举例来说,如果字符串"ABCDAB"的后一个"AB"是"好后缀"。那么它的位置是5(从0开始计算,取最后的"B"的值),在"搜索词中的上一次出现位置"是1(第一个"B"的位置),所以后移 5 - 1 = 4位,前一个"AB"移到后一个"AB"的位置。
再举一个例子,如果字符串"ABCDEF"的"EF"是好后缀,则"EF"的位置是5 ,上一次出现的位置是 -1(即未出现),所以后移 5 - (-1) = 6位,即整个字符串移到"F"的后一位。
这个规则有三个注意点:
(1)"好后缀"的位置以最后一个字符为准。假定"ABCDEF"的"EF"是好后缀,则它的位置以"F"为准,即5(从0开始计算)。
(2)如果"好后缀"在搜索词中只出现一次,则它的上一次出现位置为 -1。比如,"EF"在"ABCDEF"之中只出现一次,则它的上一次出现位置为-1(即未出现)。
(3)如果"好后缀"有多个,则除了最长的那个"好后缀",其他"好后缀"的上一次出现位置必须在头部。比如,假定"BABCDAB"的"好后缀"是"DAB"、"AB"、"B",请问这时"好后缀"的上一次出现位置是什么?回答是,此时采用的好后缀是"B",它的上一次出现位置是头部,即第0位。这个规则也可以这样表达:如果最长的那个"好后缀"只出现一次,则可以把搜索词改写成如下形式进行位置计算"(DA)BABCDAB",即虚拟加入最前面的"DA"。
回到上文的这个例子。此时,所有的"好后缀"(MPLE、PLE、LE、E)之中,只有"E"在"EXAMPLE"还出现在头部,所以后移 6 - 0 = 6位。
可以看到,"坏字符规则"只能移3位,"好后缀规则"可以移6位。所以,Boyer-Moore算法的基本思想是,每次后移这两个规则之中的较大值。
更巧妙的是,这两个规则的移动位数,只与搜索词有关,与原字符串无关。因此,可以预先计算生成《坏字符规则表》和《好后缀规则表》。使用时,只要查表比较一下就可以了
继续从尾部开始比较,"P"与"E"不匹配,因此"P"是"坏字符"。根据"坏字符规则",后移 6 - 4 = 2位。
从尾部开始逐位比较,发现全部匹配,于是搜索结束。如果还要继续查找(即找出全部匹配),则根据"好后缀规则",后移 6 - 0 = 6位,即头部的"E"移到尾部的"E"的位置。
BM算法总体框架
int match ( char* P, char* T ) { //Boyer-Morre算法(完全版,兼顾Bad Character与Good Suffix)
int* bc = buildBC ( P ); int* gs = buildGS ( P ); //构造BC表和GS表
size_t i = 0; //模式串相对于文本串的起始位置(初始时与文本串左对齐)
while ( strlen ( T ) >= i + strlen ( P ) ) { //不断右移(距离可能不止一个字符)模式串
int j = strlen ( P ) - 1; //从模式串最末尾的字符开始
while ( P[j] == T[i + j] ) //自右向左比对
if ( 0 > --j ) break;
/*DSA*/showProgress ( T, P, i, j ); printf ( "\n" ); getchar();
if ( 0 > j ) //若极大匹配后缀 == 整个模式串(说明已经完全匹配)
break; //返回匹配位置
else //否则,适当地移动模式串
i += __max ( gs[j], j - bc[ T[i + j] ] ); //位移量根据BC表和GS表选择大者
}
delete [] gs; delete [] bc; //销毁GS表和BC表
return i;
}
《坏字符规则表》
这里我们把所有模式串(上面的EXAMPLE)中的所有字符根据其出现的次数,将其ASCII码对应的下标存放到坏字符规则表(以数组形式进行保存)
int* buildBC ( char* P ) { //构造Bad Charactor Shift表:O(m + 256)
int* bc = new int[256]; //BC表,与字符表等长,这里仅设置为256个字符
for ( size_t j = 0; j < 256; j ++ ) bc[j] = -1; //初始化:首先假设所有字符均未在P中出现
for ( size_t m = strlen ( P ), j = 0; j < m; j ++ ) //自左向右扫描模式串P
bc[ P[j] ] = j; //将字符P[j]的BC项更新为j(单调递增)——画家算法
/*DSA*/printBC ( bc );
return bc;
}
void printBC ( int* bc ) {
printf ( "\n-- bc[] Table ---------------\n" );
for ( size_t j = 0; j < 256; j++ ) if ( 0 <= bc[j] ) printf ( "%4c", ( char ) j ); printf ( "\n" );
for ( size_t j = 0; j < 256; j++ ) if ( 0 <= bc[j] ) printf ( "%4d", bc[j] ); printf ( "\n\n" );
}
其中字符‘E’在秩为0和6处出现了两次,bc['E']取其中最大者6(在主框架中 j - bc[ T[i + j] ]位移量则相对较小)。在字符串中从未出现的字符,对应的BC表统一取作-1,等效指向在字符串最左端假想着增添的通配符。
rank | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
p[] | * | E | X | A | M | P | L | E |
这里rank引入哨兵节点(-1),防止模式串在最左侧不匹配而可能造成的字符串整体左移操作。而我们默认让它右移一格。
char | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S | T | U | V | W | X | Y | Z |
bc[] | 2 | -1 | -1 | -1 | 6 | -1 | -1 | -1 | -1 | -1 | -1 | 5 | 3 | -1 | -1 | 4 | -1 | -1 | -1 | -1 | -1 | -1 | -1 | 1 | -1 | -1 |
我们发现利用坏字符的方式简而言之可以称作借鉴教训,我们每次出错的地方来获取正确的值。但是,之前我们可以匹配的地方存在的信息却被我们忽略了。因此,我们也需要善用经验。
《好后缀规则表》
ss[]表
如图11.14所示,对于任一整数j∈[0,m),在P[0,j]的所有后缀中,考察那些与P的某一后缀匹配者。若将其中的最长者记作MS[j],则 ss[j] 就是该串的长度 | MS[j] | 。特别地,当MS[j]不存在时,ss[j]=0。
综上所述,可定义ss[j]如下:
ss[j] = max{ 0 ≤ s ≤ j+1 | P(j-s,j] = P[m-s,m) }
特别地,当j=m-1时,必有s=m——此时,有P(-1,m-1] = P[0,m)。
ss表的构建
int* buildSS ( char* P ) { //构造最大匹配后缀长度表:O(m)
int m = strlen ( P ); int* ss = new int[m]; //Suffix Size表
ss[m - 1] = m; //对最后一个字符而言,与之匹配的最长后缀就是整个P串
// 以下,从倒数第二个字符起自右向左扫描P,依次计算出ss[]其余各项
for ( int lo = m - 1, hi = m - 1, j = lo - 1; j >= 0; j -- )
if ( ( lo < j ) && ( ss[m - hi + j - 1] <= j - lo ) ) //情况一
ss[j] = ss[m - hi + j - 1]; //直接利用此前已计算出的ss[]
else { //情况二
hi = j; lo = __min ( lo, hi );
while ( ( 0 <= lo ) && ( P[lo] == P[m - hi + lo - 1] ) ) //二重循环?
lo--; //逐个对比处于(lo, hi]前端的字符
ss[j] = hi - lo;
}
/*DSA*/printf ( "-- ss[] Table -------\n" );
/*DSA*/for ( int i = 0; i < m; i ++ ) printf ( "%4d", i ); printf ( "\n" );
/*DSA*/printString ( P ); printf ( "\n" );
/*DSA*/for ( int i = 0; i < m; i ++ ) printf ( "%4d", ss[i] ); printf ( "\n\n" );
return ss;
}
实例:
比如下图,其中ss[8]=4,是因为若取j=8和s=4,则有
P(8-4,8] = p (4,8] = "RICE" = P[11,15) = P[15-4,15)
由ss[]表构造gs[]表分为两种情况:
①:第一种情况如下图11.15(a)所示,该位置j满足:ss[j] = j+1 ;也就是说,MS[j](最长匹配字符)就是整个前缀p[0,j]。此时,对于p[m-j-1]左侧的每个字符p[i]而言,对应于图11.12(d)所示的情况,m-j-1都应该是gs[i]取值的一个候选。
②:第二种情况如下图11.15(b)所示,该位置j满足:ss[j] ≤ j ; 也就是说,MS[j]只是P[0,j]的一个真后缀。同时,既然MS[j]是极长的,故必有:P[ m -ss[j] -1 ] ≠ P[ j-ss[j] ] 。这就意味着,此时的字符P[ m-ss[j]-1 ] 恰好对应于图11.12(c)所示的情况,因此m-j-1 也应是 gs[ m-ss[j]-1 ]取值的一个候选。
int* buildGS ( char* P ) { //构造好后缀位移量表:O(m)
int* ss = buildSS ( P ); //Suffix Size table
size_t m = strlen ( P ); int* gs = new int[m]; //Good Suffix shift table
for ( size_t j = 0; j < m; j ++ ) gs[j] = m; //初始化
for ( size_t i = 0, j = m - 1; j < UINT_MAX; j -- ) //逆向逐一扫描各字符P[j]
if ( j + 1 == ss[j] ) //1. 若P[0, j] = P[m - j - 1, m),则
while ( i < m - j - 1 ) //对于P[m - j - 1]左侧的每个字符P[i]而言(二重循环?)
gs[i++] = m - j - 1; //m - j - 1都是gs[i]的一种选择
for ( size_t j = 0; j < m - 1; j ++ ) //画家算法:正向扫描P[]各字符,gs[j]不断递减,直至最小
gs[m - ss[j] - 1] = m - j - 1; //2. m - j - 1必是其gs[m - ss[j] - 1]值的一种选择
/*DSA*/printGS ( P, gs );
delete [] ss; return gs;
}
参考文章:字符串匹配的Boyer-Moore算法、邓俊辉《数据结构/c++语言版》