大话数据结构第五章第七节:模式匹配算法——KMP

KMP模式匹配算法原理分析


对本文快速理解

一个串(Z)存在重复结构的定义

Z串存在两个相等的子串(前串和后串),前串的第一个字符是Z的第一个字符且前串的最后一位不是Z串的最后一位。后串的最后一位是Z串的最后一个位且后串的第一位不是Z的第一个位。

本文提到的串,它的存储结构是顺序存储结构(数组),数组index=0是存储串的长度,index=1是串的第1个字符,index=2是串的第2个字符,..., index=n是串的第n个字符;

因此串的字符位置是从1开始(有别于C语言中index从0开始)

朴素的模式匹配算法

用你个简单例子介绍一下朴素模式匹配。

比如要从主串S=“gccdgccgle”中找到T=“gccgle”这个字串的位置。

第一步:

将T的第一位与S的第一位对齐,S的前三位与T的前三位相等,第四位d与g不等;

第二步:

将T的第一位与S的第二位对齐,S的第二位c与T的第一位g不等;

第三步:

将T的第一位与S的第三位对齐,S的第三位c与T的第一位g不等;

....

第N步:

将T的第一位与S的第五位对齐,S的第五、六、七、八、九、十位分别和T的第一、二、三、四、五、六位相等。

即找到了T=“gccgle”在S=“gccdgccgle"中首次出现的位置是5;

朴素匹配代码如下:

/** 在S中寻找与T串相等的字串的位置
* input:
*    char *S: 主串
*    char *T: 希望匹配的字串
* output:
*    int: T在S中的位置,T和S的位置都是从1开始,如果返回0,则表示S中不存在T串
**/
int Index(char *S, char *T)
{
    // i是主串S中当前位置下标
    int i = 1;
    // j是字串T中当前位置的下标
    int j = 1;
    // S[0]是S串的长度, T[0]是T串的长度
    // i大于S长度或者j大于T串长度,循环结束
    while (i <= S[0] && j <= T[0]) {
        // 如果i,j位置两串的字母相等,则i,j都向前移动一位
        if (S[i] == S[j]) {
            ++i;
            ++j;
        } else {
            // 如果i,j位置两串的字母不相等,则i退回到上一次匹配时首位的下一位,
            i = i - j + 2;
            // j退回到字串T的首位
            j = 1;
        }
    }
    // 如果j大于T串的长度,说明T串的所有字母都已经比较过,并且在S中找到了和它相等的字串,
    // 因为如果在某次匹配中,但凡T中有一个字母与S对应位置的字母不相等,则j会回退到T串的首位。
    if (j > T[0]) {
        return i - T[0];
    } else {
        return 0;
    }
}

上面介绍的朴素模式匹配算法中,i和j会不停的回退。一旦在某次匹配中,有一个字母不相等,则表示本次匹配结束。j会回退到T串的首位,i会回退到本次匹配首位的下一位,为下一次匹配做准备。对于S来说,下一次匹配的首位是前一次匹配的首位的下一位置;对于T来说,每一次匹配都是从T的首位开始。

有没有更好的算法来减少回退的次数呢?回答是:有,就是下面介绍的KMP模式匹配算法。

KMP模式匹配算法

KMP算法其实在某次匹配不成功时,让i不回退,仅仅让j回退。K\M\P三位前辈大佬通过分析j回退的规律,总结出当某次匹配不成功时,i不回退,至于j回退到哪一个位置,与T串中重复结构有关,与S串结构无关。也就是说给定T串,对于任意的j,我们事先就可以知道j要回退到T串什么位置。我们用一个数组next表示j的回退位置。

如果某次匹配不成功,此时的j应该回退到next[j]。

// 如果某次匹配中S[i] != T[j]
j = next[j];

如何构造next[j]数组

上文已经提到了next数组与T串重复结构有关,其实next[j]对应着T的字串Z=T[1][T2]....T[j-1]中的"重复结构数"(作者自己取得名字)

Z串的"重复结构数"可以用一个数值表示,规定Z串的重复结构数的定义是:

  1. 当j > 1时,存在一个最大的k,1<=k<j-1,使得Z串的前串Z[1]..Z[k]和后串Z[j-k]..Z[j-1]完全相等(即Z的前k个字符与后k个字符相等),则next[j] = k + 1;
  2. 当j = 1时,next[j] = 0;
  3. 其他时候,next[j] = 1;

现在用简单的例子来说明。

T=”ababaaaba",一共有9个字母

  • 当j=1,属于第2种情况,next[1] = 0;
  • 当j=2,Z="a", 不存在这样的前串和后串,属于第3种情况,next[1] = 1
  • 当j=3,Z="ab", Z[1]不等于Z[2],不存在满足1的前串和后串,next[3] = 1;
  • 当j=4,Z="aba", 前串是a, 后串是a,此时k=1,next[4] = k + 1 = 2;
  • 当j=5,Z=”abab",前串ab, 后串ab,此时k=2,next[5] = k + 1 = 3;
  • 当j=6,Z="ababa",前串aba,后串aba,此时k=3,next[6] = k + 1 = 4;

