后缀三姐妹

点点圈 提交于 2020-08-13 09:24:04

绝对不咕
一篇就够了!


写在前面

会考虑整个与标题相关的二次创作。
什么时候有能力再说


一些约定

  1. \(\mid \sum \mid\):字符集大小。
  2. \(S[i:j]\):由字符串 \(S\)\(S_i\sim S_j\) 构成的子串。
  3. \(S_1<S_2\):字符串 \(S_1\) 的字典序 \(<S_2\)
  4. 后缀:从某个位置 \(i\) 开始,到串末尾结束的子串,后缀 \(i\) 等价于子串 \(S[i:n]\)

后缀数组 SA

网上部分题解直接开讲优化后面目全非的代码。
*这太野蛮了*
这里参考了 OI-wiki 上的讲解。

SA 的定义

字符串 \(S\) 的后缀数组 \(A\),被定义为一个数组,内容是其所有后缀 按字典序排序后的起始下标。
有: \(S[A_{i-1}:n]<S[A_i:n]\) 成立。

举例:这里有一个可爱的字符串:\(S=\text{yuyuko}\)
\(\text{k<o<u<y}\),它的后缀数组 \(A = [5,6,4,2,3,1]\)
具体地,有:

排名 1 2 3 4 5 6
下标 \(5\) \(6\) \(4\) \(2\) \(3\) \(1\)
后缀 \(\text{ko}\) \(\text{o}\) \(\text{uko}\) \(\text{uyuko}\) \(\text{yuko}\) \(\text{yuyuko}\)

显然不同后缀的排名必然不同(因为长度不等)


前置知识

计数排序

可以参考:OI-wiki 计数排序

计数排序是一种与桶排序类似的排序方法。
将长度为 \(n\) 的数列 \(a\) 排序后放入 \(b\) 的代码如下,
其中 \(w\) 为值域,即 \(\max\{a_i\}\)

int a[kMaxn], b[kMaxn], cnt[kMaxw];
for (int i = 1; i <= n; ++ i) ++ cnt[a[i]];
for (int i = 1; i <= w; ++ i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; -- i) b[cnt[a[i]] --] = a[i];

其中,在对 \(cnt\) 求前缀和后,
\(cnt_i\) 为小于 \(i\) 的数的数量,即为 \(i\) 的排名。
因此在下一步中,可以根据排名赋值。

复杂度为 \(O(n+w)\),值域与 \(n\) 同阶时复杂度比较优秀。

基数排序

这玩意比较水,参考 OI-wiki 基数排序

个人认为基数排序只是一种思想,并不算一种排序方法。
它仅仅是将 k 个排序关键字分开依次考虑,实际每次排序还是靠计数排序实现。

请务必充分理解!


倍增法构造

先将所有 \(S[i:j]\) 进行排序。
每次通过 \(S[i:i+2^{k-1}-1]\) 的大小关系,求出 \(S[i:i+2^k-1]\) 的大小关系。

对于 \(S[i:i+2^k-1]\)\(S[j:j+2^k-1]\),分别将它们裂开,分成两成长度为 \(i+2^{k-1}\) 的串。

\(A_i = S[i:i+2^{k-1}-1]\)\(B_i = S[i+2^{k-1}:i+2^k-1]\)
考虑字典序排序的过程,则 \(S[i:i+2^k-1] <S[j:j+2^k-1]\) 的条件为:

\[[A_i<A_j] \operatorname{or}\ [A_i=A_j \operatorname{and} B_i<B_j] \]

考虑每一次倍增时,都使用 sort 按双关键字 \(A_i\)\(B_i\) 进行排序。
时间复杂度显然为 \(O(n\log^2 n)\)


优化

sort 太慢啦!
发现后缀数组值域即为 \(n\),又是多关键字排序。
考虑基数排序。

上面已经给出一个用于比较的式子:

\[[A_i<A_j] \operatorname{or}\ [A_i=A_j \operatorname{and} B_i<B_j] \]

\(A_i,B_i\) 大小关系已知,直接基数排序实现即可。
先将 \(B_i\) 作为第二关键字排序。
再将 \(A_i\) 作为第一关键字排序。

单次计数排序复杂度 \(O(n + w)\)\(w\) 为值域,最大与 \(n\) 同阶)。
排序变为 \(O(n)\) 级别,时间复杂度 \(O(n\log n)\)


代码及解释

P3809 【模板】后缀排序

这是一份没有优化过的代码,是对上述过程的直接实现。
只能获得 73 分。
可发现代码的空间复杂度为 \(O(n)\)
代码实现较为复杂,下面会进行详细讲解。


