G
N
I
D
A
O
L

后缀自动机

后缀自动机

之前听人说 \(\rm SA\) 能把很多问题变成数组上的问题,但 \(\rm SAM\) 变成的是树上问题,数组问题比树上问题好做。

但是我用 \(\rm SA\) 从来都没有 \(\rm SAM\) 好用。

思考一下原因的话可能是 \(\rm SA\) 研究子串是用后缀的方式理解的,而 \(\rm SAM\) 是直接研究子串将其分类后理解的。

虽然二者的本质相似,但是表面上的理解方式还是 \(\rm SAM\) 简单粗暴一点,比较适合我 。

/kel。

构建

形态概述

讲的有点乱,适合已经了解的读者再看一遍,可能有更深的理解。

\(\rm SAM\) 最终的形态有两个部分:\(\rm parent\ tree\)(称为后缀树)以及自动机(是一张 \(\rm DAG\)),它是可以动态构造的,即动态往后添加字符。

它们的点都是同一套点,而边不同。

我们绝大多数时候看问题都在后缀树上思考,少部分时候才会用到自动机。

后缀树

后缀树,顾名思义存了所有的本质不同的后缀。

你发现后缀的前缀就是子串,所以后缀树处理子串问题是极为强力的。

我们思考一下怎么存这些后缀,我们可以想到我们熟悉的 \(\rm Trie\) 结构。

但是很明显直接构建复杂度是 \(\mathcal O(n^2)\) 的,但我们可以压缩掉一些仅有一个儿子的节点,就得到了通常意义上我们口中的后缀树。

你考虑压缩后的 \(\rm Trie\) 什么时候会分叉,自然是有多个儿子的时候,所以每个后缀树上的节点所代表的子串们的出现集合(首字母出现位置集合,我们不妨称其为 \(\rm prepos\))都是相同的,否则必定会分叉而不会压缩到一起。

而每往父亲跳一步,则串变为原来的前缀,出现位置变多了,也就得到了新的一批与刚刚那批本质不同的子串们,而很自然地,一个节点所代表的那些子串是可以由它们之中最短的一个不断向后面添加字符构建而成的。

可以证明后缀树的节点个数是 \(\Theta(n)\) 的:

考虑每个节点的儿子个数都 \(\ge 2\),否则被压缩,而分出去的若干儿子们出现集合一定无交。

所以等价于起点(空串)的出现集合是 \([1,n]\cap\mathbb N\),每个节点都会对自己的集合进行划分,划分到大小为 \(1\) 时无法划分,别说线段树那样的划分方式了,就算每次只分掉一个元素那也最多只能分 \(n\) 次,总节点数是 \(\le 2n\) 的(因为中间节点也是节点)。

在想后缀树的时候,我们脑子里应该自然地有这些概念。

需要注意的是,\(\rm SAM\) 的构造算法中用的并不是 \(\rm prepos\) 而是 \(\rm end pos\),也就是利用每个本质不同子串出现的尾位置集合将子串们划分为若干个等价类。

用相同的原理我们知道这些 \(\rm endpos\) 之间也是有父子关系的,所以同样可以构成一棵树,每向父亲跳一步则串变为原来的后缀。

至于为什么不用易于理解的 \(\rm prepos\),原因我们等下介绍。

自动机

一个自动机,其实就是有若干状态,每个字符串对应一个状态,状态与状态之间有边相连,边上写着不同的字符。

往当前状态上添加一个字符到字符串末尾就转移到一个新的状态,后缀自动机自然就是你输入一些字符,就能得到该字符串作为子串的一系列信息。

看我的措辞,这是不是很像我们之前讨论出的“利用 \(\rm prepos\) 划分而成的压缩 \(\rm Trie\)”。

可以看出,我们之前讨论的那种后缀树并不能满足后缀自动机的要求,因为每个点都可能转移到自己,而转移到自己就意味着可以接受任意多个该字符,显然很多子串并不满足该性质。

而在以 \(\rm endpos\) 划分的后缀树上,我们发现在一个点后面加一个字符很显然不会转移到自己,且我们总能转移到一个位置,所以这棵后缀树上的所有节点是适合拿来构建后缀自动机的。

可以证明,后缀自动机的边数是 \(\Theta(n)\) 的,虽然我不大会证明。

构建

那么现在就要开始构建后缀自动机了,曾经的我是大力背下来啥也不懂的,但我更希望读者们理解性记忆,这样不容易忘,也更能理解它的本质。

每个节点需要维护 \(\rm fa,len,ch[|\Sigma|]\),分别表示后缀树上的父亲,等价类中 最长 的一个子串的长度,后缀自动机的所有后继。

(怎么获知等价类中最短的一个子串的长度?其实就是 \({\rm len}_{{\rm fa}_x}+1\)。)

首先上程序:

