SAM总结

后缀树

AC 自动机中文本串未知而模式串已知,而后缀树与之相反,文本串已知而模式串未知,我们需要利用某种东西维护文本串所有子串的信息。

首先可以有暴力的想法,我们把文本串所有的后缀都扔进 Trie。然而这样结点个数是 \(O(n^2)\) 的,我们需要继续压缩信息。

我们在每个后缀的最后加上一个特殊的结点(不妨叫它后缀结点)记录后缀的开始位置,然后建立 Trie 的关于 \(n\) 个后缀结点的虚树(这是压缩信息的利器),就得到了后缀树。

这棵树的性质很好。可以发现在给出一个模式串后,把它扔到这棵树上类似 AC 自动机一样跳,那么它可以匹配到的位置就是它最终跳到的结点的子树内的所有叶子结点记录的位置。在这棵树上 DFS 就可以得到后缀数组。求 \(LCP\) 就是求 LCA 的深度。

构建这棵树肯定不能直接插入然后建虚树,于是有了 SAM。

SAM

SAM 是一个能识别 \(s\) 的所有后缀的最小 DFA,有以下性质:

  • 是 DAG,结点为状态,边为转移,转移上带一个字符(与 AC 自动机和 PAM 一样)。

  • 存在一个初始状态,从这里出发可以到达每一个结点,所经转移上的字符写下来是 \(s\) 的一个子串。\(s\) 的每个子串都可以这样表示出来。

  • 存在若干终止状态,从初态出发到任意一个终态都可以得到 \(s\) 的一个后缀。\(s\) 的每个后缀都可以这样表示出来。

  • 状态数和转移数都是线性的。

构建

定义 \(endpos(t)\) 表示 \(t\)\(s\) 中所有结束位置的集合。上文后缀树就是将 \(endpos\) 相同的结点合并在一起,SAM 也可以利用这种思想来压缩信息。

可以发现 \(endpos\) 相同的子串有很多,并且对 \(endpos\) 相同的子串后面加上一个相同的字符,所得到的新子串的 \(endpos\) 仍然相同。于是可以对 \(endpos\) 等价类(就是 \(endpos\) 相同的子串的集合)建立 SAM,一种转移只会对应一个状态,当然一个状态可以通过多种转移得到。

继续发掘 \(endpos\) 的性质。

引理 1:对于字符串的任意子串 \(u\)\(v\),若 \(endpos(u)=endpos(v)\)\(|u|<|v|\),那么 \(u\)\(v\) 的后缀。这一条是显然的。

引理 2: 对于字符串的任意子串 \(u\)\(v\),若 \(|u|\le |v|\),那么 \(endpos(v)\)\(endpos(u)\) 要么包含(\(endpos(v)\subseteq endpos(u)\))要么无交。证明很简单,若 \(u\)\(v\) 的子串,那么 \(u\) 出现是 \(v\) 出现的必要不充分条件;否则 \(u\)\(v\) 一定不会同时出现。

由以上两条,启示我们可以将 \(s\) 的所有子串按 \(endpos\) 划分为若干等价类。

引理 3: 对于任意一个 \(endpos\) 等价类,其中的子串按大小排序,则一定是后一个子串是前一个子串的后缀,且长度减一,长度在值域上是连续的一段。证明很简单,设 \(u\) 是最短的,\(v\) 是最长的。当 \(u=v\) 时一定成立,否则由引理 1,\(u\)\(v\) 的子串,所以对于 \(v\) 的所有长度 \(\ge |u|\) 的后缀,由第二条性质,一定在这个等价类中。

现在我们设 \(v\) 是一个等价类中最长的一个子串,在 \(v\) 的前面加上一个字符后形成的新子串的 \(endpos\) 一定是 \(endpos(v)\) 的子集。而且显然的,在 \(v\) 的前面加上不同的字符会指向不同的 \(endpos\) 等价类,且这些等价类两两无交。于是我们相当于把这个 \(endpos\) 分割成若干部分。借这个关系可以建出一棵树。注意这棵树上的转移边表示在 \(endpos\) 等价类中的最长子串前面加上一个字符所得到的 \(endpos\)

