后缀自动机(SAM)学习笔记

可能是写过除了线性代数之外最长的学习笔记。

前置知识

自动机的定义后缀数组(SA)、字符串和集合的定义。

然而,其实你可以在不学 SA 的情况下学习 SAM。

定义

定义一个字符串 \(s\) 的 SAM 是一个满足如下条件的 DFA(可简单理解为 DAG):

  1. DAG 上的结点被称为一个状态。源点 \(t_0\) 为初始状态。一些结点被称为终止状态。
  2. DAG 上的边每条代表一个字母,被称为转移。同一个状态的不同转移上标的字母均不同。
  3. 这个 DFA 接受 \(s\) 的所有后缀。即,\(s\) 的所有后缀经过转移后一定会转移到一个终止状态,类似字典树。
  4. 这个 DAG 是满足这些条件的结点数最小的 DAG。

根据定义,可以发现:这个 DAG 每一条从源点出发的路径都构成了一个原串的子串。

求解如何构造 SAM 之前需要了解几个概念。

endpos

对于字符串 \(s\) 的非空子串 \(t\),记 \(\operatorname{endpos}(t)\)\(t\)\(s\)结束位置的集合。

比如,对于字符串 \(s=\text{ababab}\),子串 \(t=\text{aba}\)\(\operatorname{endpos}(t)=\{3,5\}\)(假设字符串下标从 \(1\) 开始标号)。

等价类

若子串 \(t_1,t_2\) 满足 \(\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\),我们将 \(t_1\)\(t_2\) 分在一个等价类中。

于是我们可以将 \(s\) 的所有非空子串分成若干个等价类。

SAM 上一个状态就是一个等价类。换句话说,SAM 上从源点到达某结点 \(u\) 经过的若干个路径组成的字符串的 \(\operatorname{endpos}\) 都相同。

在后文中,某个状态 \(v\) 对应的某个等价类的 \(\operatorname{endpos}\) 也被记作 \(\operatorname{endpos}(v)\)

引理

没有给出严格证明,只是简单解释了下。

  • 引理 1:

\(\operatorname{endpos}(x)=\operatorname{endpos}(y)\),且 \(|x|\ge |y|\),则 \(y\) 一定是 \(x\) 的后缀。

(红圈为 \(x\)\(y\)\(s\) 中出现的位置),你可以注意到,因为 \(x\)\(y\) 出现位置相同,所以 \(y\)\(x\) 的后半部分是重合的。

你可以继续注意到,\(y\) 可以出现在其他 \(x\) 不出现的地方,因为长度更短更容易满足条件。

于是可以写成:

\(\operatorname{endpos}(x) \subseteq \operatorname{endpos}(y)\),则 \(y\) 一定是 \(x\) 的后缀。

  • 引理 2:

对于两个子串 \(x\)\(y\)\(|x|\ge |y|\)),只存在 \(\operatorname{endpos}(y) \subseteq \operatorname{endpos}(x)\)\(\operatorname{endpos}(y) \cap \operatorname{endpos}(x)=\emptyset\) 这两种情况。

如果 \(\exists x\in \operatorname{endpos}(y) \cap \operatorname{endpos}(x)\),相当于出现以下情况:

此时就已经可以证明 \(y\)\(x\) 后缀了。如果不是,则不会存在 \(x\) 这个位置。

  • 引理 3:

对于一个 \(\operatorname{endpos}\) 等价类,其中子串的长度从大到小排序之后是连续的。

可以这么理解:

对于一个等价类中最长的子串 \(x\),显然这个等价类中其他子串都是 \(x\) 的后缀。

考虑让 \(x\) 每次删掉最前面的字符(即一个个遍历后缀),显然长度越小越容易和 \(s\) 匹配,即这个后缀能匹配的地方 \(x\) 也不一定能匹配。

于是可能 \(x\) 的某一个后缀在 \(s\) 的另一处出现而 \(x\) 不出现,此时这个后缀就在另一个等价类了。

\(x\) 不断删最前面的字符,那么直到下一个等价类的临界点之前时他的后缀应该都满足和 \(x\) 在一个等价类,长度自然是连续的。

后缀链接

考虑一个状态 \(x\),其代表着一个等价类,设这个等价类包含的最长子串是 \(t\),则其它子串都是 \(t\) 的后缀。

考虑上文引理 3 中提到的删最前面一个字符的“临界点”,设最长的不和 \(t\) 在一个 \(\operatorname{endpos}\) 等价类中的 \(t\) 的真后缀为 \(u\),则 \(x\) 状态的后缀链接就是 \(u\) 所在的等价类的对应状态。

比如 \(aa\),此时有两个等价类 \(\operatorname{endpos}(a)=\{1,2\},\operatorname{endpos}(aa)=\{2\}\),设他们的状态分别为 \(x,y\)

\(y\) 的后缀链接就是 \(x\)。记作 \(\operatorname{link}(y)=x\)

Parent Tree

\(y\to \operatorname{link}(y)\) 看作一条边,则最终会形成一颗内向(指向根)树,被称作 后缀树/parent tree

