「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 树。这不是废话吗。

  1. fail 树为一颗有根树,可以进行树剖等树上操作。
  2. 对于节点 \(p\) 与其对应字符串 \(t_p\),对于任意一个子树内节点 \(q\),都有 \(t_p\)\(t_q\) 的后缀。逆命题亦成立。
  3. \(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;
}

参考资料

\(\mathcal{Alex\_Wei's\ Blog}\)

\(\mathcal{OI-Wiki}\)

posted @ 2023-08-15 17:15  Eon_Sky  阅读(16)  评论(0编辑  收藏  举报