自动机
Definition
一个确定有限状态自动机(DFA)\(M=(Q,\Sigma,\delta,q_0,F)\)由以下五个部分组成:
\(1.\)状态集合\(Q\)
\(2.\)字符集\(\Sigma\)
\(3.\)转移函数\(\delta:Q\times\Sigma\rightarrow Q\)
\(4.\)起始状态\(q_0\in Q\)
\(5.\)接受状态集合\(F\subseteq Q\)
若\(s=a_1\cdots a_n\)是\(\Sigma\)上的一个字符串,则我们称\(M\)接受该字符串当且仅当存在一组状态序列\(f_0,\cdots,f_n\)满足:
\(1.f_0=q_0\)
\(2.\forall i\in[1,n]f_i=\delta(f_{i-1},a_i)\)
\(3.f_n\in F\)
Trie
Trie是接收且仅接受给定字符串集合中的字符串的DFA。
转移函数似乎不太好写,这里就不写了。
KMP自动机
KMP自动机是一个仅接受以给定字符串\(s\)为后缀的字符串的DFA。
\(Q=\{0,\cdots,|s|\},q_0=0,F=\{|s|\},\delta(i,c)=\begin{cases}i+1&s_{i+1}=c\\0&s_1\ne c\wedge i=0\\\delta(next_i,c)&s_{i+1}\ne c\wedge i>0\end{cases}\)
构建
注意到因为我们已经维护了\(\delta\),所以可以不用像KMP一样暴力跳\(next\)而可以直接一步到位。做到时间复杂度严格\(O(n|\Sigma|)\)。
for(int i=1,fail=0;i<=n;++i)
{
fail=delta[fail][s[i]],delta[i-1][s[i]]=i;
for(int j=0;j<m;++j) delta[i][j]=delta[fail][j];
}
Aho-Corasick算法
AC自动机本质是Trie上实现多串的KMP自动机。可以认为AC自动机=Trie图+fail树。
Trie图是一个接受以给定字符串集合中的字符串为后缀的字符串的DFA,\(fail\)代替了KMP中的\(next\)。
构建
首先把Trie树构建出来,然后构建fail树,最后补全Trie图。
首先显然第一层的点的\(fail\)都指向\(0\)。
然后如果\(ch_{x,c}\)存在,那么我们有\(fail_{ch_{x,c}}=ch_{fail_x,c}\)。(构建fail树)
如果\(ch_{x,c}\)不存在,我们认为有\(ch_{x,c}=ch_{fail_x,c}\)。(补全Trie图)
for(int i=0;i<26;++i) if(ch[0][i]) fail[ch[0][i]]=0,q.push(ch[0][i]);
while(!q.empty())
{
int x=q.front();q.pop();
for(int i=0;i<26;++i) ch[x][i]? q.push(ch[x][i]),fail[ch[x][i]]=ch[fail[x]][i]:ch[x][i]=ch[fail[x]][i];
}
fail树
把AC自动机的\(x\rightarrow fail_x\)拉出来会构成一棵树。
显然这棵树的深度是\(O(n)\)的,也就是说很多时候暴跳fail更新答案的复杂度是错的。
所以我们可以先把fail树建出来,然后用树上算法来优化暴跳fail。
Trie图
我们知道Trie树作为一个DFA是有很多\(\delta(x,c)=?\)的,而Trie图则利用\(fail\)补全了这些\(\delta\)。(下面是一个很形象但是很不严谨的表述)
\(\delta(x,c)=\begin{cases}ch_{x,c}&ch_{x,c}\ne0\\ch_{fail_x,c}&ch_{x,c}=0\end{cases}\)
Some Applications
\(1.\)求\(t_1,\cdots,t_n\)在\(s\)中的出现次数。
首先把\(t_1,\cdots,t_n\)的AC自动机建出来,然后在Trie图上沿着\(s\)的字符走。
每当我们走到一个点\(x\)时,以这个点在fail树上的祖先(包括自己)为终止态的字符串在\(s\)中的出现次数就会\(+1\)。
所以我们可以先求出每个点走到了多少次,然后在fail树上dfs一遍做个子树和得到答案。
时间复杂度是\(O((\sum m)+n)\)。
后缀自动机
一个字符串\(s\)的后缀自动机(SAM)是一个接受\(s\)的所有后缀的最小DFA。
endpos
\(\forall t\subseteq s\),定义\(endpos(t)\)表示\(t\)在\(s\)中的所有结束位置。
\(s\)的所有非空子串都根据\(endpos\)分成若干等价类,而SAM的\(q\in Q\)与\(endpos\)等价类一一对应(\(q_0\)对应的是空子串的\(endpos\)),为了方便我们用\(endpos(u)\)表示\(u\in Q\)对应的\(endpos\)等价类。
性质1:对于\(u,v\subseteq s(|u|\le|v|)\),若\(u\)是\(v\)的一个后缀,那么\(endpos(v)\subseteq endpos(u)\),否则\(endpos(v)\cap endpos(v)=\varnothing\)。
性质2:对于一个\(endpos\)等价类,等价类中的子串互为后缀且长度构成了一个区间\([l,r]\)。
因此\(\forall u\in Q\),定义\(minlen(u),len(u)\)表示\(endpos(u)\)中的最短子串和最长子串的长度。
实际上SAM中\(endpos(u)\)中的字符串就是所有能够走到\(u\)的字符串。
link&parent树
后缀链接\(link(u)\)连接到\(endpos(u)\)中最长子串的最长的不属于\(endpos(u)\)的后缀\(v\)所在的\(endpos\)等价类对应的状态。
性质1:\(G=(Q,\{(u,v)|v=link(u)\})\)构成一棵以\(q_0\)为根的树。这棵树被称为parent树。
性质2:\(endpos(u)\subseteq endpos(link(u))\)
性质3:\(minlen(u)=len(link(u))+1\)
性质4:从任意状态\(u\)开始沿着parent树往上跳直到\(q_0\),经过的节点的\([minlen(v),len(v)]\)互不相交,且并集为\([0,len(u)]\)。
性质5:\(u\)在parent树上的祖先的endpos集合中的字符串是\(u\)的endpos集合中的字符串的后缀。
构建
对于SAM的每个状态,我们记录\(\delta,len,link\)三个信息,当然还可以记录\(is\)表示是否是终止态。
SAM的构建是一个在线的算法,换言之每次我们将往字符串末尾插入一个新的字符,并在SAM上进行扩展。
最初SAM仅有一个状态\(q_0\),编号为\(0\),我们认为\(len(0)=0,link(0)=-1\)。
同时我们实时记录\(cnt,las\)分别表示当前SAM中的最大编号以及当前前缀所对应的状态。
那么我们考虑如何往字符串末尾添加一个字符\(c\),算法流程如下:
\(1.\)新建状态\(now\),且令\(len(now)=len(las)+1\)。
\(2.\)从\(las\)开始往上跳parent树,并令经过的点\(x\)的\(\delta(x,c)=now\),直到第一个\(\delta(x,c)\)存在的点\(p\)。
\(3-1.\)若\(p=-1\)即不存在满足条件的\(p\),那么直接令\(link(now)=0\)并跳至\(5\)。
\(3-2.\)若\(p\ne-1\wedge len(p)+1=len(\delta(p,c))\),那么直接令\(link(now)=\delta(p,c)\)并跳至\(5\)。
\(3.\)否则记\(q=\delta(p,c)\),新建其副本\(copy\),修改\(len(copy)=len(p)+1\),然后令\(link(q)=link(now)=copy\)。
\(4.\)从\(p\)开始往上跳parent树,并令经过的点\(x\)的\(\delta(x,c)=copy\),直到\(\delta(x,c)\ne q\)。
\(5.\)更新\(las=now\)。
如果还需要\(is\),那么我们可以先构建出SAM,那么parent树上\((0,las)\)这条链上的状态都是终止态,其它的都不是终止态。
一般而言使用数组记录\(\delta\)可以做到时间-空间复杂度\(O(n|\Sigma|)-O(n|\Sigma|)\)。
利用哈希可以做到\(O(n)-O(n)\),不过常数大很多。
实际上可以在\(las,now\)的记录上稍稍优化一下。
void extend(int c)
{
int p=now,q,copy;t[now=++cnt].len=t[p].len+1;
for(;~p&&!t[p].ch[c];p=t[p].link) t[p].ch[c]=now;
if(!~p) return void();
if(t[q=t[p].ch[c]].len==t[p].len+1) return t[now].link=q,void();
t[copy=++cnt]=t[q],t[copy].len=t[p].len+1,t[now].link=t[q].link=copy;
for(;~p&&t[p].ch[c]==q;p=t[p].link) t[p].ch[c]=copy;
}
性质1:记\(|s|=n\),则\(|Q|\le 2n-1\)。
性质2:记\(|s|=n\),则\(|\delta|\le 3n-4\)。(注意很多\(\delta(x,c)=NULL\))
Some Applications
\(1.\)求\(s\)的本质不同子串个数
\(\sum\limits len(x)-len(link(x))\)
时间复杂度\(O(n)\)。
\(2.\)求\(s\)的所有本质不同的子串的长度之和
\(\sum\limits {len(x)+1\choose2}-{len(link(x))\choose2}\)
时间复杂度\(O(n)\)。
\(3.\)求\(s\)的出现次数超过\(1\)次的子串的出现次数乘上该串长度的最大值
通过在parent树上dfs求出每个点的\(size(u)=|endpos(u)|\)。
那么答案就是\(\max\limits_{size_u\ge2}(len(u)size(u))\)。
时间复杂度\(O(n)\)。
\(4.\)求\(t_1,\cdots,t_m\)在\(s\)中的首次出现的初始位置
考虑对SAM中的每一个状态预处理\(firstpos(u)=\min(endpos(u))\)。
我们可以在SAM的扩展途中维护\(firstpos\)。
具体而言:
新建状态\(now\)时,\(firstpos(now)=len(now)\)。
复制状态\(q\)到\(copy\)时,\(firstpos(copy)=firstpos(q)\)。
查询\(t_i\)时在SAM上走,如果中途没有\(\delta\)了那么说明\(t_i\not\subseteq s\),假如到了\(x_i\),那么首次出现的初始位置就是\(firstpos(x_i)-|t_i|\)+1。
时间复杂度\(O(|s|+\sum|t|)\)。
\(5.\)求\(t_1,\cdots,t_m\)在\(s\)中的末次出现的初始位置
类似于例题\(4\),考虑对SAM中的每一个状态预处理\(lastpos(u)=\max(endpos(u))\),不过\(lastpos\)的维护相比\(firstpos\)更加复杂。
新建状态\(now\)时,\(lastpos(now)=len(now)\)。
最后再parent树上dfs一遍做个子树\(\max\)就好了,\(lastpos(u)=\max\limits_{link(v)=u}lastpos(v)\)。
当然这个做法也是可以用来维护\(firstpos\)的,把\(\max\)换成\(min\)就好了。
查询\(t_i\)时在SAM上走,如果中途没有\(\delta\)了那么说明\(t_i\not\subseteq s\),假如到了\(x_i\),那么首次出现的初始位置就是\(lastpos(x_i)-|t_i|\)+1。
时间复杂度\(O(|s|+\sum|t|)\)。
\(6.\)求\(t_1,\cdots,t_m\)在\(s\)中的所有出现的位置
构建SAM的时候额外记录\(is\)表示这个状态中是否有一个子串为\(s\)的\(pre\)。
那么查询\(t_i\)时在SAM上走,如果中途没有\(\delta\)了那么说明\(t_i\not\subseteq s\),假如到了\(x_i\),那么就在parent树上\(x_i\)的子树中dfs,碰到\(is\)为\(1\)的节点就输出。
注意到对于大小为\(size\)的子树,子树中的\(is\)为\(1\)的节点不少于\(\frac12size\),因此时间复杂度\(O(|s|+\sum|t|+\sum|ans|)\)。
\(7.\)给定\(s\),求最短的基础上字典序最小的未在\(s\)中出现的字符串
考虑dp,设\(f_u\)表示\(u\)节点的答案。
转移为:\(f_u=\min\limits_{(u,v,c)}f_v+1\)
为了保证字典序最小,只需在转移时保证\(f_v\)最小的前提下\(c\)最小即可。
如果没有转移即\(\forall c\in\Sigma,\delta(u,c)=NULL\),那么取\(\Sigma\)中最小的字符即可。
那么要求的串的长度就是\(f_{q_0}\),具体的字符串沿着转移走回去即可。
\(8.\)给定\(s\),求最长的至少不相交地出现了\(k\)次的子串
对于SAM中的一个节点\(u\),如果可以在\(endpos(u)\)中选出\(k\)个数,使得两两之差不小于\(x(x\in[minlen(u),len(u)])\),那么我们就找到了一个合法的子串。
那么用线段树合并维护每个节点的\(endpos\),然后再每个节点上二分\(x\),再贪心地判定即可。
可以有两个小剪枝:
\(1.\)如果某个节点已经存在合法的子串,那么它的祖先都是无用的了。
\(2.\)记录当前找到的最大的答案\(mx\),如果\(len(u)<mx\)就跳过\(u\)和它的祖先,同时二分下界可以改成\(mx\)。
时间复杂度\(O(nk\log^2n)\)。
\(9.\)给定\(s\),求最长的至少不相交地出现了\(2\)次的子串
相比例题\(8\),只需要记录\(firstpos=\min(endpos(u)),lastpos=\max(endpos(u))\)即可。
具体方法已经在例题\(4\)和例题\(5\)中写到了。
时间复杂度\(O(n)\)。
\(10.\)给定\(s\),求\(s\)的字典序第\(k\)小的本质不同的子串
令\(f_u\)表示\(u\)对应的字符串集合的出现次数。
因为要求本质不同,所以\(\forall u\ne q_0,f_u=1\)。
然后再SAM的DAG上dp算出\(g_u\)表示经过\(u\)的字符串数。
转移是非常显然的:\(g_u=f_u+\sum\limits_{(u,v,c)}g_v\)
然后输出方案就很简单了,从头开始dfs一边就行了。
时间复杂度\(O(n)\)。
\(11.\)给定\(s\),求\(s\)的字典序第\(k\)小的子串
相比例题\(10\),出现多次的子串要计算多次,因此一开始令\(\forall u\ne q_0,f_u=size_u\)即可。
其中\(size_u\)为\(u\)在parent树上的子树中接受状态的数目。
时间复杂度\(O(n)\)。
后缀树
一个字符串\(s\)的后缀树是一棵压缩后的仅接受\(s\)的后缀的Trie。
压缩指的是对于没有分岔的一条链,将这条链缩成一条边。
为了方便我们在字符串的末尾接一个$
,在后缀树中包含$
的边指向的点就是接受状态。
我们可以认为后缀树是\(n+1\)个接受状态(包括空串)的虚树。
性质1:记\(|s|=n\),则\(|Q|\le 2n-1\)。
性质2:后缀树就是反串的SAM的parent树。
性质3:后缀树按字典序的dfs序就是\(sa\)。
beginpos
令\(beginpos(t)\)表示\(t\)在\(s\)中的所有起始位置。
\(s\)的所有非空子串都根据\(beginpos\)分成若干等价类,而后缀树的\(q\in Q\)与\(beginpos\)等价类一一对应(\(q_0\)对应的是空子串的\(beginpos\)),为了方便我们用\(beginpos(u)\)表示\(u\in Q\)对应的\(beginpos\)等价类。
而后缀树的性质与SAM是相通的,这里只讲不太一样的。
性质1:对于\(u,v\subseteq s(|u|\le|v|)\),若\(u\)是\(v\)的一个前缀,那么\(beginpos(v)\subseteq beginpos(u)\),否则\(beginpos(v)\cap beginpos(v)=\varnothing\)。
性质2:一个\(beginpos\)等价类的子串互为前缀且长度构成区间\([len(link(u))+1,len(u)]\)。
若我们用\(longest(u)\)表示\(beginpos(u)\)中最长的字符串,那么我们可以得到如下性质:
性质1:\(longest(u)\)就是\((q_0,u)\)这条链上所有边上的字符串按顺序接起来。
性质2:\(beginpos(u)\)中的字符串为\(\{longest(link(u))+str(link(u),u)_{1,i}\}\),其中\(str(u,v)\)指的是\((u,v)\)这条边上的字符串。
构建
后缀树就是反串的SAM的parent树。
后缀树上某节点\(beginpos\)集合中的字符串就是parent树上该节点\(endpos\)集合中的字符串的reverse。
但是我们并不能显式地维护每个节点的字符串,也不能显式地维护边上的字符串。
一个解决办法是利用该字符串在原串中的一个起始和终止位置。
另一个解决办法是利用一个节点的出边的字符串的首字母不同的性质,转而存储边上字符串的首字母。
第二种方法并不能够维护字符串,但是能够维护字符串之间的字典序。
Some Applications
\(1.\)给定\(s\),求把\(s\)的所有子串按字典序排序并连接得到的字符串中的第\(k\)个字符
建出后缀树,并处理出\(size(u)\)表示\(u\)的子树中接受状态的个数,以及\(beginpos(u)=\min(firstpos(u))\)。
那么对于节点\(u\),\(beginpos(u)\)中所有字符串的长度之和就是\(sum_u=size(u)\frac{(len(u)-len(link(u)))(len(u)+len(link(u))+1)}{2}\)。
按字典序处理出dfs序并按照dfs序做\(sum\)的前缀和。
那么每次询问我们就可以二分出答案字符所在字符串所在节点\(u\)。
然后再在\([len(link(u))+1,len(u)]\)中二分出答案字符所在字符串,同时得到该字符是所在字符串的第\(k\)位。
因为\(beginpos(u)\)中的所有字符串的起始位置相同,所以该字符就是\(s_{firstpos_u+k-1}\)。
\(2.\)给定\(s\),求把\(s\)的所有本质不同子串按字典序排序并连接得到的字符串中的第\(k\)个字符
相比于例题\(1\),修改\(sum_u=\frac{(len(u)-len(link(u)))(len(u)+len(link(u))+1)}{2}\)即可。
广义SAM
广义SAM是接受多个字符串中任意一个字符串的后缀的最小DFA。
一般而言广义SAM有两种构建方法:
第一种是在线的方法:像构建SAM一样做,每次插入完一个字符串后令\(now=1\),另外需要加一些特判。
int extend(int now,int c)
{
if(trans[now][c]&&len[now]+1==len[trans[now][c]]) return trans[now][c];
int p=now,q,f=0;
len[now=++cnt]=len[p]+1;
for(;p&&!trans[p][c];p=link[p]) trans[p][c]=now;
if(!p) return link[now]=1,now;
if(len[q=trans[p][c]]==len[p]+1) return link[now]=q,now;
if(len[p]+1==len[now]) f=1;
len[++cnt]=len[p]+1,memcpy(trans[cnt],trans[q],40),link[cnt]=link[q],link[q]=link[now]=cnt;
for(;p&&trans[p][c]==q;p=link[p]) trans[p][c]=cnt;
return f? cnt:now;
}
复杂度为\(O(|\Sigma|\sum n)\)。
第二种是离线的方法:先构建出这些串的Trie,然后每个节点在父节点的基础上插入。
int extend(int now,int c)
{
int p=now,q;
len[now=++cnt]=len[p]+1;
for(;p&&!trans[p][c];p=link[p]) trans[p][c]=now;
if(!p) return link[now]=1,now;
if(len[q=trans[p][c]]==len[p]+1) return link[now]=q,now;
len[++cnt]=len[p]+1,memcpy(trans[cnt],trans[q],40),link[cnt]=link[q],link[q]=link[now]=cnt;
for(;p&&trans[p][c]==q;p=link[p]) trans[p][c]=cnt;
return now;
}
void build()
{
for(int i=0;i<c;++i) if(ch[1][i]) q.push(ch[1][i]);
pos[1]=1;
for(int u;!q.empty();)
{
u=q.front(),q.pop(),pos[u]=extend(pos[fa[u]],c[u]);
for(int i=0;i<26;++i) if(ch[u][i]) q.push(ch[u][i]);
}
}
复杂度为\(O(|\Sigma||T|)\),\(T\)是这些串的Trie。