SAM学习笔记
SAM 是一个自动机,描述了所有子串,然后它节点数是满足上述条件的自动机中节点数最少的。
很神奇的东西,初看没有任何规律可言。
前置知识
endpos
$endpos(t) $ 是 $t $ 在 $s $ 中所有结束位置。显然,SAM 中的每个状态对应一个或多个 $endpos $ 相同的子串。换句话说,SAM 中的状态数等于所有子串的等价类的个数,再加上初始状态。这个状态包含所有从根到这个点所有根的字符串,然后 $endpos $ 全都相同。
- 两个非空子串 $endpos $ 相同,则一个是另一个的后缀
显然。 - 两个非空子串 \(|a| \le |b|\),那么要么 \(endpos(b) \subseteq endpos(a)\),要么没交
第一种是后缀的情况,第二种是不是后缀的情况。
如果 $a $ 不是 $b $ 的后缀,如果 $endpos $ 有交,那么就有一个位置让他们为后缀。 - 考虑一个 $endpos $ 等价类,类中的子串长度是连续且公差为 1,同时短的是长的后缀
显然。
后缀链接 $link $
我们还知道字符串 $w $ 的前几个后缀(按长度降序考虑)全部包含于这个等价类,且所有其它后缀(至少有一个——空后缀)在其它的等价类中。我们记 $t $ 为最长的这样的后缀,然后将 $v $ 的后缀链接连到 $t $ 上。
因为 SAM 保存了所有子串的信息,所以我们一定能进行这样的操作。
- 所有后缀链接构成一棵根节点为 $t0 $ 的树
每个点都有边,都连向更小。 - 通过 $endpos $ 集合构造的树(每个子节点的 subset 都包含在父节点的 subset 中)与通过 $link $ 构造的树相同
感性一下吧,根据定义。
辅助符号
- $longest(v) $ 为状态 $v $ 中最长的一个字符串, $len(v) $ 为它的长度。
- $shortest(v) $ 为最短的子串,长度为 $minlen(v) $。
- 关系:\(minlen(v) = len(link(v)) + 1\)。
如果我们从任意状态 $v0 $ 开始顺着后缀链接遍历,总会到达初始状态 $t0 $。这种情况下我们可以得到一个互不相交的区间 \([minlen, len]\) 的序列,且它们的并集形成了连续的区间 \([0, len(v_0)]\)。
构造 SAM
为了保证线性的时空复杂度,我们的 SAM 维护了每个状态的 $len $、 $nxt[26] $、 $link $。
我们插入一个字符 $c $,一定会有一个新的状态,因为整个串的 $endpos $ 和其它的都不同,这就是一个新的状态。假设上一个状态是 $lst $,新状态为 $cur $,那么 $cur $ 一定是 $longest(lst) + c $ 得到的。那么当前的 $cur $ 里只有这一个字符串。那么 $longest(cur) $ 也就是整个字符串。我们利用一个指针 $p $,通过后缀链接往 $s $ 的方向走,对于路径上的状态,如果没有 $nxt(p, c) $ 的转移,那么就将他连到 $cur $。为什么呢?我们通过 $link $ 遍历下去的都是 $longest(lst) $ 的后缀,那么加上 $c $,自然也就形成了一个新的字串,所以要有转移 $nxt[c] $,有没有可能后面还有 $nxt(p, c) $ 的转移呢?显然是不可能的,因为小这是大的的后缀,所以既然大的都有 $c $ 了,那小的自然就有 $nxt(p, c) $ 的转移了。已经有转移怎么办?为什么会有这个转移,因为 $longest(lst) $ 的后缀可能在之前出现过了,所以这个后缀并不是位置上的后缀,只是字符上的后缀,这也保证了 SAM 的最小的这个性质。发现还有 $link $ 没有更新,考虑更新。有转移了代表什么,已经有这个一个子串了,那我们 $longest(lst) + c $ 的其中一个后缀就是这个字符串,发现有点像 $link $ 的定义,只要保证最大就行了。对于一个状态,从根到这个地方是一个子串,从 $nxt $ 遍历过来的时候,那么所有 $nxt $ 上的状态都是这个状态的前缀。具体而言, $longest(cur) $ 的 $endpos $ 只有 $end $,但这个东西的一些后缀已经有一个更大的 $endpos $ 了。也就是 $lst $ 的后缀加上 $c $ 即在结尾出现过还在其他地方出现过。这时我们就要分情况讨论了。如果 $len(p) + 1 == len(q) $,那么也就是说 $longest(p) + c = longest(q) $,那么 $link(cur) = q $。如果 $len(p) + 1 < len(q) $ 那么我们得想办法。举个例子 $babcab + c $,那么我们的 $p $ 是 $ab $, $q $ 是 $bac $,然后但是此时 $endpos $ 和 $abc $ 相同的还有 $babc $,但是 $babc $ 并不是 $babcabc $ 的后缀,所以我们要将 $babc $ 分离出去,那么我们新建一个状态 $clone = q $,然后将 $len(clone) = len(p) + 1 $,那么 $clone $ 里面是 $abc $, $cur $ 是 $babcabc $。然后将 $link(cur) $ 和 $link(q) $ 赋值为 $clone $,这也容易理解。然后还有最后一个 $while $。因为我们要连到末尾,所以前面都要连到 $clone $。我们保证前面了所有新的子串都可以到。然后我们的 SAM 在构造过程中是保证了最小的,容易理解。添加虚点是因为后面还加了个字符 $c $,所以还是最小的。
证明 SAM 上从每个状态开始走肯定能走成一个后缀
如果不能,那么必须还有其他状态走到这个后缀,另外状态那么这上面就有一些 $endpos $ 集合是相同的,这是不对的,所以这个差不多利用了 SAM 最小的性质。
空间为线性的证明
对于一个长度为 $n $ 的字符串,状态数不会超过 \(2n - 1\)。因为一开始有 1 个状态。然后前两步操作产生一个状态,然后后面 \(n-2\) 次操作至多产生 \(2n-4\) 个状态,比如 $abbbbbbb $。 $aaaaa $ 是不行的,因为会在 $len(clone) = len(p) + 1 $ 这里判掉。
还有一种证法, $endpos $ 树的叶子不超过 $n $ 个,也就是 $endpos $ 最小就是包含 1 个,我们往上连,它的父亲,不会,题解也没看懂。
转移数的证明
我们先建一个生成树,对于非树边我们因为有不超过 \(2n - 2\) 个转移。对于非树边 $u -> v $,我们取出根到 $u $ 的字符串,这个字符串不能经过非树边,这个显然是可以做到的。然后从 $v $ 必定可以走到一个后缀。所以每个非树边都对应着一个后缀。如果两个非树边对应着一个后缀。且一个后缀只对应着一条路径,不可能有两条不同的路径生成同一个字符串。所以每个后缀最有只有一条的上述的非树边,然后证明完毕。
时间为线性的证明
看代码,SAM 有两个 $while $,第一个是找没有 $nxt(p, c) $ 转移的,然后这个会添加转移,所以总次数不会超过 \(3n\)。第二个循环是重定向,将 $nxt(p, c) = q $ 的转移变成 $clone $,这个真的难。
应用
检查字符串是否出现
跑一遍 SAM,也可以跑 SA、AC、KMP……
不同子串个数
从 $t0 $ 出发的不同路径条数,拓扑即可:
dp[u] += dp[to]
所有不同子串的总长度
拓扑即可:
dp[u] += dp[to]; // 路径条数
dp1[u] += dp1[to] + dp[to]; // 总长度
字典序第 k 大子串
通过计算不同子串个数,我们从 $'a' $ ~ $'z' $ 枚举,可以用 SA 吧。
遍历 SAM,从 $a $ 开始枚举,看看后面这个拓扑。所以相同的算一个显然好做。
相同的算多个维护下 $endpos $ 集合大小即可。
哦,算一个,那么 $endpos $ 集合大小就是 1 否则就是正常维护 $endpos $ 集合大小。
最小表示法
将 $s + s $ 插进去,然后就是路径长度为 $s $ 的子串,然后贪心走,可以用 SA。
出现次数
建一个 SAM 维护 $endpos $ 集合字符串出现的次数:
inline void Insert(int c, int id) {
int np = ++cnt, p = Last;
len[np] = len[p] + 1;
Last = cnt;
size[np] = 1;
pos[id] = np;
// len: longest size: |endpos| pos: 第 i 个字符串的结束状态
while (!son[p][c] && p) son[p][c] = np, p = fa[p];
if (!p) fa[np] = 1;
else {
int q = son[p][c];
if (len[q] == len[p] + 1) fa[np] = q; // link
else {
int nq = ++cnt;
len[nq] = len[p] + 1; // 分出去没有 endpos 集合,为什么呢?
memcpy(son[nq], son[q], sizeof(son[q]));
fa[nq] = fa[q];
fa[q] = fa[np] = nq;
while (son[p][c] == q) son[p][c] = nq, p = fa[p];
}
}
}
inline void Build() {
Last = cnt = 1;
for (int i = 1; i <= n; i++) Insert(s[i] - 'a', i);
for (int i = 1; i <= cnt; i++) c[step[i]]++;
for (int i = 1; i <= n; i++) c[i] += c[i - 1];
for (int i = 1; i <= cnt; i++) b[c[step[i]]--] = i;
for (int i = cnt, p; i; i--) { // 按 len 排序
p = b[i];
size[fa[p]] += size[p]; // 这是对的
}
for (int i = 1, p; i <= cnt; i++) {
p = b[i];
dep[p] = dep[fa[p]] + 1;
st[p][0] = fa[p];
for (int j = 1; (1 << j) <= dep[p]; j++)
st[p][j] = st[st[p][j - 1]][j - 1];
}
}
然后我们搞出来了这个后缀连接的数组,然后我们查找出现次数就可以从 $pos[r] $ 的后缀链接往前跳。我们跳到的都是 $1~r $ 的后缀,同时越往前 $endpos $ 越大。我们想要找到 $(l, r) $ 所在的那个集合,那么我们就跳到最后一个 $longest >= (r - l + 1) $ 的状态即可,因为这个东西它包含在里面。
第一次出现的位置
类比上面的操作。我们维护 $endpos $ 中的末位置的最小值,但这里不同的是可以直接在维护 SAM 的同时维护,也就是一开始插入的时候是 $firstpos(p) = len $,然后复制的时候直接等于,然后查询的时候应为我们维护的是末位置,所以再减一下即可。
所有出现的位置
这个可以用类似出现次数,然后不断合并上来,这个复杂度本身就很大。
最短的没有出现的字符串
考虑动态规划:
dp[u] = min(dp[to]) + 1
这个是在自动机上跑的。
最长公共子串(LCS)
其实我们发现 SAM 更适合求 LCS。我们发现 $lcs((1, r1), (1, r2)) $ 是比较好求的,我们建出 SAM,我们不断往前跳, $(1, r1) $ 跳到的是 $(x, r1) $, $(y, r2) $,那么我们的 LCA 就是 $longest(lcp) $。
查询多个字符串的 LCP
每两个都跑一遍!然后就是……
广义 SAM
建一颗 Trie,然后 BFS Trie,然后每次遍历新节点,将 $lst $ 设为 $fa $,然后和普通 SAM 一样插入。还是比较简单的。
首先 $pos[u] $ 为插入的那个 $cur $, $len[pos[u]] = dep[u] $。
证不可能 $< $ 或者 $> $,小于好证,都已经有这个字符串了。不肯能有更长的了,如果有更长的,那么必然 $endpos $ 不会包含这个了。
正确性:一个是 BFS 保证了 $len[cur] = len[fa[cur]] + 1 $,因为由 $dep $ 证得。然后 Trie 保证了这个节点是新插入的。
两个字符串的最长公共子串
一个串建 SAM,另一个串跑。
多个字符串的最长公共子串
$siz $ 变成二进制即可,这个完全劣于 SA。
询问 $s $ 中第 $k $ 小的子串 \(O(n) + O(q \log n)\)
考虑反串建 SAM,然后 $link $ 子树里的所有串字典序都比这个大,随便求一下子树里有多少串(不同位置算多个)。所以我们 DFS 的时候从 $a->z $ 遍历我们 $dfn $ 增加的时候字典序也增加。然后我们 DFS 一下,我们在 $dfn $ 序上二分一下。
SA 弦论
这个版本貌似做不了。
给出一个串 $s $,将 $s $ 所有子串按照字典序排列好相接起来形成一个新串, $q $ 次询问,每一次询问新串中的第 $k $ 个字符是什么,强制在线
我们可以维护一下在哪个串中出现过,然后从这个串的 $link $ 开始 $k - siz[link] $ 个字符就是答案。
这个用 SA 直接秒了。
给出 $n $ 个字符串,求有多少个子串是其中至少 $k $ 个字符串的子串
判断一个子串是多少个字符串的子串可以从后缀往前跳,跳到它所在的状态,然后就是 $endpos $ 中的每一个都会贡献一点,这个好难算。题目看错。我们建广义 SAM,然后看一个状态。不会,然后就是大名鼎鼎的 $link $ 树上线段树合并了。具体而言,应该就是维护每个状态是哪些串的子串,然后一步一步往上合并。
用 SA 做好像很简单?我们使用双指针,然后轻松处理出有多少个子串,直接算会算重。为什么算重,我们发现一个区间一旦被算过了,他就不会再被算了。所以我们维护一个 $a[i] $,代表这个串那些被算到了,然后就是区间取 $max $ 成区间 $lcp $,然后我们得到了这个 $a $,然后对于每个串单独考虑, $ans += max(0, a[i] - h[i]) $,真是公公又式式啊。
SAM 是不是完全包含 SA,应为该有的性质都有了,然后还有一堆逆天性质。