void init() {
    fa[1]=len[1]=0;
    sz=1;
}
int append(char c,int p) {
    int x=++sz;
    len[x]=len[p]+1;
    for(;p&&!ch[p][c];p=fa[p]) ch[p][c]=x;
    if(!p) return fa[x]=1,x;
    int q=ch[p][c];
    if(len[q]==len[p]+1) return fa[x]=q,x;
    int y=++sz;len[y]=len[p]+1;
    fa[y]=fa[q];ch[y]=ch[q];//std::array<int,26> ch[N];
    for(;p&&ch[p][c]==q;p=fa[p]) ch[p][c]=y;
    fa[x]=fa[q]=y;
    return x;
}

我们需要区分一下 \(1\)\(0\)\(0\) 表示该节点不存在,也就是完全没有出现过,而 \(1\) 表示空串,也就是在所有地方出现,不能将二者混为一谈。

所以在 init() 中我们需要初始化出 \(1\) 这个节点。

然后我们来看构建过程,假设当前长度为 \(n\)

  1. 每次增加一个字符 c 的时候,都会产生 \(n\)新后缀作为新子串,我们要加上这些新后缀对应的点。
  2. x 表示 \([1,n]\) 这个串,我们可以知道 每个前缀所对应的串 一定是等价类中 最长 的一个,因为 \(\rm endpos\) 相同时无法向前添加任何字符。
  3. p 表示加这个字符 之前的整个串,我们首先可以把 xlen 初始化出来,然后我们 跳后缀 跳到第一个有 c 这个后继的点,中间跳过的这些 没有后继的点 加上 c 这个字符的 \(\rm endpos\) 只能是 \(\set n\),所以将其后继变为 x
  4. 如果所有后继全都没有 c 这个出边(!p),那么就把 x 挂在空串下面,也就是说这 \(n\) 个新子串全都是等价的,它们的 \(\rm endpos\) 都只有 \(\set n\)
  5. 否则如果找到了 q=ch[p][c],(注意此时的 p 的意义发生了变化:有某个后缀 p+'c' 之前出现过)也即其 \(\rm endpos\) 已经是 \(\set n\) 的超集了,那么我们 需要\(\set n\) 这个 \(\rm endpos\) 集合 找到一个父集合fa[x])。
  6. 如果 p 加上 c 这个字符的 等价类中最长子串的长度len[q])刚好就只多了 1,那我们很开心,此时 q\(\rm endpos\) 也新增了一个 \(n\)(但我们没有记录 \(\rm endpos\) 所以不用管),直接就找到了一个超集。
  7. 但如果 len[q]>len[p]+1,那么找到的这个等价类就需要分裂成两个节点,因为它们之中的其中一个串 p+'c' 变得和它们不再等价,它在 \(n\) 这个位置 多出现了一次
  8. 于是我们分裂出 y 表示 p+'c' 这个串,它作为 fa[x]fa[q] 这两个 \(\rm endpos\) 的超集,同时 p 的祖先中所有转移到 q 的地方都应该转移到 y,因为 y 描述了一个更短的更精确的串。

需要注意的是,若把字符集大小 \(|\Sigma|\) 看作常数,那么该算法的时空复杂度均为 \(\Theta(n)\)

如果复制后继时仅仅复制有效部分(而不复制到 \(0\) 的转移),则时间复杂度一定为 \(\Theta(n)\),上面的代码并没有仅仅复制有效部分。

否则最优的实现形式是把 ch 数组换成哈希表,此时时空复杂度仍均为 \(\Theta(n)\),但常数极大,基本无法接受,且 std::unordered_map 有额外空间占用,很不好玩。

这也是后缀数组在建立时比 \(\rm SAM\) 好的一点。

在应用中如果一道题用到了 \(\rm SAM\),那他通常不会难为你给你一个大字符集的。

点数记得开到 \(2|s|\)

应用

那我们也不说废话了,开始应用。

这部分可比上面好玩很多。

子串出现次数

我们定位到了一个子串,那我们能不能知道每个等价类的出现次数呢?

这其实就是求 \(|\rm endpos|\)

你发现每个真实的前缀节点的所有祖先就是它的所有后缀,所以其实就相当于每个前缀节点有 \(1\) 点权值,求子树和。

需要注意的是复制出来的节点并不代表任何一个 \(\rm pos\),所以并没有用。

最长公共后缀

后缀树上 \(\rm lca\) 即可。

P3804 【模板】后缀自动机(SAM)

对于每个子串,其权值定义为长度乘出现次数,求出所有出现次数不为 \(1\) 的子串的权值的最大值。

\(n\le 10^6\)

很显然,只需要用 \(\rm len\) 乘上出现次数取个 \(\max\) 就好了。

后缀树不像回文树,它每个点的父亲编号不一定小于自己的编号,所以不能用简单的循环代替树的遍历。

P2408 不同子串个数

求出本质不同的子串个数。

\(n\le 10^5\)

把每个等价类中所有的串都加上就好。

串的个数显然是 \({\rm len}_x-{\rm len}_{{\rm fa}_x}\)

P4070 [SDOI2016] 生成魔咒

对每个前缀求本质不同子串个数。

\(n\le 10^5\)

每新增一个节点时就把答案加上 \({\rm len}_x-{\rm len}_{{\rm fa}_x}\)

复制节点不用管,显然那并没有改变本质不同子串个数,只是换了一种表示而已。

