后缀数据结构学习笔记
后缀数据结构学习笔记
By Tuifei_oier
后缀数据结构通常应用于字符串,用于求解一些字符串相关的问题,也因此这种数据结构往往可以和字符串捆绑在一起和其他算法嵌套,所以它的应用也是较繁杂的,但是也有一定的定式。
Part 1 基本定义
- 字符串:在 OI 中表示为一些字符构成的数组,默认下标从 \(1\) 开始,\(n\) 表示其长度。
- 子串:一个字符串中的连续一段,从下标 \(l\) 到下标 \(r\) 的 \(S\) 的子串记为 \(S[l,r]\)。
- 前缀:下标 \(i\) 的前缀即子串 \(S[1,i]\)。
- 后缀:下标 \(i\) 的后缀即子串 \(S[i,n]\)。
Part 2 后缀数组
定义&构造
先来看一个题目:
给定字符串 \(S\),求它的所有后缀按字典序排序的结果。
\(tips:1\le n\le10^6\)
我们首先有一个朴素解法:取出 \(S\) 的所有后缀,然后直接排序即可,复杂度 \(O(n^2)\),显然有点大了。
于是,我们考虑先取出它的所有后缀,考虑我们复杂度的瓶颈:
因为我们每次比较都是比较两个后缀的下一个字符,所以比较的复杂度为 \(O(n^2)\),但是注意到后缀之间是有包含关系的,也就是说这种比较方式导致我们有可以利用的信息浪费了,所以考虑倍增优化来利用信息。
具体而言,每次比较每个后缀的前 \(l\) 个字符(不足在后面补空字符),然后接下来比较前 \(2l\) 个字符,我们发现前 \(2l\) 个字符的比较过程,可以拆成先比较前 \(l\) 个,再比较后 \(l\) 个,然后这两个东西的排名在比较前 \(l\) 个字符的过程中就求出来了,所以就可以直接做一个双关键字排序,这个东西可以用基数排序方便的完成,复杂度为 \(O(nlogn)\)。(排序 \(O(n)\),排 \(O(logn)\) 次。)
之后我们可以得到 \(S\) 所有后缀排序后的结果,记 \(sa[i]\) 表示排名为 \(i\) 的后缀在 \(S\) 中的起始下标,则 \(sa\) 数组即为我们所求得的后缀数组。
至此,我们得到一个 \(O(nlogn)\) 构造后缀数组的算法。
应用
那么,这玩意儿有什么用呢?
实话实说,没啥用。
但是,我们通过它再去求一些其它的玩意儿,就相当有用了。
首先,求一个也没啥用的东西:\(rk[i]\),它表示后缀 \(S[i:n]\) 在所有后缀中的排名。显然 \(rk[sa[i]] = i,sa[rk[i]] = i\)。
然后,我们考虑求这样一个数组 \(height[i]\),它表示所有后缀中排名为 \(i\) 的后缀和排名为 \(i-1\) 的后缀的最长公共前缀(即 LCP)。
即 \(height[i]=LCP(S[sa[i-1],n],S[sa[i],n])\)。
然后,这个东西有什么用呢?我们可以 \(O(1)\) 求任意两个后缀之间的 \(LCP\)!
定理:\(LCP(S[i,n],S[j,n])=\min\limits_{k=\min(rk[i],rk[j])+1}^{\max(rk[i],rk[j])}height[k]\)
怎么证明?自证不难(我有点懒(不算很难,较好理解))。
所以,我们只要求出 \(height[i]\),然后用数据结构维护区间最值即可。
接下来考虑怎么求 \(height[i]\)。
设一个数组 \(h[i]\) ,表示 \(LCP(S[i,n],S[sa[rk[i]-1],n])\)。
那么我们就可以推出一个定理。
定理:\(h[i]\ge h[i-1]-1\)
这个定理的证明有很多方式,而且画图后比较好解决,如果实在证明不出来也可以直接 BFS。
然后只要直接用这个定理暴力求即可,每次暴力匹配的开始长度为上一次匹配的答案 \(-1\),不难证明复杂度仍为 \(O(n)\)。
接下来是一些例题,用到了后缀数组以及一些它的性质。
Pro A (Luogu P2408)
题意:给定字符串 \(S\),求 \(S\) 的本质不同的子串个数。
\(tips:1\le n\le10^6\)
这个题目算是后缀数组的基础应用了,下面讲一种解法。
首先,因为每个子串都可以唯一地对应一个后缀的前缀,所以只要考虑所有后缀的本质不同的前缀数量。
这个过程可以这样考虑:根据之前的 \(height\) 数组的定理,可以发现在排序后的所有后缀中距离越远 \(LCP\) 越短,所以考虑每个后缀对答案的贡献时,比它排名恰好小 \(1\) 的后缀是和它重复最多的,所以减去它们的 \(LCP\) 即可。
因此,本题的答案即为 \(\sum\limits_{i=1}^nn-sa[i]+1-height[i]\)。
时间复杂度 \(O(nlogn)\)。
Pro B (Luogu P4094)
题意:给定字符串 \(S\),\(Q\) 次询问,每次询问给出 \(4\) 个正整数 \(a,b,c,d\),求 \(S[a,b]\) 的所有子串与 \(S[c,d]\) 的 \(LCP\) 的最大值。
\(tips:1\le n,Q\le10^5\)
首先利用 子串=后缀的前缀
转化为求 \(S[a,b]\) 所有后缀与 \(S[c,d]\) 的 \(LCP\) 最大值。
因为这个查询的操作不是在排序后的排名上连续的,所以不能直接利用之前的 \(height\) 的定理,而注意到答案有单调性,考虑二分答案。
二分这个最大值 \(Len\),然后就只要确定是否存在解。
我们可以先求出满足 \(LCP(S[i,n],S[c,n])\ge Len\) 的 \(i\) 在排序后排名的范围,这个可以通过二分来解决(利用 \(height\) 的定理),设为 \([L,R]\),然后只要求有多少个数对 \((i,rk[i])\) 满足 \(a\le i\le b-Len+1,L\le rk[i]\le R\),经典二维数点问题。
时间复杂度为 \(O(nlog^2n)\)。
Pro C (Luogu P1117)
题意:给定字符串 \(S\),求 \(S\) 的每一个子串表示成 \(AABB\) 形式的方案数之和。(要求 \(A,B\) 为非空串)
\(tips:1\le n\le30000\),多组数据
首先,我们考虑一个 \(AA\) 串对答案的贡献。假设从 \(i\) 开始的 \(AA\) 串数量为 \(a\),以 \(i-1\) 结束的 \(AA\) 串数量为 \(b\),则 \(i\) 这个位置对答案有 \(a\cdot b\) 的贡献,并且这样算不会出现重复和遗漏。
接下来只要考虑怎么统计以某个位置 开始/结束 的 \(AA\) 串的数量。
此时我们有一个常用的做法:插分隔符(一般不用实际插入字符)。
具体而言,为了统计长度为 \(2\cdot len\) 的 \(AA\) 串,我们在字符串上取下标分别为 \(len,2len,3len,...\) 的字符并打上标记。然后发现一个长度为 \(2\cdot len\) 的 \(AA\) 串必然经过且只经过两个相邻标记节点,所以只需统计两个相邻节点之间的贡献。
假设现在考虑 \(i\) 和 \(i+len\) 两个节点的贡献,我们只需对 \(S[i:n],S[i+len:n]\) 求 LCP,对 \(S[1:i-1],S[1:i+len-1]\) 求最长公共后缀,设这两个值分别为 \(l1,l2\)。当且仅当 \(l1+l2\ge len\) 时,这两个标记点才对答案有影响(否则,\(AA\) 串的长度必然 \(<2\cdot len\),之前统计过了)。不难发现需要维护区间加,差分一下最后还原即可。
复杂度 \(O(nlogn)\)。
Part 3 后缀自动机
后缀自动机是除了后缀数组以外另一大后缀数据结构(后缀树好像用处不蛮大?也没见到只能用后缀树做的),因此掌握这种数据结构也是很重要的。
定义与性质
后缀自动机由一些节点和边组成,每个节点代表一个状态(这也是 OI 中常用自动机的通式?)。在后缀自动机中,每个节点代表一些原串的子串,满足它们的在原串中的出现位置(即每次出现结束位置的下标)都相同。
例如:对于 \(S=abab\),子串 \(ab\) 和 \(b\) 的出现位置都相同,为 \(\{2,4\}\)。则 SAM 中 \(ab,b\) 会由同一个节点表示。
这是点的定义,不难发现一个节点包含的所有字符串长度都不相等,长度每次 \(-1\),且短的串必定是长的串的后缀。
例如,存在于同一个节点的字符串一定是这种形式:\(\{aabaa,abaa,baa,aa\}\),不可能是 \(\{aabaa,baa\}\) 或者 \(\{aabaa,aabab\}\)。
上面的定义及推论都是不难理解的。于是我们对一个点可以记录这些信息:它代表的串中的最长串长度,最短串长度,分别记为 \(maxl,minl\)。
接下来考虑边。
SAM 中,边分两种:组成 DAG 的转移边和组成 parent 树的父亲边。
转移边表示 \(u\) 代表的所有子串后面加上这条转移边上的字符可以得到 \(v\) 中的子串,而父亲边表示 \(u\) 代表串的出现位置是 \(v\) 的子集。
具体而言:
- 节点 \(u\) 向节点 \(v\) 连 DAG 边当且仅当该节点代表所有字符串在原串中的下一位都为字符 \(c\);
- 节点 \(u\) 向节点 \(v\) 连父亲边当且仅当 \(minl_u=maxl_v+1\)。
例如,节点 \(u\) 代表 \(\{abaa,baa\}\),节点 \(v\) 代表 \(\{aa,a\}\),节点 \(c\) 代表 \(\{abaac,baac\}\),则 \(u\) 向 \(v\) 连父亲边,向 \(c\) 连一条带着字符‘c’的转移边。
由此,我们就可以得到 SAM 的定义了。这之后是由它的定义可以得到的一系列性质:
- SAM 中的某一个节点 \(u\) 的所有祖先 \(v\) 都满足 \(v\) 中串是 \(u\) 中串的后缀,并且从 \(u\) 开始沿着 parent 树(即父亲边)向上一定会走到初始节点,这个过程会访问到 \(u\) 中最长子串的所有后缀。
- SAM 中每个节点存储的子串数量 = \(maxl_u-maxl_{fa_u}\)。
- SAM 中每个节点代表子串的出现次数 = \(sz_u\)。
接下来是 SAM 的构建。
考虑用增量法来构建 SAM,每次加入一个新字符。
构建过程自行 BFS,结合以上性质就比较好理解了。
由构建过程可以得到 SAM 点数 \(\le 2n-1\),边数 \(\le 3n-4\)。
应用
SAM 有一大堆基础应用。
- 判断 \(T\) 是否在 \(S\) 中出现。
只需从初始节点开始沿着转移边一直走即可。 - 不同子串个数。
SAM 上每一条 DAG 上的路径都对应一个子串,所以就是经典的 DAG 上 DP。
还有一种求法:\(\sum\limits_{i=1}^{tot}maxl_i-maxl_{fa_i}\)(应用之前的性质)。 - 所有不同子串总长度。
一样的 DP,和上面的 DAG 上 DP 差不多。
或者考虑每个节点的贡献:
- 最小循环移位。
建出 \(S+S\) 的 SAM,在上面贪心地挑最小边走 \(n\) 步即可。 - 子串出现次数。
dfs 预处理出每个节点所代表集合的出现位置集合大小,然后直接在 DAG 上从初始节点开始走即可。 - 求 \(T\) 在 \(S\) 中第一次出现的位置。
考虑对每个节点预处理出一个信息 \(firstpos_i\),表示这个节点代表的所有串出现位置的最小值。
然后答案即为 \(firstpos_u-|T|+1\)。
考虑怎么求这个:
- 最短的未出现子串。
同样在 DAG 上 DP,设 \(dp_u\) 表示 \(u\) 节点开始最短的没有出现的子串,则答案为 \(dp_{root}\)。
考虑它的求法:
- 求 \(S,T\) 的最长公共子串。
对 \(S\) 构造 SAM,相当于求 \(T\) 中所有前缀在 \(S\) 中的最长公共后缀。
通过指针在 SAM 上游走来求,设当前走到的节点为 \(u\),最长长度为 \(l\),则要求 \(\max\{l\}\)。
具体步骤如下:
目前已经匹配到 \(T\) 的第 \(i\) 个字符,考虑匹配第 \(i+1\) 个。
a. 如果存在下一个字符的转移,则 \(u\) 转移到 \(v\)(下一个状态),同时 \(++l\);
b. 如果不存在,\(u=fa_u,l=maxl_{fa_u}\),继续如上过程直到存在转移。
Pro A (SPOJ 1812)
给定 \(k\) 个字符串 \(S_{1,..,k}\),求它们的最长公共子串。
\(tips:1\le k\le10,1\le n\le10^5\)
直接对第一个串建出 SAM,然后把其他的串放进去按两个字符串的方式跑,这个过程中实时更新每个点的答案,最后求最大即可。
时间复杂度 \(O(kn)\)。
Summary
后缀数据结构在字符串中算是比较有套路可循的题,通常在确定应该维护什么后还是比较模板的。所以在今后的练习中一些基本的套路是需要积累才行的。