字 符 串 全 家 桶
字符串
Trie
基础的内容,但是当然要会。
哈希
没什么好说的。哈希算法和大部分的字符串算法似乎不是一个体系的。使用的时候注意尽量双哈希即可。
KMP 与 border
border 的定义:如果 满足 ,则称 是 的一个 border。
的所有 border 构成的集合记为 。一般不认为 。
KMP 算法可以在 的时间内求出字符串 的所有前缀的最长 border。若记 的最长 border 为 (不存在则为 ),则有结论: 的所有 border 是 。因此求 时只要在这些 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 引理: 的所有 border 可以划分为不超过 个等差数列。更进一步地,对任何 , 内的所有 border 构成等差数列。
至于 KMP 解决模式匹配问题就略过了。
AC 自动机
AC 自动机是 以 Trie 的结构为基础,结合 KMP 的思想 建立的自动机,用于解决多模式匹配等任务。——摘自 OI Wiki。
要建立 AC 自动机,首先建一个 Trie。然后添加一些 fail 指针,状态 的 fail 指针指向的状态 是 的(出现过的)最长后缀。fail 指针的添加要按照深度处理,也就是需要做一遍拓扑排序。假设要求 的 fail 指针, 的 父结点是 , 是在 后面添加了一个字符 得到的。类似 KMP,从 fail[u]
开始不断跳 fail 指针,直到添加一个 之后的状态存在,就把 的 fail 指针指向这个状态。
实际实现时可以更简单一点,不用一直跳 fail,可以预先记下从这个点开始跳,后面增加 ,最终会跳到哪个点。
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 指针单拿出来看作一棵树去处理。比如说在状态 的子树内的所有状态都包含 对应的字符串作为子串。或许还可以在这棵树上 DP。
回文树
又叫回文自动机,可以简写为 PAM。它可以存储一个字符串中所有回文子串的信息。
首先容易知道 PAM 的状态数不超过 。可以归纳证明这个结论。
PAM 的转移边表示在当前字符串前后各加一个相应字符,而 fail 指针指向最长的回文前缀(也就是最长后缀,还是最长 border)。另外每个状态上还要记下该回文串的长度。PAM 有两个初始状态,分别代表长度为 的回文串,称为奇根,偶根。进行一个增量构造,假设已经构造了前 个字符的 PAM,添加 时,不断跳 fail 指针直到 。然后如果新的状态存在就直接跳过去,否则新建一个状态,并且继续跳 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)要解决的问题是:对所有 ,求 与 的最长公共前缀的长度 。Manacher 要解决的问题是:对所有 ,求以 为中心的最长回文串的长度 ,这里 可能是某个字符或者某个缝隙。
二者都有线性解法,思路都是利用已有信息减少比较量。对于 Z 函数而言,如果有一个段 和 的前缀匹配了,那么在求 的时候,如果 ,可以知道要么 (当 时),要么 。因此维护最右边的这样的段,然后暴力往后扩展即可。会发现右端点是不降的,所以时间复杂度就是线性。
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 也类似。以 是字符的情况为例,如果有一个回文段 ,那么在求 的时候,如果 ,可以知道要么 (当 时),要么 。同样维护最右边的段,然后暴力往后扩展。同样右端点是不降的。一个小技巧是给原字符串的每两个字符之间加一个特殊字符,这样就可以避免掉 是缝隙的情况。
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; }
后缀数组
后缀数组要解决的问题是把 的所有后缀按照字典序排序。排序得到的结果记为 数组,同时用 数组表示每个位置的排名。
做法:熟知比较两个字符串的字典序可以通过二分+哈希的方法在 的时间内完成,再套一个 sort
就可以做到 。
做法:运用倍增的思想。
设 表示字符串 ,当 表示对 的排序结果, 表示相应的排名。显然当 达到 时就得到了需要的 。 的边界情况是容易的。对于从 到 ,要比较 和 ,需要先比较 和 ,再比较 和 。这个比较用的是 。所以这里就需要一个双关键字的排序,使用双关键字基数排序完成即可。
#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
数组可以 求,因为有结论: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 表预处理后可以 求任意两个后缀的 LCP。
后缀树
考虑把 的所有后缀插入到一个 Trie 中。这个 Trie 有一个优越的性质:它的每一个非根结点恰好对应一个 的非空子串。但是这个 Trie 就很大,所以考虑压缩:如果某个点只有一个儿子,那么就把它和子结点缩起来。特别的,如果一个结点作为某个后缀的终止结点,也将其保留下来。这样得到的树称为后缀树。
后缀树就是反串的后缀自动机的 slink 建出的树。
后缀自动机
十级算法,先咕咕咕了。进省队了,不咕了!
字符串 的后缀自动机(简称 SAM),是一个可以接受 的所有后缀的最小自动机。SAM 最重要的是它包含了 的所有子串的信息——一个子串对应一条从初始状态 出发的路径。
对 SAM 很重要的概念有两个。其一是结束位置 endpos
。对 的任意子串 ,用 表示 在 中所有出现的末尾。根据 ,所有子串可以分为若干等价类,它们在 SAM 中被存储在同一个结点内。同时我们还可以得到一些重要推论:
- 如果 ,则 在 中的每次出现都是 的后缀。
- 如果 ,要么 ,要么 。
- 一个 等价类内的所有子串的长度互不相同,且恰好构成一段区间。根据这个结论,只用在结点上记录这个等价类的最长字符串长度
maxlen
或者简记为len
,以及最短字符串长度minlen
。
其二是后缀链接 slink
。对于一个 等价类 (也就是 SAM 中的一个结点 ), 连接到对应于 中最短的字符串 删掉第一个字符后(即最长真后缀)所在的 等价类。特别的,如果 只有一个字符,那么连接到 。方便起见,令 。实际上会发现 对应的 集合包含了 对应的 集合,于是后缀链接会构成一棵根结点为 的树,表示了 集合的包含关系。这棵树一般称为 树。同时会有 (所以就不用记 了。
SAM 的构建就是增加一个字符 ,然后进行一些分类讨论:
- 令 为添加 之前整个串对应的状态。创建一个新的状态 ,令 ,然后将 的值更新为 。
- 如果 没有字符 对应的转移,添加到 的转移。遍历后缀链接,将所有没有字符 转移的结点的这个转移定向到 ,直到找到第一个存在字符 的转移的结点,设为 。
- 如果 不存在,也就是到达了 ,相当于说 是一个新出现的字符,将 赋值为 并退出。
- 否则,设 通过 转移到的状态为 。如果 ,直接将 赋值为 并退出。事实上, 要连接到的状态包含了整个字符串在加入 前就出现过的当前最长后缀,也就是以 结尾且长度为 的字符串。
- 否则,我们要从 中拆出一部分 作为 。这时 等于原来的 , 变成了 ;。然后从 遍历后缀链接往前跳,对所有字符 转移到 的结点,将这个转移重定向到 。
根据构造方式可以知道 SAM 是线性的。具体的,点数最大为 ,边数最大为 。
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]; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具