当然如果你是 \(\rm SAM\) 超级小白那只要有变动就减去原来的贡献加上新的贡献也不失为一种很好的策略。

(曾经的我就是这么做的。)

P5341 [TJOI2019] 甲苯先生和大中锋的字符串

给定 \(s,k\),求 \(s\) 中出现了 \(k\) 次的所有子串中,出现次数最多的长度是多少。

\(n\le 10^5\)

对合法的 \(x\) 进行区间加 \(({\rm len}_{{\rm fa}_x},{\rm len}_x]\) 即可。

CF802I Fake News (hard)

求每个本质不同子串出现次数平方和。

\(n\le 10^5\)

朴素。

P5212 SubString

支持往字符串后面加字符串,动态询问一个串的出现次数,强制在线。

\(\sum |s|\le3\times 10^6\),只有 \(\rm AB\) 两种字符。

也就是相当于动态维护每个点的子树和了。

你发现这个东西需要 \(\rm link\ cut\ tree\),变成了一道码农题。

不会 \(\rm lct\),先鸽了。

CF653F Paper task

给定一个括号串,问有多少本质不同的合法括号子串。

\(n\le 5\times 10^5\)

要注意的是,\(\rm SAM\) 并不神通广大,但我们可以把原问题转化成新的问题。

具体来说,对于每个点任意求出一个 \(\rm endpos\),求出该 \(\rm pos\) 前面长度在 \((\rm len_{fa_{\mathcal x}}, len_{\mathcal x}]\) 内有多少个合法括号串即可。

这个很好求啊(”左端点“指的是左开右闭),首先保证所有左端点后缀和都必须非负,使用二分 \(+\ \rm ST\) 表求出一个左端点,然后要保证左端点的后缀和 \(=\) 右端点后缀和,那直接主席树上数点就好了。

P4094 [HEOI2016/TJOI2016] 字符串

给定串,\(q\) 次询问 \([a,b]\) 的所有子串和 \([c,d]\)\(\rm lcp\)

\(n,q\le 10^5\)

先二分一步答案,这样就变成了求 \([c,c+\rm mid-1]\) 是否在 \([a,b]\) 中出现过。

我们发现 给定一个区间我们其实可以定位到它的后缀树节点,只需要在后缀树上从结尾节点开始倍增即可:

int Pos(int l,int r) {
    int p=pos[r];
    UF(j,20,1) if(len[f[p][j]]>=r-l+1) p=f[p][j];
    return p;
}

我们定位到 \([c,c+\rm mid-1]\) 的后缀树节点,然后就变成了 \([a+{\rm mid}-1,b]\) 是否在它的 \(\rm endpos\) 中,求出所有节点的 \(\rm endpos\) 只需要 可持久化线段树合并 即可。

做完了。

P4482 [BJWC2018] Border 的四种求法

给定串,\(q\) 次询问 \([l,r]\) 的最长 \(\rm Border\)

\(n,q\le 2\times 10^5\)

刚开始我的想法是:

注意到就是上题的简化版,\((a,b,c,d)=(l+1,r,l,r)\)

然后打完才发现被骗了,那个 \(\rm lcp\) 不一定顶到结尾,故大概率不是 \(\rm Border\)

这个问题较上个问题来说失去了可二分性,你很难把它变成一个判定性问题。

我突然想到一个结构:将一个串正反两次插入 \(\rm SAM\),里面的子串仍然还是原串中的子串,而我们现在可以同时对 \(\rm prepos\)\(\rm endpos\) 进行定位。

你只需要找二者的 \(\rm lca\) 即可,但这样找到的是原串,所以还需要再往上找一个。

这个也是假的,显然那个反串 \(\rm SAM\) 被统计时会统计到翻转后的串而不是原串。

这个题有点难,题解都很啰嗦,先跳了。

P6292 区间本质不同子串个数

\(n\le 10^5,q\le 2\times 10^5\)

好题,绝世好题,题解里全是 \(\rm lct\) 给我整不会了。

考虑扫描线,维护每个 \(l\) 的答案。

考虑维护 \(p_i\) 表示已经出现的串中有多少个串的左端点在 \(i\) 处,这样答案其实就是区间和。

加入一个 \(r\) 时相当于后缀树上一整条 \(\rm endpos\) 相同的链都出现了,我们考虑将其剖成 $\log $ 条重链。

我们并不是要对它们所在的左端点进行加贡献,而是要先撤销它们原来的贡献(因为要本质不同),再把左端点的贡献加上。

考虑把每个 \(\rm endpos\) 视为一种颜色,则我们要做树链染色,这是一个巨典的东西,直接上珂朵莉。

染色后的撤销和添加贡献是一个区间加,因为每个右端点对应的左端点们是一个区间。

区间加区间求和,不嫌麻烦可以写树状数组。

P5284 [十二省联考 2019] 字符串问题

现有一个字符串 \(S\)

Tiffany 将从中划出 \(n_a\) 个子串作为 \(A\) 类串,第 \(i\) 个(\(1 \leqslant i \leqslant n_a\))为 \(A_i = S(la_i, ra_i)\)

