SAM & 广义 SAM
SAM
再梳理
关于 parent tree 的性质:
- 把某个前缀插入后得到的终点称之为终点节点,则一个节点的 endpos 集合为其子树内所有终点节点对应的前缀终点的集合。
- 若 \(A\) 是 \(B\) 的祖先,则 \(A\) 上任意一字符串都是 \(B\) 上任意一字符串的后缀。根据这一结论,树上两前缀 \(x,y\) 的 \(lca\) 代表两者的最长公共后缀。对于非前缀的情况,需要额外讨论 LCA 是自己且自己不是所在等价类最长串的情况。
每次插入一个字符 \(c\),形成长为 \(n\) 的串时,整串对应一个全新的 endpos 等价类,我们考虑找到这个等价类对应的父亲,可以从原字符串整串对应点往 parent(endpos) tree 的父亲处跳,寻找 endpos 有 \(n-1\) 的所有点,直到找到一个 \(p\) 使得 \(p\) 存在 \(c\) 作为一个儿子。注意跳的过程中遇到的所有点都要新建一个 \(x\) 出边。
- 若找不到,则说明不存在一个 endpos 等价类包含 \(n\) 以及其它位置。直接将这个点接在根节点下方。
- 设找到的 \(p\) 的字典树上 \(c\) 儿子为 \(q\)。注意 \(q\) 的 endpos 是没有任何限制的。若 \(q\) 中最长的串是 \(p\) 中最长的串 \(+1\),则说明该等价类中最长的串是 \(p\) 最长的串加上 \(c\)。根据同一个等价类的串有后缀关系,该等价类下所有串都会包含 \(n\) 这个 endpos,于是直接让整串的 endpos 接在这个等价类下方做儿子即可。
- 否则,\(q\) 中最长的串比 \(p\) 最长的串 \(+1\) 更大。这说明这个等价类中存在一些串非原串的 \(n-1\) 前缀的后缀,只是某些较短的串去掉 \(x\) 后恰是 \(n-1\) 前缀的后缀,它们应当是等价类中所有长度 \(\leq len_{p}+1\) 的串。这些串在加入 \(x\) 后,会形成一个新的等价类 \(nq\) 并将原来的 \(q\) 包含,同时也直接包含当前长度为 \(n\) 的整串等价类。因此将当前点和 \(q\) 全部接在 \(nq\) 下方。最后,对于 \(p\) 在树上的祖先,若他们的图上边连向的 \(q\)(这些点是连续的一段),此时应当改连向 \(nq\),因为这些点都包含 \(n-1\) 这个 endpos,\(nq\) 才是包含 \(n-1\) 这个 endpos 的点。
广义 SAM
对多个串建立 SAM 时,考虑每一个串时都将前缀指针指向根,然后一个一个往前走,建立新节点的方式和普通 SAM 一致。唯一区别时在碰到下一个节点是原来就有的节点时:
- 设当前指在 \(p\),下一步指向 \(q\)。若 \(q\) 中最长的串是 \(p\) 中最长的串 \(+1\),则所有串的等价类都可以扩展到当前串上来,直接令下一个节点是 \(q\)。
- 否则新建节点 \(nq\) 作为 parent tree 上 \(q\) 的父亲,并更新 parent tree 上所有指向 \(q\) 的点,改为指向 \(nq\),同时指针指向 \(nq\)。此过程与普通 SAM 的第三点基本类似,唯一区别在于普通 SAM 此处有 \(np,nq\) 两个节点,这里只有 \(nq\) 一个新节点。
int n;
struct SAM{
int cntp=1,la=1;
int ch[N][26],fa[N],len[N];
void insert(int x){
if(ch[la][x]){
int p=la,q=ch[p][x];
if(len[q]==len[p]+1){
la=q;
return;
}
int nq=++cntp;
rep(i,0,25)
ch[nq][i]=ch[q][i];
fa[nq]=fa[q],len[nq]=len[p]+1,fa[q]=nq;
while(ch[p][x]==q)
ch[p][x]=nq,p=fa[p];
la=nq;
return;
}
int p=la,np=++cntp;
len[np]=len[p]+1,la=np;
while(p&&!ch[p][x])
ch[p][x]=np,p=fa[p];
if(!p){
fa[np]=1;
return;
}
int q=ch[p][x];
if(len[q]==len[p]+1){
fa[np]=q;
return;
}
int nq=++cntp;
rep(i,0,25)
ch[nq][i]=ch[q][i];
fa[nq]=fa[q],len[nq]=len[p]+1,fa[np]=fa[q]=nq;
while(ch[p][x]==q)
ch[p][x]=nq,p=fa[p];
}
ll query(){
ll ans=0;
rep(i,2,cntp)
ans+=len[i]-len[fa[i]];
return ans;
}
}S;
signed main(){
read(n);
rep(i,1,n){
string s;
cin>>s,S.la=1;
for(auto j:s)
S.insert(j-'a');
}
printf("%lld\n",S.query());
return 0;
}
这是广义 SAM 的板子。事实上去掉开头的 if 剩下的内容就是普通 SAM 的板子。
例题
P3975 [TJOI2015] 弦论
建立 SAM,对本质不同子串统计时,注意到一个子串对应一个路径,于是处理从 \(i\) 出发的路径数即可。
相同子串多次计入时,利用 parent tree 预处理每个点的 endpos 个数,就是一个带权路径数问题。直接在 DAG 上 dfs 地 dp 即可。
P7409 SvT
为了方便处理 LCP,我们对反串建立 SAM,注意到每个前缀都是所在等价类里最长的串,则两个前缀(原来的后缀翻转得到)的最长公共后缀就是 parent tree 上 LCA 的最长串。虚树即可。
P6793 [SNOI2020] 字符串
等价于给两组的字符串两两匹配,最大化每一个匹配的 LCP 长度。策略显然都是选择 LCP 尽可能大的,能匹配就匹配。有两种思路,第一步都是把子串看成前缀或后缀,统计答案的时候对串长取 \(\min\) 即可。
首先可以 SA。从大到小枚举 LCP,每次等价于在 height 数组上做合并,合并的时候统计答案即可。
然后可以广义 SAM。找到前缀对应的点,直接 DFS 的时候合并。
P7879 「SWTR-7」How to AK NOI?
判定是一个 dp:设 \(f_i\) 表示能否在 \(i\) 处作为一个分割断点,则转移时 \(f_{i}=\bigcup_{j=1}^{i-k} f_j\cap g_{j+1,i}\),其中 \(g\) 表示该子串有没有在 \(s\) 中出现过。由于要修改,于是可以把 \(f\) 写成矩阵形式。注意由广义矩阵乘法结合律的判据(外部运算有交换律,内部运算对外部运算有分配律,两个运算均有结合律)可知该矩阵乘法有结合律。注意到一段的长度不会超过 \(2k\),故可以写成一个 \(2k\times 2k\) 的矩阵,上线段树维护。矩阵乘法复杂度可以用位运算做到 \(O(k^2)\)。在一个叶子处,求 \(g\) 时,要对每一个 \(g_{x,i}\) 判断其是否出现过,可以直接对反串建立 SAM,跑一次即可 \(O(k)\) 求出每一个 \(g\) 值。
因此查询复杂度 \(O(qk^2\log m)\),build 复杂度 \(O(mk^2)\)。一次修改要对询问区间及询问区间后面的 \(2k\) 个叶子处的矩阵进行修改。考虑暴力修改,总修改次数为 \(O(L+qk)\),复杂度瓶颈来到 \(qk^3\log m\),难以通过。注意到一次修改等价于重构线段树上的子区间,类似 build,我们不每次做单点修改,而是整体下到一个区间之后执行 rebuild,经过的总点数为 \(O(L+qk)\),于是复杂度降至 \((L+qk)k^2\),即可通过。
P7361 「JZOI-1」拜神
字符串相等要往前缀的 LCS 或者后缀的 LCP 上转化。该问题转化为找到 \((i,j)\) 使得 \(l\leq i<j\leq r\),最大化 \(\min(lcs(s_i,s_j),i-l+1)\)。
考虑建立 SAM,在 LCA 处统计答案。对于 LCA 相关的点对问题,可以套路化地考虑支配对。
不难发现,将一个点子树内所有前缀点按升序排成一排,只有相邻的点可能作为 \((i,j)\)。因此考虑 set 启发式合并维护一个子树内的所有前缀点,插入一个点只会贡献 \(O(1)\) 个合法的 pair。也就是说,我们最终会得到 \(O(n\log n)\) 个 \((i,j,v)\) 的三元组。剩下的问题就是把 \(\min\) 拆开之后做两次扫描线即可。总复杂度 \(O(n\log ^2 n)\)。
P5576 [CmdOI2019] 口头禅
区间最长公共子串问题。
引理:设广义自动机上所有串的长度和为 \(n\),设 \(sz_i\) 表示有多少个串存在一个子串在 \(i\) 这个等价类中,则 \(\sum sz_i=O(n\sqrt n)\)。也即对每个串分别不重复地遍历每个子串所在的等价类对应节点,复杂度是根号。简单说就是:SAM 上每个串分别暴力跳父亲找子串,复杂度根号。
证明:对于长度超过 \(\sqrt n\) 的串,其不超过 \(\sqrt n\) 个,每个最多遍历所有的 \(n\) 个点,总复杂度是根号的;对于长度小于 \(\sqrt n\) 的串,设长度为 \(l\),则一个串遍历的点数至多是 \(l^2\);这样的串一共 \(\frac{n}{l}\) 个,故总复杂度取满也是根号的。
考虑离线扫描线,扫描右端点。在每个点上维护 \([l_i,r_i]\),表示当前最靠右的极长连续段,使得 \([l_i,r_i]\) 内的串都存在一个子串在该等价类中。这样的一个点会给所有在区间内的询问贡献 \(len\) 的答案。因此每次右端点扩展至 \(R\) 时,遍历每一个子串节点,若 \(r_i=R-1\),则令 \(r_i=R\),否则令 \(l_i,r_i\) 均变成 \(R\)。然后回答所有右端点为 \(R\) 的询问,注意到有效的点一定是 \(R\) 上才被更新过的点,要找的是 \(l_i\) 小于等于左端点的所有 \(i\) 中权值最大的一个。由于 \(n\sqrt n\) 次修改,\(n\) 次查询,考虑分块平衡,该问题是一个单点修改、前缀 max 的问题。注意每次移动 \(R\) 之后,之前的答案都无效了,需要清空。复杂度是根号的。
此题的根号实际上达到 \(8\times 10^5\),需要卡常。我们对 parent tree 按照重链剖分后的 dfs 序(重链 DFS 序连续)重标号,即可使访问更连续,在 c++20 的 cache 优化下可以通过。
P4094 [HEOI2016/TJOI2016] 字符串
对反串建立 SAM,等价于求 \(\max_{i\in[a,b]}\min(lcs(s_i,s_d),i-a+1)\)。注意到 \(d\) 是每个询问给定的,\(i\) 失去了比较漂亮的性质,可能产生贡献的 \(i\) 个数极多,因此很难直接求。由于有固定的 \(d\),我们可以考虑二分答案 \(mid\),从 \(d\) 开始倍增到最高的 \(x\) 使得 \(len_x\geq mid\),此时需要在线求 \(x\) 子树内的前缀终点有无在 \([a+mid-1,b]\) 内的。启发式合并会导致总复杂度来到 \(\log^3\),太大。注意到线段树合并时若每次合并都新建一个节点表示合并之后的线段树,而非利用原有点,那么原来的线段树结构不会被破坏,因此线段树合并也可以用来预处理。我们先执行线段树合并的过程,查询时在对应的线段树根上查询即可。这是单点加、区间求和。总复杂度做到 \(O(n\log^2 n)\)。
来源未知题
题意:给定一个串 \(S\) 和若干个模板串 \(T_i\),每个模板串有一个限制 \(a_i\) 和一个符号 \(op_i=\{\geq,<\}\),维护如下操作:
- 将 \([l,r]\) 内的模板串对应符号变成另一个。
- 求 \(S\) 有多少个子串满足对于任意的 \(i\),设其在第 \(i\) 个模板串中的出现次数为 \(cnt_i\),都有 \(cnt_i(op_i)a_i\) 成立。
总串长 \(n\) 不超过 \(10^6\)。
对于广义 SAM 的一个等价类,若子树中存在 \(i\) 这个串的前缀节点,则该等价类里含有第 \(i\) 个串的 \(len_x-len_{fa_x}\) 个子串。
首先考虑一个串的所有子串在其他每一个串中的出现次数和如何做。一个串在其他串中的出现次数,可以使用 SAM 解决;要问出现次数和,不妨把询问串也加进去建立广义 SAM。预处理每个点中询问串的个数(子树内前缀节点个数 \(\times\) 自己和父亲的 \(len\) 的差),同时使用启发式合并/线段树合并/直接维护 vector 做归并维护子树内某个串的前缀节点出现次数(也即当前点在每个串中的出现次数)即可。复杂度分别是 \(O(n\log n)\),\(O(n\log ^2n)\),\(O(n\sqrt n)\),最后一个暴力复杂度根号来源于“口头禅”一题中提到的暴力结论。
合并维护 \(sz_i\) 表示一个点子树内 \(i\) 串的前缀节点个数。于是对于每种颜色,在合并途中可以找到所有满足所有儿子的 \(sz_i<a_i\) 且自己的 \(sz_i\geq a_i\) 的点,打上一个 \(\geq\) 标记。对于任意一个点,若其子树内有这个标记,则显然此处的 \(sz_i\geq a_i\)。这样的标记可以在合并时一起维护,若两个不满 \(a_i\) 的合并成一个 \(\geq a_i\) 的,则打上标记,注意处理合并两侧原来就有标记的情况。
考虑一次询问的某些串要求是 \(\geq\),其它是 \(<\),则我们需要找出所有子树内的标记集合等于要求是 \(\geq\) 的串集合的点,将它们的询问串 \(sz\) 累加即为答案。注意子树内标记集合是不变的,判断集合相等可以考虑异或哈希,给每个串随机一个哈希值,然后对于预处理出每个点的哈希值并将权值累加进 map 里面。对于一次修改,等价于翻转了一个区间内串的存在性,在上一次的基础上异或上该区间的异或和即可得到新的询问哈希值。维护哈希值的前缀异或和即可。不难在线段树合并的过程中预处理出每个点子树内满足 \(\geq\) 的串集合的哈希值。
复杂度瓶颈在预处理哈希值,根据选择的预处理方式不同而决定。

浙公网安备 33010602011771号