SAM
绝大多数请参考 Alex_Wei
本文更多的是类似于算法正确性的理解、证明,以及对概念加深的理解。
定义篇
- SAM 是个确定有限状态自动机,即你考虑没经过一条边,到一个节点(状态)的时候,便加上这条边的一个字符。也就是说,SAM 上的路径代表一个字符串。更具体的,SAM 上从根到任意节点的路径代表着该字符串的子串。注意,一条路径对应唯一一个子串(显然),一个子串对应唯一一条路径(性质,我们将在构造过程种保证该性质)
SAM 上的一个节点究竟表示什么?
-
从自动机相关看,从根到该点的所有路径形成的字符串集合(注意,可能到某个点有不同路径,为什么,因为一个点代表的是一个状态,一个什么状态? endpos 相同的字符串集合,所以在这个集合里的字符串的转移路径走下来都能到这个点。)
-
从 endpos 理论看,该节点即代表着 endpos 集合等于它的字符串集合。且往往,我们不会真正记录一个节点究竟代表的 endpos 是啥,而是由指向它的若干子孙的 endpos 并起来得到的。
trans&link&endpos
-
一个 endpos 等价类,倘若有一条转移边,则跳过去之后,每个新子串的 endpos 集合仍然都是一样的。下文将有证明到。
-
trans:即我在当前节点,然后对于该节点的所有字符串加上一个字符能组成的字符串所在的等价类。
-
link:该等价类的最长子串的最长后缀使得该后缀的 endpos 不被包含在该等价类。一个节点的后缀链接的构造即为上一个不断跳后缀链接(一个个等价类),看看其加 \(c\) 的转移,是否能成为它的后缀链接。
构造篇
-
考虑我要维护啥,显然,我需要满足从根到一些节点的路径能表示出来该字符串的所有后缀(显然该条件等价于能表示出来所有子串),接着,你会考虑字符串常见处理方法,增量法来构造 SAM。
-
考虑加入一个新字符,记其位置为 \(i\),字符为 \(c\),显然会形成一个新 endpos 等价类 \({i}\),下文称该节点为新节点。那么显然该等价类的 \(len=i\),即 \(s:[1,i]\) 这个子串。接下来,你要满足上述提到的要求,考虑一个后缀一定是 \([p,i]\) 的形式,也就是说,你要满足 \(\forall p\in [1,i-1],s:[p,i-1]\) 都有 \(c\) 这个转移,才能一步步扩展到 \(n\)。那么,一个直观的想法是从 \(s:[1,i-1]\) 不断向上跳后缀链接,显然如果能顺畅跳到根的话,\(s:[p,i-1]\) 一定都能跳到。根据后缀链接性质。但是在跳到根的过程中,可能并不是特别顺畅,于是我们需要分类。
-
顺利地跳到根,意味着,\(c\) 这个字符都没出现,则新节点的后缀链接为根(空)。显然,我们为了满足要求(1),让跳到地每个节点都有一个加 \(c\) 到当前新节点的转移即可。
-
不那么顺利,在半程遇到了已经有该转移的,显然我们不能再新增一个到当前节点的转移(唯一性),于是考虑能否通过这个节点获得新节点的后缀链接。记跳到的节点为 \(u\),转移后的节点为 \(v\),则 \(u\) 为 \(s_{i-1}\) 的一段后缀,然后加上了 \(c\),到了 \(v\),于是你要考虑 \(v\) 是不是该等价类中所有的都是 \(s_{i-1}\) 后缀加上一个字符的。也就是说,判定条件即为 \(len_v=len_u+1\),显然此时 \(v\) 只有 \(1\) 个字符串。那么此时,显然 \(v\) 就是新节点的后缀了,直接后缀链接指向它。考虑 \(u\) 再往上的点,加 \(c\) 的转移了。那么这些节点的转移显然不需要修改(唯一性),且目前新节点的后缀链接已经找到了,性质(1)也满足了。故构造结束。
-
但是,倘若 \(len_v\not =len_u+1\)。那么你可能会有这样的疑惑,\(v\) 不是为 \(trans(u,c)\) 吗,咋会这样子?不妨回想一下定义。
我在当前节点,然后对于该节点的所有字符串加上一个字符能组成的字符串所在的等价类。
-
但是加上之后所形成的最长字符串不一定为转移后等价类的最长字符串(不妨考虑 \(c\) 第一个加的时候,你一路顺畅跳到根,并将转移指向新节点)
-
于是,该怎么办?不满足条件,那么我使得它满足条件就好了!于是,你会分裂出来一个 \(q\),使得 \(len_q=len_u+1\),此时 \(q\) 代表的仅仅是原先 \(u\) 的所有子串加上 \(c\) 所得到的所有字符串集合。那么 \(len_p\) 显然不变,但显然 \(q,p\) 是有后缀链接关系的。不妨从性质考虑 \(len_{link_x}+1=minlen_{x}\),根据 \(p\) 是 \(q\) 分裂出去的一段段后缀,显然 \(p\) 的后缀链接是 \(q\)。或者从 endpos 考虑,endpos(p) 被真包含于 endpos(q),显然 endpos(q) 多了一个新节点,且原先分裂出去一样。故得到一样的结论。
-
显然此时 \(q\) 的后缀链接即为原先 \(p\) 的后缀链接。因为一个等价类的后缀链接是由其最短的子串决定的,也就是该子串前面删掉一个字符,它就会到另一个真包含当前等价类的新等价类。
-
那么 \(trans(q)\) 该咋办?我们分裂后会导致 2 者转移不同吗?即分裂出来的 \(s+c\) 出现的次数比 \(mx+c\) 多,则 \(s\) 出现的比 \(mx\) 多,则两者显然不会在一个等价类。故命题矛盾。于是你只需要将 \(trans(q)=trans(p)\) 即可。
-
那么之前 \(u\) 的不断跳后缀链接的 \(trans(c)\) 为 \(p\) 的咋办?显然,它们分裂之后只会在 \(q\) 出现,故需要修改。倘若不是 \(p\) 的就不需要修改。根据前面操作修改 \(trans\) 都是不断跳,显然碰到不是的直接退了就好了。
#include <bits/stdc++.h>
#define pb push_back
#define ll long long
using namespace std;
const int N=(int)(1e6+5);
char s[N];
int n,las=1,tot=1,ch[N*3][26],fa[N*3],sz[N*3],len[N*3];
void ins(int c) {
int x=++tot,pre=las; las=x; sz[x]=1; len[x]=len[pre]+1;
for(;pre&&!ch[pre][c];pre=fa[pre]) ch[pre][c]=x;
int y=ch[pre][c];
if(!pre) return fa[x]=1,void();
else if(len[y]==len[pre]+1) {
fa[x]=y; return ; // x 这个等价类我贡献给 y 做子集了
} else {
int p=++tot; len[p]=len[pre]+1; // [pos_{pre},i-1]+c=[pos,i]
for(int i=0;i<26;i++) ch[p][i]=ch[y][i];
fa[p]=fa[y]; fa[x]=p; fa[y]=p;
for(;pre&&ch[pre][c]==y;pre=fa[pre]) ch[pre][c]=p;
}
}
vector<int>g[N*3];
ll ans=0;
void dfs(int x) {
for(int y:g[x]) dfs(y),sz[x]+=sz[y];
// cout<<x<<" "<<sz[x]<<" "<<len[x]<<'\n';
if(sz[x]>1) ans=max(ans,1ll*sz[x]*len[x]);
}
signed main() {
cin.tie(0); ios::sync_with_stdio(false);
cin>>s+1; n=strlen(s+1);
for(int i=1;i<=n;i++) ins(s[i]-'a');
for(int i=2;i<=tot;i++) g[fa[i]].pb(i);
dfs(1);
cout<<ans;
return 0;
}
广义
你搞清楚本质上维护一个 DFA 就很显然了,你要时时刻刻注意你要维护啥。
对于广义而言,你显然每个串开始要把 las 设为 1,然后你要注意一下是否之前出现过 [1,i] 这个前缀,且是那个节点所代表的,因为我当前要加入的点就是这个含义,只要二者含义完全相同,直接返回即可。
然后注意下新建的节点到底是不是空节点。
#include <bits/stdc++.h>
#define ll long long
#define pb push_back
using namespace std;
const int N=(int)(1e6+5);
char s[N];
int len[N*2],fa[N*2],ch[26][N*2],n,tot=1,las;
int ins(int c) {
if(ch[c][las]&&len[ch[c][las]]==len[las]+1) return ch[c][las];
int x=++tot,pre=las; len[x]=len[pre]+1;
for(;pre&&!ch[c][pre];pre=fa[pre]) ch[c][pre]=x;
int y=ch[c][pre];
if(!pre) {
fa[x]=1; return x;
} else if(len[y]==len[pre]+1) {
fa[x]=y; return x;
} else {
int p=++tot; len[p]=len[pre]+1;
fa[p]=fa[y]; fa[x]=fa[y]=p;
for(int i=0;i<26;i++) ch[i][p]=ch[i][y];
for(;pre&&ch[c][pre]==y;pre=fa[pre]) ch[c][pre]=p;
return x;
}
}
signed main() {
cin.tie(0); ios::sync_with_stdio(false);
cin>>n;
for(int i=1;i<=n;i++) {
las=1; cin>>s+1; int len=strlen(s+1);
for(int j=1;j<=len;j++) las=ins(s[j]-'a');
}
ll ans=0;
for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]];
cout<<ans;
return 0;
}
总结
SAM 是个 DFA,转移函数->匹配,endpos->出现位置,广义 SAM->去重子串,每个子串都能在 SAM 中跳转移函数得到,转移函数,加点,可视为边带字符权,每次 ins 代表加入 \([1,i]\) 这个前缀的所有后缀,因此,你第一个新建的点实际上就代表着 endpos 为 \(i\),然后后缀链接也都出现,所以置为其父亲。