类似地,Yazid 将划出 \(n_b\) 个子串作为 \(B\) 类串,第 \(i\) 个(\(1 \leqslant i \leqslant n_b\))为 \(B_i = S(lb_i, rb_i)\)

现额外给定 \(m\) 组支配关系,每组支配关系 \((x, y)\) 描述了第 \(x\)\(A\) 类串支配\(y\)\(B\) 类串。

求一个长度最大的目标串 \(T\),使得存在一个串 \(T\) 的分割 \(T = t_1+t_2+· · ·+t_k\)\(k \geqslant 0\))满足:

  • 分割中的每个串 \(t_i\) 均为 \(A\) 类串:即存在一个与其相等的 \(A\) 类串,不妨假设其为 \(t_i = A_{id_i}\)
  • 对于分割中所有相邻的串 \(t_i, t_{i+1}\)\(1 \leqslant i < k\)),都有存在一个\(A_{id_i}\) 支配的 \(B\) 类串,使得该 \(B\) 类串为 \(t_{i+1}\) 的前缀。

方便起见,你只需要输出这个最大的长度即可。

特别地,如果存在无限长的目标串(即对于任意一个正整数 \(n\),都存在一个满足限制的长度超过 \(n\) 的串),请输出 \(-1\)

\(|S|,n_a,n_b,m\le 2\times 10^5,T\le 10,10\ \rm seconds,1000\ MB\)

首先如果 \(A_i,B_i\) 均为输入得到:

考虑如果建出一张图,每个 \(A\) 类串连向能排在它后面的 \(A\) 类串,那么该图必定无环,否则 \(-1\),有向无环图求个直径即可。

显然支配关系 \(i\to j\) 的充要条件就是 \(B_i\)\(A_j\) 的前缀,换句话说就是 \(\rm Trie\) 树上 \(A_j\)\(B_i\) 子树中,线段树优化建图即可。

辅助建图的边权均为 \(0\),每个串拆成入点出点,边权是串长,或者说有点权也可以。

但是现在是截取区间,先考虑建立反串后缀自动机这样前缀关系变成后缀关系,也就是后缀树上子树。

不难定位每个区间对应的后缀自动机节点,然后你就发现套用思路即可。

常数比较大,你发现这个东西似乎也可以前缀和优化建图,第一层将后缀树的结构建出,第二层每个 \(A\) 类串向自己对应的第一层节点连边,第一层每个节点向对应节点连边。

一些细节:

  • 有一些 $ A$ 类串其实是共用一个节点的,它们需要在第二层开出不同的节点。
  • 前缀关系在同一个点中需要判断 \(\rm len\),所以同一个后缀树节点里面不太好处理。
    • 在同一个等价类里面的所有 \(A\) 类串应当按照 \(\rm len\) 排序然后上面指向下面。

感觉好难写,先鸽了。

P7361 「JZOI-1」拜神

给定串,区间求出现过两次的最长子串的长度。

\(n\le 5\times 10^4,q\le 10^5\)

考虑套用区间本质不同子串个数的 \(\rm odt\) 做法,建立后缀树,扫描线,右端点加入的时候相当于一条 \(endpos\) 相同的链都出现了一次。

我们需要维护每个点最后两次被覆盖的颜色,考虑维护两棵 \(\rm odt\),分别维护上次出现和上上次出现的右端点,每次把“上次覆盖的颜色”推平,把“上上次覆盖的颜色”变成“上次覆盖的颜色”段,不难证明仍然具有颜色段均摊,你发现每个颜色段对相应左端点的贡献是一段斜率为 \(1\) 的直线 + 一段平线,可以使用李超树维护这个过程。

复杂度 \(n\log^2 n+q\log n\),李超树估计为一个 \(\log\),因为只有一种斜率。

(其实也可以把两种线扔到线段树上处理,一棵维护 \(ans_i+i\) 的最大值,另一棵维护 \(ans_i\) 的最大值,只需要标记永久化的区间覆盖(因为每次覆盖都会变优),并且不用维护 pushup。)

P5115 Check,Check,Check one two!

\[\sum_{1\leq i < j \leq n}lcp(i,j)lcs(i,j)[lcp(i,j)\leq k1][lcs(i,j) \leq k2] \]


\(1 \leq n \leq 10^5,1\leq k1 , k2 \leq n\)

第一种想法:考虑树分治,\(k_2\) 的限制我们把它顶掉,利用 P4211 [LNOI2014] LCA 的套路,拆一下权值,即如果 \(lca=x\) 应当造成 \(v_x\) 的限制,那就令 \(x\) 到根的权值和为 \(v_x\),然后在第一棵树上跑 dsu,第二棵树上跑树剖线段树,线段树可以艰难地换成树状数组,三只 \(\log\)

我开局就比较冷静,观察了一下柿子,发现拼起来是两个相等串,考虑直接枚举这个相等串,这个串必须是极长的(即向左向右加字符都会使得它出现次数变化),接下来我们就变成求每个相等串的贡献次数,贡献系数是好处理的。

我最开始的想法是,如果能把所有极长串建一棵包含的树,辅以每个串的出现次数就好求了(这玩意好像是个 dag,不是树)。