这棵树被称作 Parent Tree,实际上这棵树与后缀树的区别在于这棵树的转移是在前面加字符,于是这棵树可以视作反串的后缀树。根据分割关系可知,总结点数是 \(O(n)\) 的。

我们记等价类 \(x\) 中最长子串的长度为 \(len_x\),最短子串长度为 \(\min len_x\),在 Parent Tree 上的父亲为 \(link_x\),于是由以上过程可知 \(\min len_x=len_{link_x}+1\),因为 \(x\) 中的最短的一个是 \(link_x\) 中最长的一个在前面加上一个字符得到的。

但这还不是 SAM,SAM 的转移边应该是在子串的后面加上一个字符。我们希望在 Parent Tree 上增加上一些转移边使 SAM 满足其性质。

继续构建

考虑增量法构建 SAM,新增一个字符后维护新增的子串(以新增字符结尾的子串)。

设当前加入的字符在第 \(i\) 位,字符为 \(c\),第 \(1\) 位到第 \(i-1\) 位构成的字符串所在等价类的编号为 \(x\)。设初始节点(代表着空串)的编号为 \(1\)

首先,出现了新的一个最大的子串 \(s_{1\dots i}\),于是新建一个点 \(cur\),并且有 \(len_{cur}=len_x+1\)

接下来考虑如何修改已有节点的 \(endpos\)。发现需要修改 \(endpos\) 的点都是 \(s_{1\dots i-1}\) 的一个后缀通过 \(c\) 转移边指向的点。跳后缀只需从 \(x\) 开始不断跳 \(link\) 就好。可以发现这是在从大到小遍历后缀,所以一开始较大的后缀可能没有 \(c\) 转移边,也就是说这些后缀在后面加上 \(c\) 形成的子串没有在 \(s_{1\dots i-1}\) 中出现过,于是这些后缀通过 \(c\) 转移边直接指向 \(cur\)。如果这样一直跳到了根,那么 \(cur\) 在 Parent Tree 上的父亲就是 \(1\)

否则现在跳到了 \(p\)\(p\) 经过一条 \(c\) 转移边指向了 \(q\)。那么现在 \(p\) 中最大的子串就是 \(s_{1\dots i-1}\) 的一个后缀,那么 \(q\) 中长度不大于 \(len_p+1\) 的子串就是 \(s_{1\dots i}\) 的一个后缀,这些子串的 \(endpos\) 中新增了 \(i\)

如果 \(len_q=len_p+1\),那么是好处理的,整个 \(q\)\(endpos\) 都新增了 \(i\),于是不用动 \(q\),并且将 \(link_{cur}=q\)

否则,我们需要将 \(q\) 分裂。设 \(q\) 分裂出来的节点为 \(np\)\(np\) 中的所有子串的长度都不大于 \(len_p+1\),那么 \(np\) 中最长的子串就是 \(s_{1\dots i}\) 的后缀,于是 \(len_{np}=len_p+1\)\(np\) 的转移还是和 \(q\) 的转移是一样的,于是直接拿过来。来考虑 \(link_{np}\),原来 \(link_q\) 通过在前面加上一个字符得到了原来 \(q\) 中最短的子串,而这个子串一定在 \(np\) 中,所以 \(link_{np}\gets link_q\),然后 \(link_q\gets np\)

拆分完后,考虑对其他状态的转移的影响,一些指向 \(q\) 的转移应该指向 \(np\)。从 \(p\) 开始继续跳 \(link\)(这相当于在前面减去一些字符),跳到新的 \(p\),如果能通过 \(c\) 转移边指向 \(q\),那么就将这个转移改到 \(np\) 上。如果指向的不是 \(q\),那就是指向的 \(q\) 在 Parent Tree 上的祖先,整个的 \(endpos\) 都要增加 \(i\),就不用改了。

最后将 \(x\gets cur\),就构建好了。

复杂度的证明:首先状态数就是 Parent Tree 的结点数,是 \(O(n)\) 的。现在来证转移数是 \(O(n)\) 的。我们找到 SAM 的一棵生成树,从一个终止结点开始走转移边跑到初始结点来找到对应的后缀。当走到一条非树边时,走过去,然后沿生成树上的路径走回初始状态。这样走出来的不一定是对应的后缀,但一定也是一个后缀,并且没有跑出来过。每次跑出来一个后缀,就把这个后缀划去,然后重复以上过程。因为后缀数量为 \(n\),于是非树边的数量为 \(O(n)\),然后树边的数量与状态数相关,也是 \(O(n)\) 的,于是转移数就是 \(O(n)\) 的。更紧一点,点数 \(\le 2n-1\),边数 \(\le 3n-4\)