\(fa(x)\) 为状态 \(x\) 在 parent tree 上面的直接父亲。可知 \(fa(x)=\operatorname{link}(x)\)

引理

依旧没有严格证明。

引理 4:

设一个状态 \(v\) 的最短子串为 \(s_1\)\(fa(v)\) 的最长子串为 \(s_2\),则 \(|s_1|=|s_2|+1\)

根据后缀链接的定义,\(s_2\) 为最长的不和 \(t\) 一个等价类的后缀,那么长度为 \(|s_2+1|\)\(t\) 的后缀和 \(t\) 就是一个等价类的了,并且为 \(v\) 这个状态的 \(\operatorname{endpos}\) 等价类中最短的子串。

引理 5:

\(\operatorname{endpos}(v) \subsetneqq \operatorname{endpos}(\operatorname{link}(v))\)

\(link(v)\) 中的子串是 \(v\) 中子串的后缀就可以得证包含了。

如果相等那 \(v\)\(\operatorname{link}(v)\) 就是一个结点了。所以是真包含。

引理 \(6\)

通过 \(\operatorname{endpos}\) 集合构造的树(\(\operatorname{endpos}(v)\subsetneqq \operatorname{endpos}(fa(v))\))与通过后缀链接 \(\operatorname{link}\) 构造的树相同。

看起来是废话。具体用处是后文理解 SAM 的算法原理。

注意这里的连边可能有歧义,比如 \(\{1\},\{1,3\},\{1,3,4\}\) 的连边方式是 \(\{1\}\to \{1,3\}\to \{1,3,4\}\)

小结

讲解构造 SAM 之前整理一下之前说到/没说到的知识,包括引理/约定等。

记号

  • \(\operatorname{link}(v)\) 为状态 \(v\) 的后缀链接。

  • \(fa(x)\) 为状态 \(v\) 在 parent tree 上的父亲。

  • \(\operatorname{short}(v)\operatorname{minlen}(v)\) 为状态 \(v\) 对应的 \(\operatorname{endpos}\) 等价类中最短子串以及它的长度,\(\operatorname{long}(v)\operatorname{len}(v)\) 则是最长的以及它的长度。

根据上文, \(\operatorname{minlen}(v)=\operatorname{len}(\operatorname{link}(v))+1\)

总结

  1. \(s\) 的子串会根据 \(\operatorname{endpos}\) 集合划分为若干个等价类。
  2. SAM 上的一个状态代表一个等价类。
  3. SAM 上每个状态 \(v\) 都有一个后缀链接,连向 \(\operatorname{endpos}(v)\) 中最长子串的最长真前缀 \(u\) 所在状态 \(u\),满足这个子串和它的真后缀不在同一个 \(\operatorname{endpos}\) 等价类。
  4. 后缀链接会形成一颗内向树。

构造

SAM 的构造是一个 在线算法,每次动态的加入一个字符 \(c\)

对一个字符串 \(s\) 构造 SAM,即把 \(s\) 的每一位依次加入 SAM 中。

一开始 SAM 只包含一个状态 \(t_0\),编号为 \(0\),钦定 \(\operatorname{link}(t_0)=-1\)\(\operatorname{len}(t_0)=0\)

算法流程如下:

  1. \(last\) 为添加字符 \(c\) 之前位于的状态。初始时 \(last=0\),即源点 \(t_0\)
  2. 创建一个新的状态 \(cur\),将 \(\operatorname{len}(cur)\) 赋值为 \(\operatorname{len}(last)+1\)
  3. \(last\) 开始遍历后缀链接:
    • 如果当前结点 \(p\) 没有转移字符 \(c\) 的出边,创建一条 \(v\to cur\) 的边,转移为 \(c\)
    • 如果遍历到了 \(p=t_0\),赋值 \(\operatorname{link}(cur)\gets 0\)
    • 当前结点 \(p\) 已有字符 \(c\) 的转移,停止遍历,设 \(p\) 经过字符 \(c\) 的转移到 \(q\)
      • \(\operatorname{len}(p)+1=\operatorname{len}(q)\),则赋值 \(\operatorname{link}(cur)=q\)
      • 否则,复制 \(q\) 到一个新状态 \(new\)(包括 \(\operatorname{link}(q)\) 和它的出边等),赋值 \(\operatorname{link}(q)=new\)\(\operatorname{link}(cur)=new\)\(\operatorname{len}(new)=\operatorname{len}(p)+1\)。然后从遍历 \(p\) 遍历后缀链接,若当前状态 \(v\) 有字符 \(c\) 的转移到 \(q\),则重定向为 \(v\to new\),否则停止遍历。
  4. 遍历结束后,\(last\gets cur\)

可以知道的是,对于一个长度为 \(n\) 的字符串,因为一个字符最多添加两个新状态,最多只有 \(O(n)\) 个状态。边数需要另行证明。

原理

有点长,但看完应该就能理解 SAM 的构造方式了。

Part 1