//知识点:SA
/*
By:Luckyblock
I love Marisa;
But Marisa has died;
*/
#include <cstdio>
#include <ctype.h>
#include <cstring>
#include <algorithm>
#define ll long long
const int kMaxn = 1e6 + 10;
//=============================================================
char S[kMaxn];
//sa[i]: 倍增过程中子串[i:i+2^k-1]的排名,
//rk[i] 排名为i的子串 [i:i+2^k-1],
//它们互为反函数。
//rk 和 oldrk 要开2倍空间,下面会提到原因。
int n, m, sa[kMaxn], rk[kMaxn << 1], oldrk[kMaxn << 1];
int id[kMaxn], cnt[kMaxn]; //用于计数排序的两个tmp数组
//=============================================================
inline int read() {
  int f = 1, w = 0; char ch = getchar();
  for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
//=============================================================
int main() {
  scanf("%s", S + 1);
  n = strlen(S + 1);
  m = std :: max(n, 300); //值域大小
  
  //初始化 rk 和 sa
  for (int i = 1; i <= n; ++ i) ++ cnt[(rk[i] = S[i])];
  for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
  for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;

  //倍增过程。 
  //w = 2^{k-1},是已经推出的子串长度。
  //注意此处的 sa 数组存的并不是后缀的排名,
  //存的是指定长度子串的排名。
  for (int w = 1; w < n; w <<= 1) {
    //按照后半截 rk[i+w] 作为第二关键字排序。
    memset(cnt, 0, sizeof (cnt));
    for (int i = 1; i <= n; ++ i) id[i] = sa[i];
    for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i] + w]]; //这里有越界风险,因此开了2倍空间,否则会被卡
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i] + w]] --] = id[i];

    //按照前半截 rk[i] 作为第一关键字排序。
    memset(cnt, 0, sizeof (cnt));
    for (int i = 1; i <= n; ++ i) id[i] = sa[i];
    for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i]]];
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i]]] --] = id[i];

    //更新 rk 数组。
    //这里可以滚动数组一下,但是可读性会比较差(
    //卡常可以写一下。
    for (int i = 1; i <= n; ++ i) oldrk[i] = rk[i];
    for (int p = 0, i = 1; i <= n; ++ i) {
      if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&  //判断两个子串是否相等。
          oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) { //这里有越界风险,因此开了2倍空间,否则会被卡
        rk[sa[i]] = p;
      } else {
        rk[sa[i]] = ++ p;
      }
    }
  }
  for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
  return 0;
}

这里定义了两个数组:
\(sa_i\):倍增中 排名为 \(i\) 的长度为 \(2^{k-1}\) 的子串。
\(rk_i\):倍增过程中子串 \(S[i:i+2^k-1]\) 的排名,
显然它们互为反函数,\(sa_{rk_i}=rk_{sa_i} = i\)



首先初始化 \(rk\)\(sa\)

for (int i = 1; i <= n; ++ i) ++ cnt[(rk[i] = S[i])];
for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;

初始化 \(rk_i = S_i\),即 \(S_i\)\(\text{ASCII}\) 值。
虽然这样不满足值域在 \([1,n]\) 内,但体现了大小关系,可用于更新。 \(rk\) 的值之后还会更新。

子串长度为 \(1\),直接根据 \(rk_i\) 计数排序 \(sa\) 即可。


之后进入倍增。

每次倍增先后按照 后半截,前半截的 \(rk\) 作为关键字排序来更新 \(sa\)
\(id\) 是一个 tmp 数组,存排序前的的 \(sa\)

memset(cnt, 0, sizeof (cnt));
for (int i = 1; i <= n; ++ i) id[i] = sa[i];
for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i] + w]];
for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i] + w]] --] = id[i];
memset(cnt, 0, sizeof (cnt));
for (int i = 1; i <= n; ++ i) id[i] = sa[i];
for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i]]];
for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i]]] --] = id[i];

排后半截时 会枚举到 \(id[i]+w > n\) 怎么办?
考虑实际意义,出现此情况,表示该子串后半截为空。
空串字典序最小,考虑直接把 \(rk\) 开成两倍空间,则 \(rk[i]=0(i>n)\) 恒成立。
防止了越界,也处理了空串的字典序。



更新 \(rk\) 数组。

for (int i = 1; i <= n; ++ i) oldrk[i] = rk[i];
for (int p = 0, i = 1; i <= n; ++ i) {
  if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&
      oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) {
    rk[sa[i]] = p;
  } else {
    rk[sa[i]] = ++ p;
  }
}

\(sa\)\(rk\) 的反函数。
这里相当于根据有序的 \(sa\),离散化并去重 \(rk\)

考虑两个子串 \(rk\) 相等的条件。
显然,当其前后两半均相等时,两子串相同,其 \(rk\) 才相同,则有上述的判断。

这里也会出现空串的情况,注意 2 倍空间。


再优化

被卡常了,排两次计数排序太慢啦!
观察对后半截排序时的特殊性质:

考虑更新前的 \(sa_i\) 的含义:排名为 \(i\) 的长度为 \(2^{k-1}\) 的子串。

在本次排序中,\(sa_i\) 是长度为 \(2^k\) 的子串 \(sa_{i}-2^{k-1}\) 的后半截。 \(sa_i\) 的排名将作为排序的关键字。

