字符串学习笔记
常用定义
-
字典序:对于两个字符串 \(a,b\),对于第一个满足 \(a_i \neq b_i\) 的 \(i\),若 \(a_i<b_i\) 我们就称 \(a\) 的字典序小于 \(b\)。空字符小于任何其它字符。
-
LCP(Longest Common Prefix):\(\operatorname{lcp}(a,b)\) 表示 \(a\) 和 \(b\) 最长的公共前缀,也就是一个最大的 \(i\),满足 \(1 \le j \le i,a_j=b_j,a_{i+1} \neq b_{i+1}\)。
Manacher 算法
问题:给你一个字符串 \(S\),求出它的最长回文子串。
首先,每个回文串都有一个对称中心,长度为奇数的回文串对称中心在其中间的那个字符上,长度为偶数的回文串在中间两个字符的中间。这样不统一示不好处理的,考虑在相邻的两个字符间插入分隔符,把偶回文串转为奇回文串。这种插入分隔符的策略也是字符串问题的常用套路之一。
那么接下来只需枚举 \(i\) 作为当前回文串的中心即可。如果 \(S_{l \sim r}\) 是以 \(i\) 为中心的极长的回文串,那么显然 \(S_{l+1,r-1},S_{l+2,r-2},\dots,S_{i\sim i}\) 都是回文串,所以问题转化为对于每个点 \(i\),求出它的最长回文半径 \(p_i\)。
KMP 算法/border 理论
结论 1
注意到 \(nxt_i<i\),那么连边 \(nxt_i \to i\),就形成了一棵树的结构,根据定义,每个点 \(i\) 的父亲代表了 \([1,i]\) 的 border 所对应的前缀编号。因此对于一条祖先 - 后代链,深度小的点是深度大的所有点的 border。
Suffix Array 后缀数组
整活:前缀排序怎么做?
现在有一个长度为 \(n\) 的字符串 \(S\),它有 \(n\) 个后缀 \(S_{1 \sim n},S_{2 \sim n},\dots,S_{n \sim n}\)。把它们按照字典序排序。
下文的 \(\operatorname{lcp}(i,j)\) 指代 \(\operatorname{lcp}(S_{i \sim n},S_{j \sim n})\)。
你需要求出:
- \(sa\) 数组,其中 \(sa_i\) 表示排名为 \(i\) 的后缀编号(即它的起点位置)。它应当是一个排列。
- \(rk\) 数组,其中 \(rk_i\) 表示编号为 \(i\) 的后缀排名。它应当是一个排列,因为各个串的长度不同。
- \(ht\) 数组,其中 \(ht_i\) 表示 \(sa_i\) 与 \(sa_{i-1}\) 两个后缀的 LCP 长度。
容易发现 \(sa(rk_i)=i,rk(sa_i)=i\),前一个 \(i\) 表示位置,后一个 \(i\) 表示排名。
考虑暴力排序,二分哈希比较字典序是 \(\mathcal{O}(n \log ^2 n)\) 的,但是常数很大。
考虑这样一件事:我们比较字典序,其实是先比较第一个字符,再比较第二个字符,依次下去。
如果先按照第一个字符对所有后缀进行排序,那么现在已经有一个大致的顺序了:字典序小的字符开头的后缀在前面,字典序大的字符开头的后缀在后面。
接下来就要比较第二个字符了,但其实第二个字符已经比较过了!因为这 \(n\) 个后缀开头分别是 \(S_1,S_2,\dots,S_n\),所以有第二个字符的后缀第二个字符分别是 \(S_2,S_3,\dots,S_n\),这个和刚刚的比较是如出一辙的。
所以是直接以第一个字符的排名为第一关键字,第二个字符的排名为第二关键字排序即可。
接下来,我们已经有了所有长度为 \(2\) 的小串的排名,再去考虑下一轮,可以直接比较两个小串的排名,这样就能处理出来长度为 \(2+2=4\) 的小串的排名。
以此类推,只需要 \(\log n\) 次就能处理出长度为 \(n\) 的串的排名,也就完成了后缀排序。
所以我们的流程就是:先对单个字符进行排序,得到初始的 \(rk\)(它此时并不是一个排列) 和 \(sa\)(它确实是一个排列)。再进行一个倍增,设当前的步长为 \(w\),以 \(rk_i\) 为第一关键字,\(rk_{i+w}\) 为第二关键字,对 \(sa\) 进行排序。重复 \(\log n\) 轮即可。
直接使用 sort 的复杂度还是 \(\mathcal{O}(\log ^2n)\) 的,但是值域在 \(1 \sim n\) 中,所以利用基数排序进行双关键字排序即可,时间复杂度 \(\mathcal{O}(n \log n)\)。常数还是很大。
怎么办呢?进行一些优化:
- 首先开始是对第二关键字的排序,即以 \(rk_{i+w}\) 为关键字排序。\(rk\) 虽然不是排列,但它随着 \(sa\) 编号的增加也是单调不减的。所以这个排序没啥用,只是先把 \(i+w>n\) 的放到前面,因为越界后是 \(0\),再把剩下的 \(sa_i >w\) 依次放回去,只是这里要减去 \(w\)。因为这里的 \(sa_i\) 其实在排序的时候我们是看做 \(+w\) 的。
- 考虑这个倍增还没有跑满的时候可能 \(rk\) 已经互不相同了,所以若值域达到 \(n\) 可以直接结束。
- 在清空桶和复制 \(rk\) 数组时(因为要用旧的 \(rk\) 更新 \(rk\)),不用 memset,而是循环赋值,这样省了一部分没有用到的地方。
这样洛谷的板题就能在 650ms 内跑完了。
结论 1:对于 \(rk_i<rk_j<rk_k\),有 \(\operatorname{lcp}(i,j),\operatorname{lcp(i,k)} \ge \operatorname{lcp}(i,j)\)。
证明是容易的,排名越相近,字典序相差越小,相应的 LCP 也会越长。
结论 2:\(ht(rk_i) \ge ht(rk_{i-1})+1\)。
证明:假设我们已经求出了 \(ht_i=\operatorname{lcp}(sa_{i-1},sa_i)\)。令 \(p=sa_{i-1}+1,q=sa_i+1\)。
那么 \(\operatorname{lcp}(p,q)=ht_i-1\),原因显然,与 \(ht_i\) 的区别仅仅在于去掉了第一个相同的字符。同时显然有 \(rk(sa_{i-1})<rk(sa_i)\),因此 \(rk_p<rk_q\)。
考虑 \(r=sa(rk_q-1)\),即排名小于 \(q\) 的最大的后缀。由于 \(rk_p<rk_q\),可以得到 \(rk_p\le rk_r<rk_q\)。由结论 1,我们可以得到 \(\operatorname{lcp}(r,q) \ge \operatorname{lcp}(p,q)=ht_i-1\)。
考虑 \(r,q\) 的排名分别为 \(rk_q-1,rk_q\),所以 \(\operatorname{lcp}(r,q)=ht(rk_q) \ge ht_{i}-1\)
\(q=sa_i+1\),所以 \(ht(rk_q)=ht(rk(sa_i+1))\ge ht_i-1=ht(rk(sa_i))\),令 \(u=sa_i+1\),就得到了 \(ht(rk_u) \ge ht(rk_{u-1})\),得证。
那么按 \(i\) 递增求解 \(ht(rk_i)\),维护一个指针即可,这个指针的移动量是 \(2n\) 级别的。
请注意:\(ork\) 的大小只需要开到 \(n+1\),因为如果前面的相等后面必不超过 \(n+1\),但是这样在多测的时候要把 \(ork_{n+1}\) 置为 \(0\)。
void SA(int n){
int m=127,p;
for(int i=1;i<=n;i++) buc[rk[i]=s[i]]++;
for(int i=1;i<=m;i++) buc[i]+=buc[i-1];
for(int i=n;i;i--) sa[buc[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) id[++p]=sa[i]-w;
for(int i=1;i<=m;i++) buc[i]=0;p=0;
for(int i=1;i<=n;i++) buc[rk[i]]++;
for(int i=1;i<=m;i++) buc[i]+=buc[i-1];
for(int i=1;i<=n;i++) ork[i]=rk[i];
for(int i=n;i;i--) sa[buc[rk[id[i]]]--]=id[i];
for(int i=1;i<=n;i++) rk[sa[i]]=(ork[sa[i]]==ork[sa[i-1]]&&ork[sa[i]+w]==ork[sa[i-1]+w])?p:++p;
if(p==n) break;
}
for(int i=1,k=0;i<=n;i++){
if(k) k--;
while(s[i+k]==s[sa[rk[i]-1]+k]) k++;//这里要求解 ht[rk[i]],也就是 sa[rk[i]-1] 和 sa[rk[i]] 的 LCP
ht[rk[i]]=k;
}
}
我们可以说:后缀数组基本上就是为了求 \(ht\),当然也有只会用到 \(sa,rk\) 的情况。
后缀数组维护的是诸多后缀的信息,但是我们更多时候需要的是子串信息,这里,我们可以把子串转化为「后缀的前缀」,用 LCP 等方式来维护这些前缀信息。
应用一:两个后缀的 LCP
结论 3:设 \(rk_i < rk_j\),那么 \(\operatorname{lcp}(i,j)=\min_{rk_{i}<p \le rk_{j}} ht_p\)。证明比较平凡。
扩展一下就是,多个后缀的 LCP 是 \(\min_{L < i \le R} ht_i\),其中 \(L=\min rk_i,R=\max rk_i\)。
如果只是查询一些 LCP,那只需要用 ST 表维护区间最小值;如果要查询所有 LCP 之和,那也就是查询除了 \(1\) 的所有最小值之和,但是 \(ht_1=0\) 因此直接当做普通的单调栈问题对每个数算贡献即可。
应用二:本质不同子串
用后缀的前缀表示子串,每次加入一个新的后缀时减去已经出现过的部分即可。因为我们以前缀方式表示子串,所以需要去掉的是在这个后缀和当前已经加入的后缀集合 \(S\) 中的 前缀 中都出现过,也就是 \(\max_{j \in S}\operatorname{lcp}(i,j)\)。
考虑按照 \(i\) 从小到大依次加入 \(sa_i\),那么每次这个值就是 \(ht_i\),因此答案就是 \(\sum_{i=1}^n n-i+1-ht_i\)。
类似地,对于若干字符的后缀,我们同样有这种思想,按照 \(sa\) 的顺序插入后缀即可。
应用三:出现 \(k\) 次子串的最长长度
一个字符串出现了 \(k\) 次,就相当于它是 \(k\) 个不同后缀的前缀,那么我们想要最大化这 \(k\) 个后缀的 LCP 的长度,而 LCP 是 \(ht\) 的最小值,想要达到 \(L\) 的长度必须满足所有加入的 \(ht\) 值都 \(\ge L\)。
因此按照 \(ht_i\) 从大到小加入,每次合并 \(ht_i\) 对应的 \(sa_i,sa_{i-1}\),出现了大小为 \(k\) 的连通块时的 \(ht_i\) 就是答案。同时,加入一些 \(ht\) 更小的也无意义,因为只要选了一个它们中的数就爆了。
这里之所以不用考虑那个加一,是因为我们在使用 \(ht_i\) 的时候合并了 \(sa_{i-1}\),因此这个 \(sa_{i-1}\) 的 \(rk+1\) 恰好对应了刚加入的 \(ht_i\)。本来求 LCP 的时候 \(rk\) 要加一就是因为涉及到的 \(i-1,i\) 都应在区间内。
*应用四:\(n>1\) 个串的最长公共子串
考虑一个公共子串有什么要求:它是每个串后缀的前缀。
P2852 [USACO06DEC] Milk Patterns G
就是上问的应用三。
P2178 [NOI2015] 品酒大会
想要 \(r\) 相似,就意味着有长度 \(\ge r\) 的公共子串,也就是 \(ht\ge r\),因此倒序枚举 \(r\),每次将 \(ht=i\) 的 \(sa\) 合并,同时更新答案即可。更新答案可以记录一堆最值或直接启发式合并。
P6640 [BJOI2020] 封印
公共子串,转化为后缀的前缀求解。由于 \(S\) 可用的区间是变的但是 \(T\) 是不变的,对 \(S\) 的每个后缀,我们考虑它和 \(T\) 的所有后缀求 LCP 的结果。
对于 \(S\) 中的每个位置 \(i\),我们要求出 \(S_{i \sim n}\) 与 \(T\) 的某个后缀的最长 LCP,那么考虑把 \(S\) 和 \(T\) 拼起来做 SA,那么这个 LCP 就是 \(ht\) 的最小值的最大值。由于是取 \(\min\),所以 \(rk\) 的延伸长度越短越好。
因此,可能贡献位置 \(i\) 的只有 \(rk_i\) 左右的第一个 \(T\) 中的位置。(我们处理处 SA 后按照 \(rk\) 递增大顺序遍历,也就是提到的位置都表示的是 \(sa\))。
求这个数组可以用有一点手法的暴力,对于每个在 \(T\) 中的位置去贡献左右两边,直到下一个 \(T\) 的位置,容易发现若干个 \(T\) 中的位置把串分成了不交的若干段,因此总复杂度是线性的。
对于求解的部分,记求得的数组为 \(v\),那么答案就是 \(\max_{l \le i\le r}\min(v_i,r-i+1)\)。考虑二分,如果答案想要 \(\ge mid\),那么 \(\min\) 中的两个数都要 \(\ge mid\),也就是求 \([l,r-mid+1]\) 中 \(v_i\) 的最大值。
P5028 Annihilate
把这些串加上分隔符拼到一起跑 SA,分隔符应该互不相同。
枚举每个 \(sa_i\),它所处的串想要贡献到第 \(j\) 个串的话,一定是在 \(i\) 左边或右边的第一个位置贡献的。又因为 \(ans_{i,j}=ans_{j,i}\),我们可以一次更新两个,那么总有一个是由后面的贡献来的,因此只需考虑向前贡献的情况。
维护数组 \(Mn_j\) 表示 \(i\) 到上一个代表串 \(j\) 的位置的 \(ht\) 最小值,每次更新 \(ht_i\) 对应的位置,再用 \(Mn\) 更新答案即可。
P7361「JZOI-1」拜神
翻译题目,要求区间内出现了至少两次子串的最大长度。区间内的长度要求,类似封印,先考虑二分一个 \(L\),那么如果有一对 \(p\neq q \in [l,r-L+1]\),满足它们的 LCP 长度 \(\ge L\),那么 \(L\) 就是合法的。
依然考虑从大到小加入 \(ht\),用某种可持久化数据结构存下加入完等于某个数的 \(ht\) 后,整个合并的状态。现在有两种想法。
1.维护可持久化并查集,要求在 \([l,r-L+1]\) 内有 \(ge 2\) 个数,问题在于对于一个 \(L\) 的状态,可能分成了多个联通块,这是不好维护的,我们是想要对整体考虑的。
2.用可持久化线段树维护每个数在当前状态下的后继。那么二分的条件就变为了 \([l,r-L]\) 中的最小值 \(\le r-L+1\)。
显然做法二非常有前途,在并查集合并的时候修改后继。做法是对并查集中的每个点维护 set 然后做启发式合并。每插入一个数都更新一下它在 set 中的前驱和他自己。
?你还差得远呢
Aho-corasick Automaton AC 自动机。
Suffix Automaton 后缀自动机
还是一个长度为 \(n\) 的字符串 \(S\),你猜要干什么。
先来一些结论。
对于一个字符串的任意子串,它在原字符串中均有若干个结尾位置,记 \(\operatorname{endpos(s)}\) 表示 \(s\) 在 \(S\) 中的出现位置的集合。endpos 相同的所有子串称为一个 endpos 等价类。
结论 1
对于一个 endpos 等价类,较短的字符串必然为较长字符串的后缀,且这个集合中的字符串长度连续。
证明:
结论 2
endpos 不同的集合最多只有 \(2n\) 种。
证明:考虑在一个 endpos 等价类中选出最长的一个串,再在它的前面添加一个字符,所形成的新串的 endpos 必然与当前的 endpos 不同(因为选取了最长的串),且添加的字符不同,形成的新 endpos 必然不交。所以添加字符可视为对 endpos 集合进行划分,初始集合为 \(\{1,2,\dots,n\}\),最多只能划分出 \(2n\) 种不同的集合(线段树的方式),因此命题得证。
SAM 的出现次数:维护 endpos 集合大小 \(siz_i\),那么对 SAM 上的每个节点就是长度为 \([len_{fa_i}+1,len_i]\) 的子串各出现了 \(siz_i\) 次。
例题:
[SDOI2016] 生成魔咒
考虑 Parent Tree 上每个点的 endpos 都不相同,所以每个点代表的子串必然不同,那么答案就是 \(\sum_{p} len_p-minlen_p=len_p-len_{fa_p}\)。
SAM 是增量构造的,所以直接往后插,每次加上新的字符后加上最新状态的贡献即可。因为后续的加入不会再改变 Parent Tree 的形态。字符集很大,可以用 map 压一下。
正确性证明:如果新建了两个点,相当于在原先的结构中间插了一个点,也就是相当于把原来一起计算的一段拆成了不相交的两段,这个显然不会引起答案的变化。
[NOI2018] 你的名字
题意是问你有多少个本质不同的串是给定的 \(T\) 的子串但不是 \(S_{l \sim r}\) 的子串。先把问题转化成计算 \(T\) 的子串个数减去 \(S_{l \sim r}\) 和 \(T\) 中都出现的子串个数。
考虑 \(l=1,r=|S|\) 时的做法。现在就是问你有多少个串,既是 \(S\) 的子串,又是 \(T\) 的子串。这应当不是一个困难的问题吧。
对 \(S\) 建立 SAM,对于 \(T\) 的每个前缀,我们统计它合法的后缀数量。这应当是一段区间,求出匹配的最长后缀的长度 \(L_i\),那么每次加上这个数即可。另外一边,我们建立 \(T\) 的 SAM,统计他的本质不同子串数量。这不就算重了?
把这个过程搬回 SAM,本来我们的答案为 \(\sum_p {len_p-len_{fa_p}}\),现在 ban 掉了一部分串,对于一个点它对应的 endpos 集合,它代表着一段长度连续的成后缀关系的串,因此肯定是一些长的串没有出现过,那么贡献为 \(\max(len_p-\max(\max L_i(i \in endpos(p)),len_{fa_p}),0)\)。
接下来我们证明一个结论:对于 SAM 上所有的点 \(p\),我们只需要记录它所代表的 endpos 集合中的任意一个位置即可。
如果一个 endpos 集合中的两个位置 \(i_1,i_2\) 满足 \(L_{i_1} \neq L_{i_2}\),且 \(\max(L_{i_1},L_{i_2}) <len_p\),那么这两个位置往前 \(\min(L_{i_1},L_{i_2})+1\) 个字符肯定不同,那么它们不可能在一个等价类里,矛盾。
如果 \(\max(L_{i_1},L_{i_2}) \ge len_p\),那么 \(p\) 中最长的子串也能被匹配,而对于任何一个位置 \(i \in endpos(p)\) 这些串的情况都是一样的,所以对于每个合法的 \(i\),\(L_i \ge len_p\),就可以确定这个点没有贡献。
现在考虑全部的做法,\(S\) 变成区间只会影响到 \(S\) 的 SAM,而 \(S\) 的 SAM 只在我们跑匹配判断有没有转移边的时候才会用到,因为如果当前点合法他的父亲也肯定合法。那么用可持久化线段树合并维护 endpos 集合每次查询有没有在给定区间的数即可。
考虑一个点往下转移合法的条件:存在一个 endpos 的位置 \(p\),使得 \(l \le p \le r,p -L_{i} \ge l\),即,在区间 \([l+L_i,r]\) 之间有数。
但是这样还没完,如果匹配不上就直接跳父亲的话算法还是错误的,原因是现在虽然匹配不上但可能 \(L_i\) 更小的时候可以匹配上,因为取值区间变大了。那么每次匹配不上我们就暴力缩短长度直到匹配上或者发现 \(L_i=0\) 仍然匹配不上就退出。
这么做的复杂度是什么呢?考虑每次成功匹配都会使 \(L\) 增大 \(1\),最多成功匹配 \(|T|\) 次,且 \(L\) 始终非负,所以复杂度就是 \(\mathcal{O}(|T| \log |S|)\) 的。
总时间复杂度 \(\mathcal{O}((|S|+\sum|T|)\log |S|)\)。
[HEOI2016/TJOI2016] 字符串
唐。注意读题。
二分 LCP 长度 \(L\),那么 \(S_{c \sim c+L-1}\) 在 \(S_{a\sim b}\) 的子串中出现过。建出 SAM,定位到代表 \(S_{c\sim c+L-1}\) 这个子串的节点 \(p\),那么它的子树中有 endpos 在 \([a+L-1,b]\) 中的位置就合法,线段树合并维护即可。
General Suffix Array 广义后缀自动机
从单串变为多串。
考虑 SAM 的出现次数为 endpos 大小最本质的意义是什么。对于 SAM 上的一个点 \(p\),它的子树中的每一个 endpos 等价类都是由它在前面添加了一些字符(见前面结论二的证明)得到的。所以对于它的子树中的每一个点都对应着 \(p\) 所代表的字符串集合的一次出现。而子树外显然不可能出现 \(p\) 所代表的字符串,所以某个字符串的出现次数就是它的子树大小即 endpos 集合大小。如果你认为,一个串可能出现多次,但是这里是不是统计少了呢?不会的,我们从下向上统计,到这个点的时候已经得到了它正确的 endpos 大小,再向上一层是直接加上这个大小,所以很明显是正确的。
现在对于广义 SAM 也是一样的,如果把两个字符串 \(S\) 和 \(T\) 建出广义 SAM,那么 \(S\) 的某个子串就是它在广义 SAM 中对应的点的 \(T\) 的 endpos 大小,原因同上。
CF666G 的使用到的套路:
- 某个点对应的字符串在一些串中出现的次数是 Parent tree 上它的子树中每个点的贡献之和。只要一个点代表着字典的一个子串就有贡献。
- 通过记录右端点和倍增快速定位子串。
[CTSC2012] 熟悉的文章
首先二分答案,然后要求的量是 \(\lceil \frac{9n}{10}\rceil\)。
考虑用 dp 判定,设 \(f_i\) 表示 \([1,i]\) 最多能匹配几个。
那么 \(f_i=i+\max_j(f_j-j)\)。其中 \(j\) 要满足 \(0 \le j<i,i-j \ge mid\),\((i,j]\) 是字典中的某个子串。
考虑对字典建出广义 SAM,那么可以直接把这个串放到 SAM 上跑匹配得到 \(L_i\) 表示给定的串的 \([1,i]\) 前缀作为字典中某个串子串出现的最长后缀长度。
那么条件转化为 \(0 \le j<i,i-j \ge mid,i-j \le L_i\),即 \(j\in[\max(i-L_i,0),i-mid]\)。
然后还有就是 \(L_{i+1} \le L_i+1\),所以 \(i+1\) 时 \(i-mid\) 增大 \(1\),\(i-L_i\) 不变或增大一,所以可以用单调队列维护。check 复杂度线性。
有点细节,比如带上一个不合法子串更新 \(f_i=f_{i-1}\)。