先找到 \(last\),为之后的更新做准备。设没加入之前的字符串是 \(S\)。此时 SAM 仅接受 \(S\) 的后缀。

考虑变化,加入字符 \(c\) 之后,字符串变成了 \(S+c\),显然要接受一个最长的后缀 \(S+c\),即 \(last\to cur\),并把 \(\operatorname{len}(cur)\) 设为 \(\operatorname{len}(last)+1\)

考虑加入 \(c\) 之后这个 SAM 接受的其他后缀发生了什么变化。显然是所有后缀都要多一个字符 \(c\)

如果只考虑要多一个 \(c\) 转移的话直接把 \(S\) 的所有后缀和 \(cur\) 直接连边就行了。但由于 SAM 要求一个相同状态不能有两个字符 \(c\) 的转移,所以可能出现矛盾。

Part 2

最朴素的情况,\(S\) 中没有出现过 \(c\)

则考虑直接 \(S\) 的所有后缀全部连上 \(c\)。具体是哪些结点?所有 \(S\) 的后缀都要有一个字符 \(c\) 的转移,即所有 \(S\) 的后缀的等价类。

这里需要注意到后缀链接的定义。parent tree 上 \(last\)\(t_0\) 这条链可以包含完所有 \(S\) 的后缀,一个个跳后缀链接连边即可。

Part 3

较复杂的情况。

跳后缀链接的时候出现这个情况:\(x\)\(S\) 的一个后缀,且 \(x+c\) 已经在 \(S\) 中出现过了。假设第一个出现这种情况的结点是 \(p\),指向 \(q\)

即如下图:

(前面也出现了一个 \(x+c\)

此时 \(x+c\) 多出现了一次(在新串末尾),\(\operatorname{endpos}(x+c)\) 自然会改变。

此时需要分讨一下。

Part 3.1

第一种情况。

\(q\) 这个等价类中所有的子串都来自 \(p\),即整个状态里的子串的 \(\operatorname{endpos}\) 都变了。

\(q\) 中还是只包含了一个等价类,满足 SAM 的性质(一个状态代表一个等价类)不用分裂。

此时 \(\operatorname{long}(p)+c\)\(x+c\) 这个串是 \(S+c\) 这个新串的后缀,所以 \(n\in \operatorname{endpos}(q)\)。(假设 \(n=|S|+1\) 即新串的长度)

然后我们又知道的是 \(\operatorname{endpos}(cur)=\{n\}\)。因为 \(S+c\) 只在自己的末尾位置出现。

于是这两个 \(\operatorname{endpos}\) 是包含关系,根据引理 \(6\) 他们就是在 parent 树上的父子关系,即 \(\operatorname{link}(cur)=q\)

这里可能会产生疑问。

\(\operatorname{link}\) 的定义是最长的满足不在同一等价类的后缀,如何保证 \(q\) 就是最长的?

考虑 \(\operatorname{link}\) 的定义,即最长的满足条件的真后缀。

因为从 \(u\) 遍历后缀链接时相当于不断删 \(\operatorname{long(u)}\) 最前面的字符,长度是递减的。

\(q\) 是第一个出现字符 \(c\) 的转移的状态,即长度最长的出现 \(c\) 的一个后缀。就满足条件了。

如何判断出现这种情况?

\(\operatorname{long}(q)=x+c\) ,此时所有 \(q\) 中的子串都是 \(x+c\) 的后缀,也都是新串后缀,而这个条件等价于 \(\operatorname{len}(q)=\operatorname{len}(p)+1\)

Part 3.2

第二种情况。

\(q\) 这个等价类中部分子串来自 \(p\),部分来自其他结点。

即整个状态里只有一部分子串的 \(\operatorname{endpos}\) 因字符 \(c\) 的加入改变了。此时 \(q\) 这个状态就维护了两个不同的 \(\operatorname{endpos}\)

而这并不满足 SAM 的性质,于是需要把这个状态分裂了,假设分裂出来的新结点为 \(new\)

此时状态 \(new\) 代表的就是因 \(c\) 改变的串的等价类。所以有 \(\operatorname{len}(new)=\operatorname{len}(p)+1\),即 \(\operatorname{long}(new)=x+c\)

然后,可以知道的是 \(\operatorname{endpos}(q)\subsetneqq \operatorname{endpos}(new)\),因为新分裂出来的结点的 \(\operatorname{endpos}\) 比原来的多了一个 \(n\)。于是 \(\operatorname{link}(q)=new\),还是根据引理 \(6\) 确定的父亲。

然后是 \(\operatorname{link}(cur)=new\),和上一个情况差不多的原理,不过因为改变 \(\operatorname{endpos}\) 的那部分子串的状态已经分裂出来了,所以是 \(new\) 不是 \(q\)

分裂后还会造成什么影响?

\(q\) 的转移 \(new\) 都显然都应该有。即复制除了 \(\operatorname{len}\) 之外的所有东西。

然后遍历 \(p\) 的后缀链接,这些后缀链接经过转移到 \(q\) 转移出来的东西都是原结点中 \(new\) 那一部分的,因为原结点已经分裂出来了,所以这条路径上的 \(np\) 状态就不再连向 \(q\),而是需要重定向到 \(new\)

但是必须注意的是只有通过字符 \(c\) 的转移到 \(q\) 的才需要重定向,转移的字符不一样就意味着转移出来不是 \(new\) 而是原 \(q\) 状态里 \(\operatorname{endpos}\) 不变那一部分的,就不需要重定向了。

判断这种条件的情况就是 \(\operatorname{len}(q)\not =\operatorname{len}(p)+1\)

复杂度

构建一个长度为 \(n\) 的字符串的 SAM 复杂度是 \(O(n)\)

证明不会。

Code

首先将一个状态的信息封装,记录 SAM 的大小以及 \(last\)

struct state
{
	int link,len;
	sd map<int,int> nex;
}st[N];
int siz,last;

初始化 SAM。

void init()
{
	st[0].link=-1;
	st[0].len=0;
	siz++;
	last=0;
}

加入一个字符。

void extend(char c)
{
	int cur=siz++;
	st[cur].len=st[last].len+1;
	int p=last;
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1) st[cur].link=0;
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1) st[cur].link=q;
		else
		{
			int nw=siz++;
			st[nw].nex=st[q].nex;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[cur].link=nw;
			st[q].link=nw;
		}
	}
	last=cur;
}