\(sa_i\) 的排名为 \(i\),则排序后 \(sa_{i}-2^{k-1}\) 的排名必为 \(i\)
考虑直接赋值,那么第一次计数排序就可以写成这样:

int p = 0;
for (int i = n; i > n - w; -- i) id[++ p] = i; //后半截为空的串
for (int i = 1; i <= n; ++ i) { //根据后半截,直接推整个串的排名
  if (sa[i] > w) id[++ p] = sa[i] - w;
}

注意后半截为空串的情况,这样的串排名相同且最小。


以及一些奇怪的常数优化:

减小值域。
发现值域大小 \(m\) 与计数排序复杂度有关。
其最小值应为 \(rk\) 的最大值,在更新 \(rk\) 时将其更新即可。

减少数组嵌套的使用,从而减少不连续内存访问。
在第二次计数排序时,将 \(rk_{id_i}\) 存下来。

用 cmp 函数判断两个子串是否相同。
同样是减少不连续内存访问,详见代码。

最终代码

//知识点:SA
/*
By:Luckyblock
I love Marisa;
But Marisa has died;
*/
#include <cstdio>
#include <ctype.h>
#include <cstring>
#include <algorithm>
#define ll long long
const int kMaxn = 1e6 + 10;
//=============================================================
char S[kMaxn];
int n, m, sa[kMaxn], rk[kMaxn << 1], oldrk[kMaxn << 1];
int id[kMaxn], cnt[kMaxn], rkid[kMaxn];
//=============================================================
inline int read() {
  int f = 1, w = 0; char ch = getchar();
  for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
bool cmp(int x, int y, int w) { //判断两个子串是否相等。
  return oldrk[x] == oldrk[y] && 
         oldrk[x + w] == oldrk[y + w]; 
}
//=============================================================
int main() {
  scanf("%s", S + 1);
  n = strlen(S + 1);
  m = std :: max(n, 300); //值域大小
  
  //初始化 sa数组
  for (int i = 1; i <= n; ++ i) ++ cnt[rk[i] = S[i]];
  for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
  for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;

  //倍增过程。 
  //此处 w = 2^{k-1},是已经推出的子串长度。
  //注意此处的 sa 数组存的并不是后缀的排名,
  //存的是指定长度子串的排名。
  for (int p, w = 1; w < n; w <<= 1) {
    //按照后半截 rk[i+w] 作为第二关键字排序。
    p = 0;
    for (int i = n; i > n - w; -- i) id[++ p] = i; //后半截为空的串
    for (int i = 1; i <= n; ++ i) { //根据后半截,直接推整个串的排名
      if (sa[i] > w) id[++ p] = sa[i] - w;
    }

    //按照前半截 rk[i] 作为第一关键字排序。
    memset(cnt, 0, sizeof (cnt));
    for (int i = 1; i <= n; ++ i) ++ cnt[(rkid[i] = rk[id[i]])];
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rkid[i]] --] = id[i];

    //更新 rk 数组。
    //这里可以滚动数组一下,但是可读性会比较差(
    //卡常可以写一下。
    std ::swap(rk, oldrk);
    m = 0; //直接更新值域 m
    for (int i = 1; i <= n; ++ i) {
      rk[sa[i]] = (m += (cmp(sa[i], sa[i - 1], w) ^ 1));
    }
  }
  for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
  return 0;
}

LCP 问题

感谢论文爷!后缀数组-许智磊

\(\operatorname{lcp}(S,T)\) 定义为字符串 \(S\)\(T\) 的最长公共前缀 (Longest common prefix),
即为最大的 \(l\le \min\{\mid S\mid,\mid T\mid\}\),满足 \(S_i=T_i(1\le i\le l)\)
在许多后缀数组相关问题中,都需要它的帮助。

下文以 \(\operatorname{lcp}(i,j)\) 表示后缀 \(i\)\(j\) 的最长公共前缀。

下文会延续后缀数组中一些概念:
\(sa_i\):排名为 \(i\) 的后缀。
\(rk_i\):后缀 \(i\) 的排名。


一些定义

定义一些新的概念:

\(\operatorname{height}_i\) 表示 \(sa\) 中相邻两后缀 \(i-1\)\(i\) 的 最长公共前缀。

\[\operatorname{height}_i = \operatorname{lcp}(sa_{i-1},sa_i) \]

\(h_i\) 表示后缀 \(i\),和 \(sa\) 中排名在 \(i\) 之前一位的后缀的 最长公共前缀。

\[h_i=\operatorname{height}_{rk_i} = \operatorname{lcp}(sa_{rk_i-1}, sa_{rk_i})= \operatorname{lcp}(i, sa_{rk_i -1}) \]

\(rk_{sa_i} = i\),显然有 \(h_i = h_{rk_{sa_i}}=\operatorname{height}_{sa_i}\)


引理:LCP Lemma

\[\forall 1\le i<j<k\le n, \, \operatorname{lcp}(i,k) = \min\{\operatorname{lcp}(i,j), \operatorname{lcp}(j,k)\} \]

