字符串进阶
后缀数组
初学易混:虽然是给后缀排序,但是每一个后缀的字典序是从前往后看的。
\(rk_i\) 表示 \(suf_i\) 在所有后缀中的排名,\(sa_i\) 表示排名为 \(i\) 的后缀的下标,其中 \(rk\) 与 \(sa\) 互为逆映射。但是在 SA 的建立过程中并不满足这个性质,因为存在并列排名。
在 SA 的建立过程中,\(rk\) 中存在并列排名就算一个人(也就是说如果两个并列第三之后还存在第四名),在 \(sa\) 中存在并列算多人(也就是说如果有两个人并列第三就不存在第四名了)。
建立
简述一下 SA 的实现方法,就是倍增排序。
每次倍增后要合并相邻两段。在前面那段肯定是第一关键,后面那段是第二关键字。我们在上一次排序之后已经完成了对于小段的排序。一直排序,直到所有段可区分。
这是一些定义,\(rk_i\) 表示下标位置 \(i\) 对应当前所在第一关键字的映射,也就是第一关键字的排名。\(y_i\) 表示第二关键字排名为 \(i\) 的东西第一关键字的位置。
先对所有字符离散化一下,按照字符的字典序压缩进 \([1,\lvert \sum\rvert]\)。
我们先要对单字符特殊预处理一下排名,对于 \(rk\) 数组,\(rk_i\gets s_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\),那么存在。在第二个哨兵节点后一段区间可以整体加一,差分即可。
P7361 「JZOI-1」拜神
高质量的 SA 练习题。比题库中的大部分 SA 配合数据结构的无聊套路题多了点变化。
题目即求给定多次区间询问,其中区间内任意两个子串的 \(\operatorname{lcp}\) 长度的最大值。
考虑二分答案转化为判定问题,即求是否存在 \(i,j\in[l,r-ans+1]\),满足 \(\operatorname{lcp}(i,j)\ge ans\)。
可以使用 SA 配合上 height 数组的从大到小的启发式合并很好地刻画上述条件。
如果存在上述 \(i,j\) 即代表他们俩在同一长度 \(\ge L\) 集合内。如果暴力对于集合内点对进行标记统计是 \(O(n^2)\) 的,很劣。
可以发现如果没有 \(i,j\) 的范围要求,那么只需要检查集合内是否有至少两个元素即可,这是一个很简单的问题。于是这启发我们从这个范围的约束条件下手,双变量不妨固定其中的一个,假设我们固定了 \(i\)(其中 \(i<j\)),那么只需要寻找一个 \(j\) 落在 \([i,r-ans+1]\) 的范围内即可,于是我们可以贪心地维护每个位置的最近后继问题,然后判定最近后继是否 \(\le r-ans+1\) 即可。
于是我们就大大减小了问题的规模,从原本的标记所有点对到现在只用考虑维护后继。
在启发式合并的过程中,我们是把小集合的元素一一加入大集合中,在这个过程中,每次加入一个元素,二分寻找它在新的集合中的后继并更新是很简单的。现在问题来了如何更新大集合中的后继,我们不可能扫描大集合中的每个元素来寻找后继,这会使得复杂度不对了。其实仔细思考一下会发现,每个新加入的小集合中的元素其实只会对于它在大集合中的前驱产生贡献,比如我们加入了 \(k\),满足 \(i<j<k\),\(k\) 只会对于 \(j\) 产生贡献,因为 对于 \(i\) 来说 \(j\) 比 \(k\) 更优。于是我们在加入小集合元素时查找前驱后驱并相应更新即可。
如果询问给定了需要判定的长度,那么我们直接离线按照长度从大到小一遍启发式合并集合一边回答对应长度的询问即可。但是因为我们是在二分答案,所以需要在线算法。
可以发现要保留所有长度的信息,这提示了我们要使用可持久化线段树,每次二分之后在对应长度代表的线段树上区间查询所有点的后继中最小的那个进行判定即可。
时间复杂度 \(O(n\log^2n)\)。
后缀自动机
目的:为了在一个 DAG 上表示一个字符串的所有子串。
为了表示所有子串,我们可以对于所有前缀把它的所有后缀都拉出。采用增量法加入字符,然后加入以这个字符结尾的所有后缀即可。
endpos 集合的构建
最暴力的做法是把 \(S\) 的所有子串抽出来建立一个 AC 自动机。但是这个复杂度显然不对。我们需要合并相同状态,最小化 DFA。如果两个状态在接受一个相同字符的时候都会转移到相同状态或者失配,那么两个状态不可区分,可以合并。
对于字符串 \(S\) 的某个子串,其 endpos 集合为它在 \(S\) 中所有出现位置的右端点组成的集合。每次加入 \(O(n)\) 个后缀显然是无法接受的,有了 endpos 集合,我们就可以合并一些重复的了。
考虑动态构建 endpos 集合,由于我们保存的是右端点所以考虑从 \(r\) 开始往前扩展。我们从可以从 endpos 集合的所有位置出发,设下一个待扩展字符为目标字符,如果所有位置的下一个目标字符都相同那么所有位置都可以向前扩展一位,endpos 集合保持不变,必然存在向后扩展长度在 \([L,R]\) 之内的字符串使得它们的 endpos 集合相同,如果超过了 \(R\) 那么会出现目标字符不同。这样根据目标字符的种类,大的 endpos 集合会分裂为小集合。
endpos 性质:
-
endpos集相同的子串呈后缀关系。
-
两个 endpos 集要么包含要么交集为空,因此根据这个性质任意一个 SAM 的 endpos 集都可以构成树。
-
endpos 等价类中的串长度连续。
-
endpos 等价类的个数为 \(O(n)\) 级别的。
-
\(\rm {len}(fa_u)+1=\rm {minlen}(u)\),得到这个结论之后我们就可以只记录当前等价类的最长长度即可,因为最短长度可以由父节点推出来,而一个等价类中长度又是连续的,故所有信息都知道了。
后缀链接树
现在我们开始建立 parent tree,若 \(v_j\) 由 \(v_i\) 通过上述说法分裂而来的话,那么 \(link(v_j)=v_i\),\(v_i \to v_j\) 就是树上的一条边。每一个点 \(u\) 的不同儿子是从 \(R-len_u\) 位置选择不同目标字符得到的。这样子后缀树就建好了。
于是我们在这棵树的基础上建立后缀自动机,自动机涉及子串之间状态的转移,类比 ACAM,我们需要求出 \(ch_{u,c}\) 数组,表示在 \(u\) 的状态上向后添加一个字符 \(c\) 可以转移到后缀树上的什么状态上面。后缀自动机的起始节点是根节点(空),终止节点是 \(S_{[1,n]}\) 在树上所在节点及其祖先节点。于是我们可以发现延后缀树上的 \(ch\) 边走相当于在末尾添加字符,而延树上边走相当于在前面添加字符。
SAM 的建立
采用增量法,假设已经构造完了 \(S[1::i]\),我们现在加入 \(S_{i+1}\)。
我们在末尾添加了一个目标字符 \(c\),并新开一个节点 \(np\)。我们直接从 \(S[1::i]\) 对应节点开始在树上跳 link,这相当于由长到短压缩地遍历原串的所有后缀 \(S[l,i]\),\(l\) 递增。至于为什么是压缩遍历,因为我们把很多状态相同的 \(l\) 合并在了同一个节点。
我们希望加入所有状态 \(S[l::i+1]\)。同样的,我们希望把所有状态相同的 \(l\) 合并在同一个节点。针对 \(S[1::l+1]\) 我们新建一个节点,其 endpos 集合为 \(\{i+1\}\),不断跳 \(l\),如果其没有连出 \(c\) 边的情况,说明之前没有出现过子串 \(S[l::i]+c\),那么全部都和 \(S[1::i+1]\) 在同一个 endpos 集合 \(\{i+1\}\) 内,这个时候就不需要新建 endpos 节点集合。但是需要连 \(ch_{u,c}\) 边,全部指向新建的节点就行了。
直到出现一个有 \(c\) 边的。有一个 corner case 先判掉:如果此时到了根节点就直接将 \(np\) 的父亲设置为根。
对于其他情况,说明之前出现过子串 \(S[l::i]+c\),而且现在其多了一个新 end 节点就是 \(i+1\),必定会产生新节点,而且注意对于 \(\forall l'\in [l,i]\),其对应的 \(S[l::i]+c\) 这个子串都会产生新的 endpos 集合。
我们沿着 \(c\) 边走到一个节点 \(q\),如果 \(len(q)=len(p)+1\),说明 \(q\) 恰好只增加了一个 \(c\),于是 \(q\) 是 \(np\) 的后缀,直接连 link 即可。
否则新建一个等价类,代表在这个位置进行分裂 endpos 集合,分裂出 \(q\) 和 \(np\),直接把 \(q\) 与 \(np\) 连上去就行了。然后向上跳 link,如果 \(ch_{u,c}\) 指向的是 \(q\),我们就更新 \(ch_{u,c}\) 连向我们新建立的等价类,否则不更新。
注意连边的时候 \(fa\) 和 \(ch\) 不是逆数组关系,我们新建的节点 \(nq\) 其父亲应该是 \(fa_q\),而不是 \(ch_{p,c}=q\) 的 \(p\)。
在 link 树上行走的时候,一个节点会代表多个串(endpos 集合内的串),在 \(ch_{u,c}\) 上面行走的时候当前节点只代表一个串:DAG 前驱上的路径构成的字符串。
复杂度证明:
后缀链接树的状态数不超过 \(2n-1\)。后缀树的 \(n+1\) 的叶子节点代表了从各个位置延伸至开头的状态。同时每个非叶子节点至少两个子节点(这点很重要,如果只有一个子节点那么可以合并),于是整颗树有不超过 \(2n+1\) 个节点。注意到 \(0\) 可以直接去掉,\(1\) 也就一个点就是叶子节点,于是某个点存在至少三个子节点,所以总节点数不超过 \(2n-1\)。这是后缀链接树的复杂度,SAM 的复杂度我目前不会证。
与 AC 自动机的比较
首先 AC 自动机是多串的,SAM 为单串,但是也可以通过广义 SAM 变成多串的。
最重要的一点是 AC 自动机一般是整个串产生贡献,而 SAM 一般为子串产生贡献。
我们一般有这种题:\(A\) 串在另一个串 \(B\) 中的子串出现。如果从 \(A\) 的角度考虑就建立 ACAM,如果从 \(B\) 的角度考虑就建立 SAM。具体问题具体分析。
广义后缀自动机
先插入到 Trie 树上,然后在 Trie 树上 dfs 的同时建立 GSAM 即可。
在 SAM 中我们有 \(last\),代表上一个状态的节点,也就是 \(S[1::i]\) 代表的状态。这里的 \(last\gets fa_u\),fa 为 Trie 树上的父亲。
基础应用
以下基本都是模板题的一些考法,在比赛/正规考试基本不会出现。
一个串在其 link 树上的所有儿子中都出现过,以下的出现次数类问题都用这个思路来求解。
-
检查字符串是否作为子串在某个模板串中出现出现。直接在 SAM 上行走即可。
-
不同子串的个数。最直观的是 \(\sum\limits_u len(u)-len(fa(u))\)。也可以通过递推解决,\(d_u=1+\sum d_v\),最后答案是 \(d_{root}-1\),因为要减去空串。
-
不同子串总长度。可以直接在每个节点处算等差数列,也可以 \(f_u=\sum f_v+d_v\)
注意:以上两个递推式中的边都是 ch 数组里面的。
-
字典序第 \(k\) 大子串,通过 \(d\) 数组来寻找即可。P3975 [TJOI2015] 弦论
这题的第 \(k\) 大串分相同子串是算一个还是多个。算一个的话很容易,拿 \(d\) 数组解决。如果是算多个的话就需要统计出 endops 集合大小即可,也就是顺着 link 树求和即可。 -
最小表示。建立 \(S+S\) 的 SAM 然后走字典序最短的长度为 \(\lvert S \rvert\) 的路径即可。
-
子串出现次数,对于每个非复制节点,设 \(sum_p=1\),然后对于 link 树跑一遍从底向上求和就行了。
-
第一次出现位置:预处理 \(\rm firstpos\) 对于新建节点就是 \(len\),对于复制节点就是被复制节点的 \(\rm firstpos\),答案就是 \(\rm {firstpos}-\lvert p\rvert+1\)。
-
所有出现位置:对着后缀链接遍历即可,如果不是复制节点就输出。
-
最短未出现字符串:如果不存在一种字符使得 \(u\) 的边没有,那么 \(d_u=1\),否则 \(d_u=\min_{(u,v)\in sam} d_v\)
-
最长公共子串:有边就走,否则延着后缀链接跳,同时缩短长度。如果是多个串就建立广义 SAM 然后选择 \(size=n\) 的节点。
-
最长不可重叠重复子串
if(sz[u]>=2) ans=max(ans,min(len[u],r[u]-l[u]));
进阶应用
以下是需要重点掌握的在非模板题中的必备技巧。
-
查找子串 \([l,r]\) 位于的节点,记录所有 \(s[1,r]\) 的节点,然后往上倍增,跳到 \(len\) 合适的地方。
-
\(s[l,r]\) 在 \(x\) 之后第一次出现的位置。线段树合并求出 endpos 集合,然后线段树二分。
例题
CF1780G Delicious Dessert
在 link 树上 DP 求出每个子串的出现次数。
使用调和级数标记倍数,处理出 \([1,n]\) 每个数的因数集合,然后直接在因数集合内二分就行了。
CF666E Forensic Examination
对于 \(T\) 建立广义后缀自动机之后,在 link 树上使用线段树合并得到每种串的在各个 \(T\) 的出现次数。
然后对于 \(S\) 在 GSAM 上行走一遍,得到 \(S\) 的每个前缀在后缀自动机上匹配的位置 \(to_x\)。然后对于 \(S\) 的区间 \([l,r]\) 从 \(to_r\) 开始利用字符串长度的比价来树上倍增得到这个串所属的节点,在对应节点进行线段树查询即可。
注意特判 \(S\) 前缀 \(r\) 的匹配长度小于 \(r-l+1\) 的情况。线段树合并的时候不能销毁/改变之前的信息,要新建节点保留之前信息,因为我们是对于所有节点都会进行查询。
P3346 [ZJOI2015] 诸神眷顾的幻想乡
其实需要发现本质就是求不同字串的个数。可以用广义 SAM 求解。
但是我们太不方便跨过 LCA 绕一圈求串。这里有一个结论就是从所有叶子节点出发,可以遍历到树上的所有路径。
而本题中叶子节点数目很少,于是可以直接暴力,题目相当于给定了若干颗 Trie,dfs 的时候合并就行了。
P8947 Angels & Demons
由于 \(T\) 是动态加入的且要求在线,所以不能离线从 \(T\) 角度考虑对于 \(S\) 的贡献,也就无法使用 AC 自动机了。对于考虑直接从 \(S\) 的子串角度计算答案,因为需要建立广义 SAM。
本题需要知道的就是一个串在其 link 树上的子树内的所有 endpos 集合内出现。
对于 \(S\) 串建立广义 SAM 之后,动态加入 \(T\) 的时候,就让 \(T\) 在 GSAM 上顺着转移边行走,如果失配就跳 fail,并且维护匹配长度。
一个 \(S\) 中的串在当前这个 \(T\) 中出现,需要满足 \(T\) 的某个状态存在于 \(S\) 的子树内(不包含本身节点),或者经过了本身节点且匹配长度大于当前串长。
第一个贡献形式是子树数点,第二种贡献形式就是对于每个点开一个动态开点线段树维护其每种长度被贡献次数。但是注意一个 \(T\) 只能贡献一次,所以需要去重。考虑提取出 \(T\) 在 GSAM 上行走经过点构成的虚树,只保留虚树的叶子节点(否则子孙关系会重复贡献),对于叶子节点可能被 \(T\) 多次经过,保留最长的匹配长度在线段树上进行贡献。注意到两个叶子会产生重复贡献,也就是他们 \(\rm LCA\) 以及 \(\rm LCA\) 祖先这一段。于是我们按照 \(\rm DFS\) 序排序之后对于相邻点在其 \(\rm LCA\) 处差分一下就行了。
时间复杂度单 \(\log\)。
P14729 [ICPC 2022 Seoul R] Longest Substring
回文自动机
其实可以类比 SAM,二者都是维护子串信息。
在 SAM 中我们维护的 link 表示最长后缀,在 PAM 中我们维护 link 表示最长回文后缀。
注意要建立两个根节点,分别代表偶串和奇串。
每次往上跳就是看对称之外的两个字符是否相同。
P5555 秩序魔咒
两颗 PAM 上同时 dfs 即可。注意需要对于奇,偶两个根都跑一遍。

浙公网安备 33010602011771号