然后来看构造部分的时间复杂度。对于跳 \(x\)\(link\) 连向 \(cur\) 的转移和将 \(q\) 的转移拿到 \(np\) 上,都是增加了转移,而转移数是 \(O(n)\) 的,于是总的是 \(O(n)\) 的。对于跳 \(p\)\(link\) 修改到 \(q\) 的转移,可以发现每条边只会被修改一次,因为已经满足了 \(len_{np}=len_p+1\),下一次遍历到它时不会进行修改转移的遍历,又因为边数是 \(O(n)\) 的,所以还是 \(O(n)\) 的,于是构造 SAM 的时间复杂度是 \(O(n)\) 的。

应用

判断子串

把文本串扔到 SAM 上,看能否找到对应节点。

访问后缀

直接从一个结点开始跳 \(link\)

\(endpos\)

由 Parent Tree 的分割关系,可以在 Parent Tree 上线段树合并求出一个结点的 \(endpos\)

广义 SAM

正确的写法可以离线也可以在线。

离线构造

可以 DFS 也可以 BFS。

BFS

先对多串建出 Trie。

还是使用普通 SAM 的 ins,但是 lst 要用在 Trie 上的父亲,然后要返回当前点在 SAM 上对应的结点编号。如此 BFS 将 Trie 上每个点插入一遍。注意要记录 Trie 上每个点在 SAM 中对应的结点编号。

这里用 Trie 其实是在压缩前缀,然后 BFS 是按长度递增的顺序插入每一个前缀。可以说明这样不会有空节点(即没有任何转移边指向的点)。

时间复杂度是 \(O(\text{Trie 的结点数})\) 的。注意当 Trie 的结点数为 \(O(n)\) 时,实际字符串总长可能是 \(O(n^2)\) 的。这在给出 Trie 树结构而不给出每个字符串的题中有所不同。

代码
#include<bits/stdc++.h>

using namespace std;

constexpr int maxn=1e6+10;
using i64=long long;

int n;
char s[maxn];

int tot,pos[maxn];
struct SAM{
	int len,lnk,tr[26];
}sam[maxn<<1];

int ins(int c,int lst){
	int p=lst;int nw=++tot;
	sam[nw].len=sam[p].len+1;
	for(;p&&!sam[p].tr[c];p=sam[p].lnk) sam[p].tr[c]=nw;
	if(!p) sam[nw].lnk=1;
	else{
		int q=sam[p].tr[c];
		if(sam[q].len==sam[p].len+1) sam[nw].lnk=q;
		else{
			int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
			sam[nw].lnk=sam[q].lnk=nq;
			for(;p&&sam[p].tr[c]==q;p=sam[p].lnk) sam[p].tr[c]=nq;
		}
	}
	return nw;
}

int sz,tr[maxn][26];

void ins(char *ch){
	int u=0;
	for(int i=0;ch[i];++i){
		int c=ch[i]-'a';if(!tr[u][c]) tr[u][c]=++sz;
		u=tr[u][c];
	}
}

void bfs(){
	queue<int> q;tot=1;pos[0]=1;
	for(int i=0;i<26;++i){
		if(tr[0][i]) q.push(tr[0][i]),pos[tr[0][i]]=ins(i,1);
	}

	while(!q.empty()){
		int u=q.front();q.pop();
		for(int i=0;i<26;++i) if(tr[u][i]) q.push(tr[u][i]),pos[tr[u][i]]=ins(i,pos[u]);
	}
}

int main(){
	ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;++i){
		cin>>s;ins(s);
	}
	
	bfs();
	
	i64 ans=0;
	for(int i=2;i<=tot;++i) ans+=sam[i].len-sam[sam[i].lnk].len;
	
	cout<<ans<<'\n'<<tot<<'\n';
	return 0;
}

DFS

没什么用,ins 和在线构造是一样的。

在线构造

