数据结构与算法之美(笔记18)字符串匹配:BM算法

BM算法的核心思想

我们把模式串和主串的匹配过程,看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时,BF算法和RK算法的做法是,模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。

在这个例子中,主串中的c在模式串中是不存在的,所以,模式串向后滑动的时候,只要c与模式串有重合,肯定无法匹配。所以,我们可以一次性把模式串往后移动几位,把模式串移动到c的后面。

 

今天要讲的BM算法,本质上就是在寻找这种规律。在模式串与主串匹配的过程,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的过程,将模式串往后多滑动几位。

BM算法原理分析

1.坏字符规则

前面讲的匹配算法 ,在匹配的过程中,我们都是按照模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的。这种匹配顺序比较符合我们的思维习惯,而BM算法的匹配顺序比较特别,它是按照模式串下标从大到下的顺序,倒这匹配的。

我们把模式串的末尾往前倒匹配,当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符。(主串中的字符)。

 

当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作xi。如果xi不存在,我们把xi记作-1。那模式串往后移动的位数就等于si -xi 。(注意,这里说的下标,都是字符在模式串的下标)。

这里要特别注意,如果坏字符在模式串中多处出现,那我们在计算xi的时候,选择靠后的那个 ,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。

利用坏字符规则,BM算法在最好情况下的时间复杂度非常低,是O(n/m)。比如,主串是aaabaaabaaabaaab,模式串是aaaa。每次比对,模式串都可以直接后移4位。所以,匹配具有类似特点的模式串和主串的时候,BM算法非常有效。

不过,单纯使用坏字符规则是不够的,因为根据si-xi计算出来的移动位数,有可能是负数,比如主串是aaaaaaaaaaaaaaa,模式串是baaa,这样不会向后滑动模式串,还有可能倒退。所以还需要“好后缀规则” 。

2.好后缀规则

当模式串滑动到图中的位置的时候,模式串和主串有2个字符是匹配的,倒数第3个字符发生了不匹配的情况。

我们把已经匹配的bc叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*},那么我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置。

 

如果在模式串中找不到另一个等于{u}的子串, 我们就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况。

不过,这样的滑动是否有点过头?这里面bc是好后缀,尽管在模式串中没有另外一个相匹配的子串{u*},但是如果我们将模式串移动到好后缀的后面,如图,就会错过完全匹配的情况。

如果好后缀在模式串中不存在可匹配的子串,那我们一步一步 往后滑动模式串的过程中,只要主串中的{u}与模式串有重合,那肯定无法完全匹配。但是当模式串滑动到前缀与主串中{u}的后缀部分重合的时候,并且有重合的部分相等的时候,就有可能会存在完全匹配的情况。

所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。

 

那么,当模式串和主串中的某个字符不匹配的时候,如何选择用好后缀规则或者是坏字符规则来计算模式串往后滑动的位数?

我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。

BM算法代码实现

“坏字符”规则不难理解,当遇到坏字符的时候,要计算往后移动的位数si - xi,其中xi的计算是重点。如果我们拿坏字符在模式串中顺序遍历查找,这样就会比较低效。对于这个查找操作,我们使用散列表。我们可以将模式串中的每个字符及其下标都存到散列表中。

关于这个散列表,我们只实现一种最简单的情况,假设字符串的字符集不是很大,每个字符长度是1字节,我们用大小为256的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的ASCII码值。数组中存储这个字符在模式串中出现的位置。并且,如果在模式串出现多个匹配的坏字符,储存的是靠后的那个。

 

这里给出代码的实现:

#define MAX_SIZE 256
void gennerateBC(char* c,int size,int* hash_arr){
    for(int i=0;i<MAX_SIZE;++i){
        hash_arr[i] = -1;
    }

    for(int i=0;i<size;++i){
        int ascii = (int)c[i];
        hash_arr[ascii] = i;
    }
}

 掌握了坏字符规则之后,我们先把BM算法代码的大框架写好,先不考虑好后缀规则,仅用坏字符规则,并且不考虑si -xi计算得到的移动位数可能会出现负数的情况。代码中注释非常详细。

int bm(char* main_s,int main_size,char* patten_s,int patten_size){
    int* hash_arr = new int[MAX_SIZE];// 创建一个哈希表
    gennerateBC(patten_s,patten_size,hash_arr);// 构建坏字符哈希表
    int i=0;// 表示模式串和主串对齐的第一个字符
    while(i <= main_size-patten_size){
        int j;
        for(j=patten_size-1;j>=0;--j){// 模式串从后面进行比较
            if(patten_s[j] != main_s[i+j]){// 坏字符对应模式串中的下标是j
                break;
            }
        }
        if(j = -1){
            return i;// 匹配成功,返回主串与模式串第一个匹配的字符的位置
        }
        int bad_s = main_s[i+j];// 得到主串中与模式串不一样的坏字符
        int pos_in_patten = hash_arr[(int)bad_s]; // 通过哈希表得到bad_s在模式串中的位置
        int si_xi = j - pos_in_patten;// 得到移动的位数
        i = i+si_xi;// 模式串向后移动
    }
    return -1;
}

至此, 我们已经实现了包含了坏字符规则的框架代码,只剩下往框架代码中填写好后缀规则了。好后缀的处理规则中最核心的内容:

  • 在模式串中,查找跟好后缀匹配的另一个子串。
  • 在好后缀的后缀子串中,查找最长的能跟模式前缀子串匹配的后缀子串。

在不考虑效率的情况下,这两个操作都可以用很“暴力”的匹配查找方式解决。但是,如果想要BM算法的效率很高,这部分就不能太低效。如何来做?