注意,这个程序的实际可用状态的下标范围是 \(0\sim siz-1\),因为 \(siz\) 多加了一次。

其他

其他需要知道的东西。

边数点数

可以不知道证明,但要记住的。

对一个长度为 \(n\) 的字符串 \(s\) 构造 SAM,状态数最多约 \(2n\) 个,转移数最多约 \(3n\) 个。

这里“约”取的是略高于上界的,即状态数 \(<2n\) 个。

注意对应数组空间开 2 倍/3 倍。

endpos 的大小

考虑对字符串 \(s\) 建立自动机时新建的共 \(n\) 个结点 \(cur\),假设只看这 \(n\) 个结点。

(很多 SAM 结构示意图中中间那一条链就是。)

然后发现,从 \(t_0\) 开始,走到的每个结点都代表了一个 \(s\) 的前缀。把这些节点称作 终止结点

考虑 parent 树(\(fa(x)=\operatorname{link}(x)\)),有以下结论:

状态 \(u\)\(\operatorname{endpos}\) 集合与 parent 树上 \(u\) 子树中所有终点结点对应的前缀的终点的集合相同。

上文中说到 ”\(x+c\) 就又在末尾又出现了一次”,此时有 \(\operatorname{link}(cur)=q\) 或者 \(\operatorname{link}(cur)=new\)\(\operatorname{link}\) 上的连边代表那些子串又出现了一次。(可能是这样吧)

例题: 【模板】后缀自动机(SAM)

处理出 \(d_u\) 表示状态 \(u\)\(\operatorname{endpos}\) 集合大小,最终答案为 \(\max\{d_u\times len_u\}\)

extend 的时候将终点标记一下,最后对 parent 树做子树求和即可。

#include<bits/stdc++.h>
#define sd std::
#define int long long
#define F(i,a,b) for(int i=(a);i<=(b);i++)
#define f(i,a,b) for(int i=(a);i>=(b);i--)
#define MIN(x,y) (x<y?x:y)
#define MAX(x,y) (x>y?x:y)
#define me(x,y) memset(x,y,sizeof x)
#define pii sd pair<int,int>
#define X first
#define Y second
#define Fr(a) for(auto it:a)
int read(){int w=1,c=0;char ch=getchar();for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;for(;ch>='0'&&ch<='9';ch=getchar()) c=(c<<3)+(c<<1)+ch-48;return w*c;}
void printt(int x){if(x>9) printt(x/10);putchar(x%10+48);}
void print(int x){if(x<0) putchar('-'),printt(-x);else printt(x);}
void put(int x){print(x);putchar('\n');}
void printk(int x){print(x);putchar(' ');}
const int N=3e6+10;
struct state
{
	int link,len;
	sd map<int,int> nex;
}st[N];
int siz,last;
char s[N];
void init()
{
	st[0].link=-1;
	st[0].len=0;
	siz++;
	last=0;
}
int dp[N];
void extend(char c)
{
	int cur=siz++;
	dp[cur]=1;
	st[cur].len=st[last].len+1;
	int p=last;
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1) st[cur].link=0;
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1) st[cur].link=q;
		else
		{
			int nw=siz++;
			st[nw].nex=st[q].nex;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[cur].link=st[q].link=nw;
		}
	}
	last=cur;
}
struct node
{
	int nex,to;
}a[N];
int tot,head[N];
void add(int u,int v)
{
	a[++tot].nex=head[u];
	head[u]=tot;
	a[tot].to=v;
}
void dfs(int u)
{
	for(int i=head[u];i;i=a[i].nex)
	{
		int v=a[i].to;
		dfs(v);
		if(~v) dp[u]+=dp[v];
	}
}
int n;
void solve()
{
	scanf("%s",s+1);
	n=strlen(s+1);
	init();
	F(i,1,n) extend(s[i]);
	F(i,0,siz-1) add(st[i].link,i);
	dfs(0);
	int ans=0;
	F(i,1,siz-1) if(dp[i]>1) ans=MAX(ans,dp[i]*st[i].len);
	put(ans);
}
signed main()
{
	int T=1;
//	T=read();
	while(T--) solve();
    return 0;
}

