后缀结构
全都扔到一块儿
Suffix Array (SA)
对一个字符串的所有后缀进行排序,最终得到所有后缀的排名。
采用基数排序,对于 ,每次排序只看后缀的前 位来给后缀排序(对于一个起点如果后面不够了,补一堆最小的字符),由于上一步已经排完了 ,所以可以直接双关键字排序:先按第二关键字(即 的后半部分的排名)排序,然后再按照第一关键字( 的前半部分的排名)排序,这样就能双关键字排序了。由于排名是 的,所以采用计数排序就能使得总复杂度为 ,当然还得看代码里的具体细节:
struct SA{
int sa[N],rk[N],id[N],ork[N],bac[N];
int ht[N];
int st[19][N],lg[N];
int lcp(int x,int y){
if(x==y)return n-x+1;
x=rk[x];y=rk[y];
if(x>y)swap(x,y);
++x;
int k=lg[y-x+1];
return min(st[k][x],st[k][y-(1<<k)+1]);
}
void getSA(char *s){
int m=1<<7,p=0;
for(int i=1;i<=n;i++)bac[rk[i]=s[i]]++;
for(int i=1;i<=m;i++)bac[i]+=bac[i-1];
for(int i=n;i>=1;i--)sa[bac[rk[i]]--]=i;
for(int w=1;;m=p,p=0,w<<=1){
for(int i=n-w+1;i<=n;i++)id[++p]=i;
for(int i=1;i<=n;i++)if(sa[i]-w>=1)id[++p]=sa[i]-w;
memset(bac,0,sizeof(int)*(m+1));
memcpy(ork,rk,sizeof(int)*(n+1));
for(int i=1;i<=n;i++)bac[rk[i]]++;
for(int i=1;i<=m;i++)bac[i]+=bac[i-1];
for(int i=n;i>=1;i--)sa[bac[rk[id[i]]]--]=id[i];
p=0;
auto cmp=[&](int x,int y){
return ork[x]!=ork[y]||ork[x+w]!=ork[y+w];
};
for(int i=1;i<=n;i++){
if(cmp(sa[i],sa[i-1]))++p;
rk[sa[i]]=p;
}
if(p==n)break;
}
for(int i=1;i<=n;i++){
int k=max(0,ht[rk[i-1]]-1);
int p=sa[rk[i]-1];
while(p+k<=n&&i+k<=n&&s[p+k]==s[i+k])++k;
ht[rk[i]]=k;
}
for(int i=2;i<=n;i++)lg[i]=lg[i>>1]+1;
for(int i=1;i<=n;i++)st[0][i]=ht[i];
for(int i=1;i<19;i++)
for(int j=1;j+(1<<i)-1<=n;j++)
st[i][j]=min(st[i-1][j],st[i-1][j+(1<<(i-1))]);
}
};
后缀数组的精华在于其 height 数组,下面是一些定义:
-
:排名为 的后缀编号。
-
:后缀 的排名( 和 互为逆排列)。
-
排名为 的后缀和排名为 的后缀的 LCP 长度为 .
如果想求原串任意两个后缀的最长 LCP,如果按照排名来排序,那么这两个后缀 LCP 即为它们之间所有相邻后缀 LCP 的最小值。所以就是 上的一个 RMQ 问题。
定理:
证明很简单,假设 是 ,排它前面那个后缀是 ,这里 都是字符且 ,。那么 至少会和 形成一个长度为 的 LCP。中间再插入什么后缀也不会使得这个 LCP 变小。
所以 可以枚举 然后均摊 求出了。
Suffix Automaton (SAM)
对 SAM 结构的基础理解
字符串 的 SAM 是接受 所有后缀的最小 DFA.不严谨地说:
- SAM 是一个 DAG.
- 有一个源点 .
- 转移边上有一些字符。
- 满足从 开始走一条路径到一个终止状态路径上的字符连起来是一个后缀,并且所有后缀都可以用这么一条路径表示出。这是 DFA 的定义。如果不要求路径停在终止状态,那么路径和子串对应。
为 在 中所有结束位置的集合。将 相同的划分成一个等价类,代表 SAM 中一个状态。那么有以下性质:
- 一个等价类里的串一定是里面最长串的若干后缀,并且是长度从大往小数的若干个。
- 对于子串 ,要么 ,要么 .
这个关键性质同时导出了后缀链接(suffix link): 连向了 这个等价类最长的不在 里的后缀所代表的等价类(这里后缀指的是等价类里最长串的后缀)。而第二个性质指出后缀链接形成了一棵树,称之为 parent 树。
- parent 树满足祖先的 endpos 是子孙的 endpos 的子集。
- parent 树是反串的(隐式)后缀树:
parent 树相当于把所有前缀插入到 Trie 里建出来的 fail 树,然后对只有一个儿子的节点进行压缩(对 fail 树跑个压缩 Trie);一个串的隐式后缀树相当于把所有后缀插入到 Trie 里面然后建出来压缩 Trie.
补充定义: 指 这个状态上的最长的那个字符串长度, 为最短的那个。
构建 SAM
均摊复杂度 的在线构造方法。
现在要添加一个字符 ,令 为插入 之前代表整个串的节点,初始为 .
- 创建当前串的状态 , 是当前串长。然后不断跳 的 suffix link,如果没有 转移边就连转移边到 ,如果走到头都没有那么 指向 ;否则考虑当前状态为 有 的转移边连向 ;
- 如果 ,那么可以直接根据定义将 指向 ;
- 否则,考虑 真正要指向的是 这个节点中长度为 的那个后缀,于是要从这里劈开形成两个节点,那就新建一个节点 继承 除 以外的所有信息(suffix link 和转移边),然后将 .再连下 suffix link,也就是 ,;
- 最后考虑转移边的变化,不断跳 的 suffix link,如果原先有到 的转移边,将它指向 即可。
复杂度证明和状态/转移数证明不写了,不会并且不想学。
记住节点数是 ,转移边的个数是 就行了。所以要记住 SAM 要开两倍空间。
有很多经典应用,不学相当于没学 SAM.
1. 找模式串在文本串出现的最大前缀长度
根据 SAM 的定义,直接按照转移边不断匹配即可。
2. 求 end_pos 集合大小(求该节点所代表字符串出现次数)
考虑较为一般的情况,对于一个节点,代表的字符串右端点出现次数,是其所有前面加上一个字符的串右端点出现次数的总和,也就是它 parent 树上儿子的 end_pos 集合大小总和。
但如果其是母串的一个前缀,会有一个右端点没有统计上,初始它们的
end_pos 集合大小为 即可。
也就是初始时在每个非克隆节点(也就是代表前缀的节点)设 为 ,然后在 parent 树上对 作子树和。
栗题:Codeforces 802I Fake News (hard)
3. 不同子串数/不同子串总长度
方法一:利用 parent 树。
所以 节点所代表的串的个数为 .
既然知道 ,就可以算出 对不同子串总长度的贡献,是个等差数列求和的形式。
方法二:在 DAWG 上 dp。
考虑到不同子串个数相当于自动机中以初始状态为起点的不同路径条数,则设 为状态 开始的路径数量,转移有:
去掉空子串还要 .
不同子串总长度的话,也类似地 dp 即可:
要加上 是因为从 出发的子串都多了一个字符。
4. 字典序第 小子串
考虑在 DAWG 上匹配的过程,只需要知道从这条转移边走,能获取的多少个状态,就可以知道该不该走这条边。注意到这个节点本身也是有状态数的,经过这个点的时候要减去。
不同位置的相同子串算作一个状态:一个节点能到达的状态数,就是 3. 中的方法二所 dp 的 .
不同位置的相同子串算作多个状态:一个子串出现次数就是其 end_pos 集合大小 ,利用 4. 中的方法求出 end_pos 集合大小,一个节点能到达的状态数就是它所有后继节点能到达的状态数,加上其 end_pos 集合的大小。
5. 最小循环移位
类似 4. 的思路,贪心地在 的后缀自动机上寻找最小的长度为 的路径。同样地记录一些信息可以做到。
6. 求 LCS 最长公共子串
给其中一个串建 SAM,然后让另一个串在 SAM 上匹配即可,如果失配,则不断跳 parent 树上的父亲,实际上 SAM 的 parent 树就是其后缀 Trie 的 fail 树的压缩版(或者理解这个东西为,所有后缀的 AC 自动机)。
SP1811 LCS - Longest Common Substring LCS
8. 第一次出现的位置
多次查询模式串 在文本串 中第一次出现位置。
如果对 的 SAM 中的每个状态 ,维护第一次出现 的末端位置 ,也就是该状态的 集合中的最小元素。
每次加入一个新的字符的时候,创建一个新的状态 ,其代表当前的整个字符串,则令 为当前插入字符的下标。
在建立好 复制出的 以及更新其他节点的后缀链接后,考虑新加进的 肯定不如它们现在的 更优,所以无须更新。只需将 赋值为 .
那么查询的答案就是 ,其中 为对应字符串 的状态。单次查询只需要 的时间。
广义 SAM
给定一个字符串集合,接受所有集合中的字符串的子串。
广义 SAM,如此恐怖!很多构建方法都是错的!
整理两个正确的建法:
离线 bfs
先把所有的字符串都插到一个 Trie 里,然后在这个 Trie 上修改结构建后缀自动机。bfs 保证 len 从小到大插入。与不同 SAM 不同之处是:
- 插入一个 的时候, 就是 在 Trie 上的父亲。
- 复制信息给 的时候,不能复制还没有插入的点。
在线增量构造
每次将 重置成 .如果 在 就找到了这个出边 到 了,如果 就直接令 ,否则就把 分裂出 然后 (注意这里和之前的区别是并没有新建一个 ).
(希望下面这个写的没问题)
void Ins(int x){
if(a[las].ch[x]){
int p=las,q=a[las].ch[x];
if(a[q].len==a[p].len+1){
las=q;
return ;
}
int c=++tot;a[c]=a[q];
a[c].len=a[p].len+1;
a[q].fa=c;
las=c;
for(;p&&a[p].ch[x]==q;p=a[p].fa)a[p].ch[x]=c;
return ;
}
int cur=++tot;
a[cur].len=a[las].len+1;
int p=las;las=cur;
for(;p&&!a[p].ch[x];p=a[p].fa)a[p].ch[x]=cur;
if(!p)a[cur].fa=1;
else{
int q=a[p].ch[x];
if(a[q].len==a[p].len+1)a[cur].fa=q;
else{
int c=++tot;a[c]=a[q];
a[c].len=a[p].len+1;
a[q].fa=a[cur].fa=c;
for(;p&&a[p].ch[x]==q;p=a[p].fa)a[p].ch[x]=c;
}
}
}
回文结构
Manacher
回文中心:回文串内到两端距离相等的位置(可以在两个字符中间)。
回文半径:回文中心到两端之间的字符数。
Manacher 是求出每个位置 作为回文中心时最长回文半径 .有回文中心在字符中间的情况,所以在原串每个间隔位置插入一个字符 #
.所以 Manacher 要注意开两倍空间。
- 从小到大枚举,同时记录前面最大的 以及其对应的 ;
- 如果 那么令 (画图理解);
- 然后不断暴力 check 是否还能往右扩展。
由于每 check 成功一次 一定会增加 ,而 最大不超过字符串长度,所以时间复杂度是线性的。
回文自动机 ( PAM )
结构
定理:一个串的本质不同回文子串数不超过 .
首先这个上界就是全一样字符的时候取到。然后考虑每次往后新加一个字符的时候,新增了以它为右端点的若干回文串,只有最长的那个可能是新出现的,因为较短的那些一定在这个最长的字符串的开头出现了。
这个定理告诉我们如果建一个接受字符串的所有回文子串的自动机,它的状态是大致是 的。
然后想想一个回文自动机应该是什么样子的,首先是如果走转移边添字符是从两边添字符,如果按照 ACAM 和 SAM 一个方式理解的话它是在走串的一半。而且回文串分奇数和偶数两种情况,那么需要两个源点分别去索引奇数和偶数长度的回文子串。然后 suffix link 就直接定义为非自身的最长回文后缀。定义偶根为 ,奇根为 ,然后将 的 suffix link 指向 , 不可能失配因为它指向一个单个字符(这里这样定义是为了方便后面构造)。
这个回文自动机实际上形成了一个树结构,也称为回文树。
构建
然后 PAM 的构建就挺简单的了,没有像 SAM 那样需要划分等价类来压缩状态于是要讨论很多情况,还是直接不断尝试在 (以上一个字符为结尾的最长后缀)后面接上这个字符,如果不行则不断跳 fail 直到能接上位置(因为最后会跳到 而 能接上任何字符)。如果节点不存在那就直接新建一个。
如果新建节点要算当前这个节点的 fail,就再不断跳 fail 判断能不能接上。而再往上跳 fail 不会新建节点,就是 PAM 的那个前置定理。
这里判断后面能不能接上就是判断 ,这里 代指中间那个回文串的长度。这样构造的正确性和 KMP 自动机构造正确性类似,回文子串具有和 border 一样的全序关系性质,即回文子串的回文子串依然是回文子串。所以不断跳 fail 判断能不能接上就可以。
这个构建方法的复杂度分析和 AC 自动机的构建方法一复杂度分析同理(事实上就是和 AC 自动机构建方法一几乎完全一样),fail 树上每条边最多被跳两次。
回文后缀结构
回文子串和 border 一样是一个全序关系。那么就想想弱周期引理,border 划分等东西是否也能搬到回文串这个结构上来。类比 border 是前缀等于后缀,那这里就尝试搬到回文后缀上。
引理:若 是一个回文串,那么 是 的回文后缀当且仅当 是 的 border.
分 和 两种情况,画图证明一下即可。
有了这个,那么就能证明弱周期引理搬到回文串的回文后缀这里依然成立(因为回文后缀已经等价于 border 了)。border 划分在这里也是成立的。
这里回文树上的 fail 和那个失配树类似。令 ,令 是 祖先中离 最近的 不同的节点,那么 想要跳出它所在的等差数列就直接跳 就可以。(border 那里用失配树这么跳也是一样的)。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?