---------------------------------------------------

我们在这里稍作停留,观察观察,其实可以发现,在满足第一种情况时,k是此时的Z串的前串长度,也是后串的长度。这个结论对后面理解很重要。next[j] = k + 1其实表示j回退时应该回退到前串末位置后面一个位置。这是什么原因呢?这是由Z串的重复结构决定的。next[j]的值是要说明Z串的前next[j]-1个字符组成的字串与后next[j]-1个字串重复。

如果要计算next[j+1],此时上一步的Z串(下标从1到j-1)后面多了一个字符Z[j]。我们先让k = k + 1;此时next[j] = k。

将这个末尾字符Z[j]与Z串的第next[j]个字符(z[k])比较,如果相等,则前串长度等于k(注意k已经加了1),next[j+1] = k + 1 = next[j] + 1;如果不等呢?

则符合情况1时Z串(从Z[1]到Z[j])的前串长度肯定小于k,因此k要回退,回退到k-1?回退到k-2?还是回退到哪里?我们已经知道Z[j-1]=z[k-1],z[j-k-1] = z[1](注意k已经加了1);我们也知道Z串的 前k-1个字符的重复结构,我们知道next[k]代表Z串前k-1个字符的重复结构数,满足情况1时Z串 前k-1个字符组成的子串的 前串长度是next[k] - 1。k回退到next[k]是最好的选择!原因我在唠叨一下:

我们已经知道Z[j]与Z[k](注意k已经加了1)不等,接着要比较Z[j]与Z[k-1](注意k已经加了1),Z串 前k-1个字符具有一定重复结构,Z[k-1]与Z[1]Z[2]...Z[next[k] - 1]中的某个字符很有可能相等,我们让k回到next[k]其实是回到 前k-1个字符的前串末尾位置的下一个位置。next[k]我们前面几步已经计算过它的值。直接用就可以。接着可以比较Z[j]与z[next[k]],如果不等,则比较z[next[next[k]]....,直到相等为止或者k回到了1为止。

现在知道为什么k的回退位置是next[k]了。我们在接着看j=7时的推导。

--------------------------------------------------

  • 当j=7,Z=“ababaa",我们先让k=k+1,k=4;我们发现按照上一步j=6的节奏,k应该是4,本来应该是前串abab与后串abab,而事实上是前串abab和后串abaa不相等,所以k不能继续增大,k要往小的走,k应该回退,回退到哪里呢?回退到next[k]还是回退到k-1?如果回退到k = k -1 =3,要比较aba和baa,仍然不相等,按照上面的分析,应该回退到next[k]。k=next[k] = next[4]=2;b与a不等,k = next[k] = next[2] = 1, a等于a,next[7] = k + 1 = 2;
  • 当j = 8,Z=“ababaaa",我们先让k=k+1=2; b不等于a,k = next[k] = next[2] = 1; a等于a, next[8] = k + 1 = 2;
  • 当j = 9, Z="ababaaab",我们先让k=k+1=2; b等于b,next[9] = k + 1 = 3;

现在我们可以写一下求T串的next数组的代码了。

/**
* 功能:计算T串的next数组
* input:
*    char *T: T串
*    int *next: T串每一个位置回退数组
**/
void GetNext(char *T, int *next)
{
    int i, k;
    i = 1;
    k = 0;
    next[1] = 0;
    while (i < T[0]) {
        if (k == 0 || T[i] == T[k]) {
            next[i + 1] = k + 1;    // 这样写好理解一些,和上文的讲解对应。
            i++;
            k++;
        } else {
            k = next[k];  // 此处回退到next[k],上文已经详细的讲解了原因了。
        }
    }
}

然后在写一下KMP的算法过程:

/** 在S中寻找与T串相等的字串的位置
* input:
*    char *S: 主串
*    char *T: 希望匹配的字串
* output:
*    int: T在S中的位置,T和S的位置都是从1开始,如果返回0,则表示S中不存在T串
**/
int KmpIndex(char *S, char *T)
{
    // i是主串S中当前位置下标
    int i = 1;
    // j是字串T中当前位置的下标
    int j = 1;
    // next数组用于存储T串每个j的回退位置
    int next[255];
    GetNext(T, next);
    // S[0]是S串的长度, T[0]是T串的长度
    // i大于S长度或者j大于T串长度,循环结束
    while (i <= S[0] && j <= T[0]) {
        // 如果i,j位置两串的字母相等,则i,j都向前移动一位
        if (j == 0 || S[i] == S[j]) { // j = next[]中的值,有可能会等于0
            ++i;
            ++j;
        } else {
            // i不回退
            // j回退到next[j]
            j = next[j];
        }
    }
    // 如果j大于T串的长度,说明T串的所有字母都已经比较过,并且在S中找到了和它相等的字串,
    // 因为如果在某次匹配中,但凡T中有一个字母与S对应位置的字母不相等,则j会回退到T串的首位。
    if (j > T[0]) {
        return i - T[0];
    } else {
        return 0;
    }
}

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值