后缀数据结构学习笔记

后缀数据结构学习笔记

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:1le nle10^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])=minlimits_{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:1le nle10^6)

这个题目算是后缀数组的基础应用了,下面讲一种解法。
首先,因为每个子串都可以唯一地对应一个后缀的前缀,所以只要考虑所有后缀的本质不同的前缀数量。
这个过程可以这样考虑:根据之前的 (height) 数组的定理,可以发现在排序后的所有后缀中距离越远 (LCP) 越短,所以考虑每个后缀对答案的贡献时,比它排名恰好小 (1) 的后缀是和它重复最多的,所以减去它们的 (LCP) 即可。

因此,本题的答案即为 (sumlimits_{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:1le n,Qle10^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])) 满足 (ale ile b-Len+1,Lle rk[i]le R),经典二维数点问题。

时间复杂度为 (O(nlog^2n))

Pro C (Luogu P1117)

题意:给定字符串 (S),求 (S) 的每一个子串表示成 (AABB) 形式的方案数之和。(要求 (A,B) 为非空串)
(tips:1le nle30000),多组数据

首先,我们考虑一个 (AA) 串对答案的贡献。假设从 (i) 开始的 (AA) 串数量为 (a),以 (i-1) 结束的 (AA) 串数量为 (b),则 (i) 这个位置对答案有 (acdot b) 的贡献,并且这样算不会出现重复和遗漏。
接下来只要考虑怎么统计以某个位置 开始/结束 的 (AA) 串的数量。
此时我们有一个常用的做法:插分隔符(一般不用实际插入字符)。
具体而言,为了统计长度为 (2cdot len)(AA) 串,我们在字符串上取下标分别为 (len,2len,3len,...) 的字符并打上标记。然后发现一个长度为 (2cdot 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+l2ge len) 时,这两个标记点才对答案有影响(否则,(AA) 串的长度必然 (<2cdot 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。
    还有一种求法:(sumlimits_{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=egin{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=egin{cases}minlimits_{(u,v)in E}{dp_v}+1(out\_degree e0)\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:1le kle10,1le nle10^5)

直接对第一个串建出 SAM,然后把其他的串放进去按两个字符串的方式跑,这个过程中实时更新每个点的答案,最后求最大即可。
时间复杂度 (O(kn))

Summary

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

原文地址:https://www.cnblogs.com/tuifei-oiers-home/p/14226115.html