字符串匹配的Boyer-Moore算法(BM算法)

下面引用阮一峰先生在《字符串匹配的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++语言版》

发布了119 篇原创文章 · 获赞 152 · 访问量 25万+

猜你喜欢

转载自blog.csdn.net/weixin_40539125/article/details/99227222