单模式字符串匹配算法(BM、KMP)

在字符串A中查找字符串B,那么管字符串A叫主串,字符串B叫模式串。主串长度m > 模式串长度n。

  • BF算法和RK算法

       BF算法:暴力匹配算法(或者叫朴素匹配算法),我们在主串,检查起始位置分别是0, 1, 2, 3 ... n-m 且长度是m 的n-m+1个子串,看有没有跟模式串匹配。在极端情况下,每次比对 m 个字符,共比对 n-m+1 次,所以最坏情况时间复杂度是O(n*m)。所以时间复杂度是极高的,但是实际开发中却较为常用,原因有两点,一是,实际开发中,字符长度不会太长,而且每次模式串与主串匹配的时候,中途就会停止,不会匹配m次,所以统计意义上的,大部分情况的执行都要比O(n*m)高很多;二是这种匹配算法,思想简单,实现简单,在工程中,在满足性能要求条件下,简单是首选。

       RK算法:借助哈希算法对BF算法进行改造,对主串的每个子串求哈希值,然后拿子串哈希值和模式串哈希值进行比较,减少了比较时间,所以理想情况,RK算法时间复杂度是O(n),极端情况下,哈希算法大量冲突,时间复杂度退化为O(n*m)。

       这里只对这两种算法作简单介绍,重点在于阐释BM算法和KMP算法思想和代码实现。

  • BM算法

       核心思想:本质上就是寻找某种规律,在模式串和主串匹配过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。

       算法原理分析:坏字符规则和好后缀规则。

       坏字符规则:将模式串倒着匹配,当发现主串中某个字符不能跟模式串中对应字符匹配时,把这个主串中没有匹配的字符叫坏字符。如下图所示,我们拿坏字符 c 在模式串中查找,发现跟哪个字符都不匹配,就将模式串往后滑动三位,再从模式串末尾字符开始比较,这时我们发现模式串中字符 d 还是无法跟主串中 a 字符比较,但是这个时候 a 在模式串中是存在的,模式串下标是0的位置也是字符 a ,这个就是如果还是直接将模式串向后滑动3位,将会错过匹配的机会。所以,这时候我们只能将模式串往后滑动2位。

                                           

       我们可以这样计算滑动位数,把坏字符对应模式串中字符的下标是si,把坏字符对应模式串中相同字符下标记着xi,如果不存在,xi记作-1,如果出现多个相同字符,把最靠后字符所在下标作为xi值,那模式串往后移动的位数就是si - xi。选择最靠后的字符,可以防止模式串向后滑动过多,导致本来可能匹配情况被滑动略过。利用坏字符规则,BM算法在最坏情况的时间复杂度非常低,是O(n/m)。另外,si - xi还有可能出现负数,比如主串aaaaaaaaaaaa,模式串baaa,这时候模式串会出现倒退情况,这时候就要使用另外一种规则,“好后缀规则”。

                                           

        好后缀规则:如下图,模式串与主串有两个字符是匹配的,倒数第3个字符发生不匹配情况。这里,把已经匹配的 bc 叫做好后缀,记作{u}。我们拿它在模式串中查找,如果找到一个跟{u}相匹配的子串记作{u*},那我们就将模式串滑动到{u*}和{u}对齐的位置。

                                          

        如果找不到另一个等于{u}的子串,我们可以将将模式串滑动到{u}的后面,但是这种做法还是有问题的。看下图,bc 是好后缀,尽管在模式串中没有另外一个相匹配的子串{u*},但是如果直接将模式串滑动到{u}后边,就会错过匹配的情况。这里,当模式串滑动到前缀和主串中{u}的后缀有部分重合,且重合的部分相等的时候,是否存在是否存在就有可能出现完全匹配的情况。所以我们除了看在模式串中是否有另一个匹配的子串情况,我们还要考察好后缀的后缀子串是否存在跟没试穿的前缀子串匹配的。所谓后缀子串和前缀子串,可以看例子,如abc的后缀子串就包括 c,bc;前缀子串包括a,ab。

                                               

       针对刚刚述说的情况,我们从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是{v},然后将模式串滑动到如图位置。综上,当 模式串中出现跟主串某个字符不匹配的情况,如何选择坏字符规则还是好后缀规则?我们可以分别计算两种情况下,需要滑动的位数,选择较大的情况,这样可以避免前面提到的坏字符规则出现负数的情况。

                                        

       BM算法实现: 

       坏字符规则:如果我们拿坏字符,在模式串中顺序遍历查找会比较低效,这里可以使用散列表,可以将模式串中的字符及下  标存到散列表中,这里用到最简单的情况,将设字符串的字符集不是很大,我们用大小256bytes的数组,数组的下标对应字        符的ASCII码值,数组中存储的是模式串字符的位置。具体实现见代码中generateBC()函数。

       好后缀规则:先总结一下上面关于好后缀规则的操作,在模式串中查找跟好后缀匹配的另一个子串;在好后缀的后缀子串中找到最长且能与模式串的前缀子串匹配的后缀子串。在不考虑效率的情况下可以使用“暴力“”匹配的查找方式解决,但是这里要想整个BM算法比较高效,这部分的不能太低效。因为好后缀也是模式串的后缀子串,我们可以预处理模式串,找出模式串的每一个后缀子串对应的,匹配得上的另一个子串的位置。

       模式串的每一个后缀子串的最后一个字符都是确定的,下标是m-1(模式串长度为m),因此可以用长度唯一确定一个后缀子串,这里引入 suffix[ ]数组,数组下标就是后缀子串的长度,数组中存储的值表示能与该后缀子串匹配的另一个子串的起始下标的值。如果这里有多个子串与该后缀子串匹配,则这里只需记录最后一个子串的起始位置,也就是起始下标最大的那个子串。

        上面只实现了前半部分操作,这里再引入另一个bool类型的 prefix[ ]数组,记录后缀子串是否能与前缀子串匹配。这里我们可以拿下标从0到 i 的子串(i 可以是0到m-2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是k,那么可以记录suffix[k] = j(j 表示公共后缀子串在子串中的起始下标),如果j = 0, 那么说明模式串的后缀子串与前缀子串匹配,记录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 位。

  

代码实现:

#include <iostream>
#include <cstring>
using namespace std;

#define SIZE  256
//数组b表示模式串,m表示模式串长度,数组bc表示数组形式的散列表
void generateBC(char b[], int m, int bc[])
{
    for(int i=0; i<SIZE; i++)
    {
        bc[i] = -1;           //初始化散列表  
    }
    for(int i=0; i<m; i++)    //记录模式串中字符最后出现的位置
    {
        int ascii = (int)b[i]; 
        bc[ascii] = i;         
    }
}

void generateGS(char b[], int m, int suffix[], bool prefix[])
{
    for(int i=0; i<m; i++)      //初始化
    {
        suffix[i] = -1;
        prefix[i] = false;
    }
    for(int i=0; i<m-1; i++)
    {
        int j=i;
        int k=0;       //公共后缀子串的长度
        while(j >= 0 && b[j] == b[m-1-k])       //求公共后缀子串
        {
            j--;
            k++;
            suffix[k] = j+1;   //记录公共后缀子串起始下标
        }
        if(j == -1)          //如果公共后缀子串也是前缀子串
            prefix[k] = true;
    }
}

//j表示坏字符对应模式串中字符下标
int moveByGS(int j, int m, int suffix[], bool prefix[])
{
    int k = m-1-j;          //好后缀长度
    if(suffix[k] != -1)
        return j-suffix[k]+1;
    for(int r=j+2; r<=m-1; r++)
    {
        if(prefix[m-r] == true)
            return r;
    }
    return m;
}

int bm(char a[], int n, char b[], int m)
{
    int *bc = new int[SIZE];
    generateBC(b, m, bc);
    int *suffix = new int[m];
    bool *prefix = new bool[m];
    generateGS(b, m, suffix, prefix);
    int i=0;        //主串与模式串对齐的第一个字符下标
    while(i <= n-m)
    {
        int j;
        for(j=m-1; j>=0; j--)     //模式串从后往前匹配
        {
            if(a[i+j] != b[j])      //坏字符对应的下标j
                break;
        }
        if(j<0)
            return i;          //匹配成功
        //i = i + (j-bc[(int)a[i+j]]);
        int x = j-bc[(int)a[i+j]];
        int y = 0;
        if(j<m-1)
            y = moveByGS(j, m, suffix, prefix); //如果有好后缀
        i += x>y?x:y ;
    }
    return -1;
}

int main()
{
    char ori_str[] = "ajfgiuasdkwudgeugrvydfhvasdfdhvf";
    char pat_str[] = "asd";
    bm(ori_str, strlen(ori_str), pat_str, strlen(pat_str));

    return 0;
}
  • KMP算法

       算法原理:可以利用上面BM算法来帮助理解KMP算法,当模式串和主串从前往后相匹配的过程中,在遇到不配的字符时,希望找到规律,将模式串往后多移动几位,跳过一些不匹配的情况。这里,不匹配的那个字符叫做坏字符,前面已经匹配上的字符串叫做好前缀。

  

       我们拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟它的前缀子串匹配的后缀子串(最长可匹配后缀子串和最长可匹配前缀子串)。假设最长可匹配前缀子串{v},长度k,则把模式串一次性往后滑动 j-k 位,相当于每次遇到坏字符,就把 j 更新为 k, i 不变然后继续比较。

                             

       这里我们该如何求得最长可匹配前缀子串和最长可匹配后缀子串?可类似BM算法的bc、prefix、suffix数组,KMP可提前构建一个数组,来存储模式串每个前缀的最长可匹配前缀子串的结尾字符下标,数组的下标是该前缀子串的结尾字符下标,把这样的数组叫做 next 数组(或者失败函数)。

                          

       next数组计算方法:按照下标从小到大,依次计算next数组值,利用已经计算出来的next值,推导出next[i] 的值。

       如果 next[i-1] = k-1,也就是b[0, k-1] 是b[0, i-1] 的最长可匹配前缀子串,如果子串b[0, k-1]的下一个字符b[k] == b[i],那么b[0, k] 就是 b[0, i] 的最长可匹配前缀子串,则next[i] = k。但是如果b[k] != b[i],则不能通过next[i-1] 推导 next[i]。这时我们可以考察b[0, i-1]的次长可匹配后缀子串 b[x, i-1] 对应的可匹配前缀子串b[0, i-1-x] 的下一个字符 b[i-x],是否等于b[i],如果等于,那么b[x, i] 就是 b[0, i] 的最长可匹配后缀子串。

       怎样去求b[0, i-1]次长可匹配后缀子串?次长可匹配后缀子串,必然被包含在最长可匹配后缀子串中,最长可匹配后缀子串又对应最长可匹配前缀子串b[0, y]。于是,查找b[0, i-1] 的次长可匹配后缀子串就转化为求b[0, y] 的最长可匹配后缀子串的问题。按照这个思路,我们可以考察所有b[0, i-1] 的可匹配后缀子串b[y, i-1],直到找到一个可匹配的后缀子串,它对应的前缀子串的下一个字符等于b[i],则b[y, i] 就是 b[0, i]的最长可匹配后缀子串。

代码实现:

#include <iostream>
#include <cstring>

using namespace std;

int *getNexts(char b[], int m)
{
    int *next = new int[m];
    next[0] = -1;
    int k = -1;                //最长可匹配子串结尾字符下标
    for(int i=1; i<m; i++)
    {
        /*当出现b[k+1] != b[i],需要求次长可匹配子串,再转而求b[0, next[k]]的最长可匹配子串,                            
            一直往下找,直到找到或者没有*/
        while(k != -1 && b[k+1] != b[i])
        {
            k = next[k];        
        }
        if(b[k+1] == b[i])
            k++;
        next[i] = k;
    }
    return next;
}

int kmp(char a[], int n, char b[], int m)
{
    int *next = getNexts(b, m);
    int j = 0;
    for(int i=0; i<n; i++)
    {
        //当遇到坏字符, j更新为k(k表示最长可匹配前缀子串的长度),然后继续比较
        while(j>0 && a[i] != b[j])
        {
            j = next[j-1] + 1;
        }
        if(a[i] == b[j])
            j++;

        if(j == m)
        {
            delete[] next;
            return i-m+1;
        }
    }
    delete[] next;
    return -1;
}

int main()
{
    char ori_str[] = "kioabdqhwabdfgabdcabdwuqe";
    char pat_str[] = "abdcabd";

    kmp(ori_str, strlen(ori_str), pat_str, strlen(pat_str)) ;
    return 0;
}

       算法复杂度分析:

       空间复杂度:因为只需要额外申请一个next数组,数组大小跟模式串想同,所以空间复杂度为O(m).

       时间复杂度:KMP算法包含两个操作,一是构建next数组,二是借助next数组去匹配。

       先看getNexts()方法,以 i 和 k 为参考变量,外层for循环,i从0到m,k不是每次for循环都会增加,k<m,而while循环,k=next[k],实际是在减小k值,执行次数不会超过m,所以next数组时间复杂度是O(m).

       再看kmp()方法,i从0到n-1, j增长量<n,而while循环也是让 j 减小,减小量也不会超过n,所以这部分时间复杂度也是O(n)。

       所以,总的时间复杂度就是O(n+m)。

以上所述,是本人最近在极客时间上学习数据结构和算法相关课程做的笔记吧。

具体参照: https://time.geekbang.org/column/intro/126

发布了37 篇原创文章 · 获赞 20 · 访问量 4969

猜你喜欢

转载自blog.csdn.net/qq_24436765/article/details/90906965