例题 2:CF1780G Delicious Dessert

先处理出每个状态 \(u\) 对应的 \(\operatorname{endpos}\) 大小即出现次数 \(cnt_u\)

因为 \(\operatorname{minlen}(u)=\operatorname{len}(\operatorname{link}(u))+1\),于是我们可以知道这个状态的子串长度区间。

题目转化为:

给定一个区间,求有多少个数是 \(cnt_u\) 的因数。

考虑预处理出 \(cnt_u\) 的所有因数,一段区间包含的因数用两个二分求解即可。

#include<bits/stdc++.h>
#define sd std::
//#define int long long
#define F(i,a,b) for(int i=(a);i<=(b);i++)
#define f(i,a,b) for(int i=(a);i>=(b);i--)
#define MIN(x,y) (x<y?x:y)
#define MAX(x,y) (x>y?x:y)
#define me(x,y) memset(x,y,sizeof x)
#define pii sd pair<int,int>
#define X first
#define Y second
#define Fr(a) for(auto it:a)
int read(){int w=1,c=0;char ch=getchar();for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;for(;ch>='0'&&ch<='9';ch=getchar()) c=(c<<3)+(c<<1)+ch-48;return w*c;}
void printt(long long x){if(x>9) printt(x/10);putchar(x%10+48);}
void print(long long x){if(x<0) putchar('-'),printt(-x);else printt(x);}
void put(long long x){print(x);putchar('\n');}
const int N=1e6+10;
struct state
{
	int len,link;
	sd map<int,int> nex;
}st[N<<1];
int siz,last;
int cnt[N<<1];//记录数量
inline void extend(char c)
{
	int cur=siz++,p=last;
	cnt[cur]=1;
	st[cur].len=st[last].len+1;
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1)
	{
		st[cur].link=0;
	}
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1)
		{
			st[cur].link=q;
		}
		else
		{
			int nw=siz++;
			st[nw].nex=st[q].nex;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[cur].link=st[q].link=nw;
		}
	}
	last=cur;
}
struct node
{
	int nex;
	int to;
}a[N<<1];
int tot,head[N<<1];
inline void add(int u,int v)
{
	a[++tot].nex=head[u];
	head[u]=tot;
	a[tot].to=v;
}
long long ans;
sd vector<int> v[N];
void dfs(int u)
{
	for(int i=head[u];i;i=a[i].nex)
	{
		int v=a[i].to;
		dfs(v);
		cnt[u]+=cnt[v];
	}
	int l=sd lower_bound(v[cnt[u]].begin(),v[cnt[u]].end(),st[st[u].link].len+1)-v[cnt[u]].begin();
	int r=sd upper_bound(v[cnt[u]].begin(),v[cnt[u]].end(),st[u].len)-v[cnt[u]].begin()-1;
	ans+=1ll*cnt[u]*(r-l+1);
}
int n;
char s[N];
void solve()
{
	st[0].link=-1;
	st[0].len=0;
	siz++;
	last=0;
	n=read();
	F(i,1,n) for(int j=i;j<=n;j+=i) v[j].emplace_back(i);
	scanf("%s",s+1);
	F(i,1,n) extend(s[i]);
	F(i,1,siz-1) add(st[i].link,i);
	dfs(0);
	put(ans);
}
int main()
{
	int T=1;
//	T=read();
	while(T--) solve();
    return 0;
}

应用

每道例题都给出了和思路一致的代码。

检查字符串是否出现过

给一个文本串 \(T\) 和多个模式串 \(P\),检查 \(P\) 是否在 \(T\) 中出现过。

看读入 \(P\) 的时候读入完有没有在一个终止结点。

代码是简单的,没找到题就懒得打了。

不同子串个数

给定字符串 \(s\),求 \(s\) 有多少个本质不同的子串。

构造 \(S\) 的后缀自动机。

每个从 \(t_0\) 出发的路径都是 \(S\) 的子串。从 \(t_0\) 出发的两条不同路径转移不可能完全一样,于是子串数转化为路径计数。

因为SAM 是个 DAG,dp 即可。

\(f_u\) 表示从 \(u\) 出发的长度 \(\ge 0\) 路径数量。

把贡献拆成 \(=0\)\(\ge 1\) 的。长度为 \(0\) 即自己(数量 \(1\)),长度 \(\ge 1\) 的路径即存在 \(u\to v\) 边的 \(v\) 出发的长度 \(\ge 0\) 的路径,即 \(f_v\) 之和。

于是 \(f_u=1+\sum\limits_{\{u,v\}\in SAM}f_v\)

