KMP&Z函数详解

KMP

一些简单的定义:

  • 真前缀:不是整个字符串的前缀
  • 真后缀:不是整个字符串的后缀

当然不可能这么简单的,来个重要的定义

  • 前缀函数:
    给定一个长度为n的字符串s,其 为一个长度为n的数组π,其中πi表示
    1. 如果字串s[0...i]存在一对相等的真前缀和真后缀s[0...k] and s[i(k1)...i],则πi为这个真前缀(真后缀)的长度k
    2. 如果有不止一对,则πi为其中最长的一对
    3. 若没有最长的,则πi=0

简而言之,πi为字串s[0...i]最长相等的真前缀和真后缀长度

特别的,规定π0=0

举个例子,对于字符串s="abcabcd",其前缀函数π={0,0,0,1,2,3,0}

考虑如何去求前缀函数,最暴力的做法肯定使O(n3)的,枚举前缀位置、真前缀(真后缀)的长度和真前缀(真后缀)的每一位,代码就不放出来了(其实是没写)

优化一

我们发现当i+1时,πi最多+1,也就是说我们枚举πi的长度时,上界为πi1+1,这样复杂度可以被优化为O(n2)

代码如下

void Getnex(string str){
    int len=str.size();
    nex[0]=0;
    for (int i=1;i<n;i++) {
        for (int j=nex[i-1];j>=0;j--) {
            if (str.substr(0,j)==str.substr(i-j+1,j)) {
                nex[i]=j;
                break;
            }
        }
    }
}

如何证明这个复杂度呢

我们发现每次进行一次substr操作,都意味着πi的值都会1

显然有一种最坏的情况是,pi的值先变成n1,然后再掉回0

容易看出这不会超过O(n)次,然后每次substr的复杂度为O(n)

所以总复杂度O(n2)

优化二

我们在优化一中发现了一个性质,当s[i+1]==s[πi]时,πi=πi1+1

考虑把这个性质推下去,当s[i+1]!=s[πi]

我们发现,我们要找的转移点j是要满足i1的前缀性质的

即满足s[1...j]=s[i(j1)...i]

πi不行时我们自然要去找下一个满足性质的转移点

很容易想到这东西不就是ππi

由于真前缀和真后缀相等,所以ππi必然满足既是真前缀的真前缀又是真后缀的真后缀

可能有点绕,举个例子s="abaabaa"(下标从0开始)

当我们求π6时,前面的π值为

π[0...5]={0,0,1,1,2,3}

我们发现s[6]!=s[π5=3]

那么我们就需要找到下一个转移点j满足s[0...j]=s[5(j+1)...5]

因为π5=3所以s[0...5]的满足前缀性质的真前缀(真后缀)为aba

所以对于字符串"aba"满足前缀性质的真前缀(真后缀)一定满足s[0...5]的前缀性质

所以转移点即为π3=ππ5

至此,求前缀函数便可以优化成O(n)

void Getnex(std::string S) {
    for (int i=2,j=0;i<S.size();i++) {
        while(j && S[j+1]!=S[i]) j=nex[j];
        if (S[j+1]==S[i]) j++;
        nex[i]=j;   
    }
}

前话到此完结,接下来是真正的KMP

KMP是对于前缀函数的典型运用

举个例子,给定一个文本t和字符串s,我们尝试求出st中的所有出现

我们记n,mst的长度

我们构造一个字符串为s+'.'+t,其中.为不在s,t中出现的分隔符

计算出这个字符串的前缀函数,考虑这个前缀函数除去前n+1个值意味着什么

根据定义,πi为右端点为i,且为一个前缀的最长真子串长度

且由于有分割符的存在,π不可能超过n

πi=n时,则意味着st中完整出现一次,其右端点为i

因此KMP可以在O(n+m)的复杂度内解决问题

void KMP(std::string S,std::string T) {
    for (int i=1,j=0;i<S.size();i++) {
        while(j && T[j+1]!=S[i]) j=nex[j];
        if (T[j+1]==S[i]) j++;
        if (j==m-1) {
            std::cout<<i-m+2<<std::endl;
            j=nex[j];
        }
    }
}

字符串的周期

定义:

  • 对于字符串s,若存在p满足s[i]=s[i+p](i[0,|s|p1]),则ps的周期

  • 对于字符串s,若存在r满足s长度为r的前缀和长度为r的后缀相等,则称s长度为r的前缀是sborder

由这两个定义不难看出|s|rs的周期

根据前缀函数的定义我们可以得出s所有border长度,即πn1,ππn1,...

所以我们可以在O(n)的时间复杂度内求出s的所有周期

