数据结构基础学习——字符串匹配KMP算法

数学功底偏弱,最近花了1天时间才理解了用于字符串匹配的KMP算法。


KMP算法是由 Knuth、Pratt 和 Morris 三个人同时发现的,因此取名为KMP,并不是什么功能性短语的缩写。该算法主要希望:“在字符串匹配的过程中尽可能地减少重复比较的次数,从而提高匹配效率”。

假设有源字符串S和模式字符串T,现在需要设计一个函数来查找S串中与T串相同的子串所在的位置i,若子串不存在,则返回0。

typedef struct string
{
	uint  Length;
	char *Ch;
} STRING, *P_STRING;

P_STRING p_S;
P_STRING p_T;

一种最容易想到且最为简单的方法是,定义两个索引 i、j,分别指向S串的T串中的字符,从第1个字符开始逐一比较;若字符相等,则 i、j 进位;若不相等,则将 i 索引 -j+2,再将 j 索引归 1,重新开始进行比较。直到 i 索引超出S串最后一个字符。

代码实现如下:

int SubStrIndex(P_STRING S, P_STRING T)
{
    uint j = 1;
    uint i = 1;

    while(i<=S->Length && j<=T->Length)
    {
        if(S->Ch[i] == T->Ch[j])
        {
            i++; j++;  // 进位比较后续字符
        }
        else
        {
            i = i-j+2;
            j = 1;
        }
    }
    
    if(j == T->Length) // 若T串完全匹配
    {
        return i;
    }
    else
    {
        return 0;
    }
}

然而,当遇到 S = “aaaaaaaaaaaaaaaaaaaaab” 而 T = “aaab” 这类情况时,前面的所有重复的字符 ‘a’ 都要进行T->Length轮比较,这样的匹配效率十分低下。

那么使用KMP算法就可以解决这个问题。


我们先来分析一下上述算法效率低下的原因:无论前面存在多少个已配对的字符,每当发生“失配”时就要回溯索引 i,并放弃所有已经配对的T串字符(j 归 1),并重新头开始配对。当S串中存在大量与T串“相似”的子串时,将会导致大量的回溯和重复比较。

然而实际上,这些已配对的T串字符本身已经包含了S串的信息,如果能够利用好这些信息,就能够全程不回溯索引 i。

除此之外,若在已配对的T串字符中也存在重复的子串,那么我们还可以在“失配”时减少索引 j 的回溯位数(而不需要每次都归1)。

基于以上假设,每当发生“失配”时,我们只需要为索引 j 指定一个新的值即可,在KMP算法中,通常将该值命名为 Next[],代码实现如下:

int SubStrIndex_KMP(P_STRING S, P_STRING T)
{
    /* 计算T串的Next[]数组 */
    // ...


    /* 开始进行字符匹配 */
    uint j = 1;
    uint i = 1;

    while(i<=S->Length && j<=T->Length)
    {
        if(S->Ch[i] == T->Ch[j])
        {
            i++; j++;     // 进位比较后续字符
        }
        else
        {
            j = Next[j];  // 直接为索引 j 赋新值
        }
    }
    
    if(j == T->Length)    // 若T串完全匹配
    {
        return i;
    }
    else
    {
        return 0;
    }
}

可见,KMP算法与前文所述的普通算法最大的区别在于引入了数组“Next[]”,且该数组仅和T串相关。


那么 Next[] 又该如何获得呢?这里我们推导一下 Next(j) 函数的定义:

  • 假设现在T串与S串已经匹配了 j-1 个字符,而在比较第 j 个字符时“失配”,既 T[1]...T[j-1] = S[i-j+1]...S[i-1],T[j] ≠ S[i],此时,我们需要:“判断是否存在一个尽可能大的索引值 k,且1 < k < j,使得 T[1]...T[k-1] = S[i-j+1]...S[i-1],且 T[j-k+1]...T[j-1] = S[i-j+1]...S[i-1],也就是说 T[1]...T[k-1] = T[j-k+1]...T[j-1]”。

       > 若 k 存在,则可将该索引值 k 作为 赋值给 j,既:

                                                                                  Next[j] = k , k ≠ ∅

      如下图所示,这等同于令索引 j 的值刷新为 k,从而可以在T串的 k 处开始再次进行比较。

      说白了就是:如果已知在已被匹配的T串的一头一尾还存在着两个相同的“头、尾子串”(图中的绿色部分),当“尾子串”的后一个字符 T[j] 与 S[i] 失配时,就可以从“头子串”的后一个字符 T[k] 开始重新与 S[i] 比较,从而避免了将索引 j 归1导致一切从头开始。 

       > 若 k 不存在(相同“头、尾子串”不存在),则只能将索引 j 归1了,既:

                                                                                  Next[j] = 1, k = ∅

      这就意味着重头开始匹配。

