字 符 串 全 家 桶

字符串

Trie

基础的内容,但是当然要会。

哈希

没什么好说的。哈希算法和大部分的字符串算法似乎不是一个体系的。使用的时候注意尽量双哈希即可。

KMP 与 border

border 的定义:如果 i 满足 s[1i]=s[|s|i+1|s|],则称 is 的一个 border。

s 的所有 border 构成的集合记为 Border(s)。一般不认为 |s|Border(s)

KMP 算法可以在 O(n) 的时间内求出字符串 s 的所有前缀的最长 border。若记 s[1i] 的最长 border 为 fi(不存在则为 0),则有结论:s[1i] 的所有 border 是 fi,ffi,fffi,。因此求 fi+1 时只要在这些 border 里面尝试就可以了。

结论的证明是容易的,画出图来就很直观。

for(int i=2,j=0;i<=n;i++){
while(j&&s[j+1]!=s[i])j=f[j];
if(s[j+1]==s[i])++j;f[i]=j;
}

border 还有一个重要性质,称为 border 引理:s 的所有 border 可以划分为不超过 log2|s| 个等差数列。更进一步地,对任何 k[k,2k) 内的所有 border 构成等差数列。

至于 KMP 解决模式匹配问题就略过了。

AC 自动机

AC 自动机是 以 Trie 的结构为基础,结合 KMP 的思想 建立的自动机,用于解决多模式匹配等任务。——摘自 OI Wiki。

要建立 AC 自动机,首先建一个 Trie。然后添加一些 fail 指针,状态 u 的 fail 指针指向的状态 vu 的(出现过的)最长后缀。fail 指针的添加要按照深度处理,也就是需要做一遍拓扑排序。假设要求 x 的 fail 指针,x 的 父结点是 ux 是在 u 后面添加了一个字符 c 得到的。类似 KMP,从 fail[u] 开始不断跳 fail 指针,直到添加一个 c 之后的状态存在,就把 x 的 fail 指针指向这个状态。

实际实现时可以更简单一点,不用一直跳 fail,可以预先记下从这个点开始跳,后面增加 c,最终会跳到哪个点。

queue<int> Q;
for(int i=0;i<26;i++)
if(trie[0][i])Q.push(trie[0][i]);
while(!Q.empty()){
int u=Q.front();Q.pop();
for(int i=0;i<26;++i){
if(trie[u][i]){
fail[trie[u][i]]=trie[fail[u]][i];
Q.push(trie[u][i]);
}
else trie[u][i]=trie[fail[u]][i];
}
}

事实上会发现 KMP 就相当于是只有一个串时的 AC 自动机。既然 KMP 可以解决单模式串的匹配问题,AC 自动机就可以解决多模式串的匹配问题了。

一般的应用方法是把 fail 指针单拿出来看作一棵树去处理。比如说在状态 u 的子树内的所有状态都包含 u 对应的字符串作为子串。或许还可以在这棵树上 DP。

回文树

又叫回文自动机,可以简写为 PAM。它可以存储一个字符串中所有回文子串的信息。

首先容易知道 PAM 的状态数不超过 |s|。可以归纳证明这个结论。

PAM 的转移边表示在当前字符串前后各加一个相应字符,而 fail 指针指向最长的回文前缀(也就是最长后缀,还是最长 border)。另外每个状态上还要记下该回文串的长度。PAM 有两个初始状态,分别代表长度为 1,0 的回文串,称为奇根,偶根。进行一个增量构造,假设已经构造了前 p1 个字符的 PAM,添加 sp 时,不断跳 fail 指针直到 sp=splen1。然后如果新的状态存在就直接跳过去,否则新建一个状态,并且继续跳 fail 以获得这个状态的 fail 指针。

int tot,lst,trans[N][26],len[N],fail[N];
int node(int l){
tot++;for(int j=0;j<26;j++)trans[tot][j]=0;
len[tot]=l;fail[tot]=0;return tot;
}
void init(){tot=-1;lst=0;node(0);node(-1);fail[0]=1;}
int getfail(int x,int y){
while(s[x-len[y]-1]!=s[x])y=fail[y];
return y;
}
void build(char *s){
int Len=strlen(s+1);
for(int i=1;i<=Len;i++){
int now=getfail(i,lst),c=s[i]-'a';
if(!trans[now][c]){
int x=node(len[now]+2);
fail[x]=trans[getfail(i,fail[now])][c];
trans[now][c]=x;
}
lst=trans[now][c];
}
}

与回文串有关的问题都可以考虑使用 PAM。

Z函数,Manacher

这两个放到一起是因为它们真的很像。

Z 函数(或称为扩展 KMP)要解决的问题是:对所有 i,求 ss[i|s|] 的最长公共前缀的长度 zi。Manacher 要解决的问题是:对所有 i,求以 i 为中心的最长回文串的长度 ai,这里 i 可能是某个字符或者某个缝隙。