其中最小周期为nπn1

统计每个前缀的出现次数

以下默认字符串下标从1开始

主要是两种问题,一个是求s的前缀在s中的出现次数,另一个是求s的前缀在另一个字符串t中的出现次数

考虑位置i的前缀函数值πi,根据定义,其意味着字符串s一个长度为 的前缀在位置i出现并以i为右端点,同时不存在一个更长的前缀满足前述定义。

与此同时,更短的前缀可能以该位置为右端点。

容易看出,我们遇到了在计算前缀函数时已经回答过的问题:给定一个长度为j的前缀,同时其也是一个右端点位于i的后缀,下一个更小的前缀长度k<j是多少?该长度的前缀需同时也是一个右端点为i的后缀。

因此以位置i为右端点,有长度为πi的前缀,有长度为ππi的前缀,等等,直到长度变为0。

故而我们可以通过下述方式计算答案。

void Getcnt(std::string str) {
    for (int i=1;i<str.size();i++) ans[nex[i]]++;
    for (int i=str.size()-1;i>0;i--) ans[nex[i]]+=ans[i];
    for (int i=1;i<str.size();i++) ans[i]++;
}

例题:CF432D Prefixes and Suffixes

题意:

给你一个长度为n的长字符串,“完美子串”既是它的前缀也是它的后缀,求“完美子串”的个数且统计这些子串的在长字符串中出现的次数

模板题,直接写就行

#include <ctime>
#include <cstdio>
#include <iostream>
#define file(a) freopen(#a".in","r",stdin),freopen(#a".out","w",stdout)

const int maxn=1e5+5;
int n,nex[maxn],ans[maxn];
std::string str;

void chkmax(int &x,int y) {if (x<y) x=y;}
void chkmin(int &x,int y) {if (x>y) x=y;}
int read() {
    int x=0,f=1;
    char ch=getchar();
    while(ch<'0' || ch>'9') {if (ch=='-') f=-1;ch=getchar();}
    while(ch<='9' && ch>='0') {x=(x<<3)+(x<<1)+ch-'0';ch=getchar();}
    return x*f;
}

void Getnex(std::string s) {
    for (int i=2,j=0;i<s.size();i++) {
        while(j && s[j+1]!=s[i]) j=nex[j];
        if (s[j+1]==s[i]) j++;
        nex[i]=j;
    }
}
void out(int i,int cnt) {
    if (!i) {std::cout<<cnt<<std::endl;return ;}
    out(nex[i],cnt+1);
    std::cout<<i<<' '<<ans[i]<<std::endl;
}

int main() {
    std::cin>>str;
    str=" "+str;
    Getnex(str);
    // for (int i=1;i<str.size();i++) std::cout<<nex[i]<<' ';puts("");
    for (int i=1;i<str.size();i++) ans[nex[i]]++;
    for (int i=str.size();i>=1;i--) ans[nex[i]]+=ans[i];
    for (int i=1;i<str.size();i++) ans[i]++;
    out(str.size()-1,0);
    return 0;
}

Z函数(扩展KMP)

定义:

对于一个字符串sz[i]表示ss[i...n1]LCP(最长公共前缀)的长度,z则被称为sZ函数

我们在计算的时候,可以通过前面已知的z来计算

对于i,我们称区间[i,i+z[i]1]i的匹配段

我们在算法过程中维护右端点最靠右的匹配段,记作[l,r]

则有[l,r]s的前缀,并且在计算z[i]时我们保证li

算法流程

最开始时,l=r=0

在计算z[i]的过程中

  • ir,则有s[i,r]=s[il,rl],所以z[i]min(z[il],ri+1)

    • z[il]<ri+1,则z[i]=z[il]
    • z[il]ri+1,我们令z[i]=ri+1,然后暴力向后枚举字符
  • i>r,我们直接暴力从s[i]开始比较,求出z[i]

  • 求出z[i]后,还要更新l,r

void GetZ(std::string s) {
    int l=0,r=0;
    for (int i=1;i<s.size();i++) {
        if (i<=r && z[i-l]<r-i+1) z[i]=z[i-l];
        else {
            z[i]=std::max(0,r-i+1);
            while(i+z[i]<s.size() && s[z[i]]==s[i+z[i]]) z[i]++;
        }
        if (i+z[i]-1>r) {l=i;r=i+z[i]-1;}
    }
}

复杂度分析

对于内层的while,每次执行都会使r向后移动至少一位,而r<n1,所以总共最多做n

加上外层的for,总复杂度O(n)

posted @   xzj213  阅读(376)  评论(4编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示