「Note」字符串方向 - 自动机相关
1. AC 自动机 ACAM
1.1. 简介
AC 自动机用于解决多模式串匹配问题,例如求多个模式串在文本串中的出现次数。显著地,它的应用实际上非常广泛。
借助 KMP 的思想,我们对 Trie 树上的每个节点构造其失配指针 \(fail_i\),指向对于当前字符串的最长后缀(其他(前缀)作为当前串后缀的最长的一个),显著地,每个节点的失配指针都指向一个更短的状态。当这样的后缀不存在时,失配指针会指向表示空串的根节点。
考虑如何构建 \(fail_i\):
根据每个节点的失配指针都指向一个更短的状态这个性质,考虑用 BFS 解决 \(fail_i\) 的构建,对于当前节点 \(now\) 来说,假设深度较小的节点都已经被处理完了。
现在假设当前节点 \(i\) 由 \(fa_i\) 经过字符 \(ch\) 转移过来,使 \(fail_i\leftarrow trans(fail_{fa_i},ch)\),若不存在 \(fail_{fa_i}\) 通过 \(ch\) 转移到的某一节点,则尝试使 \(fail_i\leftarrow trans(fail_{fail_{fa_i}},ch)\)。直到 \(fail\) 指向根节点,说明根本不存在合法前缀,我们使 \(fail_i\leftarrow rt\)。
特殊地,若不存在 \(trans(fa,ch)\) 这个转移方式,则直接令 \(trans(fa,ch)\leftarrow trans(fail_{fa_i},ch)\)。
1.2. 常见技巧
1.2.1 fail 树的性质
构建的 \(fail\) 指针会形成一棵树,称为 fail 树。这不是废话吗。
- fail 树为一颗有根树,可以进行树剖等树上操作。
- 对于节点 \(p\) 与其对应字符串 \(t_p\),对于任意一个子树内节点 \(q\),都有 \(t_p\) 是 \(t_q\) 的后缀。逆命题亦成立。
- 设 \(cnt_p\) 表示作为 \(t_p\) 后缀的字符串数量。若无重复串,则 \(cnt_p\) 为树上节点 \(p\) 到根节点上字符串节点数量。
1.2.2 应用
ACAM 可以与 DP 结合,在自动机中进行 DP。
1.3. 例题
\(\color{blueviolet}{P5357}\)
时间瓶颈在于每次跳 \(tail\) 的重复访问,考虑经过每个点时记录权值,最后一起统计,可以采用 DFS 或者拓扑排序。
$\text{Code}$:
#include<bits/stdc++.h>
#define LL long long
#define UN unsigned
using namespace std;
//--------------------//
//IO
inline int rd()
{
int ret=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')f=-f;ch=getchar();}
while(ch>='0'&&ch<='9')ret=ret*10+ch-'0',ch=getchar();
return ret*f;
}
//--------------------//
const int N=2e6+5,Ch=30;
int n;
char str[N];
int id[N],ans[N];
struct Edge
{
int to,nex;
}edge[N];
int etot,head[N];
void add(int from,int to)
{
edge[++etot]={to,head[from]};
head[from]=etot;
return;
}
struct ACAM
{
struct Trie_Node
{
int nex[Ch];
int fail,flag,cnt;
}t[N];
int tot,fcnt;
void insert(char *s,int temp)
{
int now=0,len=strlen(s+1);
for(int i=1;i<=len;i++)
{
if(!t[now].nex[s[i]-'a'+1])
t[now].nex[s[i]-'a'+1]=++tot;
now=t[now].nex[s[i]-'a'+1];
}
if(!t[now].flag)
t[now].flag=++fcnt;
id[temp]=t[now].flag;
return;
}
void get_fail()
{
queue<int>q;
for(int i=1;i<=26;i++)
{
if(t[0].nex[i])
q.push(t[0].nex[i]);
}
while(!q.empty())
{
int now=q.front();
q.pop();
for(int to,i=1;i<=26;i++)
{
to=t[now].nex[i];
if(!to)
{
t[now].nex[i]=t[t[now].fail].nex[i];
continue;
}
t[to].fail=t[t[now].fail].nex[i];
q.push(to);
}
}
return;
}
void get_ans(char *s)
{
int len=strlen(s+1),now=0;
for(int i=1;i<=len;i++)
{
now=t[now].nex[s[i]-'a'+1];
t[now].cnt++;
}
return;
}
void build()
{
for(int i=1;i<=tot;i++)
add(t[i].fail,i);
return;
}
void DFS(int now)
{
//printf("%d %d\n",now,t[now].cnt);
for(int to,i=head[now];i;i=edge[i].nex)
{
to=edge[i].to;
DFS(to);
t[now].cnt+=t[to].cnt;
}
if(t[now].flag)
ans[t[now].flag]=t[now].cnt;
return;
}
}A;
//--------------------//
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%s",str+1),A.insert(str,i);
A.get_fail();
scanf("%s",str+1);
A.get_ans(str);
A.build();
A.DFS(0);
for(int i=1;i<=n;i++)
printf("%d\n",ans[id[i]]);
return 0;
}
2. 后缀自动机 SAM
2.1. 简介
2.1.1. 基本定义与结论
SAM 一般用于在线性时间内解决如下问题:
- 在一个字符串中求另一字符串出现的位置。
- 一个字符串的本质不同的子串个数。
SAM 的定义:一个长为 \(n\) 的字符串 \(s\) 的SAM 是一个接受 \(s\) 的所有后缀的最小的有限状态自动机。
较为人话的说法:
- SAM 是一张有向无环图。每个节点为一个状态,边则为状态间的转移。
- 存在一个源点 \(t_0\),称作初始状态,其他状态均可从 \(t_0\) 出发到达。
- 每个转移(边)对应一个字符(一条路径表示一个字符串),从一个状态(节点)出发的转移均不同。
- 从初始状态 \(t_0\) 出发,最终转移到一个终止状态,则此径代表的字符串一定是原字符串 \(s\) 的一个后缀,\(s\) 的每个后缀都可以用一条这种路径表示。
- 满足上述条件的自动机中,SAM 的节点数量最少。
SAM 的较为重要的一条性质:
- 从初始状态出发到任意状态的路径与串 \(s\) 的所有子串(本质不同)一一对应。
接下来给出一些定义和符号表示:
- \(c_{p,q}\):转移 \(p\to q\) 代表的字符。
- \(\mathrm{st}(p,c)\):状态 \(p\) 经过字符 \(c\) 转移所到达的状态。
- \(\mathrm{endpos}(t)\):字符串 \(t\) 在原字符串中所有结束位置的集合。
- 等价类:对于 \(\mathrm{endpos}\) 集合相同的子串,我们将它们划分为一个等价类,作为一个状态。
- \(\mathrm{ep}(p)\):状态 \(p\) 所对应的 \(\mathrm{endpos}\) 集合。
- \(\mathrm{substr}(p)\):状态 \(p\) 所表示的所有子串的集合。
- \(\mathrm{longest}(p)\):状态 \(p\) 所表示的所有子串中,长度最长的那一个子串。
- \(\mathrm{shortest}(p)\):状态 \(p\) 所表示的所有子串中,长度最短的那一个子串。
- \(\mathrm{len}(p)\):状态 \(p\) 所表示的所有子串中,长度最长的那一个子串的长度。
- \(\mathrm{minlen}(p)\):状态 \(p\) 所表示的所有子串中,长度最短的那一个子串的长度。
方便理解,我们再次整理上述定义。SAM 的每个状态对应一个等价类,即 \(\mathrm{endpos}\) 集合相同的子串所组成的状态。具体地,我们给出例子。假设现有串 \(s=\texttt{"abcab"}\),则 \(\mathrm{endpos}(\texttt{"ab"})=\mathrm{endpos}(\texttt{"b"})={2,5}\),而串 \(\texttt{"ab"},\texttt{"b"}\) 便属于统一等价类。
\(\mathrm{longest}(p),\mathrm{shortest}(p),\mathrm{len}(p),\mathrm{minlen}(p)\) 则描述了状态 \(p\) 对应的字符串集合 \(\mathrm{substr}(p)\) 中最长、最短的字符串以及它们的长度。
下面介绍两个结论:
- 对于两个非空字符串 \(x,y\)(\(|x|\leq |y|\)),要么有 \(\mathrm{endpos}(x)\subseteq \mathrm{endpos}(y)\),要么有 \(\mathrm{endpos}(x)\cup\mathrm{endpos}(y)=\varnothing\)
- 对于一个状态 \(p\),其包含的字符串长度连续,且较短者是较长者的后缀。
两个结论的证明过程并不复杂,简单思考也可以感性理解,在这里不给出具体证明,具体可参考 Alex_Wei 的博客或者 OI-Wiki,链接见参考资料部分。
总结以上两个性质有:
- 对于一个子串 \(t\) 的所有后缀,其 \(endpos\) 集合的大小随着后缀长度减小而单调不降,并且较大的集合包含较小的集合。简单定性分析,当后缀越短时,约束条件越宽松,出现位置更可能多。
根据上面的性质,我们给出 SAM 中最核心的定义:
- 后缀链接 \(\mathrm{link}(p)\):对于所有 \(\mathrm{ep}(p)\subseteq\mathrm{ep}(q)\),\(\mathrm{link}(p)\) 指向 \(\mathrm{len}(q)\) 最大的那个 \(q\)。
稍微直观一点理解我们仍然用一个例子,假设现有串 \(s=\texttt{"babcab"}\)
我们假设状态 \(p\) 对应着我们的字符串集合 \(\{\texttt{"cab"}\}\),对应有 \(\mathrm{ep}(p)=\{6\}\)。
状态 \(a\) 对应字符串集合 \(\{\texttt{"ab"}\}\),对应有 \(\mathrm{ep}(a)=\{3,6\}\)。
状态 \(b\) 对应字符串集合 \(\{\texttt{"b"}\}\),对应有 \(\mathrm{ep}(b)=\{1,3,6\}\)。
根据刚才的定义,现有 \(\mathrm{ep}(p)\subseteq\mathrm{ep}(a),\mathrm{ep}(p)\subseteq\mathrm{ep}(b)\),且只有 \(a,b\) 两状态的 \(\mathrm{ep}\) 包含状态 \(p\) 的 \(\mathrm{ep}\),又有 \(\mathrm{len}(a)>\mathrm{len}(b)\),所以 \(\mathrm{link}(p)\) 应指向状态 \(a\)。
再次重复一下 \(\mathrm{link}(p)\) 的意义,它指向了状态 \(p\) 的所有后缀状态中(最长)长度最大的那个,易知 \(\mathrm{len}(\mathrm{link}(p))+1=\mathrm{minlen}(p)\)。
对于后缀链接有这样一条性质:
- 所有后缀链接形成一颗以 \(t_0\) 为根的树。(\(t_0\) 是我们最开始定义的初始状态,它包含了空串。)
显著的,对于任意状态(除了 \(t_0\)),沿着后缀链接移动总会达到一个 \(\mathrm{len}\) 更短的状态,直到 \(t_0\)。
后缀链接构成的树本质上是 \(\mathrm{endpos}\) 集合构成的一棵树,我们一般称为 Parent 树。
2.1.2. 关键结论
这部分主要摘自 Alex_wei 的博客,理解构建 SAM 的过程需要理解此部分的结论。如果你能较为直观地理解 Parent 树,那么这部分的结论都很显然,大部分证明请参考 Alex_wei 的博客。
第一组结论:
- 从任意状态 \(p\) 出发通过后缀链接跳转到 \(t_0\) 的路径,所有路径上的状态 \(q\) 的 \([\mathrm{minlen}(q),\mathrm{len}(q)]\) 无交集,并且范围随着在 Parent 上的深度减小而减小,并且他们的并集形成一个连续区间 \([0,\mathrm{len}(p)]\)。
- 从任意状态 \(p\) 出发通过后缀链接跳转到 \(t_0\) 的路径,所有路径上的状态 \(q\) 的 \(\mathrm{substr}(q)\) 的并集为 \(\mathrm{longest}(p)\) 的所有后缀。
第二组结论:
- 有任意状态 \(p\) 使得有从 \(p\) 到 \(q\) 的转移,对于 \(\forall t_p\in \mathrm{substr}(p)\),有 \(t_p+c_{p,q}\in\mathrm{substr}(q)\)。
- 对于 \(\forall t_q\in\mathrm{substr}(q)\),存在且只存在一个状态 \(p\) 使得有从 \(p\) 到 \(q\) 的转移,并且 \(\exist t_p\in \mathrm{substr}(p)\) 使得 \(t_p+c_{p,q}=t_q\)。
第三组结论:
- 不存在从状态 \(p\) 到状态 \(q\) 的转移使得 \(\mathrm{len}(p)+1>\mathrm{len}(q)\)。
- 存在唯一状态 \(p\),有 \(p\) 到 \(q\) 的转移使得 \(\mathrm{len}(p)+1=\mathrm{len}(q)\)。
- 存在唯一状态 \(p\),有 \(p\) 到 \(q\) 的转移使得 \(\mathrm{minlen}(p)+1=\mathrm{minlen}(q)\)。
在给出第四组结论之前,我们先给出两个定义:
- \(\mathrm{maxtrans}(q)\):有 \(p\) 到 \(q\) 的转移使得 \(\mathrm{len}(p)+1=\mathrm{len}(q)\) 的唯一 \(p\)。
- \(\mathrm{mintrans}(q)\):有 \(p\) 到 \(q\) 的转移使得 \(\mathrm{minlen}(p)+1=\mathrm{minlen}(q)\) 的唯一 \(p\)。
第四组结论:
- 对于转移 \(p\to q\),一定有 \(p\) 在 Parent 树上为 \(\mathrm{maxtrans}(q)\) 或其祖先。
- 对于转移 \(p\to q\),一定有 \(p\) 在 Parent 树上为 \(\mathrm{mintrans}(q)\) 或其子树内节点。
- 对于转移 \(p\to q\),所有这样的 \(p\) 在 Parent 树上构成了一条深度递减链,即 \(\mathrm{mintrans}(q)\to\mathrm{maxtrans}(q)\)。
并不难理解,考虑到 Parent 树的定义以及性质,一条从上到下的链中字符串长度连续并且都为链底长串的子串。
2.1.3 SAM 的构建
至此为止,我们可以用以上的所有性质来构建 SAM 了。
我们考虑在前缀串 \(s[1,i-1]\) 的 SAM 基础上插入当前字符更新整个 SAM。
设上一状态(目前已插入的前缀所在状态)为 \(las\),当前状态为 \(cur\),状态总数为 \(tot\)。初始时 \(las,cnt\) 均为 \(1\),即我们设初始状态 \(t_0=1\)。
我们使先新建编号为 \(cur\) 赋值,\(cur\) 表示的是以当前插入字符结尾前缀的状态,然后令 \(p\leftarrow las\),\(p\) 表示我们现在更新到的节点。
考虑转移边的处理,我们将 \(p\) 沿着 Parent 树向上跳,可以保证的是每到一个节点都是 \(s[1,i-1]\) 的后缀,所以我们要更新其向 \(s_i\) 的转移,若其没有此转移,我们就为其新建出边,并继续沿着 Parent 向上跳转。直到我们到一个节点存在此转移,说明再往上的节点都有此转移,就不必再更新了。
接下来考虑 Parent 树边的构建,我们分三种情况讨论。
情况一:
不存在一个 \(p\) 有以 \(s_i\) 的转移。
这种情况存在且只存在于 \(s_i\) 这个字符从未被加入过,我们令 \(\mathrm{link}(cur)\leftarrow t_0\) 即可。
情况二:
存在 \(p\) 有以 \(s_i\) 的转移,令 \(q=\mathrm{st}(p,s_i)\),且 \(\mathrm{len}(p)+1=\mathrm{len}(q)\)。
我们设 \(las\to t_0\) Parent 树上的路径 \(p\) 的前一个状态有 \(p'\),并且 \(p'\) 已经新建了 \(s_i\) 的转移到 \(cur\),根据 Parent 性质有 \(\mathrm{minlen}(cur)=\mathrm{len}(p')+1=(\mathrm{len}(p)+1)+1=\mathrm{len}(q)+1\),根据定义令 \(\mathrm{link}(cur)\leftarrow q\)。
情况三:
存在 \(p\) 有以 \(s_i\) 的转移,令 \(q=\mathrm{st}(p,s_i)\),且 \(\mathrm{len}(p)+1\not=\mathrm{len}(q)\)。
当 \(\mathrm{len}(p)+1\not=\mathrm{len}(q)\),只能存在 \(\mathrm{len}(p)+1<\mathrm{len}(q)\)。状态 \(q\) 中有一部分是无法转移到我们当前状态 \(cur\) 的,可以理解为 \(\mathrm{substr}(q)\) 不全为 \(\mathrm{substr}(cur)\) 的后缀,因为在 \(q\) 中存在以除 \(p\) 之外的状态转移过来的部分。我们考虑将 \(q\) 分为小于等于 \(\mathrm{len}(p)+1\) 和大于 \(\mathrm{len}(p)+1\) 的两部分,并新建状态 \(cl\) 存储小于等于 \(\mathrm{len}(p)+1\) 的部分。对于继承我们需要进行以下操作:
- \(cl\) 保存所有 \(q\) 向外的转移。
- \(\mathrm{len}(cl)\) 应等于 \(\mathrm{len}(p)+1\)。
- \(\mathrm{link}(cl)\) 应等于原来的 \(\mathrm{link}(q)\)。
- 对于 Parent 树上 \(p\to t\) 路径上的状态,我们也应该将原指向 \(q\) 的转移指向 \(cl\)。
- \(\mathrm{link}(q),\mathrm{link}(cur)\) 应等于 \(cl\)。
在构建完 Parent 树边后,我们使 \(las\leftarrow cur\),退出构建即可。
2.1.4. SAM 的时空间限制
对于 SAM 构造、使用时间复杂度为线性的证明略复杂,仍是可参考 Alex_Wei 的博客或者 OI-Wiki。
因为每次加入字符最多新建两个节点,所以空间应当开双倍,特殊地,当字符集很大时,可以用 map 维护转移。
2.2. 常用技巧
2.2.1. 求本质不同子串个数
考虑每个子串存在且只存在于一个状态,考虑计数所有状态中的子串总和,对于每个状态 \(i\) 有答案 \(\sum\mathrm{len}(i)-\mathrm{len}(\mathrm{link}(i))\)。
另一种考虑方式,SAM 上一条路径对应一个子串,对于每个状态求一下以此状态结尾的路径数量,最后求和,可以考虑用拓扑排序。(相当于转化为所有前缀的后缀个数。)
2.2.2. 解决匹配串问题
较为简单的,直接在 SAM 上各种跑,失配就跳 Parent,与 KMP 的思想相似。
2.2.3. 求 \(\mathrm{endpos}\) 集合大小
每加入一个新状态 \(cur\),为其计数器打上 \(1\),SAM 构建好后求一下 Parent 子树内计数器和即可。
2.3. 例题
\(\color{blueviolet}{P3804}\)
SAM 板子。
$\text{Code}$:
#include<bits/stdc++.h>
#define LL long long
#define UN unsigned
using namespace std;
//--------------------//
//IO
inline int rd()
{
int ret=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')f=-f;ch=getchar();}
while(ch>='0'&&ch<='9')ret=ret*10+ch-'0',ch=getchar();
return ret*f;
}
//--------------------//
const int N=1e6+5,N2=2e6+5;
struct Edge
{
int to,nex;
}edge[N2];
int etot,head[N2];
void add(int from,int to)
{
edge[++etot]={to,head[from]};
head[from]=etot;
return;
}
char s[N];
struct SAM
{
struct SAM_Node
{
int nex[30];
int len,fa;
int cnt;
}a[N2];
int las=1,tot=1;
void insert(char ch)
{
int it=ch-'a'+1,p=las;
int cur=++tot;
a[cur].len=a[las].len+1,las=cur,a[cur].cnt=1;
while(!a[p].nex[it]&&p)
a[p].nex[it]=cur,p=a[p].fa;
if(!p)
{
a[cur].fa=1;
return;
}
int q=a[p].nex[it];
if(a[p].len+1==a[q].len)
{
a[cur].fa=q;
return;
}
int cl=++tot;
a[cl]=a[q],a[cl].cnt=0,a[cl].len=a[p].len+1;
a[cur].fa=a[q].fa=cl;
while(a[p].nex[it]==q&&p)
a[p].nex[it]=cl,p=a[p].fa;
return;
}
void build()
{
for(int i=1;i<=tot;i++)
add(a[i].fa,i);
return;
}
LL ans=0;
void DFS(int now)
{
for(int to,i=head[now];i;i=edge[i].nex)
{
to=edge[i].to;
DFS(to);
a[now].cnt+=a[to].cnt;
}
if(a[now].cnt!=1)
ans=max(ans,1LL*a[now].cnt*a[now].len);
return;
}
}S;
//--------------------//
int main()
{
scanf("%s",s+1);
int len=strlen(s+1);
for(int i=1;i<=len;i++)
S.insert(s[i]);
S.build();
S.DFS(1);
printf("%lld",S.ans);
return 0;
}