后缀数据结构学习笔记

后缀数据结构学习笔记

By Tuifei_oier

后缀数据结构通常应用于字符串,用于求解一些字符串相关的问题,也因此这种数据结构往往可以和字符串捆绑在一起和其他算法嵌套,所以它的应用也是较繁杂的,但是也有一定的定式。

Part 1 基本定义

  1. 字符串:在 OI 中表示为一些字符构成的数组,默认下标从 \(1\) 开始\(n\) 表示其长度。
  2. 子串:一个字符串中的连续一段,从下标 \(l\) 到下标 \(r\)\(S\) 的子串记为 \(S[l,r]\)
  3. 前缀:下标 \(i\) 的前缀即子串 \(S[1,i]\)
  4. 后缀:下标 \(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\) 的子集。
具体而言:

  1. 节点 \(u\) 向节点 \(v\) 连 DAG 边当且仅当该节点代表所有字符串在原串中的下一位都为字符 \(c\)
  2. 节点 \(u\) 向节点 \(v\) 连父亲边当且仅当 \(minl_u=maxl_v+1\)

例如,节点 \(u\) 代表 \(\{abaa,baa\}\),节点 \(v\) 代表 \(\{aa,a\}\),节点 \(c\) 代表 \(\{abaac,baac\}\),则 \(u\)\(v\) 连父亲边,向 \(c\) 连一条带着字符‘c’的转移边。

由此,我们就可以得到 SAM 的定义了。这之后是由它的定义可以得到的一系列性质:

  1. SAM 中的某一个节点 \(u\) 的所有祖先 \(v\) 都满足 \(v\) 中串是 \(u\) 中串的后缀,并且从 \(u\) 开始沿着 parent 树(即父亲边)向上一定会走到初始节点,这个过程会访问到 \(u\) 中最长子串的所有后缀。
  2. SAM 中每个节点存储的子串数量 = \(maxl_u-maxl_{fa_u}\)
  3. SAM 中每个节点代表子串的出现次数 = \(sz_u\)

接下来是 SAM 的构建。
考虑用增量法来构建 SAM,每次加入一个新字符。
构建过程自行 BFS,结合以上性质就比较好理解了。
由构建过程可以得到 SAM 点数 \(\le 2n-1\),边数 \(\le 3n-4\)

应用

SAM 有一大堆基础应用。

  1. 判断 \(T\) 是否在 \(S\) 中出现。
    只需从初始节点开始沿着转移边一直走即可。
  2. 不同子串个数。
    SAM 上每一条 DAG 上的路径都对应一个子串,所以就是经典的 DAG 上 DP。
    还有一种求法:\(\sum\limits_{i=1}^{tot}maxl_i-maxl_{fa_i}\)(应用之前的性质)。
  3. 所有不同子串总长度。
    一样的 DP,和上面的 DAG 上 DP 差不多。
    或者考虑每个节点的贡献:

\[\sum_{i=1}^{tot}\dfrac{maxl_i(maxl_i+1)-maxl_{fa_i}(maxl_{fa_i}+1)}{2} \]

  1. 最小循环移位。
    建出 \(S+S\) 的 SAM,在上面贪心地挑最小边走 \(n\) 步即可。
  2. 子串出现次数。
    dfs 预处理出每个节点所代表集合的出现位置集合大小,然后直接在 DAG 上从初始节点开始走即可。
  3. \(T\)\(S\) 中第一次出现的位置。
    考虑对每个节点预处理出一个信息 \(firstpos_i\),表示这个节点代表的所有串出现位置的最小值。
    然后答案即为 \(firstpos_u-|T|+1\)
    考虑怎么求这个:

\[firstpos_u=\begin{cases} maxl_u(u\ is\ a\ new\ node)\\ firstpos_v(u\ is\ cloned\ from\ v) \end{cases}\]

  1. 最短的未出现子串。
    同样在 DAG 上 DP,设 \(dp_u\) 表示 \(u\) 节点开始最短的没有出现的子串,则答案为 \(dp_{root}\)
    考虑它的求法:

\[dp_u=\begin{cases}\min\limits_{(u,v)\in E}\{dp_v\}+1(out\_degree\ne0)\\1(out\_degree=0)\end{cases} \]

  1. \(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

后缀数据结构在字符串中算是比较有套路可循的题,通常在确定应该维护什么后还是比较模板的。所以在今后的练习中一些基本的套路是需要积累才行的。

posted @ 2021-01-03 17:10  Tuifei_oier  阅读(139)  评论(0编辑  收藏  举报