答案为 \(d_{t_0}-1\),要去掉从 \(t_0\) 出发长度为 \(0\) 的串即空串。

原题:不同子串个数

#include<bits/stdc++.h>
#define sd std::
#define int long long
#define F(i,a,b) for(int i=(a);i<=(b);i++)
#define f(i,a,b) for(int i=(a);i>=(b);i--)
#define MIN(x,y) (x<y?x:y)
#define MAX(x,y) (x>y?x:y)
#define me(x,y) memset(x,y,sizeof x)
#define pii sd pair<int,int>
#define X first
#define Y second
#define Fr(a) for(auto it:a)
int read(){int w=1,c=0;char ch=getchar();for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;for(;ch>='0'&&ch<='9';ch=getchar()) c=(c<<3)+(c<<1)+ch-48;return w*c;}
void printt(int x){if(x>9) printt(x/10);putchar(x%10+48);}
void print(int x){if(x<0) putchar('-'),printt(-x);else printt(x);}
void put(int x){print(x);putchar('\n');}
void printk(int x){print(x);putchar(' ');}
const int N=2e5+10;
int n;
char s[N];
struct state
{
	int link,len;
	sd map<int,int> nex;
}st[N];
int siz,last;
void init()
{
	st[0].link=-1;
	st[0].len=0;
	siz++;
	last=0;
}
void extend(char c)
{
	int cur=siz++,p=last;
	st[cur].len=st[last].len+1;
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1)
	{
		st[cur].link=0;
	}
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1)
		{
			st[cur].link=q;
		}
		else
		{
			int nw=siz++;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			st[nw].nex=st[q].nex;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[cur].link=st[q].link=nw;
		}
	}
	last=cur;
}
struct node
{
	int nex;
	int to;
}a[N<<1];
int tot,head[N];
void add(int u,int v)
{
	a[++tot].nex=head[u];
	head[u]=tot;
	a[tot].to=v;
}
int dp[N],d[N];//dp求解
sd queue<int> q;
void solve()
{
	n=read();
	scanf("%s",s+1);
	init();
	F(i,1,n) extend(s[i]);
	F(i,0,siz-1) Fr(st[i].nex) add(it.Y,i),d[i]++;//建的反边
	F(i,0,siz-1) if(!d[i]) q.push(i);
	while(!q.empty())//DAG 上拓扑序求解
	{
		int u=q.front();q.pop();
		dp[u]++;
		for(int i=head[u];i;i=a[i].nex)//反边,所以答案是u推到v
		{
			int v=a[i].to;
			dp[v]+=dp[u];
			if(!--d[v]) q.push(v);
		}
	}
	put(dp[0]-1);
}
signed main()
{
	int T=1;
//	T=read();
	while(T--) solve();
    return 0;
}

最小循环移位

给定字符串 \(S\),求字符串最小的循环移位。

循环移位就是可以任意将 \(S\) 中最后一个字符移到串首,使得字典序最小。

相当于将 \(S\) 首尾相连形成一个环,选择一个起点使得字典序最小。

通常的办法是断环成链,即在 \(S+S\)(“+” 的含义是字符串加法,两个串拼一起)上选择一个长度为 \(|S|\) 的子串使得字典序最小。

考虑对 \(S+S\) 建 SAM,由于从 \(t_0\) 开始的任意一条路径都是原串的子串,则问题转化为从 \(t_0\) 出发,字典序最小的长度为 \(|S|\) 的路径。

\(t_0\) 开始贪心选 \(|S|\) 次最小的转移即可。

原题:【模板】最小表示法

#include<bits/stdc++.h>
#define sd std::
//#define int long long
#define F(i,a,b) for(int i=(a);i<=(b);i++)
#define f(i,a,b) for(int i=(a);i>=(b);i--)
#define MIN(x,y) (x<y?x:y)
#define MAX(x,y) (x>y?x:y)
#define me(x,y) memset(x,y,sizeof x)
#define pii sd pair<int,int>
#define X first
#define Y second
#define Fr(a) for(auto it:a)
int read(){int w=1,c=0;char ch=getchar();for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;for(;ch>='0'&&ch<='9';ch=getchar()) c=(c<<3)+(c<<1)+ch-48;return w*c;}
void printt(int x){if(x>9) printt(x/10);putchar(x%10+48);}
void print(int x){if(x<0) putchar('-'),printt(-x);else printt(x);}
void put(int x){print(x);putchar('\n');}
void printk(int x){print(x);putchar(' ');}
const int N=2e6+10;
struct state
{
	int len,link;
	sd map<int,int> nex;
}st[N];
int siz,last;
void init()
{
	st[0].len=0;
	st[0].link=-1;
	siz++;
	last=0;
}
void extend(int c)
{
	int cur=siz++;
	st[cur].len=st[last].len+1;
	int p=last;
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1)
	{
		st[cur].link=0;
	}
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1)
		{
			st[cur].link=q;
		}
		else
		{
			int nw=siz++;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			st[nw].nex=st[q].nex;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[q].link=st[cur].link=nw;
		}
	}
	last=cur;
}
int n,a[N];
void solve()
{
	n=read();
	init();
	F(i,1,n) a[i]=read(),extend(a[i]);
	F(i,1,n) extend(a[i]);
	int now=0;
	F(i,1,n)
	{
		printk((*st[now].nex.begin()).X);
		now=(*st[now].nex.begin()).Y;
	}
}
int main()
{
	int T=1;
//	T=read();
	while(T--) solve();
    return 0;
}

