字符串进阶
后缀数组
初学易混:虽然是给后缀排序,但是每一个后缀的字典序是从前往后看的。
\(rk_i\) 表示 \(suf_i\) 在所有后缀中的排名,\(sa_i\) 表示排名为 \(i\) 的后缀的下标,其中 \(rk\) 与 \(sa\) 互为逆映射。但是在 SA 的建立过程中并不满足这个性质,因为存在并列排名。
建立
简述一下 SA 的实现方法,就是倍增排序。\(rk_i\) 表示下标位置 \(i\) 对应当前所在第一关键字的映射,也就是第一关键字的排名。\(y_i\) 表示第二关键字排名为 \(i\) 的东西第一关键字的位置。我们先要对单字符特殊预处理一下,用 \(rk_i\) 记录单字符然后对桶 \(++\),最后对于桶前缀和,然后枚举 \(i\),用 \(i\) 更新 \(sa(c_{x_i})\),同时桶 \(--\)。
然后开始倍增,我们要先更新 \(y_i\),对于 \(y_i \in [n-k+1,n]\),我们可以发现他们凑不齐第二关键字,空串的字典序小,所以先特判分配一下。然后对于 \(sa_i>k\) 的位置作为第二关键字,他们才有第一关键字,所以我们依次用 \(sa_i-k\) 更新 \(y\)。接着还是还是想上面那样处理 \(rk\),然后在第一关键字的前提下排序第二关键字 for(int i=n;i>=1;i--) sa[c[x[y[i]]]--]=y[i]
。由于排序单位长度变化,所以第一关键字的种类也要变了,所以我们先复制一下刚刚的 \(rk\) 数组记为 \(rk'\),当枚举 \(i\),当 \(sa(rk'_i)\) 与 \(sa(rk'_i+k)\) 都与 \(i-1\) 的对应信息相同时,下一轮这俩的第一关键是相同的所以 \(rk(sa_i)=rk_(sa_{i-1})\),否则新建一类。如果遇到第一关键种类恰好为 \(n\) 就代表已经完成排序了可以退出。
height 数组
定义 \(h_i=\operatorname{lcp}(sa_i,sa_{i-1})\)。对 \(rk\) 有如下性质,
若 \(rk_i<rk_j<rk_k\),则 \(\operatorname{lcp}(i,j),\operatorname{lcp}(i,j) \ge \operatorname{lcp}(i,k)\)。
由此我们可以推出来一个性质 \(h_{rk_i} \ge h_{rk_{i-1}}-1\)。
于是可以线性求解 \(h\) 数组了。
lcp
两个后缀之间 \(\operatorname{lcp}\) ,注意要对于 \(i=j\) 特判。
对于 \(rk_i<rk_j,\operatorname{lcp}(i,j)=\min\limits_{k=rk_i+1}^{rk_j}h_k\)。
预处理 ST 表,可以 \(O(1)\) 回答。
如果是求后缀两两 \(\operatorname{lcp}\) 之和,可以按照排名加入后缀,\(\sum\limits_{i=2}^n\sum\limits_{j=1}^{i-1}\operatorname{lcp}(sa_j,sa_i)\)。
化简式子,\(\sum\limits_{i=2}^n\sum\limits_{j=1}^{i-1}\min\limits_{k=j+1}^ih_k\),可以看成不断加入矩形后的矩形面积和,单调栈维护即可。
本质不同子串数计数
我们发现每个后缀的每个前缀构成了串的所有子串,考虑每次添加一个并且删除以加入后缀中前缀相同者,也就是 \(\max\limits_{j\in S}\{\operatorname{lcp}(i,j)\}\)。于是我们按照 \(rk\) 值依次加入这样子上述式子就可以变成 \(h_i\)。
所以就是 \(\begin{pmatrix}n\\2\end{pmatrix}-\sum\limits_{i=2}^n h_i\)。
如果要求某个后缀集合的也是好办的,我们对于这些后缀集合按照排名排序,设集合大小为 \(m\),然后就是 \(\sum\limits_{i=1}^m n-p_i+1+\sum\limits_{i=2}^m\operatorname{lcp}(p_{i-1},p_{i})\)。用 ST 表预处理后可以 \(O(m)\) 求解。
最长公共子串
对于多个串插入分隔符建立 SA,然后就是双指针扫排名区间求区间最小 \(h\)。同时这个区间需要满足包含所有串的至少一个后缀,容易发现区间满足双指针性质,直接双指针 \(+\) 单调队列维护即可。
与数据结构结合
区间最小值联想到笛卡尔树。
可以根据 \(h\) 数组大小在 \(sa_{i-1}\) 与 \(sa_i\) 之间连边用并查集之类维护。
例题
P3763 [TJOI2017] DNA
将两个串拼接在一起,建立 SA。枚举起始位置,利用 \(\operatorname{lcp}\) 快速匹配,往前跳,如果断点数 \(\le 3\) 就合法。
P2852 [USACO06DEC] Milk Patterns G
从大到小枚举 \(h_i\),然后就相当于在 \(sa_i\) 和 \(sa_{i-1}\) 之间连边,当出现大小为 \(k\) 的联通块的时候,此时的 \(h_i\) 就是答案。
P2463 [SDOI2008] Sandy 的卡片
都加上一个数不太好处理,于是考虑差分数组,然后就是上面的模型了。
P1117 [NOI2016] 优秀的拆分
我们发现对于 "AA" 与 BB”,二者是独立的。所以我们只需要枚举分界点,算一下它前面的 "AA" 个数以及它后面的 “BB” 个数。乘起来就行了。
可以笛卡尔树上+二维数点是 \(2\log\) 的。
可以发现如果我们从尾节点统计很难产生类似一段区间都满足的情况,于是考虑从中间枚举。我们可以枚举长度 \(len\),然后每隔 \(len\) 个点就设置一个哨兵节点。对于一个合法 \(AA\),可以发现必然覆盖至少两个哨兵节点。于是我们对于两个哨兵位置查询 lcs 和 lcp,如果 lcp+lcs \(\ge len\),那么存在。在第二个哨兵节点后一段区间可以整体加一,差分即可。
后缀自动机
目的:为了在一个 DAG 上表示一个字符串的所有子串。
如果直接 Trie 的话,复杂度太大。我们需要合并相同状态,最小化 DFA。如果两个状态在接受一个相同字符的时候都会转移到相同状态或者失配,那么两个状态不可区分,可以合并。
下面是 endpos 集推导所包含的字符串,我们从可以从 endpos 集合的位置出发,设下一个字符为目标字符,如果所有位置的下一个目标字符都相同那么所有位置都可以向后扩展一位,endpos集合保持不变,必然存在向后扩展长度在 \([L,R]\) 之内的字符串使得它们的 endpos 集合相同,如果超过了 \(R\) 那么会出现目标字符不同,这样根据目标字符的种类大的 endpos 集合会分裂为小集合。
endpos 性质:
- endpos集相同的子串呈后缀关系。
- 两个 endpos 集要么包含要么交集为空,因此任意一个 SAM 的 endpos 集可以构成树。
- endpos等价类中的串长度连续。
- endpos等价类的个数为 \(O(n)\) 级别的。
- \(len(fa(a))+1=minlen(a)\),得到这个结论之后我们就可以只记录当前等价类的最长长度即可,因为最短长度可以由父节点推出来,而一个等价类中长度又是连续的,故所有信息都知道了。
后缀链接树
现在我们开始建立 parent tree,若 \(v_j\) 由 \(v_i\) 通过上述说法分裂而来的话,那么 \(link(v_j)=v_i\),\(v_i \to v_j\) 就是树上的一条边。每一个点的不同儿子是从 \(R\) 位置选择不同目标字符得到的。
于是我们在这棵树的基础上建立后缀自动机,自动机的起始节点是根节点(空),终止节点是 \(S_{[1,n]}\) 在树上所在节点及其祖先节点。于是我们可以发现延后缀自动机上的边都相当于在末尾添加字符,而延树上边走相当于在前面添加字符。
SAM的建立
我们在末尾添加了一个目标字符 \(c\),并新开一个节点 \(np\)。我们直接在树上跳 link,这相当于压缩地遍历原串的所有后缀。如果没有 \(c\) 这条边,那就直接连到新节点上,直到出现一个有 \(c\) 边的。如果此时到了根节点就直接将 \(np\) 的父亲设置为根。否则沿着 \(c\) 边走到一个节点 \(q\),如果 \(len(q)=len(p)+1\),说明 \(q\) 恰好只增加了一个 \(c\),于是 \(q\) 是 \(np\) 的后缀,直接连 link 即可。否则新建一个等价类,把 \(q\) 与 \(np\) 连上去就行了。
复杂度证明:
后缀链接树的状态数不超过 \(2n-1\)。后缀树的 \(n+1\) 的叶子节点代表了从各个位置延伸至开头的状态。同时每个非叶子节点至少两个子节点(这点很重要,如果只有一个子节点那么可以合并),于是整颗树有不超过 \(2n+1\) 个节点。注意到 \(0\) 可以直接去掉,\(1\) 也就一个点就是叶子节点,于是某个点存在至少三个子节点,所以总节点数不超过 \(2n-1\)。这是后缀链接树的复杂度,SAM 的复杂度以后再说。
与 AC 自动机的比较
首先 AC 自动机必然是多串的,SAM 一般为单串,但是也可以通过广义 SAM 变成多串的。
最重要的一点是 AC 自动机一般是整个串产生贡献,而 SAM 一般为 子串产生贡献。
广义后缀自动机
先插入到 Trie 树上,然后在 Trie 树上建即可,\(last=fa_u\),fa 为 Trie 树上的父亲。
基础应用:
-
检查字符串是否出现。
-
不同字串的个数
\(d_u=1+\sum d_v\),最后答案是 \(d_{root}-1\),因为要减去空串。
或者 \(\sum\limits_u len(u)-len(fa(u))\) -
不同字串总长度。
\(f_u=\sum f_v+d_v\)
注意:2,3 中递推式中的边都是 ch 数组里面的。 -
字典序第 \(k\) 大子串
通过 \(d\) 数组来寻找即可。
例题:P3975 [TJOI2015] 弦论
这题的第 \(k\) 大串分相同子串是算一个还是多个。
算一个的话很容易,拿 \(d\) 数组解决。
如果是算多个的话就需要统计出 endops 集合大小即可,也就是顺着 link 树求和即可。 -
最小表示
建立 \(S+S\) 的 SAM 然后走字典序最短的长度为 \(\lvert S \rvert\) 的路径即可。 -
子串出现次数
对于每个非复制节点,设 \(sum_p=1\),然后对于 link 树跑一遍从底向上求和就行了。 -
第一次出现位置
预处理 \(firstpos\) 对于新建节点就是 \(len\),对于复制节点就是被复制节点的 \(firstpos\),答案就是 \(firstpos-\lvert p\rvert+1\)。 -
所有出现位置
对着后缀链接遍历即可,如果不是复制节点就输出。 -
最短未出现字符串
如果不存在一种字符使得 \(u\) 的边没有,那么 \(d_u=1\),否则 \(d_u=\min_{(u,v)\in sam} d_v\) -
最长公共子串
有边就走,否则延着后缀链接跳,同时缩短长度。如果是多个串就建立广义 SAM 然后选择 \(size=n\) 的节点。 -
查找子串 \([l,r]\) 位于的节点
记录所有 \(s[1,r]\) 的节点,然后往上倍增,跳到 \(len\) 合适的地方。 -
最长不可重叠重复子串
if(sz[u]>=2) ans=max(ans,min(len[u],r[u]-l[u]));
-
\(s[l,r]\) 在 \(x\) 之后第一次出现的位置。线段树合并求出 endpos 集合,然后线段树二分。
CF1870G Delicious Dessert
预处理每个数的因数集合,只需要对于每个数都向上标记倍数,复杂度 \(o(n\log n)\)。然后对于每个 endpos 集合,对出现次数二分因数即可。
long long calc(int z,int l){ return upper_bound(p[z].begin(),p[z].end(),l)-p[z].end(); }
long long sum=calc(f[u],len[u])-calc(f[u],len[fa[u]]);
CF666E Forensic Examination
P3346 [ZJOI2015] 诸神眷顾的幻想乡
其实需要发现本质就是求不同字串的个数。可以用广义 SAM 求解。
但是我们太不方便跨过 lca 绕一圈求串。这里有一个结论就是从叶子节点出发,可以遍历到所有串。而且由题可知,叶子节点数目很少,于是可以暴力。