只是在普通 SAM 的插入中加入一个特判。每次在新插入一个字符串之前将 lst 置为 \(1\)。每次插入一个字符后要返回这次插入对应的 SAM 结点编号,作为下一次插入的 lst

特判本质是不建空节点,用已经建出来的结点,看是否直接能用,不能直接用就分裂一下。

代码
int ins(int c,int lt){
	if(tr[lt][c]){
		int p=lt,q=tr[lt][c];
		if(len[q]==len[p]+1) return q;
		int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
		lnk[nq]=lnk[q];lnk[q]=nq;
		for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
		return nq;
	}
	int p=lt;int nw=lt=++tot;
	len[nw]=len[p]+1;
	for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
	if(!p) lnk[nw]=1;
	else{
		int q=tr[p][c];
		if(len[q]==len[p]+1) lnk[nw]=q;
		else{
			int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
			lnk[nq]=lnk[q];lnk[q]=nq;lnk[nw]=nq;
			for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
		}
	}
	return nw;
}

一些应用

不同子串个数

根据 SAM 结点的定义,\(len_u-len_{lnk_u}\) 就是这个点中的串的个数。于是对每个点求和就好。

\(k\) 小子串

SAM 的 DAG 上从起点出发的一条路径就是一个子串。先 DP 一遍求出从一个点 \(u\) 出发有多少条路径,然后从起点开始贪心地走即可。

注意判掉 Parent 树的根,因为它没意义,然后每个点判断一下是否结束。注意本质相同算一次和出现位置不同算多次要稍微改一点。

最长公共子串

先是两个串的。对一个串建 \(SAM\),然后扫另一个串。对于当前扫到的位置 \(r\),从 \(r-1\) 处继承并转移当前能匹配的最长后缀 \(L\)。对每个位置取 \(\max\)

再看多串的。每次扔一个串进去跑上面的东西。但是所有串的最长公共子串应该是所有的取 \(\min\)。于是对 SAM 上的每一个结点维护一个 \(mi\) 表示这个点可以对最长公共子串造成的贡献。

双串最长公共子串其实可以对 SAM 上每个结点跑出来最长匹配长度,这个东西记为 \(mx\)\(r\) 位置直接跳到的最长匹配后缀对应的结点记为 \(u\)。我们应当对 \(u\) 到根的链上每个点都更新一遍 \(mx\)。一个点的 \(mx\) 可以从它的儿子处传上来,但是要和自己的最长串长度取 \(\min\),再和自己本来的 \(mx\)\(\max\)。然后一个点的 \(mi\) 就是每次的 \(mx\)\(\min\)。最后的答案是每个点的 \(mi\)\(\max\)

一些题目

1. P3975 [TJOI2015] 弦论

\(k\) 小子串板子。

2. P6640 [BJOI2020] 封印

\(L_i\) 表示 \(s\) 中第 \(i\) 位向左在 \(t\) 中的最长匹配长度。这个东西可以类似求最长公共子串一样地求出来。

然后写式子, 答案即 \(\max\limits_{l\le i\le r}\Big\{\min\{i-l+1,L_i\}\Big\}\)

发现这玩意有个单调性:如果某处 \(L_i<i-l+1\),那么对于 \(j>i\) 都有 \(L_j<j-l+1\)

每次二分找到这个分界点 \(x\) 满足 \(x\) 是最后一个位置使得 \(x-l+1<L_x\),然后 \(x\) 及之前位置的贡献就是 \(x-l+1\),之后位置的贡献就是 \(\max\limits_{x<i\le r} L_i\),可以 ST 表搞一下。二者取 \(\max\) 即为答案。

\(O(q\log n)\)

3. SP687 REPEATS - Repeats

对于一个子串,考虑其 Endpos,如果记它的 Endpos 的最小差值为 \(d\),它的长度为 \(len\),那么它的连续出现次数为 \(\left\lfloor \frac{len+d}{d}\right\rfloor\)

所以在 SAM 的 Parent 树上线段树合并维护 Endpos,并且顺带维护 \(d\)。在一个 Endpos 等价类中,肯定选最长的一个串最优,所以直接这样更新答案即可。

\(O(n\log n)\)

4. P2336 [SCOI2012] 喵星球上的点名

首先把所有串扔到广义 SAM 中,对每个前缀标记对应着哪一个人。