至此,我们不难发现 Next(j) 函数的实际意义 —— “已匹配模式串中,最长的相同头尾子串中,头子串的后1位”。

       > 最后,定义Next的初始条件:

                                                                                        Next[1] = 0

      意味着当已匹配的T串的大小为0时,串内相匹配的头尾子串不存在,因此其头子串的后1位也不存在。这将作为求解Next(j) 函数时,向前嵌套索引最大k值的终止条件(详细描述见后文)。

综上,Next(j) 的数学表达式如下:

 

                          


获得Next(j) 函数的表达式后,我们可以利用递推法,来设计数组 Next[] 的求解程序:

初始条件,根据定义有:

Next[1] = 0;

现假设已有 T[1]...T[k-1] = T[j-k+1]...T[j-1],根据定义可知 Next[j] = k;接着我们要比较 T[k] 和 T[j] 以计算 Next[j+1]:

  • 若 T[k] = T[j],则:

    

                                                                             Next[j+1] = Next[j]+1 = k+1

  • 若 T[k] ≠ T[j],则:

    
                                                                      Next[j+1] = Next[ Next[k] ] +1 , j > k > 1

        意思是:当T[k] ≠ T[j] 时,T串与S串在该位“失配”时,索引 j 就不能简单地回到 Next[j] 所指示的索引 k 上了,但根据Next函数的定义,还是希望返回给 j 的 k 尽可能地大;于是就往前索引上一个满足条件的 k' 值(1 < k' < k,已知该值存储在 Next[k] 中)+1并赋给 Next[j+1];若T[k'+1] = T[j] 就与第一种情况相同了,若T[k'+1] ≠ T[j] 则还需要继续向前索引下一个k',以此类推。最终获得一个在 T[j] 位与主串失配时,索引 j 所能返回的距离最近的 k'+1 位。

        可见,在上述向前嵌套索引的过程中,只要遇到一个Next[k] = 1,都将直接导致:

                                                                                     Next[j+1] = Next[1] = 0

        这就意味着满足条件的 k 不存在,终止向前嵌套索引操作,并重头开始比较。


根据上述推导,可设计如下代码:

int SubStrIndex_KMP(P_STRING S, P_STRING T)
{
    /* 计算T串的Next[]数组 */
    
    // 为 Next 数组动态分配空间 
    
    uint j = 1;
    uint k = 0;

    Next[1] = 0;
    while( j < T->Length )
    {
        if(k==0)
        {
            k=1;
            ++j;
            Next[j] = 1;
        }

        if(T->Ch[k] == T->Ch[j])
        {
            ++k;
            ++j;
            Next[j] = k;
        }
        else
        {
            k = Next[k];  //若右k为1,则表明k'不存在,需要将j归1
        }
    }

    /* 开始进行字符匹配 */
    j = 1;
    uint i = 1;

    while(i<=S->Length && j<=T->Length)
    {
        if (j == 0)
        {
            j=1;
            i++;
        }   

        if(S->Ch[i] == T->Ch[j])
        {
            i++; j++;     // 进位比较后续字符
        }
        else
        {
            j = Next[j];  // 直接为索引 j 赋新值,若赋值为0,则表明重头开始
        }
    }
    
    if(j == T->Length+1)    // 若T串完全匹配
    {
        return i;
    }
    else
    {
        return 0;
    }
}

       注意Next函数的计算部分。将 Next[1] 的值定义为 0 而不是 1,就是为了可以在 T[k'] ≠ T[j] 时,让任何 Next[ Next[k] ](Next[k] = 1)都返回 0,并在下一轮循环中+1后返回给 Next[j+1],使得 Next[j+1] = 1,既命令失配时索引 j 归 1;否则,Next[j+1] 将变为2,索引 j 归 2,这将导致错误。


实际上,判断k不存在的条件还可以与T[k'] ≠ T[j] 条件融合,更简洁的代码如下:

int SubStrIndex_KMP(P_STRING S, P_STRING T)
{
    /* 计算T串的Next[]数组 */
    
    // 为 Next 数组动态分配空间 
    
    uint j = 1;
    uint k = 0;

    Next[1] = 0;
    while( j < T->Length )
    {
        if(k==0 || T->Ch[k] == T->Ch[j])
        {
            ++k;
            ++j;
            Next[j] = k;
        }
        else
        {
            k = Next[k];  //若右k为1,则表明k'不存在,需要将j归1
        }
    }

    /* 开始进行字符匹配 */
    j = 1;
    uint i = 1;

    while(i<=S->Length && j<=T->Length)
    {
        if(j==0 || S->Ch[i] == T->Ch[j])
        {
            i++; j++;     // 进位比较后续字符
        }
        else
        {
            j = Next[j];  // 直接为索引 j 赋新值,若赋值为0,则表明重头开始
        }
    }
    
    if(j == T->Length+1)    // 若T串完全匹配
    {
        return i;
    }
    else
    {
        return 0;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值