前缀函数与Z函数介绍

字符串算法果然玄学=_=
参考资料:
OI Wiki:前缀函数与KMP算法
OI Wiki:Z函数(扩展KMP)

0. 约定#

字符串的下标从 0 开始。|s| 表示字符串 s 的长度。
对于字符串 s,记其每一个字符分别为 s0,s1,,s|s|1
子串 sl,sl+1,,sr1,sr 简记为 s[l:r]。特别地,若 l=0,可记作 s[:r];若 r=|s|1,可记作 s[l:]
对于字符串 a,ba+b 表示拼接操作,即将字符串 b 拼接到字符串 a 之后,构成新的字符串。
记构成的新字符串为 c,则上述拼接操作记为 ca+b
其中符号 xy 表示将 y 的值赋给 x
不论是字符还是字符串,皆不加引号。

1. 前缀函数#

1.1. 前缀函数的定义#

对于字符串 s,若存在一个非本身的子串 t 使得 t 既是 s 的前缀又是 s 的后缀,称 ts 的一个 Border
更加符号化地,对于一个长为 n 的字符串 s,若存在 m 使得 mns[:m1]=s[nm:]
s[:m1] (s[nm:] 同理) 为 s 的一个长度为 mBorder
对于一个字符串 s,定义 MaxBorder(s)sBorder 中最长的。
接下来定义前缀函数:
对于一个字符串 s,定义前缀函数 π(s,i)=|MaxBorder(s[:i])|
在不引起歧义的情况下,可简记为 π(i);特别地,定义 π(s,0)=0
有时我们关心一整个字符串的前缀函数值,故此时我们也可以记 π(s)={π(s,0),π(s,1),,π(s,|s|1)}

举一个实例:π(abacaba)={0,0,1,0,1,2,3}

1.2. 前缀函数的重要性质#

显然直接根据定义计算前缀函数的时间复杂度是不能接受的,于是我们需要依赖一些有用的性质来加速计算。
主要的性质有下面两个:

  1. π(i+1)π(i)+1,且仅当 sπ(i)=si+1 时有 π(i+1)=π(i)+1
  2. js[:i] 次长的 Border 的长度,则 j=π(π(i)1)

第一个性质是显然的。第二个性质由 s[:j1]=s[ij+1:i]=s[π(i)j:π(i)1] 自然导出。

1.3. 快速求前缀函数#

这样我们就有了快速求前缀函数的算法:
我们从前向后迭代求。假设我们已经求出了 π(0),π(1),,π(i1),现在要求 π(i)
j=π(i1)。如果 si=sj,则 π(i)=j+1
否则,令 jπ(j1),重复以上判断直到满足 si=sjj=0
j=0,则 π(i)=0

如此重复直到前缀函数全部计算完成。可以证明,时间复杂度为 O(n)

code:

void prefix()
{
    for(int i=1;i<=n-1;i++)
    {
        int j=pi[i-1];
        while(j>0&&s[i]!=s[j])j=pi[j-1];
        if(s[i]==s[j])j++;
        pi[i]=j;
    }
}

1.4. 前缀函数的应用#

1.4.1. KMP#

luoguP3375
一般的方法是通过 π(i) 优化匹配,这里就不介绍了。
我们有个简单粗暴地多的方法。
假设模式串为 s,文本串为 ts 的长度为 nt 的长度为 m
构造字符串 as+#+t,其中 #s,t 中均不会出现的字符。
然后求 a 的前缀函数,若π(a,i)=n,则 sti2n 处出现。
总时间复杂度 O(n+m)。代码略。
优化:注意到前缀函数是可以一个一个字符在线处理的,所以空间复杂度可优化到 O(n)

1.4.2 字符串的周期#

luoguP4391
对于字符串 s,若 p, 0<p|s| 使 i[0,|s|p1], si=si+p,则称 ps 的周期。
运用 Border 理论,可以发现,若 s 存在一个长为 lBorder,则 |s|ls 的周期。
我们应用之前的结果,可知 |s|π(|s|1), |s|π(π(|s|1)1),s 的周期,其中最小的周期为 |s|π(|s|1)

1.4.3 统计每个前缀的出现次数#

根据前缀函数的定义及其性质,以 i 为右端点有长度为 π(i), π(π(i)1), 的前缀。
我们不能按照端点来统计,因为在极端情况下,如 aaaaaa,时间复杂度达到了平方级别。
但是注意到每一个长为 i 的前缀的出现中也一定包含长为 π(i1) 的前缀的出现,因此我们可以考虑用较长的前缀的值来更新较短的前缀的值。(具体见代码)
最后别忘了加上每个前缀在初始位置出现的一次。
code:

for(int i=1;i<n;i++)ans[pi[i]]++;
for(int i=n-1;i>0;i--)ans[pi[i-1]]+=ans[i];
for(int i=1;i<=n;i++)ans[i]++;

2. Z函数#

2.1. Z函数的定义#

定义 lcp(a,b) 为字符串 a,b 的最长公共前缀。
定义Z函数:
对于字符串 s,定义Z函数 z(s,i)lcp(s,s[i:]) 的长度。
在不引起歧义的情况下,可简记为 z(i);特别地,定义 z(s,0)=0
根据以上定义,我们有 s[:z(i)1]=s[i:i+z(i)1];对此我们称 Z-box(i)=s[i:i+z(i)1]
有时我们关心一整个字符串的Z函数值,故此时我们也可以记 z(s)={z(s,0),z(s,1),,z(s,|s|1)}

举一个实例:z(abacaba)={0,0,1,0,3,0,1}

2.2. 快速求Z函数#

想要快速求出Z函数的值,我们需要充分发掘Z函数的性质以使信息得到高效利用。
考虑若lil+z(l)1,即 iZ-box(l)之内:
方便起见,设 r=l+z(l)1
根据Z函数的定义有 s[il:rl]=s[i:r],则 s[il:il+z(i)1]=Z-box(i)=s[:z(i)1]
很明显,若 z(il)<ri+1,则直接有 z(i)=z(il);否则,我们能得到 z(i)ri+1,想要得出具体数值还需向后枚举。

为了更好地利用上述性质,我们可以在计算过程中维护最大的 r。具体操作如下:
初始时令 l=r=0
从前到后开始计算。若 ir,利用上述性质进行计算。否则,暴力枚举。
如果新的 r 比之前的更大,即 i+z(i)1>r,更新 l=i, r=i+z(i)1
code:

z[0]=l=r=0;
for(int i=1;i<n;i++)
 {
    if(r>i)z[i]=min(z[i-l],r-i+1);//简便起见,我们可以直接让z[i]取为z[i-l]和r-i+1中较小的一个
    for(;b[i+z[i]]==b[z[i]];z[i]++);//暴力枚举
    if(i+z[i]-1>r){l=i;r=i+z[i]-1;}//更新l,r
}

可以看到每个字符只会被暴力匹配一次,因此复杂度为 O(n)

这个网站有对该算法的演示。
luoguP5410
对于这道题,我们还要求 ba 的每一个后缀的 lcp。可以对之前的算法进行修改,也可以直接把 a 接到 b 的后面来做。

作者:pjykk

出处:https://www.cnblogs.com/pjykk/p/15012747.html

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   pjykk  阅读(609)  评论(0编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示