第一问,先将询问串在 SAM 找到对应结点。考虑所有前缀的所有后缀就是子串,于是标记的每个前缀应当在根链上造成贡献。

但是可能重复贡献,所以将每个点按 DFS 序排序,然后对当前点到与上一个点(没有上一个点就默认为根)的 LCA 这条链上 \(+1\) 即可。答案即对应结点的权值。

第二问,在询问串对应结点上打上标记。类比第一问,一个串的所有子串其实就是第一问的路径并上的点。对于一个人的答案,其实就是路径并上的标记个数。这个也类比第一问做就好。

\(O(n\log n)\)

5. P4465 [国家集训队] JZPSTR

不会正解,记录一下暴力。

字符集很小,对每种字符开一个 bitset。第 \(i\) 位为 \(1\) 就表示原串的第 \(i\) 位是这种字符。

插入和删除都是好做的。

如何查询?考虑设 \(u_i\) 表示询问串的前 \(i\) 位能在给定区间中匹配上的 Endpos。考虑一个位置 \(x\),当且仅当 \(x\in u_i \land \text{原串第 } x+1 \text{ 位能与询问串第 } i+1 \text{ 位匹配}\) 时,\(x+1\in u_{i+1}\)。实现这个条件就是 \(u\) 左移一位再和一个 bitset 取交集。可以简单实现。

\(O(\frac{nl}{\omega})\)

6. P4094 [HEOI2016/TJOI2016] 字符串

翻转整个串,变成求最长公共后缀。

发现可以二分最长公共后缀长度,然后判是否在 \([a,b]\) 中出现。但是感觉上就不太好。

首先可以直接将 \(c\) 扔掉,考虑前缀 \(d\) 的最长的在 \([a,b]\) 间出现的后缀,然后和 \(d-c+1\)\(\min\) 就好。

可以考虑两种类型的贡献:设匹配上的后缀长度为 \(len\),一个 Endpos 为 \(x\le b\),若 \(x-a+1\le len\),那么贡献为 \(x-a+1\),否则贡献为 \(len\)。要在二者中取 \(\max\)

先来看看第一种贡献。

假设当前确定了 \(len\),一个自然的想法是 \(x\) 在满足 \(x\le b\) 的前提下越大越好。

我们在 Parent 树上跳父亲找后缀,设当前 Endpos 等价类中最长串的长度为 \(len\),通过线段树合并找到了最优的 \(x\),如果满足 \(x-a+1\le len\),可以发现这个点的贡献就是 \(x-a+1\),并且其中的其它串造成的贡献不会更优。跳父亲的过程中,\(x\) 单增,\(len\) 单减,于是可以二分找最好的 \(x-a+1\),这里可以倍增跳父亲找。

再看第二种贡献。

设第一种贡献最后找到的最优的 Endpos 等价类为 \(u\)。那么设 \(u\) 的父亲为 \(k\)\(k\) 中最长串的长度为 \(L\),找到了最优的 \(x\),必然有 \(x-a+1>L\),所以这个点的贡献就是 \(L\),并且这个点的祖先的贡献必然不优于此。

这样两种贡献都找完了。

\(O(n\log^2 n)\)

7. P4022 [CTSC2012] 熟悉的文章

首先建 SAM,可以对询问的 \(s\) 的每个位置 \(i\) 处理出向左能匹配的最长长度 \(L_i\)

发现答案可以二分,于是二分,考虑判定。

肯定希望符合条件下答案最优,于是 DP。设 \(f_i\) 表示考虑前 \(i\) 位的最优答案。尝试转移,首先可以继承上一位的答案,\(f_i\gets f_{i-1}\);可以选这一位及以前至少 \(mid\) 位,\(f_i=\max\limits_{mid\le i-j+1\le L_i}f_j+1=\max\limits_{i-L_i+1\le j\le i-mid+1}f_j+1\)。可以注意到左右端点都单调不降,于是单调队列优化。

\(O(n\log n)\)

8. P4482 [BJWC2018] Border 的四种求法

\(\text{LCS}(i,j)\) 表示前缀 \(i\) 和前缀 \(j\) 的最长公共后缀。

