字符串匹配(BF,BM,Sunday,KMP算法解析)

字符串匹配一直是计算机领域热门的研究问题之一,多种算法层出不穷。字符串匹配算法有着很强的实用价值,应用于信息搜索,拼写检查,生物信息学等多个领域。
今天介绍几种比较有名的算法:
1. BF
2. BM
3. Sunday
4. KMP

—,BF算法
BF(Brute Force)算法又称为暴力匹配算法,是普通模式匹配算法。

其算法思想很简单,从主串S的第pos个字符开始,和模式串T的第一个字符进行比较,若相等,则主串和模式串都后移一个字符继续比较;若不相同,则回溯到主串S的第pos+1个字符重新开始比较。
依次类推,直到模式串T中每个字符依次和主串S中的一个连续字符串全部相等时,则匹配成功,返回模式串T的第一个字符在主串S中的位置;若主串遍历完也没有成功,则匹配失败。

算法步骤如下:

下标i    0  1  2  3  4  5  6  7  8  9  
主串S    a  b  a  b  c  a  b  c  a  c  
模式串T  a  b  c  a          

第一次比较,从左到右,S[0] = T[0],计数器++;比较S[1] = T[1],i++;当s[2] != T[2],主串回溯,从S[1]重新开始比较。

下标i    0  1  2  3  4  5  6  7  8  9  
主串S    a  b  a  b  b  a  b  c  a  c  
模式串T     a  b  c  a          

也就是说,主串i从0开始,每次比较失败i++,重新比较,直到

下标i    0  1  2  3  4  5  6  7  8  9  
主串S    a  b  a  b  b  a  b  c  a  c  
模式串T                 a  b  c  a   

i = 5,匹配成功。返回 i=5。

可推得,BF算法在最坏情况下需要比较,主串长度M 乘以 模式串长度N, 为-O(M * N)。

代码实现如下:

int BF(const char *str1, const char *str2)
{
    int str1_len = strlen(str1);
    int str2_len = strlen(str2);
    int i = 0;
    int j = 0;

    if(str1 == NULL || str2 == NULL){
        return -1;
    }

    while(i < str1_len && j < str2_len){
        if(str1[i] == str2[j]){
            i++;
            j++;
            //相等则继续逐个比较
        }
        else{
            i = i -j + 1;
            j = 0;
            //不相等则主串回溯,重新比较
        } 
    }
    if (j == str2_len){

        return i - j;
    }else{
    return -1;
    }
}

BF算法简单易懂,但是这个算法效率很差,原因在于每次失败后回溯,浪费了之前的比较,导致了很多次的无用比较,时间损耗较大。

二,BM算法
BM(Boyer Moore)算法是1977年,Robert S.Boyer和J Strother Moore提出了一种在O(n)时间复杂度的匹配算法。时间复杂度要低于BF。

BM算法之所以能够在模式匹配中有更高的的效率,主要是因为BM算法构造了两个跳转表,分别叫做 坏后缀表 ,和 好后缀表 。这两个表涉及了BM算法中的两个规则:

坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果”坏字符”不包含在模式串之中,则最右出现位置为-1。

好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。

算法步骤如下:
1. 首先,”文本串”与”模式串”头部对齐,从尾部开始比较。”S”与”E”不匹配。这时,”S”就被称为”坏字符”(bad character),即不匹配的字符,它对应着模式串的第6位。且”S”不包含在模式串”EXAMPLE”之中(相当于最右出现位置是-1),这意味着可以把模式串后移6-(-1)=7位,从而直接移到”S”的后一位。
字符串匹配(BF,BM,Sunday,KMP算法解析)_第1张图片
2. 依然从尾部开始比较,发现”P”与”E”不匹配,所以”P”是”坏字符”。但是,”P”包含在模式串”EXAMPLE”之中。因为“P”这个“坏字符”对应着模式串的第6位(从0开始编号),且在模式串中的最右出现位置为4,所以,将模式串后移6-4=2位,两个”P”对齐。
字符串匹配(BF,BM,Sunday,KMP算法解析)_第2张图片
3. 依次比较,得到 “MPLE”匹配,称为”好后缀”(good suffix),即所有尾部匹配的字符串。注意,”MPLE”、”PLE”、”LE”、”E”都是好后缀。
字符串匹配(BF,BM,Sunday,KMP算法解析)_第3张图片
4. 发现“I”与“A”不匹配:“I”是坏字符。如果是根据坏字符规则,此时模式串应该后移2-(-1)=3位。问题是,有没有更优的移法?
字符串匹配(BF,BM,Sunday,KMP算法解析)_第4张图片
5. 更优的移法是利用好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串中上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。
所有的“好后缀”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的头部出现,所以后移6-0=6位。
可以看出,“坏字符规则”只能移3位,“好后缀规则”可以移6位。每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。
字符串匹配(BF,BM,Sunday,KMP算法解析)_第5张图片
6. 继续从尾部开始比较,“P”与“E”不匹配,因此“P”是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2位。因为是最后一位就失配,尚未获得好后缀。
字符串匹配(BF,BM,Sunday,KMP算法解析)_第6张图片
匹配完成,可见BM算法根据好坏字符规则,使得失配时,文本串能够后移更多位,使得效率高于BF按部就班的匹配。