AlexWei 的做法是后缀数组,只需要对所有 \(s_{i-1}\ne s_{j-1}\) 的串统计 \(lcp(i,j)\),前者只有 \(26\) 种,可以对每个 \(s_{i-1}\) 统计单调栈前缀和之类的。

qwaszx 有很简洁神秘的后缀树做法,考虑我们要统计独立极长出现次数,那我们先把所有出现次数求出 \(\binom {sz}2\),然后减掉不独立的。

考虑维护 \(cnt_{i,c}\) 表示往 \(i\) 这个点后面加 \(c\) 这个字符后,在串中的出现次数。

那我们在树上 \(\rm dp\) 的时候,不断把儿子合到自己身上来,\(siz_x\times siz_v\) 就是这个串总的出现 pair 的新增量,\(cnt_{x,c}*cnt_{v,c}\) 就是这个串非极长 pair 的新增量,之后 \(siz_x,cnt_{x,c}\) 要相应加上 \(siz_v,cnt_{v,c}\)

初始的时候 \(cnt_{pos_i,s_{i+1}}=1\),表示没合并的时候的 \(cnt\)

广义 SAM

刚刚才发现自己对广义 \(\rm SAM\) 的理解是有问题的。

对多个串建立的 \(\rm SAM\) 叫广义 \(\rm SAM\),虽然它也包括对 \(\rm Trie\) 建立 \(\rm SAM\)(将 \(\rm endpos\) 理解为 \(\rm Trie\) 上的末节点),但后者通常意义不大没有应用前景。

根据 该帖子 以及改进后的 这篇题解,我们建立广义 \(\rm SAM\) 的时候只需要加一点小特判即可:

void init() {
    fa[1]=len[1]=0;
    las=sz=1;
}
int app(char c,int p) {
    if(ch[p][c]) {
        int q=ch[p][c];
        if(len[q]==len[p]+1) return q;
        int y=++sz;len[y]=len[p]+1;
        fa[y]=fa[q];ch[y]=ch[q];
        for(;p&&ch[p][c]==q;p=fa[p]) ch[p][c]=y;
        fa[q]=y;return y;
    }
    int x=++sz;
    len[x]=len[p]+1;
    for(;p&&!ch[p][c];p=fa[p]) ch[p][c]=x;
    if(!p) return fa[x]=1,x;
    int q=ch[p][c];
    if(len[q]==len[p]+1) return fa[x]=q,x;
    int y=++sz;len[y]=len[p]+1;
    fa[y]=fa[q];ch[y]=ch[q];//std::array<int,26> ch[N];
    for(;p&&ch[p][c]==q;p=fa[p]) ch[p][c]=y;
    fa[x]=fa[q]=y;
    return x;
}

其实还是比较简单的,保证了在线,理解起来也不麻烦,但这并不适用于对 \(\rm Trie\) 建立。

如果记不住的话,也可以使用更加万能的 bfs 写法:

void bd() {
    pos[1]=1;//空串
    F(c,0,25) if(Trie.ch[1][c]) pos[Trie.ch[1][c]]=app(c,1),q.push(Trie.ch[1][c]);
    while(!q.empty()) {
        int x=q.front();q.pop();
        F(c,0,25) if(Trie.ch[x][c]) pos[Trie.ch[x][c]]=app(c,x),q.push(Trie.ch[x][c]);
    }
}

其中 app 就是普通 \(\rm SAM\) 的建立函数。

就像 \(\rm ACAM\) 的建立方式一样。

SP1811 LCS - Longest Common Substring

求两个字符串的最长公共子串的长度。

\(n\le2.5\times 10^5\)

出现多个字符串时,通常模仿后缀数组那样加分隔符拼起来建 \(\rm SAM\)

其实就是要对同时在两边出现的所有串做一点什么事。

我们可以先把第一个串的所有前缀对应的节点进行一个祖先的链的并的加,再对第二个串的所有前缀对应的前缀进行一个祖先的链的并的加,这样权值为 \(2\) 的串就求出来了。

至于如何进行祖先的链的并的加,可以利用虚树相关结论,先按照 \(\rm dfn\) 排序并对每个点加,再把相邻点的 \(\rm lca\) 减掉。

(温馨提示:字符串少(常数个)的时候可以暴力每个点往上跳打标记,这样复杂度显然不会超过字符串个数 \(\times\) 自动机点数,多个串时似乎可以证明是根号的。)

这个做法的可扩展性很强,可以扩展到多个串,求每个串都出现过的本质不同串的任何东西。

比如说求“每个串都出现过的本质不同串的总出现次数乘长度的和”这种东西。

SP8093 JZPGYZ - Sevenk Love Oimaster

给定 \(n\) 个模板串,\(m\) 次询问给定串是多少个模板串的子串。

\(n\le 10^5\)

这不是 \(\rm ACAM\) 板子吗。

不过对于子串的包含问题后缀树和 \(\rm Fail\) 树是一样的。

思考一个问题:每个等价类包含了很多串,那如何处理该等价类内部的情况?

答案是你无需处理,每个等价类的出现位置和出现次数一定是严格相等的。