写出答案式子,\(\max\limits_{l\le i< r,\text{LCS}(i,r)\ge i-l+1}\Big\{i-l+1\Big\}\)

放到 SAM 上,\(\text{LCS}(i,r)\) 就是 Parent 树上前缀 \(i\)\(r\) 对应的结点的 \(\text{LCA}\)。一个暴力的想法是从 \(r\) 对应的结点开始跳祖先,相当于枚举 \(\text{LCS}(i,r)\),线段树合并维护 Endpos,然后在线段树中查最大的 \(i<r\land i\le LCS(i,r)+l-1\)。显然这里的 \(\text{LCS}(i,r)\) 一定取等价类中的最长串最优。

但是这太暴力了。考虑链分治优化这个过程。发现一个询问在暴力的过程中会遍历根链上每一个点,那其实就是会经过 \(O(\log n)\) 条重链,且每条重链经过的都是一段前缀。可以将询问挂在每条重链上第一个被经过的点,一个询问就被拆到了 \(O(\log n)\) 条重链上。

考虑在一条重链上回答询问。直接遍历重链上的每一个点显然是可行的,因为重链长度之和 \(O(n)\)。得到一个询问后,我们要得到这条重链的前缀信息和当前点的信息。不妨设这个点为 \(u\)

可以发现前缀 \(r\) 对应的点在 \(u\) 的轻子树内,这个询问其实是要询问这条重链上 \(u\) 及其上方的所有点。对于 \(u\) 上方的点,前缀 \(r\) 对应的点在其重子树内。

如何查询 \(u\) 的信息?我们相当于钦定 \(u\)\(\text{LCA}\),那么可以和 \(r\) 造成贡献的应该是 \(u\) 的子树扣掉 \(r\) 所在的子树。不过发现一个性质:错解必然不优。因为错解与 \(u\)\(\text{LCA}\) 的深度必然更深,其中最长串的长度更大,\(i\le \text{LCS(i,r)}+l-1\) 的限制也就越松,而将 \(u\) 作为 \(\text{LCA}\) 相当于加强了限制,所以不优。于是直接在 \(u\) 的 Endpos 中查询符合限制的最大的 \(i\) 即可。

现在来看怎么查询重链前缀信息。发现对于前缀上的每一个点,我们都只要它来自轻子树的信息并上自身的信息,因为 \(r\) 在它的重子树内。并且在这个询问中,对于前缀上的一个点,相当于枚举它作为 \(LCS(i,r)\),这一项的值不是固定的。

不妨对限制变形,\(i<r\land i-\text{LCS}(i,r)<l\)。考虑对已经扫过的重链前缀用一棵线段树维护信息,位置 \(i\) 上存 \(i-\text{LCS}(i,j)\),然后可以线段树上二分找最大的 \(i\)。如何维护这一棵线段树?每扫到一个点,就暴力扫它的轻子树,将信息暴力加入线段树上。轻子树大小之和为 \(O(n\log n)\),是正确的。处理完一条重链后又重复一遍上述操作,将加入信息改为撤销信息即可。

\(O(n\log^2 n)\)

9. P6152 [集训队作业2018] 后缀树节点数

不太会后缀树,于是翻转原串,变成区间 SAM 结点数。记得询问也要翻转。

考虑 SAM 在什么时候会新建结点。先抄一遍 SAM 板子:

void ins(int c){
	int p=lst;int nw=lst=++tot;
	len[nw]=len[p]+1;
	for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
	if(!p) lnk[nw]=1;
	else{
		int q=tr[p][c];
		if(len[p]+1==len[q]) lnk[nw]=q;
		else{
			int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
			lnk[nw]=nq;lnk[nq]=lnk[q];lnk[q]=nq;
			for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
		}
	}
}

第一处是对于新出现的前缀新建结点。这说明区间 \([l,r]\) 对应的 SAM 中会有 \(r-l+1\) 个点因为插入一个前缀而新建出来。不妨称此类结点为 前缀结点

