浅学 SAM

posted on 2025-04-14 13:54:24 | under | source

算法介绍

简介

SAM 是一种 DFA,存储了串 \(S\) 的所有子串。功能十分强大,而且复杂度是线性的。

它满足一个基本性质:从起点开始的路径,一一对应 \(S\) 中的子串。

endpos

对于 \(S\) 的一个子串 \(T\),记它的 endpos 集合为 \(S\)\(T\) 出现位置的结尾的集合。

它具有良好的性质:

  • 任意子串的 endpos 集合不交或包含。

启发我们建一棵关于 endpos 的树,它叫做 parent 树。它的根是空集,每个节点的所有儿子即为它的划分。

endpos 与 SAM

SAM 的点恰代表 parent 树上的点,也就是代表一个 endpos。进一步的,所有到该点的路径,代表 endpos 相同的子串。

可以构建对 SAM 的基本印象:按照 endpos 划分等价类,而这些等价类构成 parent 树。

进一步给出如下性质:

  • 一个点代表的子串 \(P_1\dots P_k\) 长度互不相等、且长度集合构成一段区间。且按照长度排序后,\(P_i\)\(P_{i+1}\) 的最长真后缀。
  • 节点 \(x\) 代表的最短串 \(Y\),与 \(fa_x\) 的最长串 \(X\),满足 \(X\)\(Y\) 的最长真后缀。

构建流程

\(len_x\)\(x\) 最长串长度,\(fa_x\)\(x\) 在 parent 树上的父亲,\(ch_{x,i}\)\(x\) 走字符 \(i\) 转移到的节点。

考虑逐次加入字符 \(c\)。记上一个前缀对应节点 \(lst\)

  • 首先新建节点 \(now\) 代表当前前缀。
  • 然后从 \(lst\) 开始跳 \(fa\),直到某个节点存在 \(c\) 转移。此前的节点向 \(now\)\(c\) 转移。
  • 记这个节点为 \(q\)\(p=ch_{u,c}\)。假如 \(len_q+1=len_p\),意味着 \(p\) 代表的串均为 \(q\) 前缀加上 \(c\) 转移而来的,那么直接令 \(fa_{now}=p\) 即可;否则,意味着 \(p\) 有一些串是由其它串转移来的,考虑把 \(p\) 拆成两半,定义 \(cl\)\(q\) 前缀加 \(c\) 转移而来,最后令 \(fa_{now}=cl\)

具体看代码:

inline int ins(int id, int lst){
    int now = ++tot, q = lst;
    len[now] = len[lst] + 1; 
    while(q && !ch[q][id]) ch[q][id] = now, q = fa[q];
    if(!q) fa[now] = 1;
    else if(len[q] + 1 == len[ch[q][id]]) fa[now] = ch[q][id]; 
    else{
        int cl = ++tot, qq = ch[q][id];
        len[cl] = len[q] + 1, fa[cl] = fa[qq];
        fa[qq] = fa[now] = cl;
        memcpy(ch[cl], ch[qq], sizeof ch[cl]);
        while(q && ch[q][id] == qq) ch[q][id] = cl, q = fa[q];
    }
    return now;
}

复杂度

考虑跳 \(fa\) 的过程,每个节点至多被跳一次,所以是 \(O(n)\) 的。

可以证明,点数小于 \(2|S|\),转移边数小于 \(3|S|\)

经典拓展

线段树合并维护 endpos

RT。

桶排维护 endpos 个数

只需按照 \(len\) 进行大到小排序,即可保证祖先较后更新。

SAM 上匹配

有两个串 \(S,T\),如何对 \(T\) 的每个前缀,求出在 \(S\) 出现的最长后缀?

\(S\) 建 SAM,直接在上面跑匹配。新增一个字符,就不断跳 fa 直到存在该转移边。同时维护长度。

匹配均摊复杂度 \(O(|T|)\),证明考虑长度即可。

广义 SAM

对多个串建出 SAM。

只需在做完一个串后,将 \(lst\) 指针指向空串即可,吗?

会出现神秘错误!因为有可能当前这个前缀,之前已经作为某个子串出现了。需要特判。

另一种写法是建 Trie 然后跑 bfs 建 SAM,这样不可能出现上述情况。

例题

SP1812 LCS2 - Longest Common Substring II

题意:求若干个字符串的最长公共子串。

抽取串 \(S\) 作为基准串,然后对于其它的所有串 \(T_j\) 建 SAM,将 \(S\) 放上去跑匹配得到 \(slen_{i,j}\) 表示 \(S_1\dots S_i\)\(T_j\) 中最长匹配后缀长度。

答案即为 \(\max\limits_{i} \min\limits_{j} slen_{i,j}\)。分析复杂度,建 SAM 是 \(O(\sum |S|)\),跑匹配 \(O(n|S|)\)。一般选取最短串为 \(S\)

P5576 [CmdOI2019] 口头禅

膜拜 cmd。

题意:有串 \(S_1\dots S_n\)\(m\) 次询问,求区间最长公共子串。\(\sum S_i\le 4\times 10^5,n\le 2\times 10^4,m\le 10^5\)

做法一:猫树分治 + 倍增分治

猫树分治,考虑处理跨过串 \(S_{mid}\) 的询问。预处理 \(mid\) 向左、右出发的 \(slen\),然后询问只需拼起来即可。但是可能 \(|S_{mid}|\) 过大,复杂度不对。