不管是 \(\rm ACAM\) 还是 \(\rm SAM\) 都利用之前说的 \(\rm dfn\) 序做即可。

P6640 [BJOI2020] 封印

给定两个串,\(q\) 次询问 \(s_{[l,r]}\)\(t\) 的最长公共子串。

\(n,q\le 2\times 10^5\)

可以求出每个点为结尾的所有串与 \(t\) 的最长公共子串,方法显然是找到 \(\rm pos_{\mathcal i}\) 以及其祖先中在两个串都出现的最长串,然后就变成了 \(n\) 条线段,求区间最长线段。

离线右端点,每加入一条线段 \([k,r]\) 对左端点 \(\le k\) 的询问的贡献是定值的区间长,而对 \(>k\) 的询问的贡献是一个一次函数,其斜率恒定为 \(-1\),所以我们只需要求区间最大截距即可。

但是你还要区间询问这个最值,所以还是得上简化版的李超树(或者说,直接把最大截距形成的最值当作参数传上去即可)。

后一部分也可以在询问的时候换成二分答案 + \(\rm RMQ\) 判定。

P3346 [ZJOI2015] 诸神眷顾的幻想乡

给定一棵每个点都有 \([1,c]\) 颜色的树,问有多少种不同(经过的颜色序列不完全相同)的有向简单路径,叶子 \(\le 20\)

\(n\le 10^5,c\le 10\)

考虑叶子 \(\le 20\),直接以每个叶子作为根把所有可能的直上直下路径组加进 \(\rm Trie\) 里面,然后广义 \(\rm SAM\) 就可以求了。

CF427D Match & Catch

给定两个字符串,求最短的各只出现一次的公共子串。

\(n\le 5000\)

\(\rm SAM\) 最大的好处就是你可以枚举所有本质不同的子串,这比后缀数组刻画的要深刻一些。

枚举公共的等价类,判断是否只出现一次即可。

CF452E Three strings

给定三个串,对于每个长度 \(l\),求出有多少对 \((i,j,k)\) 满足 \(a_{[i,i+l-1]}=b_{[j,j+l-1]}=c_{[k,k+l-1]}\)

对每个本质不同串求出它在三个串中的出现次数,将三者的乘积区间加到答案中就好。

CF204E Little Elephant and Strings

给定 \(n\) 个字符串,对于每个字符串 \(a_i\) 求出有多少 \([l,r]\) 满足 \(a_{i}[l,r]\) 是至少 \(k\) 个字符串的子串。

\(\sum|a_i|\le 10^5\)

首先可以用之前的 \(\rm Trick\) 求出有哪些字符串是符合条件的。

我们可以用可持久化线段树合并求出每个等价类(中每一个串)在每个字符串中出现的次数。

其实就是要把这些线段树分别乘上不同的系数再加起来嘛,如果仅仅是有系数的话还是可以打标记的(注意这不像区间加会改变有值的位置数)。

但它们是可持久化着合并出来的,并不满足线段树合并的条件,合并复杂度可能很高。

那我们自然就要想一些符合条件的合并方式,因为合法的串在树上形成了一个包含根的邻域,我们可以用邻域之外的若干棵树乘上系数组合起来。

因为上面的树其实也是由下面的树合起来的,而把上面的树乘上系数(\(\rm len_{\mathcal x}-len_{fa_{\mathcal x}}\))再相加,其实是多此一举,\(\rm len_{fa_{\mathcal x}}\)\(x\) 减掉又在 \(\rm fa_{\mathcal x}\) 加回来,简直就是吃饱了撑的。

邻域之外的树内容来源并无相交,故合并复杂度是正确的。

我们再次思考一个问题:邻域内部如果有单点新增量咋办。

其实刚刚的算法都是在脱裤子放屁,其本质是每个点都会向上贡献一整条链,而你只需要求出这个点向上跳到的第一个合法点的 \(\rm len\) 就好了,它会把 \([1,\rm len]\) 全都贡献一遍。

P4081 [USACO17DEC] Standing Out from the Herd P

给定 \(n\) 个串,对每个串求出只属于它的本质不同子串个数。

\(n\le 10^5,\sum|s|\le 10^5\)

直接求出每个串被多少个串覆盖然后每个点网上找仅出现一次的串统计贡献即可。

P1117 [NOI2016] 优秀的拆分

给定一个字符串,问在所有的子串的所有拆分形式中,有多少是 \(\text{AABB}\) 的形式,其中 \(\text{A,B}\) 是任意非空字符串,同一子串的不同拆分方式算多种。

\(n\le3\times 10^4\),多测 \(10\) 组,实际单测可做 \(n\le 5\times 10^5\)

考虑每个合法答案一定是两个 \(\text{AA}\) 串拼起来的,所以设 \(a_i/b_i\) 为以 \(i\) 开头/结尾的 \(\text{AA}\) 串数量,则答案为:

\[\sum_{i=1}^{n-1}b_ia_{i+1} \]

简单的方法是暴力哈希枚举一个 \(\text{A}\) 来判断,复杂度 \(\Theta(n^2)\)

考虑优化。