第二处为什么要新建结点?不妨记当前插入的字符为 \(c\)。对于上次插入的前缀的一段后缀(即 Endpos 等价类 \(p\) 中的最长串,不妨记为 \(s\)),经过一条字符 \(c\) 的转移边到达 \(q\) 这个 Endpos 等价类。如果这个等价类中最长串的长度就是 \(|s|+1\),说明 \(q\) 中所有的串都是插入 \(c\) 后的串的后缀。否则需要分裂结点,将是后缀的分裂出来。这说明当前前缀的一段后缀满足:

  1. 在当前前缀中出现了不少于两次;

  2. 至少存在两个 起始 位置满足上一个字符不同。这等价于该串在其 Endpos 等价类中是最长串,且该串不是前缀

不妨称此类结点为 分裂结点

考虑如何刻画 分裂结点。翻译一下上文,就是找区间中的子串,满足:

  1. 出现次数不少于两次;

  2. 是所在 Endpos 等价类中的最长串;

  3. 不是一段前缀;

我们可以只考虑整个串的 SAM 上的结点的最长串,然后扔掉第二条限制。

不妨记当前考虑的串的长度为 \(L\)\(\text{LCS}(i,j)\) 表示前缀 \(i\) 和前缀 \(j\) 的最长公共后缀。形式化剩下两条限制:

  1. 至少存在一对 \(x,y\),使得 \(x\le r\land y\le r\land x-L+1\ge l\land y-L+1\ge l\land \text{LCS}(x,y)=L\)

  2. \(l+L-1\not\in \text{Endpos}\)

考虑容斥掉现在的第二条限制。那么现在的思路就是统计符合第一条限制的串,然后减去既满足第一条限制,又是一段前缀的串。

套路地将 \(\text{LCS}\) 转化为 SAM 上的 \(\text{LCA}\)。不妨设 \(u\) 为当前的 \(\text{LCA}\),考虑不同子树中的一对 \(x,y\),不难发现只有 \(x,y\) 互为前驱后继时才是有用的。于是可以树上启发式合并搞出来 \(O(n\log n)\) 个支配对,形如 \((x-len_u,y,u)\) 表示 Endpos 为 \(x,y\)\(x<y\)\(\text{LCS}(x,y)\) 是整个串的 SAM 上结点 \(u\) 的最长串。其中 \(len_u\) 表示 \(u\) 中最长串的长度。

将这些支配对挂在 \(y\) 上,然后对原串扫描线。考虑扫右端点 \(r\),维护每个左端点的答案。对于每个 \(u\),记录上一个支配对的 \(apr_u=x-len_u\)。考虑在当前右端点 \(r\) 处新加入一个关于 \(u\) 的支配对 \((l,r,u)\),那么 \([apr_u+1,l]\) 中每一个位置的答案都要 \(+1\)。这是一个区间加,单点查的问题,用树状数组维护一下。

现在只剩下在回答询问时减去既满足第一条限制,又是一段前缀的串的个数。设前缀 \([l,x]\) 满足第一条限制,那么容易发现对于 \(i\le x\),前缀 \([l,i]\) 都满足第一条限制。于是可以二分。

二分得到 \(mid\) 后如何判定?首先统计的串必定是一个 Endpos 等价类中的最长串,于是可以事先将所有最长串扔进哈希表,然后判定 \([l,mid]\) 是否在哈希表中;其次,如果 \([l,mid]\) 是一个最长串,那么通过哈希表找到它对应的结点 \(u\),判定它是否在这个区间中造成贡献,即 \(apr_u\ge l\)

这样就做完了。\(O(n\log^2 n+m\log n)\)

代码
#include<bits/stdc++.h>

using namespace std;

constexpr int maxn=1e5+10;

int n,m,a[maxn],rpos[maxn<<1],ps[maxn<<1];

constexpr uint64_t bs=19491001;

uint64_t hsh[maxn],w[maxn];
struct hshtb{
	int htot,h[1<<20],nxt[maxn<<1],to[maxn<<1];
	uint64_t fr[maxn<<1];
	
	void init(){
		for(int i=w[0]=1;i<=n;++i){
			w[i]=w[i-1]*bs;hsh[i]=hsh[i-1]*bs+a[i];
		}
	}
	
	uint64_t get(int l,int r){
		return hsh[r]-hsh[l-1]*w[r-l+1];
	}
	
	void ins(uint64_t x,int u){
		to[++htot]=u;fr[htot]=x;nxt[htot]=h[x&1048575];h[x&1048575]=htot;
	}
	