字典序第 \(k\) 大/小子串

给定字符串 \(s\),求 \(s\) 的子串中字典序第 \(k\) 大/小的。

\(s\) 建 SAM,转化为字典序第 \(k\) 大/小路径。

因为可以处理出由每个状态出发的路径数,遍历算一遍即可。

例题:TJOI2015 弦论

\(T=0\) 类似第 \(k\) 大的问题。

\(T=1\) 即一个路径占有的排名数量是其终点 \(\operatorname{endpos}\) 大小而非 \(1\)

先把每个状态的 \(\operatorname{endpos}\) 大小处理出来,记 \(|\operatorname{endpos}(u)|=cnt_u\)

还是考虑 dp。设 \(f_u\) 表示从状态 \(u\) 出发 \(\ge 0\) 的路径总共的子串个数。

\(f_u\) 分为 \(=0\) 的路径以及 \(\ge 1\) 的路径。

前者贡献为 \(cnt_u\),后者贡献 \(\sum \limits_{ \{u,v,c\}\in SAM } f_v\)

则得到转移式 \(f_u=cnt_u+\sum \limits_{ \{u,v,c\}\in SAM } f_v\)

然后遍历一遍就做完了。

#include<bits/stdc++.h>
#define sd std::
#define int long long
#define F(i,a,b) for(int i=(a);i<=(b);i++)
#define f(i,a,b) for(int i=(a);i>=(b);i--)
#define MIN(x,y) (x<y?x:y)
#define MAX(x,y) (x>y?x:y)
#define me(x,y) memset(x,y,sizeof x)
#define pii sd pair<int,int>
#define X first
#define Y second
#define Fr(a) for(auto it:a)
int read(){int w=1,c=0;char ch=getchar();for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;for(;ch>='0'&&ch<='9';ch=getchar()) c=(c<<3)+(c<<1)+ch-48;return w*c;}
void printt(int x){if(x>9) printt(x/10);putchar(x%10+48);}
void print(int x){if(x<0) putchar('-'),printt(-x);else printt(x);}
void put(int x){print(x);putchar('\n');}
void printk(int x){print(x);putchar(' ');}
const int N=2e6+10;
int n;
char s[N];
struct state
{
	int len,link;
	sd map<int,int> nex;
}st[N];
int siz,last;
void init()
{
	st[0].link=-1;
	st[0].len=0;
	siz++;last=0;
}
int cnt[N];//endpos 大小
void extend(char c)
{
	int cur=siz++,p=last;
	st[cur].len=st[last].len+1;
	cnt[cur]=1;
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1)
	{
		st[cur].link=0;
	}
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1)
		{
			st[cur].link=q;
		}
		else
		{
			int nw=siz++;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			st[nw].nex=st[q].nex;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[cur].link=st[q].link=nw;
		}
	}
	last=cur;
}
struct node
{
	int nex;
	int to;
}a[N];
int tot,head[N];
void add(int u,int v)
{
	a[++tot].nex=head[u];
	head[u]=tot;
	a[tot].to=v;
}
void dfs1(int u)
{
	for(int i=head[u];i;i=a[i].nex)
	{
		int v=a[i].to;
		dfs1(v);
		cnt[u]+=cnt[v];
	}
}
void clear()
{
	tot=0;
	F(i,0,siz-1) head[i]=0;
}
sd queue<int> q;
int f[N],d[N],g[N];

sd string ans;
void dfs(int u,int now)//从u出发找第now小的
{
	Fr(st[u].nex)
	{
		int v=it.Y;
		if(now>f[v])//不可能从 v 转移
		{
			now-=f[v];
			continue;
		}
		if(now>cnt[v])
		{
			ans+=it.X;
			dfs(v,now-cnt[v]);
			break;
		}
		ans+=it.X;
		break;
	}
}
int K,tt;
void solve()
{
	scanf("%s",s+1);
	n=strlen(s+1);
	tt=read(),K=read();
	init();
	F(i,1,n) extend(s[i]);
	F(i,1,siz-1) add(st[i].link,i);
	dfs1(0);//处理 cnt
	
	clear();
	F(i,0,siz-1) Fr(st[i].nex) add(it.Y,i),d[i]++;
	F(i,0,siz-1) if(!d[i]) q.push(i);
	
	while(!q.empty())
	{
		int u=q.front();q.pop();
		f[u]+=cnt[u];
		g[u]++;
		for(int i=head[u];i;i=a[i].nex)
		{
			int v=a[i].to;
			f[v]+=f[u];
			g[v]+=g[u];
			if(!--d[v]) q.push(v);
		}
	}
	
	//只用f数组
	if(tt==0)
	{
		F(i,0,siz-1) sd swap(f[i],g[i]);
		F(i,0,siz-1) cnt[i]=1;
	}
	
	int all=f[0]-cnt[0];
	if(K>all) return put(-1);
	dfs(0,K);//第K小
	sd cout<<ans;
}
signed main()
{
	int T=1;
//	T=read();
	while(T--) solve();
    return 0;
}