首先枚举 \(\text{len}_\text{A}=k\),把 \(k\) 的倍数点成为关键点 \(\text{}\),则每个 \(\text{AA}\) 串一定过恰好两个关键点。

枚举这两个相邻关键点的位置,用这两个点的后缀 \(\rm lcp\) 和前缀 \(\rm lcs\) 判断一下有几个 \(\rm AA\) 串即可。

\(\rm SAM\) 怎么求 \(\rm lcp\)?可以对着反串建 \(\rm SAM\),然后后缀树上 \(\rm lca\)

广义 \(\rm SAM\) 同时求多个串的 \(\rm lcp\) 写的极其舒服,不像后缀数组要做繁杂的下标变换。

SP687 REPEATS - Repeats

给定字符串,求重复次数最多的连续重复子串的重复次数。

\(T\le 20,n\le 5\times 10^4\)

考虑利用上面这个题的结论,首先枚举该串的长度 \(k\),然后每 \(k\) 个点打一个关键点,则重复了 \(d\) 次的答案必定恰好过了 \(d\) 个关键点。

其实根据周期相关知识答案的充要条件就是:

\[\frac{\rm lcp+lcs+\mathcal d}{k}\ge d \]

然后枚举两个相邻关键点判断即可。

(这个题都没让你计数,只需要求最值,所以只需要朴素地做就行了。)

CF666E Forensic Examination

给定一个串 \(s\) 和一个串数组 \(t\),每次问 \(s[l,r]\)\(t[L,R]\) 中哪个串出现次数最多,出现了多少次。

\(|s|,q\le 5\times 10^5,\sum |t|\le 5\times 10^4\)

定位到 \(s[l,r]\) 的后缀树节点,类比求 \(\rm endpos\) 的方法求出它在每个串里出现的次数,线段树维护区间最大值和区间最大值的位置即可。

P4770 [NOI2018] 你的名字

给定 \(s\)\(q\) 次询问 \((t,l,r)\),问有多少串在 \(t\) 中但不在 \(s[l,r]\) 中。

\(|s|\le5\times 10^5,\sum |t|\le10^6,5\rm\ seconds\)

考虑先把所有串串扔到 \(\rm SAM\) 上,那么一个串在 \([l,r]\) 中或者在 \(t\) 中无非就是在两坨点到祖先的链上了,但区别是后面那一坨点是可枚举的,而前面的无法枚举。

考虑如果我们知道哪些点被 \(\rm ban\) 了(在线段树上),那我们可以直接利用到祖先的链的并 \(\rm trick\) 配合树剖线段树来 \(\mathcal O(|t|\log^2n)\) 求出答案。

那我们怎么知道哪些点被 \(\rm ban\) 了呢?

我想过主席树,但主席树并不适用于区间加求区间 \(0\) 的个数,这玩意不可差分。

考虑利用区间本质不同子串的 \(\rm trick\),离线扫描线,你可以求出每个点的死亡线 \(l_i\),只要询问左端点 \(L\le l_i\) 那它就不存在。

于是我们再来一步区间询问有多少个点的死亡线 \(>L\),这是一个二维数点,树套树 \(\log^2 n\),外面还有一层树剖,总复杂度 \(\mathcal O(n\log^3 n)\)

这一看想过就很困难,而且更新死亡线是一个区间修改,树套树想区间修改还是太困难了。

重新梳理一下,只套上链并 \(\rm trick\),抛弃树剖,你发现你的询问变成了 \(\mathcal O(\sum|t|)\) 次从一个点到根询问有多少点不在 \([l,r]\) 这些点到根的链上。

你发现不在链上可以正难则反成在链上,也就是求一条链和根向外的邻域的交,而这条链也是自根而下的,所以这个交一定是一个自根而下的链!

或者说我们只需要找到该点祖先中最深的在 \([l,r]\) 这些点到根的链上的点的深度即可。你发现这其实可以直接倍增,找到第一个 \(\rm endpos\) 里面没有 \([l,r]\) 的节点即可!

所以说我们正难则反了两次又反回去了,难绷。

找到一个拓展性强的思路其实是很重要的,哪怕思路是错的也可以想回到正确的思路上。

写了之后发现没有 \(\rm AC\),于是你开始检查,于是你发现第一步就错了,我们查询 \([l,r]\) 只能查到 \({\rm endpos}\in[l,r]\) 的情况,而不是该串在 \([l,r]\) 的情况,这可怎么办!

然后你发现加上了这个限制因素串串的合法性仍然是单调的,于是你把倍增魔改一下,首先倍增到合法的点,再在该点内部二分即可。

值得注意的是这个错的离谱的东西过了前 \(17\) 个点,非常强悍。

P8147 [JRKSJ R4] Salieri

给出 \(n\) 个字符串 \(s_i\),每个字符串有一个权值 \(v_i\)\(m\) 次询问每次给出一个字符串 \(S\) 和一个常数 \(k\)。设 \(cnt_i\)\(s_i\)\(S\) 中的出现次数,求 \(cnt_i\times v_i\)\(k\) 大(从大往小第 \(k\) 个)的值。

对于 \(100\%\) 的数据,\(1\le n,m\le10^5\)\(L\le5\times10^5\)