此引理是证明其他引理的基础。

证明,设 \(p = \min\{\operatorname{lcp}(i,j), \operatorname{lcp}(j,k)\}\),则有:

\[\operatorname{lcp}(i,j)\ge p,\, \operatorname{lcp}(j,k)\ge p \]

\(sa_i[1:p] = sa_j[1:p] = sa_k[1:p]\),可得 \(\operatorname{lcp}(i,k)\ge p\)

再考虑反证法,设 \(\operatorname{lcp}(i,k) =q > p\)
\(sa_i[1:q]=sa_k[1:q]\),则有 \(sa_i[p+1]=sa_k[p+1]\)
\(p\) 的取值分类讨论:

  1. \(p=\operatorname{lcp}(i,j) < \operatorname{lcp}(j,k)\)
    则有 \(sa_i[p+1] < sa_j[p+1] = sa_k[p+1]\)
  2. \(p=\operatorname{lcp}(j,k) < \operatorname{lcp}(i,j)\)
    则有 \(sa_i[p+1] = sa_j[p+1] < sa_k[p+1]\)
  3. \(p=\operatorname{lcp}(j,k) = \operatorname{lcp}(i,j)\)
    则有 \(sa_i[p+1] < sa_j[p+1] < sa_k[p+1]\)

\(sa_i[p+1]<sa_k[p+1]\) 恒成立,与已知矛盾,则 \(\operatorname{lcp}(i,k)\le p\)
结合 \(\operatorname{lcp}(i,p)\ge p\),得证原结论成立。


引理:LCP Theorem

\[\forall 1\le i < j\le n,\, \operatorname{lcp}(sa_i,sa_j) = \min_{k=i+1}^j\{\operatorname{height_k}\} \]

由 LCP Lemma,可知显然成立。

根据这个优美的式子,求解任意两个后缀的 \(\operatorname{lcp}\) 变为求解 \(\operatorname{height}\) 的区间最值问题。
可通过 st 表 实现 \(O(n\log n)\) 预处理,\(O(1)\) 查询。
问题转化为如何快速求 \(\operatorname{height}\)


推论:LCP Corollary

\[\operatorname{lcp}(sa_i,sa_j) \ge \operatorname{lcp}(sa_i, sa_k)\, (j>k) \]

排名不相邻的两个后缀的 \(\operatorname{lcp}\) 不超过它们之间任何相邻元素的 \(\operatorname{lcp}\)