最长公共子串

给定 \(n\) 个字符串,求它们的最长公共子串。

对于统计多串子串类问题,一个比较通用的做法是 伪广义后缀自动机

伪广义自动机是指将多个字符串用特殊字符分隔开,再拼在一起。

后言:回来看的时候发现有点问题。注意这些特殊字符必须不同。下文代码中没有这样做,应该是我校 OJ 数据太水了?

比如对于两个字符串,此时两个字符串的最长公共子串就转化为了大串中出现超过两次的子串的最长长度。

但也有例外,比如这个子串出现的两次都来自 \(s_1\)\(s_2\) 中并没有出现。

于是需要额外维护两个标记,一个状态的 \(\operatorname{endpos}\) 既有 \(s_1\) 部分也有 \(s_2\) 部分当且仅当这个状态两个标记都有。

对于 \(T\) 个标记,\(T\) 较大时可以使用 bitset 优化复杂度,时间复杂度为 \(O(\sum |s_i|+\dfrac{T\sum |s_i|}{w})\)

原题洛谷上没找到,不过我校 OJ 上有一道:

给定 \(n\) 个字符串,输出他们的最长公共子串长度。保证 \(n\le 15\)

呃好像有:LCS - Longest Common Substring

#include<bits/stdc++.h>
#define sd std::
//#define int long long
#define F(i,a,b) for(int i=(a);i<=(b);i++)
#define f(i,a,b) for(int i=(a);i>=(b);i--)
#define MIN(x,y) (x<y?x:y)
#define MAX(x,y) (x>y?x:y)
#define me(x,y) memset(x,y,sizeof x)
#define pii sd pair<int,int>
#define X first
#define Y second
#define Fr(a) for(auto it:a)
int read(){int w=1,c=0;char ch=getchar();for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;for(;ch>='0'&&ch<='9';ch=getchar()) c=(c<<3)+(c<<1)+ch-48;return w*c;}
void printt(int x){if(x>9) printt(x/10);putchar(x%10+48);}
void print(int x){if(x<0) putchar('-'),printt(-x);else printt(x);}
void put(int x){print(x);putchar('\n');}
void printk(int x){print(x);putchar(' ');}
const int N=1e6+10;
struct state
{
	int len,link;
	sd map<int,int> nex;
}st[N];
int siz,last;
void init()
{
	st[0].link=-1;
	st[0].len=0;
	siz++;
	last=0;
}
sd bitset<20> dp[N];
void extend(char c,int op)
{
	int cur=siz++,p=last;
	st[cur].len=st[last].len+1;
	dp[cur].set(op);
	while(p!=-1&&!st[p].nex.count(c))
	{
		st[p].nex[c]=cur;
		p=st[p].link;
	}
	if(p==-1)
	{
		st[cur].link=0;
	}
	else
	{
		int q=st[p].nex[c];
		if(st[q].len==st[p].len+1)
		{
			st[cur].link=q;
		}
		else
		{
			int nw=siz++;
			st[nw].nex=st[q].nex;
			st[nw].link=st[q].link;
			st[nw].len=st[p].len+1;
			while(p!=-1&&st[p].nex[c]==q)
			{
				st[p].nex[c]=nw;
				p=st[p].link;
			}
			st[cur].link=st[q].link=nw;
		}
	}
	last=cur;
}
struct node
{
	int nex;
	int to;
}a[N<<1];
int tot,head[N];
void add(int u,int v)
{
	a[++tot].nex=head[u];
	head[u]=tot;
	a[tot].to=v;
}
int n;
void dfs(int u)
{
	for(int i=head[u];i;i=a[i].nex)
	{
		int v=a[i].to;
		dfs(v);
		dp[u]|=dp[v];
	}
}
char s[N],t[N];
void solve()
{
	n=read();
	init();
	F(i,1,n)
	{
		scanf("%s",t+1);
		int m=strlen(t+1);
		F(j,1,m) extend(t[j],i-1);
		extend('{',i-1);
	}
	F(i,1,siz-1) add(st[i].link,i);
	dfs(0);
	int ans=0;
	F(i,0,siz-1)
	{
		if(dp[i].count()==n) ans=MAX(ans,st[i].len);
	}
	put(ans);
}
int main()
{
	int T=1;
//	T=read();
	while(T--) solve();
    return 0;
}

练习题

link

参考资料

oiwiki-SAM

洛谷日报-史上最通俗的后缀自动机详解

博客园-SAM奶妈级教程

posted @ 2024-12-27 21:52  _E_M_T  阅读(189)  评论(0)    收藏  举报