二者都有线性解法,思路都是利用已有信息减少比较量。对于 Z 函数而言,如果有一个段 s[l,r]s 的前缀匹配了,那么在求 zi 的时候,如果 ir,可以知道要么 zi=zil(当 zil<ri+1 时),要么 zirl+1。因此维护最右边的这样的段,然后暴力往后扩展即可。会发现右端点是不降的,所以时间复杂度就是线性。

for(int i=2,l=1,r=1;i<=m;i++){
if(i<=r&&z[i-l+1]<r-i+1)z[i]=z[i-l+1];
else{
z[i]=max(0,r-i+1);
while(i+z[i]<=m&&b[z[i]+1]==b[i+z[i]])++z[i];
l=i;r=i+z[i]-1;
}
}

Manacher 也类似。以 i 是字符的情况为例,如果有一个回文段 s[lr],那么在求 ai 的时候,如果 ir,可以知道要么 ai=al+ri(当 al+ri<ri+1 时),要么 airl+1。同样维护最右边的段,然后暴力往后扩展。同样右端点是不降的。一个小技巧是给原字符串的每两个字符之间加一个特殊字符,这样就可以避免掉 i 是缝隙的情况。

for(int i=1,l=1,r=0;i<=n;i++){
int k=i>r?1:min(a[l+r-i],r-i+1);
while(i-k>=1&&i+k<=n&&s[i-k]==s[i+k])++k;
a[i]=k;--k;ans=max(ans,a[i]);
if(i+k>r)l=i-k,r=i+k;
}

后缀数组

后缀数组要解决的问题是把 s 的所有后缀按照字典序排序。排序得到的结果记为 sa 数组,同时用 rk 数组表示每个位置的排名。

O(nlog2n) 做法:熟知比较两个字符串的字典序可以通过二分+哈希的方法在 O(log) 的时间内完成,再套一个 sort 就可以做到 O(nlog2n)

O(nlogn) 做法:运用倍增的思想。

sufj,i 表示字符串 s[imin(n,i+2j1)],当 saj 表示对 sufj,i 的排序结果,rkj 表示相应的排名。显然当 j 达到 log2n 时就得到了需要的 saj=0 的边界情况是容易的。对于从 j1j,要比较 sufj,i1sufj,i2,需要先比较 sufj1,i1sufj1,i2,再比较 sufj1,i1+2j1sufj1,i2+2j1。这个比较用的是 rkj1。所以这里就需要一个双关键字的排序,使用双关键字基数排序完成即可。

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int n,m,rk[N],y[N],c[N],sa[N];
//sa[i]表示排名为i的后缀
//c数组是桶,基数排序的辅助数组
//rk=x,y分别表示两个关键字
char s[N];
int main(){
scanf("%s",s+1);
n=strlen(s+1);m=300;
for(int i=1;i<=n;i++){rk[i]=s[i];++c[rk[i]];}//得到第一关键字并计入桶中
for(int i=2;i<=m;i++)c[i]+=c[i-1];//对桶做前缀和,则字典序越大,对应的c越大
for(int i=n;i>=1;i--)sa[c[rk[i]]--]=i;
for(int k=1;k<=n;k<<=1){
int num=0;
//确定第二关键字,y[i]表示第二关键字排名为i的数
for(int i=n-k+1;i<=n;i++)y[++num]=i;//不存在第二关键字的当作极小值
for(int i=1;i<=n;i++)if(sa[i]>k)y[++num]=sa[i]-k;
for(int i=1;i<=m;i++)c[i]=0;
for(int i=1;i<=n;i++)++c[rk[i]];
for(int i=2;i<=m;i++)c[i]+=c[i-1];
for(int i=n;i>=1;i--){sa[c[rk[y[i]]]--]=y[i];y[i]=0;}
//又一遍基数排序,在第二关键字已经排序完成的基础上
//对第一关键字进行排序,所以只用把开头的i换成y[i]
swap(rk,y);num=1;rk[sa[1]]=1;
//现在y不是用作第二关键字了,它记下了原来的第一关键字,即长为k的排序结果
for(int i=2;i<=n;i++){
if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])
rk[sa[i]]=num;
else rk[sa[i]]=++num;
}//x[i]表示i的排名
if(num==n)break;m=num;//m是字符集大小
}
for(int i=1;i<=n;i++)printf("%d ",sa[i]);
return 0;
}

涉及到子串比较大小的问题可以考虑后缀数组。

后缀数组可以引申出 height 数组,简记为 h,它的定义是 h[i]=LCP(suf[sa[i-1]],suf[sa[i]]),这里 LCP 表示最长公共前缀(的长度)。h 数组可以 O(n) 求,因为有结论:h[rk[i]]>=h[rk[i-1]]-1。然后就可以暴力跳了。