因为好后缀也是模式串本身的后缀子串,所以,我们可以在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配的子串的位置。

我们先来看,如何表示模式串中不同的后缀子串呢?因为后缀子串的最后一个字符的位置是固定的,下标为m-1,我们只需要记录长度就可以了。通过长度,我们可以确定一个唯一的后缀子串。

现在,我们引入最关键的suffix数组,suffix数组的下标k,表示后缀子串的长度,下标对应的数组值存储的是,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值。举个例子:

 

但是,如果模式串有多个(大于1个)子串跟后缀子串{u}匹配,那suffix数组应该储存模式串中最靠后的那个子串的起始位置,因为为了避免模式串往后滑动得过头了。

不过,这样处理就足够了吗?实际上,仅仅是选靠后的子串片段来存储是不够的。我们再回忆一下好后缀规则。

我们不仅要在模式串中,查找跟好后缀匹配的另一个子串,还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。

如果我们只记录刚刚定义的suffix数组,实际上,只能处理规则的前半部分,也就是,在模式串中,查找跟好后缀匹配的另一个子串。所以,除了suffix数组,我们还需要一个bool类型的prefix数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。

 

那么,如果来填充这两个数组呢?

我们拿下标从0到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是k,那我们就记录suffix[k] = j (j 表示公共后缀子串的起始下标)。如果 j 等于0,也就是说,公共后缀子串也是模式串的前缀子串,我们就记录prefix [k] = true。

这里给出代码的实现:

void gennerateGC(char* s,int size,int* suffix,bool* prefix){
    // 对两个数组初始化
    for(int i=0;i<size;++i){
        suffix[i] = -1;
        prefix[i] = false;
    }

    for(int i=0;i<=size-2;++i){// b[0,i],i 的范围为0到 size-2
        int j = i;// 每一个模式串的子串的最后一位
        int k = 0;// 公共后缀子串长度
        while(j >=0 && s[j] == s[size-1-k]){// b[0,m-1]求公共后缀子串
            ++k;// 每次相等长度加1
            suffix[k] = j;// j表示公共后缀子串在b[0,i]中的起始下标
            --j;// 下一位比较
        }
        if(j == -1){
            prefix[k] = true;// 如果公共后缀子串也是模式串的前缀子串
        }
    }
}

有了这两个数组之后,在模式串跟主串匹配的过程中,遇到不能匹配的字符串时,如何根据好后缀规则,计算模式串往后滑动的位数?

假设好后缀的长度是k。我们先拿好后缀,在suffix数组中查找其匹配的子串。如果suffix[k] 不等于-1,那我们就将模式串往后移动j - suffix[k] +1 位(j表示坏字符对应的模式串中的字符下标)。 

如果suffix[k] = -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。我们可以用下面这条规则来处理。

好后缀的后缀子串b[r,m-1](其中,r取值从j+2 到 m-1)的长度为 k = m-r,如果prefix[k] = true,表示长度为K的后缀子串,有可以匹配的前缀子串,这样我们可以把模式串后移r位。

 

如果两个规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移 m 位。 

至此,好后缀的代码实现也讲完了。这里给出完整的代码实现:

int moveByGC(int j,int patten_size,int* suffix, bool* prefix){
    int k = patten_size -1 -j;// 好后缀的长度
    if(suffix[k] != -1) return j - suffix[k] +1;// 在模式串中找到好后置,返回移动长度

    for(int r = j+2;r<=patten_size-1;++r){// r 从j+2开始
        if(prefix[patten_size-r] == true){
            return r;
        }
    }
    return patten_size;
}

int bm(char* main_s,int main_size,char* patten_s,int patten_size){
    int* hash_arr = new int[MAX_SIZE];// 创建一个哈希表
    gennerateBC(patten_s,patten_size,hash_arr);// 构建坏字符哈希表
    int* suffix = new int[patten_size];
    bool* prefix = new bool[patten_size];
    gennerateGC(patten_s,patten_size,suffix,prefix);// 预处理后缀子串

    int i=0;// 表示模式串和主串对齐的第一个字符
    while(i <= main_size-patten_size){
        int j;
        for(j=patten_size-1;j>=0;--j){// 模式串从后面进行比较
            if(patten_s[j] != main_s[i+j]){// 坏字符对应模式串中的下标是j
                break;
            }
        }
        if(j = -1){
            return i;// 匹配成功,返回主串与模式串第一个匹配的字符的位置
        }

        int bad_move = j - hash_arr[(int)main_s[i+j]];
        int good_move = 0;
        if(j<patten_size-1){// 如果有好后缀
            good_move = moveByGC(j,patten_size,suffix,prefix);
        }

        i = i+ max(bad_move,good_move);
    }
    return -1;
}

BM算法性能分析以及优化?

空间复杂度:整个算法用到了额外的3个数组,其中hash_arr数组的大小跟字符集大小有关,suffix数组和prefix数组的大小跟模式串的长度m有关。

如果我们处理字符集很大的字符串匹配问题,hash_arr数组对内存的消耗会比较多。因为好后缀和坏字符规则是独立的,如果我们运行的环境堆内存要求苛刻,我们可以只使用好后缀规则,不使用坏字符规则,这样就可以避hash_arr数组过多的内存消耗。不过,单纯使用好后缀规则的BM算法效率就会下降一些了。

实际上,BM算法的时间复杂度分析起来是非常复杂的,有论文证明了在最坏的情况下,BM算法的比较次数上限是3n。

猜你喜欢

转载自blog.csdn.net/weixin_42073553/article/details/88908640
今日推荐