后缀三姐妹

目录
  • 写在前面
  • 前置小碎骨
    • 计数排序
    • 基数排序
  • 一些约定
  • 后缀数组
    • 定义
    • 倍增法构造
      • 优化
      • 代码及解释
      • 再优化
    • LCP 问题
      • 一些定义
      • 引理:LCP Lemma
      • 引理:LCP Theorem
      • 推论:LCP Corollary
      • 引理
      • 快速求 height
  • 后缀树
    • 暴力构建
    • 虚树 + 后缀数组
      • 前置知识
      • 构建方法
      • 套 SA
    • 后缀自动机
    • Ukkonen
  • 后缀自动机
  • 写在最后

绝对不咕
一篇就够了!


写在前面

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


前置小碎骨

计数排序

可以参考: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 个排序关键字分开依次考虑,实际每次排序还是靠计数排序实现。

请务必充分理解!


一些约定

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

后缀数组

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

定义

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

举例:这里有一个可爱的字符串:\(S=\text{yuyuko}\)
\(\text{k,它的后缀数组 \(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}\)

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


倍增法构造

先将所有 \(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] 的条件为:

\[[A_i

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


优化

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

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

\[[A_i

\(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 
#include 
#include 
#include 
#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 
#include 
#include 
#include 
#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

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

证明,设 \(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] 恒成立,与已知矛盾,则 \(\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)\),则有下图:
后缀三姐妹_第1张图片

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

此时考虑比较 \(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,可得 \(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

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

\(sa\) 中,\(v\)\(u\) 前一位置,则有 \(y
根据 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})\),比较次数 \(

若上述情况均不满足,由引理知,\(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)\)


后缀树

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

构建后缀树 准备学习 SAM 后用 SAM 建后缀树。
暂时没有写虚树 + 后缀数组法的代码。
如果以后有必要会去写一下。


暴力构建

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

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


虚树 + 后缀数组

虽然节点数为 \(O(n^2)\),但叶节点数仅有 \(O(n)\) 个。
大部分节点有且仅有一个孩子。
考虑建后缀树的虚树,将树中的链缩成一条边。


前置知识

SA + OI-wiki-虚树。

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

对于树 \(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\) 个叶节点。
先求得关键节点的 dfs 序,规定按字典序 dfs。

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

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

在此过程中维护每个节点的父节点,在弹栈时进行连边并维护信息,即得虚树。
总复杂度 \(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}\),则 \(lca\) 已在栈中。
否则新建一个 \(dep_x=\operatorname{lcp}\) 的节点插入,当做 \(\operatorname{lca}\) 即可。

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


后缀自动机

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

因此可以使用 SAM 进行构建,时间复杂度为 \(O(n\mid \sum\mid)\)\(O(n\log n)\)


Ukkonen

Udk
复杂度 \(O(n)\),可直接构建出后缀树。
神仙算法,我直接跑路,建议百度搜索谷歌搜索学习。


后缀自动机

涛哥钦定我写一篇博客。
那我就先咕了


写在最后

参考资料:

OI-wiki SA
后缀数组详解 - 自为风月马前卒
「后缀排序SA」学习笔记 - Rainy7
后缀数组-许智磊
后缀数组学习笔记 _ Menci's Blog
OI-wiki 虚树
利用后缀数组构造后缀树_AZUI

写句子。
I want to be a rap...
犹豫了一下。
raper。
身 败 名 裂

魂音泉 喜欢。

Drinking Drinking more...
Eating Eating more...

FELT 喜欢。

你可能感兴趣的:(后缀三姐妹)