	int operator[](uint64_t x){
		for(int i=h[x&1048575];i;i=nxt[i]) if(fr[i]==x) return to[i];
		return 0;
	}
}H;

struct SAM{
	int tot,lst,lnk[maxn<<1],len[maxn<<1];
	
	map<int,int> tr[maxn<<1];
	
	SAM(){tot=lst=1;}
	
	void ins(int c){
		int p=lst;int nw=lst=++tot;
		len[nw]=len[p]+1;
		for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
		if(!p) lnk[nw]=1;
		else{
			int q=tr[p][c];
			if(len[q]==len[p]+1) lnk[nw]=q;
			else{
				int nq=++tot;len[nq]=len[p]+1;tr[nq]=tr[q];lnk[nq]=lnk[q];rpos[nq]=rpos[q];
				lnk[q]=lnk[nw]=nq;
				for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
			}
		}
	}
}sam;

int etot,head[maxn<<1];
struct edge{
	int v,nxt;
}e[maxn<<1];

inline void addedge(int u,int v){
	e[++etot].v=v;e[etot].nxt=head[u];head[u]=etot;
}

void build(){
	for(int i=2;i<=sam.tot;++i) addedge(sam.lnk[i],i);
}

struct QUERY{
	int l,id;
};vector<QUERY> ask[maxn];

struct TRI{
	int l,c;
};vector<TRI> p[maxn];
int apr[maxn<<1];

set<int> s[maxn<<1];
#define Iter set<int>::iterator

void dfs(int u){
	if(u!=1) H.ins(H.get(rpos[u]-sam.len[u]+1,rpos[u]),u);
	if(ps[u]) s[u].insert(ps[u]);
	for(int i=head[u];i;i=e[i].nxt){
		int v=e[i].v;dfs(v);if(u==1) continue;
		if(s[u].empty()) swap(s[u],s[v]);
		else{
			for(Iter it=s[v].begin();it!=s[v].end();++it){
				Iter pr=s[u].lower_bound(*it);if(pr!=s[u].end()) p[*pr].emplace_back((TRI){*it-sam.len[u]+1,u});
				if(pr!=s[u].begin()){pr--;p[*it].emplace_back((TRI){*pr-sam.len[u]+1,u});}
			}
			if((int)s[u].size()<(int)s[v].size()) swap(s[u],s[v]);
			for(Iter it=s[v].begin();it!=s[v].end();++it) s[u].insert(*it);
			set<int>().swap(s[v]);
		}
	}
}

#define lb(x) (x&(-x))
int c[maxn];

void add(int x,int v){
	for(;x<=n;x+=lb(x)) c[x]+=v;
}

int qry(int x){
	int rs=0;for(;x;x-=lb(x)) rs+=c[x];return rs;
}

int ans[maxn*3];

int main(){
	ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);	
	cin>>n>>m;for(int i=1;i<=n;++i) cin>>a[i];
	reverse(a+1,a+n+1);sam.lst=sam.tot=1;
	for(int i=1;i<=n;++i) sam.ins(++a[i]),rpos[sam.lst]=ps[sam.lst]=i;
	H.init();
	for(int i=1;i<=m;++i){
		int l,r;cin>>l>>r;l=n-l+1;r=n-r+1;ask[l].emplace_back((QUERY){r,i});
	}
	
	build();dfs(1);
	
	for(int i=1;i<=n;++i){
		for(TRI tmp:p[i]){
			int ql=tmp.l,col=tmp.c;if(ql<=apr[col]) continue;
			add(apr[col]+1,1);add(ql+1,-1);apr[col]=ql;
		}
		for(QUERY tmp:ask[i]){
			int ql=tmp.l,id=tmp.id;
			ans[id]=qry(ql)+i-ql+1;
			int L=ql-1,R=i;
			while(L<R){
				int mid=(L+R+1)>>1;
				int tmp=H[H.get(ql,mid)];
				if(tmp&&apr[tmp]>=ql) L=mid;
				else R=mid-1;
			}
			ans[id]-=L-ql+1;
		}
	}
	
	for(int i=1;i<=m;++i) cout<<ans[i]<<'\n';
	
	return 0;
}
posted @ 2025-04-18 18:59  RandomShuffle  阅读(52)  评论(1)    收藏  举报