第一次在数据结构题里用虚树。

首先不要读错题了,询问串是大串。

这种题不需要 \(\rm SAM\),直接上 \(\rm ACAM\)

考虑先把询问串在 \(\rm Trie\) 图上跑一下,求出一个点集,接下来我们要干的事情都要和这玩意同阶。

\(k\) 大确实很难求,但我们发现本质不同的 \(cnt_i\) 很少(关键性质),我们把这个点集建一棵虚树,则虚树上每条边上(\([x,fa_x)\))的 \(cnt_i\) 都相等。

\(cnt_i\) 个数很少那就好办了,二分答案,等价于链询问 \(v_i\ge\lceil\frac{mid}{cnt}\rceil\) 的数量,树上主席树数点即可。

对自动机的应用

P3975 [TJOI2015] 弦论

求一个字符串的第 \(k\) 小子串。

\(n\le 5\times 10^5,k\le 10^9\)

这个东西一看就跟后缀树不沾边,后缀树刻画的是子串间的父子关系,而自动机可以刻画子串间的转移关系。

考虑自动机上任意一条从起点开始的路径都是一个子串,且不重不漏,我们可以用 \(\rm dp\ on\ DAG\) 求出每个方向有多少个串(有多少个不同路径),然后跑一遍即可。

CF235C Cyclical Quest

给定一个串 \(s\) 和若干询问串 \(t\),对每个询问串求其所有不同的循环同构在 \(s\) 中的出现次数之和。

\(|s|,\sum|t|\le 10^6\)

考虑建出 \(s\)\(\rm SAM\),求出每个串在 \(s\) 中的出现次数(子树和),用 \(t\) 在上面跑匹配,这样就求出了不考虑循环同构时的答案。

然后我们考虑循环同构,\(\rm SAM\) 的匹配有很好玩的一点是可以删去首字符。

具体来说,我们动态维护匹配了 \(d\) 个位置,每次需要优先保证有下一条出边,不断维护 \(d\),如果 \(d\) 超过 \(|t|\) 了就减掉首字母,同时维护 \(x\) 是否需要退回到 \(\rm fa_{\mathcal x}\)

int x=1,d=0;long long ans=0;
F(i,0,2*m-2) {char c=t[i]-'a';
    while(x&&!ch[x][c]) x=fa[x],d=len[x];
    x=ch[x][c];++d;
    while(d>m) if(--d==len[fa[x]]) x=fa[x];
    if(d>=m&&!vis[x]) ans+=S[x],del+=x,vis[x]=1;
} 

P4022 [CTSC2012] 熟悉的文章

给定若干模板串,记所有模板串的所有子串为好串,给定若干询问串 \(s\),问最大的 \(L\),使得存在一种划分方式,使得划分 \(s\)\(\ge L\) 的所有好串的长度和 \(\ge90\%\times|s|\)

输入文件长度 \(\le1.1\times 10^6\),字符只有两种。

你可以对每个询问串 \(\mathcal O(|s|)\) 求,而不用必须 \(\mathcal O(1)\)

首先 \(90\%\) 这个数什么性质也没有,我们就当这个东西是任意数。

考虑首先二分答案。

\(f_i\) 表示前 \(i\) 个元素,\(i\) 作为一个划分结尾,最长的好串长度和。

转移可以把无用的划分都看成长度为 \(1\),那么要么找一个好串做结尾并增加长度 \(f_i\gets f_j+i-j\),要么从 \(i-1\) 转移过来 \(f_i\gets f_{i-1}\)

就算我们知道哪些串是好串,这个 \(\rm dp\) 也是 \(\mathcal O(|s|^2)\) 的。

我们考虑一些性质:其实我们可取的范围是一个区间,区间的右端点被二分的答案限制,左端点被好串限制,我们其实是要对该区间的 \(f_i-i\)\(\max\),你发现这是一个后缀 \(\max\),可以用树状数组维护。

那么我们可以直接上 \(\rm SAM\),显然好串的限制可以用 \(\rm SAM\) 动态维护匹配的个数来求出。

这样是 \(\mathcal O(\sum|s|\log^2|s|)\) 的,常数很小,已经可以通过。

但我们并不满足,你发现询问还有更好的性质:左端点单调不减。

于是这就是一个滑窗,单调队列即可。

CF1037H Security

给定串 \(s\)\(q\) 次询问 \((l,r,t)\),求出字典序最小的 \(s_1\),使得其为 \(s[l,r]\) 的子串,且字典序 \(> t\),输出这个 \(s_1\),无解输出 \(-1\)

\(|s|\le 10^5,q\le 2\times 10^5\)

如果 \([l,r]=[1,n]\),那我们在 \(\rm DAG\) 上任意走都是子串,可以枚举 \(\rm lcp(\mathcal s_1,t)\),然后选一个最小的合法字符。

那现在有了区间限制,无非就是原本后缀自动机上的一些子串失效了嘛,我们只需要判断一下是否失效就可以了,可以利用可持久化线段树合并来求出所有 \(\rm endpos\) 来判断。

posted @ 2024-09-04 19:18  JueFan  阅读(8)  评论(0编辑  收藏  举报