证明由引理 LCP Lemma 显然可得。
但是涛哥钦定我写一下证明,那我就不胜惶恐地写了(

类似 LCP Lemma,考虑反证法。
\(\operatorname{lcp}(sa_i,sa_j)< \operatorname{lcp}(sa_i, sa_k)\),则有下图:
Lb

考虑字典序比较的过程。
\(sa_i < sa_j\),则有 \(sa_i[{\operatorname{lcp}(sa_i,sa_j)+1}] <sa_j[{\operatorname{lcp}(sa_i,sa_j) + 1}]\)
即图中的字符 \(x<y\)

此时考虑比较 \(sa_j\)\(sa_k\) 的字典序。
由图,\(\operatorname{lcp}(sa_j,sa_k) = \operatorname{lcp}(sa_i,sa_j)\)
\(\operatorname{lcp}(sa_i,sa_k) > \operatorname{lcp}(sa_i,sa_j)\),则 \(sa_k[{\operatorname{lcp}(sa_j,sa_k)+1}] = x\)
\(x<y\),可得 \(sa_k\) 的字典序小于 \(sa_j\)


与已知矛盾,反证原结论成立。


引理

\[\forall 1\le i\le n,\, h_i\ge h_{i-1}-1 \]

\[h_i=\operatorname{height}_{rk_i} = \operatorname{lcp}(sa_{rk_i-1}, sa_{rk_i})= \operatorname{lcp}(i, sa_{rk_i -1}) \]

用来快速计算 \(\operatorname{height}\)
个人喜欢叫它不完全单调性。

证明考虑数学归纳。
\(h_{i-1}\le 1\) 时,结论显然成立,因为 \(h_i \ge 0\)

\(h_{i-1}>1\) 时:

\(u = i, \, v = sa_{rk_i-1}\),有 \(h_i = \operatorname{lcp}(u,v)\)
\(sa\)\(v\)\(u\) 前一位置。
\(u' = i-1, \, v' = sa_{rk_{i-1}-1}\),有 \(h_{i-1} = \operatorname{lcp}(u',v')\)
\(sa\)\(v'\)\(u'\) 前一位置。


\(h_{i-1} = \operatorname{lcp}(u',v')>1\),则 \(u',v'\) 必有公共前缀。
考虑删去 \(u',v'\) 的第一个字符,设其分别变成 \(x,y\)
显然 \(\operatorname{lcp}(x,y) = h_{i-1}-1\),且仍满足字典序 \(y<x\)

\(u' = i-1\),则删去第一个字符后,\(x\) 等于后缀 \(i\)
\(sa\) 中,有 \(y<x=i=u\)

\(sa\) 中,\(v\)\(u\) 前一位置,则有 \(y<v\)
根据 LCP Corollary,有:

\[h_i = \operatorname{lcp}(u,v)\ge \operatorname{lcp}(u,y) = \operatorname{lcp}(x,y) = h_{i-1}-1 \]

得证。


快速求 height

定义 \(h_i = \operatorname{height}_{sa_i}\),只需快速求出 \(h\),便可 \(O(n)\) 复杂度获得 \(\operatorname{height}\)

由引理已知 \(\forall 1\le i\le n,\, h_i\ge h_{i-1}-1\)
\(h_i=\operatorname{lcp}(i, sa_{rk_i -1})\) 具有不完全单调性,考虑正序枚举 \(i\) 进行递推。

\(rk_i=1\) 时, \(sa_{rk_i-1}\) 不存在,特判 \(h_i=0\)
\(i=1\),暴力比较出 \(\operatorname{lcp}(i,sa_{rk_i-1})\),比较次数 \(<n\)

若上述情况均不满足,由引理知,\(h_i=\operatorname{lcp}(i,sa_{rk_i-1})\ge h_{i-1}-1\),两后缀前 \(h_{i-1}-1\) 位相同。
可从第 \(h_{i-1}\) 位开始比较两后缀计算出 \(h_i\),比较次数 \(=h_i-h_{i-1}+2\)

计算出 \(h_i\),可直接得到 \(\operatorname{height}_{sa_i}\)
代码中并没有专门开 \(h\) 数组,其中\(h_i = k\)

void GetHeight() {
  for (int i = 1, k = 0; i <= n; ++ i) {
    if (rk[i] == 1) k = 0;
    else {
      if (k > 0) k --;
      int j = sa[rk[i] - 1];
      while (i + k <= n && j + k <= n && 
             S[i + k] == S[j + k]) {
        ++ k;
      }
    }
    height[rk[i]] = k;
  }
}

复杂度分析:
\(k\le n\),最多减 \(n\) 次,则最多会在比较中加 \(2n\) 次。
则总复杂度为 \(O(n)\)


后缀树

前置知识:字典树 (Trie) - OI Wiki

定义:一个字符串 \(S\) 的所有后缀 \(S[i:n]\,(1\le i\le n)\) 组成的 Trie 树。

构建后缀树 准备学习 SAM 后用 SAM 建后缀树。
暂时没有写虚树 + 后缀数组法的代码,需要的话可以来这里看:利用后缀数组构造后缀树_AZUI
如果以后有必要会去写一下。


暴力构建

考虑增量法。
暴力枚举原串的每个后缀,将其插入字典树。
本质不同的子串个数最多达到 \(O(n^2)\) 级别,故节点数最多可能达到 \(O(n^2)\) 级别。

此时使用后缀树与直接枚举原串的子串等价,复杂度 \(O(n^2)\)


虚树 + SA

虽然节点数可能达到 \(O(n^2)\) 级别,但叶节点数最多仅有 \(O(n)\) 级别。
大部分节点仅有一个孩子,这样的链信息可以合并。
考虑建后缀树的虚树,将树中的链缩成一条边。


前置小碎骨

SA + 虚树

简单介绍虚树(抄一波课件):

对于树 \(T=(V,E)\),给定关键点集 \(S\subseteq V\),则可定义虚树 \(T'=(V',E')\)
对于点集 \(V'\subseteq V\),使得 \(u\in V'\) 当且仅当 \(u\in S\),或 \(\exist x,y\in S,\operatorname{lca}(x,y)=u\)
对于边集,\((u,v)\in E'\),当且仅当 \(u,v\in V'\),且 \(u\)\(v\)\(V'\) 中深度最浅的祖先。

个人理解:
仅保留关键点及其 lca,缩子树成边,仅保留分叉点。
可能 删去一些不包含关键点的子树。
压缩了树的信息,同时丢失了部分树的信息。


显然,一棵后缀树的关键点 即其 \(n\) 个叶节点。
一个分叉点会合并两个关键点,则后缀树为完全二叉树时 虚树节点数最多,为 \(2n-1\) 个。
节点数变为了 \(O(n)\) 级别,这大棒子。


构建方法

假设已知后缀树的结构
考虑增量法,每次向虚树中添加一个关键点

先求得关键节点的 dfs 序,规定按字典序 dfs。

单调栈维护虚树最右侧的链(上一个关键点与根的链),栈顶一定为上一个关键点。
单调栈中节点深度递增。

每加入一个关键点 \(a_i\),令 \(\operatorname{lca}(a_{i-1},a_i)=w\)
将栈顶 \(dep_x > dep_w\) 的弹栈,加入 \(w,a_i\),即为新的右链。
若栈顶存在 \(dep_x=dep_w\),不加入 \(w\) 节点。

在此过程中维护每个节点的父节点,在弹栈时进行连边并维护信息,即得虚树。
需要求 \(\operatorname{lca}\),总复杂度 \(O(n\log n)\) 级别。

套 SA

但是后缀树的结构并不已知。 已知还建虚树干什么
发现上述过程中并没有用到后缀的性质。

总结一下上述建虚树的过程:

  1. 求关键点的 dfs 序。
  2. 单调栈维护右链。
  3. 插入关键节点,求两相邻关键点的 \(\operatorname{lca}\),比较深度。

关键点 \(i\) 的 dfs 序即为后缀数组中的 \(sa_i\),可 \(O(n)\) 求得。
单调栈的复杂度为 \(O(n)\)

两关键节点 代表排名相邻的 两后缀。
插入 \(sa_i\) 时,\(1\sim \operatorname{lca}\) 的链 为两后缀的最长公共前缀。
\(\operatorname{lca}\) 节点代表 \(\operatorname{lcp}(sa_{i-1}, sa_i)\)
显然 \(\operatorname{lcp}\) 的长度,即 \(\operatorname{lca}\) 的深度,等于 \(\operatorname{height}_i\)


\(\operatorname{lca}\) 节点是谁不知道,但这并不妨碍弹栈。弹栈只关心节点的深度。
若栈顶存在 \(dep_x=\operatorname{lcp}\),则 \(\operatorname{lca}\) 已在栈中,直接停止弹栈。
否则新建一个 \(dep_x=\operatorname{lcp}\) 的节点插入,当做 \(\operatorname{lca}\) 即可。

\(\operatorname{height}\) 的复杂度为 \(O(n \log n)\)
则算法总复杂度为 \(O(n \log n)\)


后缀自动机

定理:SAM 的 parent 树为 反串的后缀树。

建出反串的 SAM 之后,就会直接得到后缀树。
可使用 SAM 进行构建,时间复杂度为 \(O(n\mid \sum\mid)\)\(O(n\log n)\)


Ukkonen

Udk 算法 金发小女孩真可怜
在线性时间复杂度内构建后缀树。
远古炫酷魔术,我直接跑路。建议百度搜索谷歌搜索学习。


后缀自动机 SAM

涛哥钦定我写一篇博客,那我就写一篇了。
草感觉硬讲好麻烦,先文字描述一波。
马上就回家了,用板子再补图。

注意有些定义是 规定的,遇到它们时暂时不要考虑正确性,应用之类,只需记住就好,这些东西在下文中一般都有详细介绍。
不然会像某 SB 一样把自己套进去。

前置米斯琪

确定有限状态自动机 (DFA) - OI Wiki

个人理解(没那么严谨的定义):

一个确定有限状态自动机 (DFA) 是一个边上带有字符的有向图。
节点被称为 状态,边被称为 转移函数
存在一个指定的 起始状态,和多个 接受状态

DFA 可接受一个字符串,并对其进行判定。
一个 DFA 读入一个字符串后,会从 起始状态 开始,按照 转移函数 一个一个字符进行 转移转移函数 的字符与字符串对应位置字符相同。

读入完成后,若字符串位于一个 接受状态,则称 DFA 接受 这个字符串,反之称 DFA 不接受 这个字符串。
若转移过程中不存在对应某字符的 转移函数,也称 DFA 不接受 这个字符串。

它们都是自动机:Trie, Kmp, AC自动机,SAM,广义SAM,回文自动机。

下文中将会大量使用上述概念。


引入

给定字符串 \(S\),构造一个可识别其子串的 DFA。

能识别所有子串,等价于包含所有子串的信息。
最直观的思路是枚举 \(S\) 所有子串建立 Trie ,每个子串的结尾作为接受状态。
这样建出来的 Trie 有两个问题:

  1. 大量子串是其他子串的前缀,Trie 的节点数量最多只有 \(O(n^2)\) 级别。
  2. 除起始状态外,所有状态都是接受状态。
    一个字符串不是 \(S\) 的子串,等价于转移时不存在对应串中某字符的 转移函数

这样建出来的 Trie,结构上等价于仅将所有后缀插入进去,将后缀的结尾作为 接受状态
判断时仅需判断最终状态是否在 Trie 上即可。

这东西长得怎么这么熟悉?发现这是一棵支持 接受 后缀的后缀树。
后缀自动机具有这样的后缀树相同的性质和功能。
但其空间复杂度,构建的时间复杂度远小于后缀树的,均仅为 \(O(n)\) 级别。


SAM 的定义

字符串 \(S\) 的后缀自动机 (suffix automaton, SAM) 是一个接受 \(S\) 所有后缀的 最小的 DFA。

更形式的定义:

  • 字符串 \(S\) 的 SAM 是一张 DAWG(有向单词无环图)。节点被称作 状态,边被称作状态间的 转移
  • 存在一个起始节点 \(t_0\),称作 起始状态,其它节点均可从起始节点出发到达。
  • 每个 转移 都标有一些字母。从一个节点出发的所有转移均 不同
  • 存在数个 终止状态
    从起始状态出发,最终转移到了一个终止状态,路径上所有转移连接起来一定是 \(S\) 的一个后缀。
    \(S\) 的每个后缀均可用一条 从起始节点到某个终止状态 的路径构成。

  • 在所有满足上述条件的 DFA 中,SAM 的节点数是最少的。

SAM 并不是一个典型的 DFA,在 DAWG 基础上,除起始状态外的每个状态都被添加了一条 后缀链接
状态和后缀链接组成了树状结构,被称为 parent 树
DAWG 和 parent 树的关系有下图所示:

Wait for Updating

\[\text{Waiting for Updating} \]

字符串 \(S\) 的 SAM 能包含 \(S\) 所有子串的信息。
SAM 将这些信息以高度压缩的形式储存,对于一个长度为 \(n\) 的字符串,它的 SAM 空间复杂度仅为 \(O(n)\),构造 SAM 的时间复杂度也仅为 \(O(n)\)


独特概念

结束位置 endpos

\(S\) 的任意非空子串 \(T\)\(\operatorname{endpos}(T)\) ,为子串 \(T\)\(S\) 中的 所有结束位置组成的集合
\(S=114514, \operatorname{endpos}(14)=\{3,6\}\)

对于两个子串 \(t_1,t_2\),若 \(\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\),则 \(t_1,t_2\) 属于一个 \(\operatorname{endpos}\) 等价类

\(\operatorname{endpos}\) 的性质

引理 1:对于非空子串 \(t_1,t_2\ (\mid t_1\mid \le \mid t_2\mid)\)\(\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\iff t_1\)\(S\) 中每次出现,都是以 \(t_2\) 的后缀形式存在。

证明:正确性显然,从左右两侧分别进行讨论即可。

引理 2:对于非空子串 \(t_1,t_2\ (\mid t_1\mid \le\mid t_2\mid)\)\(\operatorname{endpos}(t_1)\)\(\operatorname{endpos}(t_2)\) 的关系取决于 \(t_1\) 是否为 \(t_2\) 的后缀:

\[\begin{cases} \operatorname{endpos}(t_2) \subseteq \operatorname{endpos}(t_1) &\operatorname{t_1\ is\ a\ suffix\ of\ t_2}\\ \operatorname{endpos}(t_2) \cap \operatorname{endpos}(t_1)=\varnothing &\operatorname{otherwise}\\ \end{cases}\]

证明:结合引理 1,分类讨论即可。

引理 3:对于一个 \(\operatorname{endpos}\) 等价类,将类中所有字符串按照长度非递增顺序排序。
则每个字符串都是前一个的后缀,且长度为上一个的长度 \(-1\)
即:\(\operatorname{endpos}\) 等价类中的串为 某个前缀长度连续的后缀

证明:结合引理 1 讨论。

\[\text{Waiting for Updating} \]

暂时没有较简练的语言证明三个引理,先留坑了。


DAWG

考虑 引入 中建出来的节点数 \(O(n^2)\) 级别的后缀树,它满足下列性质:

  1. 每个状态 唯一对应 一个子串,起始状态对应空串 \(\varnothing\)
  2. 从起始状态出发,沿转移边移动,每条 路径唯一对应 \(S\) 的一个子串。
  3. 每个子串也 唯一对应 某条从起始状态出发的路径,所有子串 都可以被某条路径表示出。

SAM 满足上述性质 2,3。
对于上述性质 1,SAM 对信息进行了压缩,每个状态可表示一个或多个子串。
到达某状态的路径可能不止一条。一个状态对应一些子串的集合,集合的元素分别对应这些路径。
不存在 可代表同一子串的两个不同状态,因为每个子串唯一对应一条路径。


接下来是 SAM 最神仙的地方:

规定:除起始状态外,每个状态都是不同的 \(\operatorname{endpos}\) 等价类,对应该等价类内子串的集合。
即 SAM 由初始状态 \(t_0\) 和每一个 \(\operatorname{endpos}\) 等价类对应的状态组成。

SAM 的状态数等价于 \(\operatorname{endpos}\) 等价类的个数 +1,而 \(\operatorname{endpos}\) 等价类的个数仅为 \(O(n)\) 级别,在 复杂度 这一小节会给出证明。

再引入一些概念:

对于一个状态 \(v\)
\(\operatorname{longest}(v)\) 为其可代表的最长的字符串,记 \(\operatorname{len}(v)\) 为其长度。
类似地,记 \(\operatorname{shortest}(v)\) 为最短的子串,记其长度为 \(\operatorname{minlen}(v)\)

由引理 3,每个状态代表着 某个前缀长度连续的后缀
则状态 \(v\) 中所有字符串都是 \(\operatorname{longest}(v)\) 的不同的后缀,且所有字符串的长度覆盖区间 \([\operatorname{minlen}(v), \operatorname{len}(v)]\)

至此,SAM 的 DAWG 已经可以建出来了。
对于字符串 \(S=\text{514141}\),它的 SAM 有下图所示:

Waiting for Updating

\[\text{Waiting for Updating} \]


后缀链接 Link

对于两个状态 \(u,v\ (u\not ={t_0})\)
定义 \(u\) 的 后缀链接 \(\operatorname{Link}(u)\) 指向 \(v\),当且仅当 \(\operatorname{minlen}(u) = \operatorname{len}(v) + 1\),且 \(v\) 代表的子串均为 \(u\) 的后缀。
记作 \(\operatorname{Link}(u)=v\)

从定义中可以得到后缀链接的一些性质:

  1. \(\operatorname{minlen}(u) = 1\),则 \(\operatorname{Link}(u) = t_0\)
  2. \(\operatorname{minlen}(u) = \operatorname{len}(v) + 1\):结合引理 3 的单调性可知,\(\operatorname{Link}(u)\) 所对应的字符串严格短于 \(u\) 所表示的字符串。
  3. \(v\) 代表的子串均为 \(u\) 的后缀:结合引理 2 可知 \(\operatorname{endpos}(u) \subseteq \operatorname{endpos}(\operatorname{Link}(u))\)
  4. 结合性质 2,3,\(\operatorname{Link}(u)\) 是所有满足 \(\operatorname{endpos}(u) \subseteq \operatorname{endpos}(v)\) 的状态 \(v\)\(\mid \operatorname{endpos}(v)\mid\) 最小的。
    \(\operatorname{longest}(\operatorname{Link}(u))\)\(\operatorname{shortest}(u)\) 的次长后缀(最长为其本身)。

引理 4:所有后缀链接构成一棵根节点为 起始状态 的树。

证明:由性质 2 可知,\(\operatorname{Link}(u)\) 所对应的字符串严格短于 \(u\) 所表示的字符串。
沿着后缀链接移动,最终总能到达起始状态。

这棵树被称为字符串 \(S\) 的 parent 树。


parent 树

所有后缀链接构成的一棵根节点为 起始状态 的树。

性质:结合后缀链接的性质 3,在这棵树上总有 \(\operatorname{endpos}(son)\subseteq \operatorname{endpos}(father)\)
可知后缀链接构成的树,本质上是 \(\operatorname{endpos}\) 集合构成的树,体现了 \(\operatorname{endpos}\) 的包含关系。

对于字符串 \(S=\text{514141}\),它的 parent 树有下图所示:

Waiting for Updating

\[\text{Waiting for Updating} \]

定理:字符串 \(S\) 的 parent 树为 \(S\) 的反串的后缀树。

证明:考虑 \(\operatorname{endpos}(u)\)\(\operatorname{endpos}(\operatorname{Link}(u))\) 的关系。

\(\operatorname{Link}(u)\) 是所有满足 \(\operatorname{endpos}(u) \subseteq \operatorname{endpos}(v)\) 的状态 \(v\)\(\mid \operatorname{endpos}(v)\mid\) 最小的。

\(\operatorname{longest}(\operatorname{Link}(u))\)\(\operatorname{shortest}(u)\) 的次长后缀(最长为其本身)。
\(\operatorname{shortest}(u)\) 可在 \(\operatorname{longest}(\operatorname{Link}(u))\) 基础上,在头部添加一个字符得到。
在正串的头部添加字符构成正串的后缀,等价于在反串尾部添加字符构成反串的后缀。
大概有下图的感觉:


Waiting for Updating

\[\text{Waiting for Updating} \]


小结

上面说的玩意全都忘光了,好耶!

  1. \(S\) 的子串可根据结束位置 \(\operatorname{endpos}\) 划分为多个 \(\operatorname{endpos}\) 等价类。
  2. SAM 由初始状态 \(t_0\) 和每一个 \(\operatorname{endpos}\) 等价类对应的状态组成。
  3. 对于一个状态 \(v\)
    \(\operatorname{longest}(v)\) 为其可代表的最长的字符串,记 \(\operatorname{len}(v)\) 为其长度。
    类似地,记 \(\operatorname{shortest}(v)\) 为最短的子串,记其长度为 \(\operatorname{minlen}(v)\)

  4. 对于两个状态 \(u,v\ (u\not ={t_0})\)\(\operatorname{Link}(u)=v\),当且仅当 \(\operatorname{minlen}(u) = \operatorname{len}(v) + 1\),且 \(v\) 代表的子串均为 \(u\) 的后缀。

构造 SAM


复杂度


写在最后

参考资料:

OI-wiki SA
后缀数组详解 - 自为风月马前卒
「后缀排序SA」学习笔记 - Rainy7
后缀数组-许智磊
后缀数组学习笔记 _ Menci's Blog
OI-wiki 虚树
利用后缀数组构造后缀树_AZUI
史上最通俗的后缀自动机详解 - KesdiaelKen 的博客
后缀自动机多图详解(代码实现) - maomao9173
博文 Суффиксный автомат 版权协议为 Public Domain + Leave a Link
博文 Суффиксный автомат 的英文翻译版 Suffix Automaton 版权协议为 CC-BY-SA 4.0









腥风血雨。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!