for(int i=1,k=0;i<=n;i++){
if(rk[i]==0)continue;if(k)--k;
while(s[i+k]==s[sa[rk[i]-1]+k])++k;
h[rk[i]]=k;
}

有结论:LCP(suf[sa[i]],suf[sa[j]])=min h[i+1...j],i<j。感性理解是容易的。因此在 ST 表预处理后可以 O(1) 求任意两个后缀的 LCP。

后缀树

考虑把 s 的所有后缀插入到一个 Trie 中。这个 Trie 有一个优越的性质:它的每一个非根结点恰好对应一个 s 的非空子串。但是这个 Trie 就很大,所以考虑压缩:如果某个点只有一个儿子,那么就把它和子结点缩起来。特别的,如果一个结点作为某个后缀的终止结点,也将其保留下来。这样得到的树称为后缀树。

后缀树就是反串的后缀自动机的 slink 建出的树。

后缀自动机

十级算法,先咕咕咕了。进省队了,不咕了!

字符串 S 的后缀自动机(简称 SAM),是一个可以接受 S 的所有后缀的最小自动机。SAM 最重要的是它包含了 S 的所有子串的信息——一个子串对应一条从初始状态 u0 出发的路径。

对 SAM 很重要的概念有两个。其一是结束位置 endpos。对 s 的任意子串 t,用 endpos(t) 表示 ts 中所有出现的末尾。根据 endpos,所有子串可以分为若干等价类,它们在 SAM 中被存储在同一个结点内。同时我们还可以得到一些重要推论:

  • 如果 endpos(u)=endpos(v),|u||v|,则 us 中的每次出现都是 v 的后缀。
  • 如果 |u||v|,要么 endpos(u)endpos(v)=,要么 endpos(u)endpos(v)
  • 一个 endpos 等价类内的所有子串的长度互不相同,且恰好构成一段区间。根据这个结论,只用在结点上记录这个等价类的最长字符串长度 maxlen 或者简记为 len,以及最短字符串长度 minlen

其二是后缀链接 slink。对于一个 endpos 等价类 v(也就是 SAM 中的一个结点 v),slink(v) 连接到对应于 v 中最短的字符串 w 删掉第一个字符后(即最长真后缀)所在的 endpos 等价类。特别的,如果 w 只有一个字符,那么连接到 u0。方便起见,令 endpos(u0)={0,1,2,,|S|},slink(u0)=1。实际上会发现 slink(v) 对应的 endpos 集合包含了 v 对应的 endpos 集合,于是后缀链接会构成一棵根结点为 u0 的树,表示了 endpos 集合的包含关系。这棵树一般称为 parent 树。同时会有 minlen(v)=maxlen(slink(v))+1(所以就不用记 minlen 了。

SAM 的构建就是增加一个字符 c,然后进行一些分类讨论:

  1. lst 为添加 c 之前整个串对应的状态。创建一个新的状态 cur,令 len(cur)=len(lst)+1,然后将 lst 的值更新为 cur
  2. 如果 lst 没有字符 c 对应的转移,添加到 cur 的转移。遍历后缀链接,将所有没有字符 c 转移的结点的这个转移定向到 cur,直到找到第一个存在字符 c 的转移的结点,设为 p
  3. 如果 p 不存在,也就是到达了 1,相当于说 c 是一个新出现的字符,将 slink(cur) 赋值为 0 并退出。
  4. 否则,设 p 通过 c 转移到的状态为 q。如果 len(p)+1=len(q),直接将 slink(cur) 赋值为 q 并退出。事实上,slink(cur) 要连接到的状态包含了整个字符串在加入 c 前就出现过的当前最长后缀,也就是以 c 结尾且长度为 len(p)+1 的字符串。
  5. 否则,我们要从 q 中拆出一部分 r 作为 slink(v)。这时 slink(r) 等于原来的 slink(q)slink(q) 变成了 rlen(r)=len(p)+1。然后从 p 遍历后缀链接往前跳,对所有字符 c 转移到 q 的结点,将这个转移重定向到 r

根据构造方式可以知道 SAM 是线性的。具体的,点数最大为 2|S|1,边数最大为 3|S|4

void insert(int c){
int cur=++tot;len[cur]=len[lst]+1;
int p=lst;lst=cur;
while(p!=-1&&!trans[p][c])trans[p][c]=cur,p=slink[p];
if(p==-1){slink[cur]=0;return;}
int q=trans[p][c];
if(len[q]==len[p]+1){slink[cur]=q;return;}
int r=++tot;len[r]=len[p]+1;slink[r]=slink[q];
for(int i=0;i<26;i++)trans[r][i]=trans[q][i];
slink[q]=slink[cur]=r;
while(p!=-1&&trans[p][c]==q)trans[p][c]=r,p=slink[p];
}
posted @   by_chance  阅读(135)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示