BM算法的时间复杂度为-O(N)。

代码实现主要在于构建两表。
坏字符表:

void BuildBadC(const char* pattern, size_t pattern_length, unsigned int* badc, size_t alphabet_size)  
{  
    unsigned int i;  

    for(i = 0; i < alphabet_size; ++i)  
    {  
        badc[i] = pattern_length;  
    }   
    for(i = 0; i < pattern_length; ++i)  
    {  
        badc[pattern[i] - 'A'] = pattern_length - 1 - i;  
    }  
}  

好后缀表:

void BuildGoodS(const char* pattern, size_t pattern_length, unsigned int* goods)  
{  
    unsigned int i, j, c;  

    for(i = 0; i < pattern_length - 1; ++i)  
    {  
        goods[i] = pattern_length;  
    }  
    //初始化pattern最末元素的好后缀值  
    goods[pattern_length - 1] = 1;    
    //此循环找出pattern中各元素的pre值,这里goods数组先当作pre数组使用  
    for(i = pattern_length -1, c = 0; i != 0; --i)  
    {  
        for(j = 0; j < i; ++j)  
        {  
            if(memcmp(pattern + i, pattern + j, (pattern_length - i) * sizeof(char)) == 0)  
            {  
                if(j == 0)  
                {  
                    c = pattern_length - i;  
                }  
                else  
                {  
                    if(pattern[i - 1] != pattern[j - 1])  
                    {  
                        goods[i - 1] = j - 1;  
                    }  
                }  
            }  
        }  
    }    
    //根据pattern中个元素的pre值,计算goods值  
    for(i = 0; i < pattern_length - 1; ++i)  
    {  
        if(goods[i] != pattern_length)  
        {  
            goods[i] = pattern_length - 1 - goods[i];  
        }  
        else  
        {  
            goods[i] = pattern_length - 1 - i + goods[i];  

            if(c != 0 && pattern_length - 1 - i >= c)  
            {  
                goods[i] -= c;  
            }  
        }  
    }  
}  

构建完表BM算法就很简单了。

unsigned int BM(const char* text, size_t text_length, const char* pattern, size_t pattern_length, unsigned int* matches)  
{  
    unsigned int i, j, m;  
    unsigned int badc[ALPHABET_SIZE];  
    unsigned int goods[pattern_length];   
    i = j = pattern_length - 1;  
    m = 0;  

    //构建好后缀和坏字符表  
    BuildBadC(pattern, pattern_length, badc, ALPHABET_SIZE);  
    BuildGoodS(pattern, pattern_length, goods);  

    while(j < text_length)  
    {  
        //发现目标传与模式传从后向前第1个不匹配的位置  
        while((i != 0) && (pattern[i] == text[j]))  
        {  
            --i;  
            --j;  
        }  
        //找到一个匹配的情况  
        if(i == 0 && pattern[i] == text[j])  
        {  
            matches[m++] = j;  
            j += goods[0];  
        }  
        else  
        {  
            //坏字符表用字典构建比较合适  
            j += goods[i] > badc[text[j]-'A'] ? goods[i] : badc[text[j]-'A'];  
        }  
        i = pattern_length - 1;  
    }  

    return m;  
}  