引入倍增分治,设立阈值 \(c\),初始为 \(1\)。将 \(\le c\) 的称为短串,反之长串。每次分治选取短串中的中间者作为 \(mid\),以此类推,直到不存在短串。那么将阈值 \(\times 2\)

分析复杂度:

  • \(slen\):对于一个 \(c\),分治区间总长是 \(O(\frac {\sum |S_i|}{c})\),分治树区间总长 \(O(\frac {\sum |S_i|}{c}\log n)\)。总共 \(O(\sum |S_i|\log n\log \max S_i)\)

  • 处理询问:倍增分治中,基准串长度是 \(O(\min\limits_{i\in [l,r]}(S_{i}))\) 级别的。根据最小值分治,假如询问不重(只要记忆化一下),复杂度就是 \(O(\sum |S_i|\sqrt m)\)

做法二:广义 SAM + 维护连续段 + 线段树

建出广义 SAM,将串 \(S_j\) 每个终止节点染颜色 \(i\)。在 parent 树上启发式合并维护颜色集合,顺便维护连续段。这里两只 \(\log\)。那么对于节点 \(x\),若询问 \([l,r]\) 被连续段包含,就可以更新答案 \(len_x\)。注意到只需关注新产生的连续段,总数是 \(O(n\log n)\) 级别的。然后线段树处理询问即可,两只 \(\log\)

P4482 [BJWC2018] Border 的四种求法

题意:求区间 \(\rm border\)\(|S|,q\le 2\times 10^5\)

欲用 SAM 解决,先用 LCS 的视角转述问题。对于询问 \([l,r]\),只需求解:

\[\max\limits_{i\in [l,r)} i[i-len_{lca(ed_i,ed_r)}+1\le l] \]

变成了树上问题。先来点暴力,枚举 \(ed_r\) 的祖先 \(x\),然后枚举其子树内的终止节点,看看是否满足条件。可能错把 \(lca\) 的祖先当成 \(lca\),没有问题因为限制更紧。

稍稍优化,对于 \(x\) 子树内的所有 \(ed_i\),建线段树,然后线段树上二分,枚举 \(mid\),查看是否存在 \(mid\le i\le r\) 满足 \(i\le l+len_x-1\)。只需维护区间最小值即可。相当于将二维数点转化为一维判定

这里有一个比较仙的转化:重链剖分,将问题转化为对 \(O(\log)\) 条重链的前缀的查询

有啥好处呢?观察到可以将枚举子树改为枚举轻子树。只需离线下来把询问挂到每条重链上,然后对每条重链分别处理即可。轻子树总大小 \(O(n\log n)\),所以 \(O(n\log^2 n)\)

此外,只有每条重链前缀末端的节点的重子树、链上节点未被统计。对于前者离线下来线段树合并即可;后者略施小计,对每个 \(ed\),新建节点替代它然后让 \(ed\) 连向它,即可保证不会出现在链上。

复杂度 \(O(n\log^2 n)\)

P4770 [NOI2018] 你的名字(待补)

题意:给定串 \(|S|=n\)\(m\) 次询问,每次给出 \(T,l,r\),求 \(T\) 有多少个不同子串满足不在 \(S_l\dots S_r\) 中出现。\(|S|\le 5\times 10^5,\sum |T|\le 10^6\)

\(l=1,r=n\)。显然容斥求出现的子串,对 \(S\) 建 SAM 用 \(T\) 跑匹配,同时建 \(T\) 的 SAM 并在上面同步字符串。那么每个前缀告诉我们 SAM 上某个节点的前缀到根的路径的串都出现过了。维护 \(ma_x\) 表示节点的最长公共子串的长度。暴力向上跳并更新,假如当前节点已经 \(ma_x=len_x\) 就不必更新。复杂度 \(O(|T|)\)

\(l,r\) 任意,只需想办法模拟 \([l,r]\) 的 SAM 即可。直接在 \(S\) 的 SAM 上跑,操作跳 fa、维护串长都是容易的。对于转移边 \(now\to x\),合法当且仅当 \(x\) 的 endpos 中 \([l+len-1,r]\) 有点。只需线段树合并维护 endpos 集合即可。\(O(|T|\log n)\)

P7879 「SWTR-7」How to AK NOI?(待补)

题意:给定串 \(S,T\),以及 \(k\)\(q\) 次操作:修改 \(T\) 的一段区间,长为 \(L\);查询 \(T_l\dots T_r\) 是否可以划分为若干 \(\ge k\) 的子串,满足每个子串都在 \(S\) 中出现。\(|S|\le 3\times 10^6,|T|\le 2\times 10^5,\sum L\le 3\times 10^5\)

结论:只需考虑长度 \(\le 2k\) 的串。若 \(>2k\) 则可以划分为若干 \(=k\) 的串,假如剩下一个 \(<k\) 的,就把它和 \(=k\) 合并。

对每个 \(T_i\),维护转移 \(a_{i,j}\) 表示串 \((i-j,i]\) 是否合法。在记 \(f_i\)\([l,i]\) 是否合法,暴力转移即可。

动态 dp,用矩阵的形式描述转移(外层或、内层且)。修改时暴力更改 \(a_{i,j}\)。注意矩乘可以压位变成 \(4k^2\)。复杂度 \(O(Lk^2\log n)\)。可以精细实现,考虑线段树上被修改的节点是 \(O(L)\) 的,以此去掉 \(\log\)

posted @ 2026-01-14 18:10  Zwi  阅读(0)  评论(0)    收藏  举报