比利♂海灵顿
2021-04-09 21:23:54
一个基于后缀自动机的另一种统计子串出现次数的方法
万字(符)长文,博客阅读
一个字符串的后缀自动机(SAM)是一个 DAG(Directed Acyclic Graph),边(或者说转移)带权,权值是一个字符,除了起始节点
从后缀自动机的
这样就可以推得后缀自动机的一个性质。
因为一个字符串的任意字串都能表示成它的某个后缀的前缀,而且从
举例
这是字符串 sdflk
的后缀自动机:
其中,节点
引入一个概念,一个字符串的子串
对于
对于两个子串
若有
如果
引入一个
所以两个引理的意思是,两个
推广第一个定理,易知一个等价类中的字符串,短的是长的的后缀。
后缀链接(
严格来说,
由于
容易发现,后缀链接由下到上组成一个树形结构,而这棵树恰恰就是
后缀链接树的节点表示的字符串是这个
如果把规模大小限制去掉,仅构造一个无大小限制的 SAM,可以很容易想到,枚举所有后缀,插入一个 Trie,每个后缀的尾字符是一个终止节点,根是
考虑优化规模,发现对于 ababab
这种字符串,Tire 有两条链,但是如果构造一条链
其中 ababab
的后缀自动机了。
但是这种优化是没有章法的,所以接下来尝试利用后缀链接树来构造 SAM。
大体思想是和后缀链接树共用节点,SAM 中
以后缀链接树的节点为 SAM 的节点,后缀链接树的节点表示的字符串作为 SAM 中
考虑递推地构造 SAM。
从边界情况开始,空串的后缀自动机是一个起始节点,后缀链接树只有一个根节点。接下来尝试在空串后面一个一个加字符,同时一边构造后缀自动机,一边维护后缀链接树,因为后缀链接树在后面的构造中会用到。
设新的字符是
希望找到
一个字符的加入后,在后缀链接树上中从
设目前跳到的节点为
这时根据
这时
从
这时
在众多有出边
后缀链接树中,一个节点的
这时要想找
这时的
最后,由于
对应节点没有出边
这个点的字符串
跳到根节点
在将
容易发现,只要
SAM 中的边,终点的公共后缀是在起点的公共后缀后加了一个字符(即这条边表示的字符)得到的字符串的后缀(因为有多个点连向这个终点,每个点公共后缀长度不同,但是一定有一个最短的公共后缀,它的后面加上连向终点的边表示的字符后得到的字符串即为终点的公共后缀)。所以 SAM 中在两点
从
接下来看起点到结束节点的路径表示的字符串是否表示了
首先,证明
接下来,证明没有后缀被漏掉。因为等价类内部的字符串是连续的,所以只要证明链上等价类的最长字符串
接下来证明时空复杂度时,将字符集的规模都暂时视为常数。
先分析点数的复杂度。由于每次加入一个字符时必然加入一个节点,而且有一定概率会添加第二个节点,所以后缀自动机最多有
SAM 的所有信息都是直接存在点对应的结构体中的,一个点包含的内容是一些指针和一些属性值,只要将字符集看作常数,则一个点的空间是常数。因为有线性数量的点,所以空间复杂度为线性,
总的时间复杂度一定是
加入一个字符的时间取决于每个节点加入时寻找
先分析向上跳的次数,这取决于从
接下来是修改转移的时候,设从
给一个字符串,求这个字符串出现次数大于
现在普遍的做法是构造后缀自动机,在表示前缀的节点(也就是每次加入字符首先创建的节点)上进行标记,然后在后缀链接树上跑 DFS,进行子树查询,但是这样需要邻接表记录一个节点的后缀链接入边,显然我是懒得打的,于是我在做题的时候想到了一种新的做法。
我一开始尝试找规律,发现可以在自动机的转移边上跑 DFS,在结束节点上打标记,统计每个点 DFS 树上的子树和,能走到结束点的路径数,这便是这个节点的等价类的字符串出现的次数。
这样做的原因也很简单,一个子串必然是一个后缀的前缀,所以这个子串的出现次数,就是这个子串作为前缀的后缀的出现次数之和。这个子串做前缀的后缀必然可以从这个子串转移到。从某个点搜起,它搜索树上儿子出现一次,它也会出现一次,所以只要统计搜索树上子树和即可。
既然知道了一个
首先是算法主体
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
#define Wild_Donkey 0
using namespace std;
unsigned m, n, Cnt(0), t, Ans(0), Tmp(0);
short nowCharacter;
char s[1000005];
struct Node {
unsigned Length, Times; // 长度(等价类中最长的), 出现次数
char endNode; // 标记 (char 比 bool 快)
Node *backToSuffix, *SAMEdge[26];
}N[2000005], *CntN(N), *Last(N), *now(N), *A, *C_c;
int main() {
scanf("%s", s);
n = strlen(s);
for (register unsigned i(0); i < n; ++i) {
Last = now; // Last 指针往后移
A = Last; // s 对应的节点
nowCharacter = s[i] - 'a'; // 取字符, 转成整数
now = (++CntN); // s + c 对应的节点
now->Length = Last->Length + 1; // len[s + c] = len[s]
while (A && !(A->SAMEdge[nowCharacter])) { // 跳到 A 有转移 c 的祖先
A->SAMEdge[nowCharacter] = now; // 没有转移 c, 创造转移 (Endpos = {len_s + 1})
A = A->backToSuffix;
}
if(!A) { // c 首次出现
now->backToSuffix = N; // 后缀链接连根
continue; // 直接进入下一个字符的加入
}
if (A->Length + 1 == A->SAMEdge[nowCharacter]->Length) {
now->backToSuffix = A->SAMEdge[nowCharacter]; // len[a] + 1 = len[a->c] 无需分裂
continue;
}
(++CntN)->Length = A->Length + 1; // 分裂出一个新点
C_c = A->SAMEdge[nowCharacter]; // 原来的 A->c 变成 C->c
memcpy(CntN->SAMEdge, C_c->SAMEdge, sizeof(CntN->SAMEdge));
CntN->backToSuffix = C_c->backToSuffix; // 继承转移, 后缀链接
C_c->backToSuffix = CntN; // C -> c 是 A -> c 后缀链接树上的儿子
now->backToSuffix = CntN; // 连上 s + c 的后缀链接
while (A && A->SAMEdge[nowCharacter] == C_c) { // 这里要将 A 本来转移到 C->c 的祖先重定向到 A->c
A->SAMEdge[nowCharacter] = CntN; // 连边
A = A->backToSuffix; // 继续往上跳
}
}
while (now != N) { // 打标记
now->endNode = 1; // 从 s 向上跳 (从 s 到 root 这条链上都是结束点)
now = now->backToSuffix;
}
DFS(N); // 跑 DFS, 统计 + 更新 Ans
printf("%u\n", Ans);
return Wild_Donkey;
}
最后补充一个普普通通的 DFS()
,记忆化搜索
inline unsigned DFS(Node *x) {
unsigned tmp(0);
if(x->endNode) {
tmp = 1;
}
for (register unsigned i(0); i < 26; ++i) {
if(x->SAMEdge[i]) { // 有转移 i
if(x->SAMEdge[i]->Times > 0) { // 被搜索过
tmp += x->SAMEdge[i]->Times; // 直接统计
}
else { // 未曾搜索
tmp += DFS(x->SAMEdge[i]); // 搜索
}
}
}
if (tmp > 1) { // 出现次数不为 1
Ans = max(Ans, tmp * x->Length); // 尝试更新答案
}
x->Times = tmp; // 存储子树和
return tmp; // 子树和用于搜索树上的父亲的统计
}
这次后缀自动机的学习时间线拉的很长,从 Apr.1st 2021
学完后缀数组,本以为是后缀自动机的前置知识,结果没想到根本没用上,只能从零开始。
一开始漏了一个条件,以为一个点可以有多个不同转移,认为一个超级源点连向
后来写代码,出现了很多匪夷所思的问题,最后写好了 DFS,运行发现输出 DFS()
在 main()
没调用。
第一次交发现没看见 "出现次数不为
最后找到了问题,过了模板以后把递归跳边改成了 main()
里面循环跳边,怎么都调不动,发现是打标记时没有初始化有问题。
收尾博客时证明打标记和搜索的正确性,证不动了去看题解,猛然发现自己的方法和他们不一样,收获了意外之喜,于是自己口胡了一个证明,如有问题希望指正。
经常有在机房对着电脑一晚上抓耳挠腮的歇斯底里,也有文化课上拿着打印的材料偷偷研究,博客也是像挖隧道一样艰难推进,代码更是最后两天才动工,直到今天 Apr.9th 2021
一周多的跌跌撞撞,终于算是结束了这个节点的学习。
然后在我因为搞出新做法,花了一晚上审稿,将我熟悉的半角标点改成全角,满怀信心地交题解的时候,因题解过多被禁止提交该题题解。(最后交上了)