BM算法的思想在字符串匹配中很重要,其中好后缀与KMP算法的思想不谋而合,而坏字符跳跃,跟Sunday算法很相似。总的来说,BM是很高效的匹配算法。

三,Sunday算法
Sunday算法是Daniel M.Sunday于1990年提出的字符串模式匹配。据网上说,Sunda算法的效率高于BM和KMP(不太明白)。在我看来,Sunday算法是BM算法的优化,逻辑简单易懂,代码也实现更容易(无需构造两表)。

Sunday算法的思想是:
1. 从文本串S的第pos个字符开始,和模式串T的第一个字符进行比较,若相等,则主串和模式串都后移一个字符继续比较;
2. 若不相同,则将文本串参与匹配的最末位字符的后一个字符与模式串逆着匹配。
3. 若匹配完模式串没有该字符,则模式串直接跳过,即移动位数 = 匹配串长度 + 1。
4. 若模式串匹配到了该字符,则模式串中相同字符移动到文本串该字符下,与该字符对齐。其移动位数 = 模式串中最右端的该字符到末尾的距离+1。

算法步骤如下:

下标i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T  c  b  b  a    

从前往后比较,在i = 2处失配,关注i= 4( d )是否包含在模式串T中。遍历比较完发现不包含,将模式T移动到i = 5继续比较。(失配后,主串这轮参与比较的后一位(d)肯定要参与下一轮的比较,T中都没有d,肯定匹配不成功啊!还比什么,直接后移至(d)的下一位。)

下标i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T                 c  b  b  a  

在i = 7处失配,关注i = 9(b),在模式串中找b,发现i = 6, i = 7处都有b,那应该和谁对齐?? 应该和后面的(i = 7)处的对齐,这就是为什么要倒着在模式串中找b了,找到了直接break,并将此处与i = 9对齐。(为什么和后面的b对齐,大家思考)

下标i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T                       c  b  b   a  

在i=7处失配,关注i=11(a),匹配到模式串中最后一位为a,break;将模式串中的a与i = 17 处的 a 对齐。继续比较。

下标i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T                          c  b   b   a  

匹配完成!

可见Sunday算法的核心思想就是失配时,模式串能尽可能多的向后移动,使得匹配次数减少,效率提高。

Sunday和BM不同点在于:
1. BM从后往前匹配,Sunday从前往后。
2. BM算法失配关注的是“最后一位”,Sunday关注的是“最后一位的下一位”。
3. BM有坏字符串好字符串之分,Sunday没有。(但思想比较相似,BM中的好字符 和 坏字符包含在模式串内,可以类比于Sunday算法找到了该字符;好字符和坏字符串没出现,则类比于没在模式串中找到该字符)。

代码实现:

int Sunday(const char *str1, const char *str2)
{
    int str1_len = strlen(str1);
    int str2_len = strlen(str2);
    int i = 0;
    int j = 0;
    enum{FALSE,TRUE};
    int Y = FALSE;   

    if(str1 == NULL || str2 == NULL){
    return -1;
    }

    while(i < str1_len && j < str2_len){
        if(str1[i] == str2[j]){
        i++;
        j++; 
        }else{
        //关注最后一位的后一位这个字符
        int num = i - j + str2_len ;
        for(j = str2_len-1; j >= 0; j--){
        //该字符与模式串比较,找到相同的则与该字符对齐。
        if(str1[num] == str2[j]){
            i = num - j;
                    Y = TRUE;
            break;
            }
        }
        if(Y == FALSE){
        //没找到模式串就直接跳到该字符的下一位。
        i = num + 1; 
        }
        Y = FALSE;
        j = 0;
        }
    }       
    if(i == str1_len){
    return -1;
    }else
        return i - str2_len;;
}

Sunday算法的应用价值很强,(实际效率高于KMP和BM算法),代码实现也很简单,希望大家能够掌握。

四,KMP算法
Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法。
KMP算法在网上说的非常麻烦,我觉得就是之前介绍的类似于BM算法的好字符后缀匹配规则,和next[]数组的推导两点而已。

算法的思想是:假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置如果j = -1(标记),或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。意味失配时,模式串P相对于文本串S向右移动了j - next [j] 位。换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值。即移动的实际位数为:j - next[j],且此值大于等于1。

