后缀自动机题目选讲
后缀自动机
2025.4.20 & 2025.5.10 七高讲课记录。
课件可能比较简陋。
在做例题之前,一定要把 SAM 的模板理解透彻。
SAM 算法
推荐【瞎口胡】后缀自动机(SAM) - Meatherm - 博客园。
画 SAM 的网站:SAM Drawer。
定义
字符串 \(s\) 的 SAM 是一个接受 \(s\) 所有后缀的最小 DFA(确定性有限状态自动机)
字符串的 \(\operatorname{endpos}\)
设字符串 \(s\) 的一个非空子串 \(s'\) 在 \(s\) 中所有结尾的位置构成的集合为 \(\operatorname{endpos}\)。
显然一个所有能走到一个节点的字符串中,它们的 \(\operatorname{endpos}\) 都是相等的。
下面是关于 \(\operatorname{endpos}\) 的一些性质:
- 对于 \(s\) 的两个非空子串 \(a,b(|a|\le |b|)\),如果 \(\operatorname{endpos}(a)=\operatorname{endpos}(b)\),那么 \(a\) 是 \(b\) 的后缀;如果 \(a\) 是 \(b\) 的后缀,则 \(\operatorname{endpos}(b)\subseteq \operatorname{endpos}(a)\),否则 \(\operatorname{endpos}(a)\cap \operatorname{endpos}(b)=\empty\)。
- 同一个 \(\operatorname{endpos}\) 等价类中的字符串的长度互不相同且连续。
parent 树(后缀链接)
对于一个字符串,我们如果不断在前面添加字符,那么它的 \(\operatorname{endpos}\) 会逐渐变小,添加不同的字符会使 \(\operatorname{endpos}\) 划分为几个不交的集合。我们把这个划分建成一棵树的形态,这就是 parent 树。每个节点都对应了一些字符串和一个 \(\operatorname{endpos}\) 集合,其中根节点对应空串和全集。
反过来看,对于一个等价类中的串,它的父亲就应该是最长不在这个等价类中的后缀所在的节点。
因为是不断划分的,所以这棵树最多有 \(2n-1\) 个点。
另一个结论:一个节点中包含了一些长度连续的字符串,设最长串为 \(len_u\),最短串为 \(minlen_u\),则有 \(minlen_u=len_{fa_u}+1\)。
例子:aababa
SAM 的构造
SAM 是一个 DAG,而 SAM 中的节点就是 parent 树上的节点。现在要做的就是在树上添加边使得其能接受所有后缀。将一个节点连向另一个节点,表示这个节点中所有串在后面加上一个字符后会到达另一个 \(\operatorname{endpos}\) 等价类。(跳 parent 树上的父亲表示在前面删字符,走一条边表示在后面加字符)。
现在考虑如何在线性复杂度内构造 SAM,我们将所有字符依次加入 SAM 中。在加入第 \(i\) 个字符时,已经维护好了 \([1,i-1]\) 这个串构成的 SAM。
int tot = 1,lst = 1;
struct node{int fa,len,ch[26];}t[N<<1];
void insert(int c)
{
int p = lst,nw = lst = ++tot;
t[nw].len = t[p].len+1;
for(;p&&!t[p].ch[c];p = t[p].fa)t[p].ch[c] = nw;
if(!p){t[nw].fa = 1;return ;}
int q = t[p].ch[c];
if(t[p].len+1 == t[q].len)t[nw].fa = q;
else
{
int nq = ++tot;t[nq] = t[q];
t[nq].len = t[p].len+1;
t[q].fa = t[nw].fa = nq;
for(;p&&t[p].ch[c] == q;p = t[p].fa)t[p].ch[c] = nq;
}
}
定义:fa 表示节点在 parent 树上的父亲,len 表示这个节点的字符串中最长串的长度,ch[26] 表示这个点的所有出边。
当插入第 \(i\) 个字符时,新建一个点 \(nw\) 表示整个串对应的节点。记 \(lst\) 表示前缀 \(i-1\) 对应的节点,我们从这个点开始,一直在 parent 上跳父亲,如果这个点没有 \(c\) 的出边,就将其连向 \(nw\),直到跳到一个有 \(c\) 出边的点 p,或者跳到了根节点。
如果是跳到了根节点且还没有出边,这就说明之前整个字符串没有字符 \(c\),将 \(nw\) 的父亲设为 \(1\)。
否则,设 \(q\) 为 \(p\) 沿 \(c\) 出边走到的点。这个时候状态 \(q\) 里会有一些字符串,其中长度 \(\le len_p+1\) 的串为整个串的后缀,其余不是整个串 \(s\) 的后缀(假设是的话,一定会在跳 fa 的时候跳到,矛盾)。
如果 \(len_q=len_p+1\),这个时候将 \(fa_{nw}\) 设为 \(q\) 即可。
否则就出现了矛盾,因为节点 \(q\) 里面出现了两种不同的 \(\operatorname{endpos}\) 集合,也就意味着这个节点必须分裂成两部分。我们新建一个节点 \(nq\),表示 \(q\) 中所有长度 \(\le len_p+1\) 的串构成的点。那么 \(fa_{nq}\) 和 \(nq\) 所有的出边都和 \(q\) 一样,然后把 \(fa_{q},fa_{nw}\) 设为 \(q\)。
最后,继续在 \(p\) 上跳 fa,如果这个节点的 \(c\) 出边指向的 \(q\),那么将其改为 \(nq\)。于是整个后缀自动机就建完了。
思考:为什么 \(p\) 不用继续往上跳了?
SAM 的点数最多为 \(2n-1\),边数最多为 \(3n-4\),构建的时间复杂度为 \(\mathcal{O}(n)\)。如果字符集很大,那么每个节点可以维护一个 map 来记录出边。
基本应用
本质不同子串个数
两种求法:
- 因为 SAM 上每一条路径都对应着一个子串,所以就相当于求一个 DAG 上有多少条路径,可以 dp。
- 考虑求出每个节点包含了多少个字符串再相加,一个节点 \(x\) 包含的字符串数为 \(len_x-len_{fa_x}\)。
求所有本质不同子串的长度和同理。
SAM 是一个 DAG,本身就有着比较好的性质,可以进行一些 dp。比如:最短的没出现过的字符串,字典序第 \(k\) 大的字符串。
求一个串的出现次数
首先从根节点走,定位到这个串所在的节点(如果定位不到就是没出现),然后就是求这个节点 \(\operatorname{endpos}\) 集合的大小。
考虑哪些节点的 \(\operatorname{endpos}\) 会包含 \(i\),我们找到前缀 \(i\) 对应的节点,然后从这个点一直跳到根都会包含 \(i\),相当于链加 \(1\)。
于是可以将所有前缀对应的节点的大小设为 \(1\),然后求子树和,dfs 即可。
最小循环移位(最小表示法)
相当于求字符串 \(S+S\) 长度为 \(|S|\) 的子串中字典序最小的。直接从根节点开始,每次走最小的字符即可。
求一个串在区间 \([l,r]\) 内的出现次数
还是定位到这个串,设串长为 \(len\),那么答案就是这个点的 \(\operatorname{endpos}\) 集合中有多少个数 \(\in[l+len-1,r]\)。用可持久化线段树合并。
类似的问题还有,求一个串最早出现位置,所有出现位置等。
求 SAM 上两个节点对应的串的最长公共后缀
根据 parent 树的定义可得,两个串的最长公共后缀等于整两个点在 paren 树上的 lca 的最长串。
求两个(多个)串的最长公共子串
首先考虑两个串,我们建出第一个串的 SAM,然后用双指针扫第二个串。对于一个 \(i\) ,找出最大的 \(j\) 使得子串 \(S[j\ldots i]\) 出现过。我们维护当前走到了哪个节点,然后看能不能走 \(b_i\) 这条边,如果不能就一直往上跳直到有 \(b_i\) 出边,这个时候就找到了当前的 \(j\)。取所有 \(i\) 的最大值即可。
如果是多个串呢?我们还是只对第一个串建 SAM,然后对于每个节点维护这个节点上的字符串与所有串的 LCS 的最小值是多少。
每次对于一个串,我们把每次到达的节点与当前的 len 取 max,于是每个节点能匹配的长度就是子树最大值,然后再把这个东西和全局取 \(\min\) 即可。
快速定位串 \(s\) 的子串 \(s[l\ldots r]\) 对应的节点
设 \(p\) 为子串 \([1,r]\) 对应的节点,这个可以预处理。然后倍增跳父亲。
如果是定位另一个串 \(t\) 的子串 \(t[l\ldots r]\) 呢?
这个时候有可能找不到 \(t[l\ldots r]\) 在 \(s\) 中的点,我们可以对每个 \(r\),处理出最小的 \(l\),满足 \(t[l\ldots r]\) 在 \(s\) 中出现过,这个可以用双指针扫,查询还是倍增跳。
后缀树
定义
将一个字符串 S 的所有后缀插入到一个 trie 树上即为后缀树。根据定义可以得出,后缀树从根节点出发恰好能接受所有 S 的子串。
构建
直接一个个插入显然复杂度为 \(\mathcal{O}(n^2)\) 的,无法接受。我们可以把只有一个儿子的点缩起来,那么每次插入一个串的时候最多新增两个节点,于是节点数是不超过 \(2n\) 的。
具体怎么构建呢?有个重要结论,一个串的后缀树就等于反串的 parent 树。你可以理解为后缀树上一个节点代表的是 \(\operatorname{startpos}\) 相同的串,与 SAM 的 \(\operatorname{endpos}\) 相反。并且,后缀树上走儿子相当于在串末尾加字符,而 parent 树上跳儿子相当于在前面加字符,两个是对应的。
后缀树的应用基本上都可以被 SAM 平替,所以不做过多讲解。
广义 SAM
广义 SAM 一般用于处理多个字符串的问题。(广义 SAM 一般可以用把所有串加上一个分隔符拼在一起的方法代替)
结论:对所有字符串建 Trie 树,然后对 Tire 树进行 bfs,每次将父亲节点对应串在 SAM 中的位置作为 lst,插入当前字符。时间复杂度为 \(\mathcal{O}(n)\),其中 \(n\) 为 Trie 树节点个数。
注意广义 SAM 有很多常见的错误写法,比如每次将 lst 设为 \(1\) 插入,或者 dfs 进行插入,这些做法都可能会产生节点,虽然在大部分题中可能不会有问题,但是还是有卡的方法,所以写广义 SAM 一定要写正确的做法。
代码
struct sam
{
int tot = 1;
struct node{int fa,len,ch[26];}t[N];
int ins(int p,int c)
{
int nw = ++tot;
t[nw].len = t[p].len+1;
for(;p&&!t[p].ch[c];p = t[p].fa)t[p].ch[c] = nw;
if(!p)return t[nw].fa = 1,nw;
int q = t[p].ch[c];
if(t[p].len+1 == t[q].len)t[nw].fa = q;
else
{
int nq = ++tot;t[nq] = t[q];
t[nq].len = t[p].len+1;
t[q].fa = t[nw].fa = nq;
for(;p&&t[p].ch[c] == q;p = t[p].fa)t[p].ch[c] = nq;
}
return nw;
}
}t;
struct trie
{
int tr[N][26],pos[N],tot = 1;
void insert(int len)
{
int r = 1;
for(int i = 1;i <= len;i++)
{
int &nex = tr[r][s[i]-'a'];
if(!nex)nex = ++tot;
r = nex;
}
}
void build()
{
queue<int> q;q.push(pos[1] = 1);
while(!q.empty())
{
int u = q.front();q.pop();
for(int i = 0,v;i < 26;i++)
if(v = tr[u][i])
pos[v] = t.ins(pos[u],i),q.push(v);
}
}
}tr;
基础例题
P3804 【模板】后缀自动机(SAM)
给定一个只包含小写字母的字符串 S。请你求出 S 的所有出现次数不为 1 的子串的出现次数乘上该子串长度的最大值。
\(|S|\le 10^6\)
对于每个 SAM 上的点,求出出现次数 \(cnt\)(\(\operatorname{endpos}\) 集合大小),然后求 \([cnt>1]len\times cnt\) 的最大值。
P4070 [SDOI2016] 生成魔咒
有一个空串,每次在串的末尾加入一个数,每次回答当前串本质不同子串数量。
\(n\le 10^5\)
每次新增的本质不同的字符串数,就等于整个串对应节点的 \(len\) 减去父亲的 \(len\)。
P3975 [TJOI2015] 弦论
给定一个字符串 S,求出 S 字典序第 \(k\) 小字符串是什么,\(t=0\) 表示相同子串算一个,\(t=1\) 表示相同子串算多个
\(n\le 5\times 10^5,k\le 10^9\)
对与 SAM 上的每个点,记 \(f_i\) 表示从这个点出发的子串个数,如果 \(t=1\) 那么到这个点的路径数有 \(\operatorname{endpos}\) 集合的大小个,否则是 \(1\) 个。
然后求字典序第 \(k\) 小,就从根节点开始,每次看走哪条边。
同样的:SP7258 SUBLEX - Lexicographical Substring Search
SP1811 LCS - Longest Common Substring,SP1812 LCS2 - Longest Common Substring II
给定一些字符串,求它们的最长公共子串长度。\(\sum |S|\le 10^6\)
同上
CF1037H Security
给定一个字符串 S,\(q\) 次询问,每次给出 \(l,r\) 和字符串 T,求字典序最小的 S',使得 S' 字典序严格大于 T,且 S' 在 \(S[l\ldots r]\) 中出现过。\(|S|\le 10^5,q\le 2\times 10^5.\sum|T|\le 2\times 10^5\)
从后往前枚举字符串 \(T\),如果这个前缀加上一个比下一个位置大的字母出现在了 \(S[l\ldots r]\) 中,那么就满足条件。如果没有,就看下一位,贪心下去即可。
CF235C Cyclical Quest
给定一个主串 S 和 \(n\) 个询问串,求每个询问串的所有循环同构在主串中出现的次数总和。
\(|S|,\sum|T|\le 10^6\)
先定位到询问串在 SAM 中的节点,然后每次相当于删掉前面的一个字符,然后往后面加一个字符。删掉前面一个字符就是判断剩下的字符串和原来的是否在一个等价类内,如果不是就跳一次父亲,往后加字符就走这条出边。
如果没有出边可以先不走,把还未走的字符存到队列内,每次删掉前面的字符后一次看队列内的字符能不能走。如果队列内字符走完了才对答案有贡献。
HDU 4416 Good Article Good sentence
给定一个串 \(A\) 和若干串 \(B_i\),求出现在 \(A\) 中且不出现在任意一个 \(B_i\) 中的本质不同子串个数
\(|A|,\sum|B_i|\le 10^5\)
类似求多个串的 LCP,每个节点维护最短的没有出现在 \(B\) 中的串的长度,每次 B 在 SAM 上走,更新一个点到根路径的答案,可以单点修改,最后统计子树 max。
HDU 6583 Typewriter
给定一个串 \(S\),你需要将字符串打印出来,每次可以花费 \(p\) 的代价打印一个字符,或者花费 \(q\) 的代价将一个已经打印的串的某个子串打印出来。求最小花费。
\(\sum|S|\le 5\times 10^6\)
设 \(f_i\) 表示打印出前 \(i\) 个字符的最小花费。第一个转移是 \(f_i\larr f_{i-1}+p\)。
对于第二个转移,我们求出最小的 \(j\) 满足串 \(S[j+1\ldots i]\) 在 \(S[1\ldots j]\) 中出现过,然后有 \(f_i\larr f_j+q\)。求 \(j\) 可以双指针,SAM 每个节点上维护这些串最早出现位置,然后每次判断即可。
P4022 [CTSC2012] 熟悉的文章
给定一个字符串 S 和 \(m\) 个字符串作为词库,一个串合法当且仅当这个串是 \(m\) 个字符串中某一个的子串。你需要从 \(S\) 中选出若干段不交的子串,使得每个串都合法且串长之和 \(\ge 0.9|S|\),求这些串的串长最小值的最大值。
\(|S|,\sum|T|\le 1.1\times 10^6\)
首先二分答案,问题变成每个串长度必须 \(\ge x\) 的情况下,最多能划分出多少个合法的字符串。
设 \(f_i\) 表示前 \(i\) 个字符串的答案,首先有 \(f_i\larr f_{i-1}\),或者是由一个 \(j\) 转移而来。考虑 \(j\) 的范围是什么,设 \(mx_i\) 表示最小的 \(j\) 使得 \(S[j\ldots i]\) 出现过。那么 \(j\in [mx_i-1,i-x]\),因为范围有单调性,所以用单调队列维护即可。
P6640 [BJOI2020] 封印
给定字符串 S 和 T,\(q\) 次询问,每次给定 \(l,r\),求 \(LCP(S[l\ldots r],T)\)。
\(|S|,|T|,q\le 2\times 10^5\)
设 \(f_i\) 表示以 \(i\) 结尾的最长在 \(T\) 中出现过的子串的长度。每次询问就相当于求 \(\max\limits_{i=1}^r\min(i-l+1,f_i)\)。
可以二分答案,假设 \(ans\ge mid\),那就是看是否有 \(\max\limits_{i=l+mid-1}^r f_i\ge mid\),可以用 ST 表做到一个 log。
P4094 [HEOI2016/TJOI2016] 字符串
给定一个字符串 S,\(q\) 次询问,每次给定两个区间 \([l1,r1],[l2,r2]\),你需要从 \(S[l_1\ldots r_1]\) 中选出一个子串,使得这个子串与 \(S[l_2\ldots r_2]\) 的最长公共前缀长度最大,求最大值。
\(n,q\le 10^5\)
将整个串反过来,变成求 lcs,那么答案就是 \(\min(r2-l2+1,\max\limits_{i=l_1}^{r_1}\min(i-l_1+1,lcp(i,r_2))\)。
跟上题一样的思路,内层 \(\min\) 用二分答案去掉,然后把 lcp 变成 parent 树上 lca 的 len,现在相当于对某个区间 \([l,r]\) 求 \(\max\limits_{i=l}^r len(lca(i,r2))\)。根据经典结论,一些点和一个点的 lca 最深的是这个点 dfn 的前驱或后继,所以只需要判断这个区间内 \(dfn_r\) 的前驱后继,现在问题是求一个区间内一个数的前驱后继。
求一个区间的前驱后继可以考虑主席树,每次在 \(r\) 版本的线段树上二分找到最近的出现位置 \(\ge l\) 的数。
P4770 [NOI2018] 你的名字
给定一个字符串 S,\(q\) 次询问,每次给定一个字符串 T 和 \(l,r\),求 T 有多少个本质不同的且没在 \(S[l\ldots r]\) 中出现过的子串。
\(|S|\le 5\times 10^5,q\le 10^5,\sum|T|\le 10^6\)
对于一次询问,我们对于每个 \(i\),求出最大的 \(j\) 满足 \(T[j\ldots i]\) 没在 \(S[l\ldots r]\) 中出现过,这个可以双指针扫,判断可以在可持久化线段树上判断。然后就相当于在原本求一个串的本质不同字串数量上加了一个限制。
不知道什么题
给两个字符串 A,B,有五种操作。
在 A 开头添加一个字符
在 A 末尾添加一个字符
在 B 开头添加一个字符
在 B 末尾添加一个字符
询问 B 在 A 中的出现次数
先把 A,B 的最终形态求出来,每次就是询问 \(B[l_1\ldots r_1]\) 在 \(A[l_2\ldots r_2]\) 中的出现次数。我们可以倍增定位 \(B[l_1\ldots r_1]\) 在 A 的 SAM 上对应的节点,然后在可持久化线段树上询问一个区间内的和。
CF653F Paper task
给定一个括号串,求有多少个本质不同的子串是合法括号序列。
\(n\le 5\times 10^5\)
建出 SAM,还是考虑对于一个节点有多少个串合法。我们找到任意一个 \(\operatorname{endpos}\),然后左端点就限制在了一个区间 \([l,r]\) 内。
将右括号视为 \(1\),左括号视为 \(-1\),那么一个合法括号序列就要求所有后缀和非负且总和为 \(0\),所有后缀和非负还是先当与限制左端点在一个区间内。总和为 \(0\) 相当于在前缀和数组上求一个区间内有多少个数等于一个数,这个可以在 vector 上二分。
#6071. 「2017 山东一轮集训 Day5」字符串
给定 \(n\) 个字符串 \(S_1\ldots S_n\),一个字符串 T 是合法的,当且仅当 T 能表示成 \(p_1+\ldots+p_n\),其中 \(p_i\) 表示 \(S_i\) 的一个子串(可以为空),求有多少个不同的合法的 T。
\(\sum |S_i|\le 10^6\)
给一个 T,考虑怎么唯一对应出 \(p_1,\ldots,p_n\),我们可以先将 T 在 \(S_1\) 匹配最长的一个前缀,然后再在 \(S_2\) 上匹配最长的串,一直下去。
于是可以在 DAG 上 dp,一个串转移到下一个串的字符必须要满足这个字符在上一个串中没有出边。
SP687 REPEATS - Repeats
给定一个字符串 S,求 S 所有子串中,循环次数最大的是多少。
\(T\le 20,|S|\le 5\times 10^4\)
对于一个串 S,假设它是由某个长度为 \(x\) 的串重复 \(y\) 次得到的,那么有 \(S[1\ldots x(y-1)]=S[x+1\ldots xy]\),假设我们枚举这个串 T,T 出现在了两个位置 \(i,j\),那么它对答案的贡献就是 \(\lfloor\frac{len}{|i-j|}\rfloor+1\)。
枚举 SAM 中的每个节点,首先 \(len\) 最大就是选出最长的串,然后 \(|i-j|\) 最小就相当于找 \(\operatorname{endpos}\) 中最近的两个点,线段树合并时维护即可。
Rikka with String BestCoder Round #37
给定一个长度为 \(n\) 的字符串 S,你需要对每个 \(1\le i\le n\) 求出 \(S_i\) 修改为 # 后 S 的本质不同的子串个数
\(n\le 3\times 10^5\)
首先包含 # 的字符串肯定都不相同,所以这部分的答案为 \(i\times (n-i+1)\)。然后考虑容斥,用总的本质不同子串数减去将 \(S_i\) 替换后会少哪些子串。那么对于一个子串,它会被算当且仅当这个串所有出现位置都包含 \(i\)。
于是我们枚举 parent 树上的每一个点,因为要求都包含,于是只需要 \(\operatorname{endpos}\) 的最小值和最大值。然后贡献就是一个等差数列加,可以两次差分后单点加,然后最后求和。
P4465 [国家集训队] JZPSTR
你要对一个字符串进行三种操作:
在位置 \(x\) 处插入一个字符串 \(y\)
删除位置 \([x, y)\) 的字符串
查询位置 \([x, y)\) 的字符串包含多少次给定的子串 \(z\)
插入总长度 \(\le 2\times 10^6\),插入次数、删除次数 \(\le 2000\),\(\sum |z|\le 10^4\)
考虑分块。对每个块维护 SAM。删除插入就重构可能受到影响的两个块。
询问的话块内在 SAM 上跳,散块和块间用 KMP 匹配即可。
多串问题:
P2336 [SCOI2012] 喵星球上的点名
给定 \(n\) 个串 \(S_1,\ldots,S_n\),有 \(m\) 次询问,每次给出一个字符串 T,求 T 在几个 \(S_i\) 中出现过,最后还要求每个 \(S_i\) 对几个询问有贡献。
\(\sum |S_i|,\sum|T|\le 10^5\)
建广义 SAM,然后标记所有前缀对应的节点属于哪个串,每次询问就是数子树内颜色数,我们可以在 dfn 序列上离线扫描线。
求一个颜色对几个询问有贡献,相当于对这个颜色中的每个节点做一次链求和,但这个可能会计算重复,所以先按 dfn 序列排序,然后减去相邻两个点的 lca 的贡献。
同样的:SP8093 JZPGYZ - Sevenk Love Oimaster
CF666E Forensic Examination
给定一个字符串 S,和 \(m\) 个字符串 \(T_{1\ldots m}\),每次询问 \(S[l\ldots r]\) 在 \(T_L\ldots T_R\) 这些串中哪个出现次数最多,如有多解输出靠前的那个。\(|S|\le 5\times 10^5,\sum|T|\le 5\times 10^4,q\le 5\times 10^5\)
建 T 的广义 SAM,首先倍增定位出 \(S[l\ldots r]\) 所在的节点,我们还是可以用线段树合并的方法求出一个 SAM 的节点中,每个串在 \(T_i\) 中的出现次数,查询直接区间询问。
HDU 4436 str2int
给定若干个数字串,问所有本质不同的子串对应的十进制数之和,对 \(2012\) 取模。
\(\sum|S|\le 10^5\)
建广义 SAM,然后在 DAG 上 dp。每个点记录 \(f_u\) 表示所有到这个点的数的和,\(g_u\) 表示有多少条到这个点的路径,然后转移。
P4081 [USACO17DEC] Standing Out from the Herd P
给定 \(n\) 个字符串,求每个字符串有多少个本质不同的子串在其它子串中没有出现过。
\(\sum|S|\le 10^5\)
建广义 SAM,然后对于一个 SAM 上的节点,如果它的 \(\operatorname{endpos}\) 集合中只有一种串,那么这个节点内的字符串就会有贡献。具体的,对一个串内的 \(\operatorname{endpos}\) 染一个颜色,如果一个节点的子树内有且仅有一种颜色,那么这个节点内对应的串都可以统计进答案。
进阶
CF1063F String Journey
给定一个字符串 S,你需要从前往后选出若干个不交的子串,使得每个子串都是上一个的真子串。求最多能选出多少段。
\(|S|\le 5\times 10^5\)
先将串反过来,那么每次选的串都要包含上一个串。我们发现最终选的串的长度一定是 \(1,2,\ldots,ans\) 的,因为如果有不符合条件的可以删去字符来使得满足条件。
设 \(f_i\) 表示前 \(i\) 个字符最多能划分出多少段,且最后一段长度为 \(f_i\) 且右端点为 \(i\)。每次求可以二分答案,假设当前二分的是 \(mid\),那么最后一段就是 \(S[i-mid+1\ldots i]\),因为上一段比当前段长度少 \(1\),于是只有可能是 \(S[i-mid\ldots i-1]\) 或 \(S[i-mid+2\ldots i]\)。我们在 \(\operatorname{endpos}\) 集合上找到这个串在 \(\ge\) 的位置中最后一次出现位置,然后转移过来。
因为对于一个 \(f_i\),一定有一种方案是把 \(f_{i+1}\) 的每一段删去最后一个字母,所以 \(f_i\ge f_{i+1}-1\),于是就可以暴力跳 \(f_i\),时间复杂度为 \(\mathcal{O}(n\log n)\)。
P4482 [BJWC2018] Border 的四种求法
给定一个字符串 S,\(q\) 次询问,每次求 \(S[l\ldots r]\) 的最长 border 长度。
\(|S|,q\le 2\times 10^5\)
枚举 \(i\in [l,r)\),如果 \(LCP(i,r)\ge i-l+1\),那么 \(i\) 就可以作为一个 border,其中 \(LCP(i,j)\) 表示前缀 \(i\) 和 \(j\) 的最长公共后缀。
那么答案就是 \(\max\limits_{l\le i < r,LCP(i,r)\ge i-l+1} i\)。因为两个前缀的 LCP 长度等于它们在 parent 树上的 LCA 的节点 \(len\),所以我们可以考虑枚举 LCA。
先只考虑一个询问,我们枚举 \(LCA(i,r)\),即从上往下枚举 \(r\) 到根的路径上的点 \(x\)。将限制改一下变成 \(i-LCP(i,r) < l\),于是我们再枚举 \(x\) 子树内 \(r\) 所在子树的点作为 \(i\),然后判断是否能成为答案。这样子单次询问复杂度为 \(\mathcal{O}(n)\)。
因为是询问一个点到根的路径,所以可以树剖,对于一条重链上,我们询问的是一个前缀信息再加上一个子树信息。子树信息可以先线段树合并预处理出来。而这个前缀信息,我们可以对这条重链上的每个点,从上往下加入所有轻儿子子树内的所有点,并持久化这个过程,然后查询就是询问一个区间内最后一个小于一个数的位置,这个可以线段树上二分。
对于每个点,它被加入的次数为它到根上的轻边数量,所以总复杂度为 \(\mathcal{O}(n\log^2 n)\)。由于持久化空间有可能爆炸,所以可以再将询问分别离线到每条重链上,然后再扫每条重链即可。
P4218 [CTSC2010] 珠宝商
给定一个字符串 S 和一棵树,每个节点上有一个字符。对于 \(n^2\) 条路径,求这条路径构成的字符串在 S 中出现次数的和。
\(n,|S|\le 50000\)
建出 S 的 SAM,于是有 \(n^2\) 暴力枚举每条路径然后求和的做法。
树上所有路径,考虑点分治。设当前分治中心为 \(p\),要求出所有跨过分治中心的串的答案。将这个串从分治中心分成两个部分,相当于要求出这两个串在原串中在一起的方案数。显然这两个东西是独立的,我们可以分别求出 \(f_i\) 表示有多少个一个点到根的字符串以 \(i\) 结尾,\(g_i\) 表示有多少个根到一个点的字符串以 \(i\) 开头,然后答案就是 \(\sum\limits_{i=1}^m f_i\times g_i\)。
这两个东西是等价的,第二种情况相当于在 S 的反串上做第一种情况。现在只考虑第一种情况。相当于每次 dfs,然后在一个串的前面加字符,然后将 SAM 上这个节点的 \(cnt++\),最后将 cnt 下放即可得到每个位置为结尾有多少个字符。注意还要减去同一个子树内的贡献。
这样子复杂度为 \(\mathcal{O}(n\log n+nm)\) (不如暴力),我们发现当分治的连通块小了之后复杂度在于枚举 \(m\)。于是我们将两个做法拼起来,设一个阈值 \(B=\sqrt n\),当前连通块大小 $ > B$ 时用第二种做法,否则用暴力。这样复杂度为 \(\mathcal{O}(n\sqrt n)\)。
思考:怎么在一个字符串前面加字符?
用 LCT 来在线维护操作:
BZOJ2555 SubString
你需要维护一个字符串,支持:
在后面插入一个字符
询问一个字符串出现了几次
强制在线。
因为要强制在线,所以我们不能把整个串的 SAM 建出来再求每个点的 \(\operatorname{endpos}\) 集合大小,而是要在线维护。
于是还是在线建 SAM,每次要询问一个点在 parent 树上的子树和,因为 parent 树有 link,cut 和子树求和操作,所以用 LCT 维护。
#6041. 「雅礼集训 2017 Day7」事情的相似度
给定一个字符串 S,\(q\) 次询问,每次给定一个区间 \([l,r]\),你要选出 \(l\le i < j\le r\) 使得 \(LCS(S[1\ldots i],S[1\ldots j])\) 最大,输出这个最大值。
\(n,m\le 10^5\)
两种做法。第一种是离线做法,找支配对。
因为两个前缀的 LCS 为它们在 parent 树上的 lca 节点的 len,我们枚举每个点作为 lca,然后看这个 len 可能对哪些询问有贡献。
如果我们能从一个点的两棵不同的子树中选出两个 \(i,j\),那么就可以对所有 \(l\le i,j\le r\) 的询问有贡献。考虑启发式合并,将一个轻儿子的点插入当前点的集合时,只有这个点的前驱后继可能有用,这样一共可以找出 \(\mathcal{O}(n\log n)\) 个支配对。然后查询就离线扫描线即可。
第二种做法可以算是在线做法(可以在线加入字符),扫右端点 \(r\),然后用 \(f_l\) 表示当前区间 \([l,r]\) 的答案。复杂度为 \(\mathcal{O}(n\log^2 n)\)
对每个 SAM 上的节点记录 \(lst\) 表示上一次能到这个节点的前缀是哪个。然后考虑当前加入了前缀 \(i\),我们从前缀 \(i\) 所在的节点开始一直跳父亲,每到一个节点 \(x\),就可以新增支配对 \((lst_x,i)\),在线段树上就是区间取 \(\max\)。询问就是单点查询,然后更新 \(lst_x\larr i\)。
然后暴力跳父亲复杂度肯定不对。可以发现对于更新中一段相同的 \(lst_x\) 的节点,只有最下面的节点是有用的,于是只更新最下面的节点即可。类似颜色段均摊的思想,现在在树上,我们就用 LCT 来维护。根据结论,LCT 上 access 的均摊复杂度为 \(m\log n\),于是总复杂度为 \(\mathcal{O}(n\log^2 n)\)。
string (2016 ACM/ICPC Asia Regional Qingdao Online)
初始有一个长度为 \(n\) 的字符串,有 \(m\) 次操作。
在末尾添加一个字符
查询子串 \(S[l\ldots r]\) 中,出现次数 \(>1\) 的串的长度最大值。
\(n,m\le 5\times 10^4\),强制在线。
先考虑离线做法,和上面的启发式合并一样,我们发现一个节点的 \(\operatorname{endpos}\) 中只有相邻两个有用,于是还是可以找出这些支配对。对于当前节点,考虑一个 \((i,j)\) 能对哪些询问造成多少的贡献。假设 \(i < j\),如果 \(l\le i-len+1,j\le r\),那么贡献为 \(len\);如果 \(i-len+1 < l\le i,j\le r\),贡献为 \(i-l+1\)。那么扫描线时用两个线段树分别维护即可。
在线的话,每次加了字符,枚举整个串对应的节点到根的链,还是每个节点记录 \(lst_x\),然后当前点 \(i\) 跳的时候更新支配对 \((lst_x,i)\),对于一段相同的 \(lst\) 的节点可以一起做,用 LCT 即可。然后因为要在线处理询问,所以要把线段树每个版本记录下来,需要可持久化。复杂度 \(\mathcal{O}(n\log^2 n)\)。
P6292 区间本质不同子串个数
题意如标题。\(n\le 10^5,m\le 2\times 10^5\)。
首先考虑一个问题,给定一个序列,怎么多次询问求一个区间内不同的数的个数?
我们将区间离线然后扫描线,到一个 \(i\) 时,维护 \(f_j\) 表示区间 \([j,i]\) 的答案,然后扫到 \(i\) 就将 \((lst_{a_i},i]\) 区间内的 \(f_i+=1\),可以用树状数组维护。
回到原问题,还是用类似的思路,扫右端点 \(j\),记 \(f_j\) 表示 \([j,i]\) 的答案。现在我们要枚举所有以 \(i\) 结尾的串,更新这些串上一次出现位置到 \(i\) 的 \(f_i\)。于是我们枚举 parent 树上当前点到根的路径,每个节点记录一个 \(lst\) 表示上一次访问这个点是哪个 \(i\),在每个点我们需要在 \(f\) 上进行一个等差数列加。
我们发现一段 \(lst\) 相同的节点可以一起做,于是还是可以用 LCT 来维护,每次还是一个区间加等差数列,然后询问就是单点求值。
为了方便维护,可以将 \(f_i\) 差分一次变成区间加,前缀求和,这个可以用线段树维护。复杂度为 \(\mathcal{O}(n\log ^2 n)\)。
P5115 Check,Check,Check one two!
给定一个字符串 S,定义 \(lcp(i,j)\) 表示 \(S[i\ldots n],S[j\ldots n]\) 的最长公共前缀长度,\(lcs(i,j)\) 表示 \(S[1\ldots i],S[1\ldots j]\) 的最长公共后缀长度。你需要求:
\(\sum\limits_{1\leq i < j \leq n}lcp(i,j)lcs(i,j)[lcp(i,j)\leq k1][lcs(i,j) \leq k2]\)
\(n\le 10^5\)
看题解看了 1h 才看懂,难绷。
先考虑没有 \(k1,k2\) 的限制。考虑对于一对 \((i,j)\),设 \(p=lcp(i,j)\),我们将 \((i,j)\) 的贡献拆到所有 \([i,i+p-1]\) 上去,现在贡献就去掉了一个 \(lcp(i,j)\),剩一个 \(lcs(i,j)\)。枚举 SAM 中的每个点,求出有多少对 \(lcs(x,y)=len\),那么对于任意一对 \((x,y)\),它们的贡献是一样的。
现在考虑考虑对于任意一对 \((x,y)\),它们会对哪些 \((i,j)\) 造成贡献。枚举 \(i\in[x-len+1,x],j=y-(x-i)\),那么对 \((i,j)\) 造成的贡献为 \(i-(x-len+1)+1\),即贡献是从 \(1\) 加到 \(len\) 的等差数列。现在就完成了没有 \(k1,k2\) 限制的部分。如果有 \(k2\),那么就要求等差数列的上限不超过 \(k2\) 即可。
然后考虑 \(k1\) 的限制,我们首先要求等差数列的首项不小于 \(len-k1+1\),然后考虑有哪些 \((i,j)\) 会被计算重复。\((i,j)\) 被计算重复当且仅当 \(lcp(i,j) > k1,lcs(i,j)\le k2\),此时多算的贡献为 \(lcs(i,j)\times k1\)。我们把会算重的 \((i,j)\) 在枚举到 \((x,y)=(i+k1,j+k1)\) 时计算掉。
代码
void dfs(int u)
{
ll sum = 0;int len = t[u].len;
for(int v : e[u]){dfs(v);sum += cnt[u]*cnt[v];cnt[u] += cnt[v];}
ans += sum*S(max(1,len-k1+1),min(len,k2));
if(len > k1&&len-k1 <= k2)ans -= sum*(len-k1)*k1;
}
P6152 [集训队作业2018] 后缀树节点数
给定一个字符串 S,\(q\) 次询问,每次给定一个区间 \([l,r]\),求 \(S[l\ldots r]\) 构成的后缀树有多少个节点(不包括根节点)。
\(n\le 10^5,m\le 3\times 10^5\)
牛逼题,感觉会这个 SAM 基本就理解透彻了。
首先将后缀树节点数转化为反串的 SAM 的节点数。
对于一个串 S,它构建出的 SAM 的节点数等于所有前缀的个数加上中途分离出来的节点数。于是我们考虑用所有分裂出的节点中最长串最为代表元计数。
我们考虑对于一个子串,它能被计数的条件是什么。首先这个串必须是所在节点中最长的串,所以对于所有这个串的出现位置,这个串前面那个字符不能全部相同。也就是存在两个在 \(\operatorname{endpos}\) 集合中的 \(x,y\),使得 \(s_{x-len}\ne s_{y-len}\)。然后还要要求这个串不是 \(s\) 的前缀。即 \(len\notin \operatorname{endpos}\)。
现在就是对每个原串的 SAM 中的节点,看这个节点的最长串能否对一个询问区间 \([l,r]\) 有贡献,那么有贡献的充要条件就是这个节点的 \(\operatorname{endpos}\) 集合中存在两个 \(x,y\) 满足 \(l+len\le x,y\le r,s_{x-len}\ne s_{y-len}\),并且 \(\operatorname{endpos}\) 不包含 \(l+len-1\)。发现这两个条件加在一起不好处理,于是容斥,我们称满足第一个条件的节点是分裂节点,满足第二个条件的是前缀节点,答案是分裂节点数加上前缀节点数减去既是分裂节点又是前缀节点的点数。
前缀节点数很好算,就是 \(r-l+1\)。求分裂节点数,相当于每个节点看能不能在两棵不同的子树内选出两个 \(x,y\in [l+len,r]\),于是只需要找出 \(\mathcal{O}(n\log n)\) 个支配对,然后扫描线维护。
最后考虑既是分裂节点又是前缀节点的个数,我们发现,如果对于一个前缀 \(i\) 是分裂节点,那么对于前缀 \(i-1\) 一定也是分裂节点,于是满足条件的一定是一段前缀,我们可以二分找出这个分界点。二分时判断相当于先定位子串 \([l,mid]\) 对应的节点,然后根据定义看这个节点是否是分裂节点即可,定位可以直接哈希维护。
其它鸽了的题目
太困难,还不会。
P3346 [ZJOI2015] 诸神眷顾的幻想乡(广义 SAM)
P6816 [PA 2009] Quasi-template
总结
- SAM 的模板一定要记熟,每句话都要理解透彻。
- 一些很典型的套路都要会,比如 DAG 上 dp,可持久化线段树合并。
- 线段树合并节点的 \(\operatorname{endpos}\) 集合,然后查询线段树上的节点信息。
- 找支配对,支配对一般是 \(\operatorname{endpos}\) 集合中所有相邻的两个元素,可以启发式合并 \(\mathcal{O}(n\log n)\) 找出来。
- 如果要把找支配对的内容在线下来,可以用 LCT。
- 树剖,将询问离线到 \(log\) 条重链上,然后对每条重链做扫描线,此时可以暴力加每个点轻儿子的子树,加的次数为 \(\mathcal{O}(n\log n)\)。
- SAM 的难题,基本上都是把一个 SAM 套上一些数据结构来维护,所以要对 DS 有一定掌握。
- ...
总的来说 SAM 的运用还是比较灵活,多做例题才能熟练运用,希望大家听完之后能有收获。

浙公网安备 33010602011771号