Next数组的值含义是:代表失配前的字符串中,有多大长度的相同的前缀后缀。比如Next[j] = k;表示 j 之前的字符串中有最大长度为k 的相同前缀后缀。
此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到j-Next[j]这个位置上。所以重点在于求Next[]。

如下:
ABCDAB ABCDABC ABCDABCDABDABD 文本串
ABCDABD 模式串

最大前缀后缀相同数:

  A               左                         右                     
  AB              A                          B                      0
  ABC            A,AB                       C,BC                    0
  ABCD          A,AB,ABC                   D,CD,BCD                 0
  ABCDA        A,AB,ABC,ABCD              A,DA,CDA,BCDA             1
  ABCDAB     A,AB,ABC,ABCD,ABCDA         B,AB,DAB,CDAB,BCDAB        2
  ABCDABD   A,AB,ABC,ABCD,ABCDA,ABCDAB  D,BD,ABD,DABD,CDABD,BCDABD  0

  最大前缀后缀公共元素长度对照表
  A   B   C   D   A   B   D 
  0   0   0   0   1   2   0    

Next 数组考虑的是除当前字符外的最长相同前缀后缀,所以通过上步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第求得的值整体右移一位,然后初值赋为-1,如下表格所示:

 A   B   C   D   A   B   D 
-1   0   0   0   0   1   2

在匹配失配时,只需要用失配位置 j 减去 Next[j],就可以得到模式串移动到什么位置了。

Next[]的求取代码实现如下:

int *get_next(const char* str2)
{
    int str2_len = strlen(str2);
    int *next = (int *)malloc(sizeof(int)*str2_len);
    next[0] = -1;
    int left = -1;
    int right = 0;

    while(right < str2_len - 1){
    if(left == -1 || str2[left] == str2[right]){
        left++;
        right++;
        next[right] = left;
    }else
        left = next[left];
    }
    return next;
}

这段代码是为了求取Next[]对应的值,并没有什么实际意思,能得出正确的Next[]就行(求Next[]的值,网上好像就这一种代码实现办法)。

       0   1   2   3   4   5   6 
 next -1   0   0   0   0   1   2   

我们来根据代码验证下是否准确
str_len - 1~6
left = -1
right = 0
——————————————

       0   1   2   3   4   5   6 
 next -1   0

  left = 0
  right = 1
  next[1] = 0  
--------------------------------------------
  ABCDABD
  right = 1
  left = next[0] = -1
--------------------------------------------
  left = 0
  right = 2
  next[2] = 0
      0   1   2   3   4   5   6 
next -1   0   0

可以看出是正确的(后来的就不用推演了),代码设计的很巧妙,能恰好算出Next[]所对应的值。(这段代码不需理解,记住就行,就是为了求Next[]而专门设计的算法)。

求出Next[]的对应值,KMP算法代码就很容易了。

int KMP(const char* str1, const char* str2)
{
    int str1_len = strlen(str1);
    int str2_len = strlen(str2);
    int *next = get_next(str2); 
    int i = 0;
    int j = 0;

    if(str1 == NULL || str2 == NULL){
    return -1;
    }
    while(i < str1_len && j < str2_len){
        if(j == -1 || str1[i] == str2[j]){
            i++;
            j++;
        }else{
            //关键一步,失配时根据Next[]跳转
            j = next[j];
        }
    }
    free(next);  
    if(j == str2_len){
        return (i-str2_len);    
    }
    return -1;
}

KMP的时间复杂度:
我们发现如果某个字符匹配成功,模式串首字符的位置保持不动,仅仅是i++、j++;如果匹配失配,i 不变(即 i 不回溯),模式串会跳过匹配过的next [j]个字符。整个算法最坏的情况是,当模式串首字符位于i - j的位置时才匹配成功,算法结束。
所以,如果文本串的长度为n,模式串的长度为m,那么匹配过程的时间复杂度为-O(n),算上计算next的-O(m)时间,KMP的整体时间复杂度为-O(m + n)。

四种经典的字符串匹配算法介绍完毕,大家在纸上多画多算,能更好的理解算法思想。

你可能感兴趣的:(算法)