后缀三姐妹 ver1.0

写在前面

这里是后缀三姐妹的 ver1.0,保留的目的是警示自己时刻自勉。
如果想学习后缀科技的话,请阅读:「笔记」后缀三姐妹

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

一些约定

  1. \(\left| \sum \right|\):字符集大小。
  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]\)。每一个后缀都与一个 \(1\sim 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\) 个排序关键字分开依次考虑,实际每次排序还是靠计数排序实现。

倍增法构造

考虑字符串大小的从前向后的比较过程,可以先将所有长度为 \(2^k\) 的子串进行排序,通过相邻子串合并并比较大小,求出所有长度为 \(2^{k+1}\) 的子串的大小关系。

对于 \(S[i:i+2^k-1]\)\(S[j:j+2^k-1]\),分别将它们裂开,分成两长度为 \(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)\)

优化

发现后缀数组值域即为 \(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\) 同阶),需进行 \(\log 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\{\left| S\right|,\left| T\right|\}\),满足 \(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,按照 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\left| \sum\right|)\)\(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\),称作 起始状态,其它节点均可从起始节点出发到达。
  • 每个 转移 都标有一些字母。从一个节点出发的所有转移均 不同
  • 存在数个 终止状态
    \(t_0\) 出发,最终转移到了一个终止状态,路径上所有转移连接起来一定是 \(S\) 的一个后缀。
    \(S\) 的每个后缀均可用一条 从起始节点到某个终止状态 的路径构成。
  • 在所有满足上述条件的 DFA 中,SAM 的节点数是最少的。

SAM 并不是一个典型的 DFA,在 DAWG 基础上,除 \(t_0\) 外的每个状态都被添加了一条 后缀链接
所有后缀链接组成了树状结构,这棵树被称为 parent 树

字符串 \(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\ (\left| t_1\right| \le \left| t_2\right|)\)\(\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\iff t_1\)\(S\) 中每次出现,都是以 \(t_2\) 的后缀形式存在。

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

引理 2:对于非空子串 \(t_1,t_2\ (\left| t_1\right| \le\left| t_2\right|)\)\(\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 讨论。

三个引理正确性显然,留给读者自证。


DAWG

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

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

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

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

规定:除 \(t_0\) 外,每个状态都是不同的 \(\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}\),它的 DAWG 有下图所示:

DAWG


对于两个状态 \(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\)\(\left| \operatorname{endpos}(v)\right|\) 最小的。
    \(\operatorname{longest}(\operatorname{Link}(u))\)\(\operatorname{shortest}(u)\) 的次长后缀(最长为其本身)。

引理 4:所有后缀链接构成一棵根节点为 \(t_0\) 的树。

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

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


parent 树

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

性质:

  1. 结合后缀链接的性质 3,在这棵树上总有 \(\operatorname{endpos}(son)\subseteq \operatorname{endpos}(father)\)
    且对于同一个 \(father\) 的不同 \(son\),有 \(\operatorname{endpos}(son_i)\cap \operatorname{endpos}(son_j)=\varnothing\ (i\not ={j})\)
  2. 从一个状态 \(v\) 沿后缀链接遍历 parent 树,总会到达 \(t_0\)
    经过的状态代表的字符串的区间互不相交,且它们的并集形成了连续的区间 \([0, \operatorname{len}(v)]\),代表 \(S\) 长度为 \(\operatorname{len}(v)\) 的前缀 的所有后缀。

由性质 2,可知 parent 树本质上是 \(\operatorname{endpos}\) 集合构成的树,体现了 \(\operatorname{endpos}\) 的包含关系。

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

parent tree

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

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

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


小结

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

  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\) 的后缀。
  5. 所有后缀链接构成一棵根节点为 \(t_0\) 的树,被称为字符串 \(S\) 的 parent 树。

构建 SAM

特别鸣谢:后缀自动机多图详解(代码实现) - maomao9173

使用增量法完成 SAM 的构建。
具体地,已知 \(S\) 的 SAM,考虑如何对其进行修改,得到 \(S+c\)\(c\) 是一个字符)的 SAM。

void Insert(int c_) { //原串 S -> 新串 S + c

加入字符 \(c\) 后,子串只新增加了 \(S + c\) 的后缀,已有的子串不受影响。

\(S + c\) 的某些后缀可能在 \(S\) 出现过,在 SAM 中有其对应的状态。
SAM 中一个串只能对应一个状态,需考虑将它们对应到相应状态上。


初始化 与 判断

\(last\) 是代表字符串 \(S\) 的状态。
\(S + c\)\(S\) 中不可能出现,它一定被对应到新状态上。
设新状态为 \(now\),则必有 \(\operatorname{len}(now) = |S+c| = \operatorname{len}(last) + 1\)

int p = last, now = last = ++ node_num; //创建新状态 now
len[now] = len[p] + 1;

考虑如何判断 \(S + c\) 的后缀在 \(S\) 出现过。
发现判断上述条件,等价于判断 \(S\) 的后缀有无转移边 \(c\)

\(S + c\) 的后缀 \(=S\) 的后缀 \(+ c\)
\(S\) 的某后缀有转移边 \(c\),它一定是新串的后缀,且说明 \(S+c\) 的该后缀在 \(S\) 中出现过。

若没有转移边 \(c\),则它应该向 \(now\) 连一条转移边 \(c\)

parent 树的性质 2,从 代表 \(S\) 的状态沿 \(\operatorname{Link}\) 遍历至 \(t_0\),等价于按长度递减遍历原串的所有后缀,则有如下代码:

for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;

枚举到第一个这样的状态 \(p\) 即可。
因为 parent 树上 \(p\) 的祖先代表的串,均为 \(p\) 代表的串的后缀。
它们 短于 \(p\) 代表的串,一定也有转移边 \(c\),也一定为 \(S+c\) 的后缀。

换言之,状态 \(p\) 是代表的 串最长 的满足条件的状态。


讨论

下面对\(S + c\) 的后缀在 \(S\) 中有无出现进行讨论。


case 1:\(S + c\) 的所有后缀在 \(S\) 中均未出现。
直接将新状态的 \(\operatorname{Link}\) 指向 起始状态。
此时新状态代表 \(S + c\) 的所有后缀 \([1, |S + c|]\)

if (! p) {link[now] = 1; return ;} 

case 2:\(S+c\) 的某后缀在 \(S\) 中出现过,
且对于包含此子串的状态 \(q\),有 \(\operatorname{len}(q)=\operatorname{len}(p) +1\)

\(q\) 代表的 所有串,及 parent 树上它的祖先代表的串,均为 \(S + c\) 的后缀。

\(\operatorname{longest}(q)\)\(S+c\) 的后缀,应有 \(\operatorname{Link}(now) = q\)
此时 \(now\) 代表 \(S + c\) 的后缀 \([\operatorname{len}(q) + 1, |S + c|]\)

int q = ch[p][c_];
if (len[q] == len[p] + 1) {link[now] = q; return ;}

case 3:\(S+c\) 的某后缀在 \(S\) 中出现过,
但对于包含此子串的状态 \(q\),有 \(\operatorname{len}(q)\not= \operatorname{len}(p) +1\)
首先有 \(\operatorname{len}(q)> \operatorname{len}(p) +1\)。,因为 \(p\) 经转移边 \(c\) 后可转移到 \(q\)\(\operatorname{len}(q)<\operatorname{len}(p)+1\) 不成立。

\(q\) 中长度 小于等于 \(\operatorname{len}(p) + 1\) 的串,及 parent 树上它的祖先代表的串,为 \(S + c\) 的后缀。
考虑将 \(q\) 拆成 \(S + c\) 的后缀部分,和非 \(S + c\) 的部分。
设将 \(q\)\(S + c\) 的后缀部分放入状态 \(newq\) 中,其余的保留在 \(q\) 中。

\(newq\) 应继承 \(q\) 的转移,因为 \(newq\) 中的串与 新的 \(q\) 的串 为后缀关系。
转移同样字符后也为后缀关系,应位于同一状态中

int newq = ++ node_num;
len[newq] = len[p] + 1;
memcpy(ch[newq], ch[q], sizeof(ch[q])); 

\(newq\) 的串长度小于 \(q\) 的串,且均为 \(q\) 的后缀,则 \(\operatorname{Link}(newq) = \operatorname{Link}(q)\)

link[newq] = link[q]; 

\(\operatorname{len}(newq) + 1 = \operatorname{minlen}(q)\),则 \(\operatorname{Link}(q) = newq\)

link[q] = newq;

\(newq\) 代表的所有串,及 parent 树上它的祖先代表的串,均为 \(S + c\) 的后缀。
应有 \(\operatorname{Link}(now) = newq\)。这样 \(now\) 代表 \(S + c\) 的后缀 \([\operatorname{len}(newq) + 1, |S + c|]\)

link[now] = newq; 

最后枚举所有 可以转移到 原来的 \(q\) 的比 \(p\) 短的 \(S\) 的后缀,将其指向 \(newq\)
这样是对的,因为 \(\operatorname{len}(newq) = \operatorname{len}(p) + 1\)\(p\) 应转移到 \(newq\),则比 \(p\) 的串还短的后缀也应转移到 \(newq\)
应转移到 新的 \(q\) 的后缀 的转移不会被修改。

for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;

代码

P3804 【模板】后缀自动机 (SAM)

建出 SAM 后在 parent 树上 DP 即可。

//知识点:SAM
/*
By:Luckyblock
试了试变量写法,挺清爽的。
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
const int kMaxn = 1e6 + 10;
const int kMaxm = 26;
//=============================================================
int n, last = 1, node_num = 1; //注意 1 为起始状态,其他状态的标号从 1 开始。 
int ch[kMaxn << 1][kMaxm], len[kMaxn <<1], link[kMaxn << 1]; //SAM
int edge_num, v[kMaxn << 1], ne[kMaxn << 1], head[kMaxn << 1]; //Graph
ll ans, size[kMaxn << 1];
char S[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;
}
void GetMax(ll &fir, ll sec) {
  if (sec > fir) fir = sec;
}
void AddEdge(int u_, int v_) {
  v[++ edge_num] = v_, ne[edge_num] = head[u_], head[u_] = edge_num;
}
void Insert(int c_) { //原串 S -> 新串 S + c
  int p = last, now = last = ++ node_num; //创建新状态 now
  size[now] = 1, len[now] = len[p] + 1; //仅为终止状态 size 赋值。

  //按长度递减 枚举代表 S 后缀的状态 p。
  //检查 S 中是否存在子串,为新串的后缀。  
  //等价于检查 S 后缀有无 c 的转移(S + c 的后缀 = S 的后缀 + c)。
  for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
  
  //原串中不存在子串,为新串的后缀。  
  //直接将新状态的 link 指向 起始状态。  
  //此时新状态代表 S + c 的所有后缀 [1, |S + c|]。  
  if (! p) {link[now] = 1; return ;} 

  //原串中存在子串 为新串的后缀。  
  //且包含此子串的状态 q 中最长串的长度 为 p 最长串长度 + 1。  
  //则 q 代表的所有串,及 parent 树上它的祖先代表的串,均为 S + c 的后缀。
  //应有 Link(now) = q。这样 now 代表 S + c 的后缀 [len(q) + 1, |S + c|]。
  int q = ch[p][c_];
  if (len[q] == len[p] + 1) {link[now] = q; return ;}

  //原串中存在子串 为新串的后缀。  
  //但包含此子串的状态 q 中最长串的长度 不为 p 最长串长度 + 1。  
  //首先,q 中最长串的长度 必大于 p 最长串长度 + 1,因为 p + c 后可转移到 q。  
  //因为 S 的后缀 + c 可转移到 q,则 q 中长度 **小于等于**  len[p] + 1 的串,及 parent 树上它的祖先代表的串,为 S + c 的后缀。
  //考虑将 q 拆成 S + c 的后缀部分,和非 S + c 的部分。
  //下面代码把 S + c 的后缀部分放到了 newq 中,其余的保留在 q 中。

  //newq 应继承 q 的转移,因为 newq 与 新的 q为后缀关系。
  //转移同样字符后也为后缀关系。  
  int newq = ++ node_num;
  memcpy(ch[newq], ch[q], sizeof(ch[q])); 

  //newq 的串长度小于 q 的串,且均为 q 的后缀,则 Link(newq) = Link(q)。
  link[newq] = link[q], len[newq] = len[p] + 1; 

  //有 len(newq) + 1 = minlen(q),则 Link(q) = newq。
  //则 newq 代表的所有串,及 parent 树上它的祖先代表的串,均为 S + c 的后缀。
  //应有 Link(now) = newq。这样 now 代表 S + c 的后缀 [len(newq) + 1, |S + c|]。
  link[q] = link[now] = newq; 

  //最后枚举所有 可以转移到 **原来的 q** 的 S 的,比 p 短的后缀,将其指向 newq。
  //这样是对的,因为 len(newq) = len(p) + 1,比 p 的串还短的后缀 应该转移到 newq。
  //能够转移到 **新的 q** 的后缀 的转移不会被修改。
  for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
}

//parent 树上 DP,用孩子更新父亲。
//孩子的长度比父亲长,则孩子出现的地方,父亲一定出现,size 累加。
//父亲的 size 也可能不为 0(为终止状态)。
void Dfs(int u) { 
  for (int i = head[u]; i; i = ne[i]) {
    Dfs(v[i]);
    size[u] += size[v[i]];
  }
  if (size[u] > 1) GetMax(ans, 1ll * size[u] * len[u]);
}
//=============================================================
int main() {
  scanf("%s", S + 1);
  int n = strlen(S + 1);
  for (int i = 1; i <= n; ++ i) Insert(S[i] - 'a');
  for (int i = 2; i <= node_num; ++ i) AddEdge(link[i], i);
  Dfs(1);
  printf("%lld\n", ans);
  return 0; 
}

复杂度

一些分析。

状态数

对于一个长度为 \(n\ (n\ge 2)\) 的字符串 \(S\),它的 SAM 的状态数 \(\le 2n-1\)

考虑 parent 树中父子节点的关系。
parent 树的性质 1,在 parent 树上总有 \(\operatorname{endpos}(son)\subseteq \operatorname{endpos}(father)\)
对于同一个 \(father\) 的不同 \(son\),有 \(\operatorname{endpos}(son_i)\cap \operatorname{endpos}(son_j)=\varnothing\ (i\not ={j})\)

对于一个父节点,其若干个儿子的 \(\operatorname{endpos}\) 相当于将父节点的 \(\operatorname{endpos}\) 分割成的若干不相交子集,最终会产生不多于 \(n\) 个叶节点。
一个分叉点会合并至少两个点,parent 树为完全二叉树时节点数最多,为 \(2n-1\) 个。
此时 parent 树表示的的集合关系类似一棵线段树。
大概有下图的感觉:

node_num

也可用算法本身证明。
最开始自动机仅含有起始状态,前两次迭代中仅会创建一个节点,剩余 \(n-2\) 步中每步至多会创建 \(2\) 个状态。

状态数在 \(S\) 形如 \(\operatorname{abb\cdots }\) 时取到上界。


转移数

对于一个长度为 \(n\ (n\ge 3)\) 的字符串 \(S\),它的 SAM 的转移数 \(\le 3n-4\)

假设已按照上述算法建出字符串 \(S\) 的 SAM。

考虑对 DAWG 建一棵生成树,钦定 \(t_0\) 为根。
已知状态数 \(\le 2n-1\),则树上的转移数 \(\le 2n-2\) 条。
接下来仅需考虑在 DAWG 上,不在生成树上的转移。

考虑一个非树上转移 \((u,v)\),可以找到下面三段字符串:

  1. 沿树上转移从根 \(t_0\)\(u\) 得到的字符串 \(t_0\rightarrow u\)
  2. 转移 \((u,v)\) 上的字符。
  3. 沿 字符字典序最小 的转移(在不在树上均可),直到到达一个接受状态 \(w\) 得到的字符串 \(v\rightarrow w\)

状态 \(u,v\) 确定,则 \((u,v)\)唯一 的。
\(T = t_0\rightarrow u +(u,v) + v\rightarrow w\),上述三段字符串都是唯一的,则 \(T\)唯一 的。
\(T\)\(t_0\) 出发,最终到达了接受状态,则 \(T\)\(S\) 的一个后缀。
并且 \((u,v)\) 是按上述方式运行时,\(T\) 经过的第一条 非树上转移

则一条非树上转移 唯一对应 一个字符串的后缀。
被对应 的后缀 唯一对应 一条非树上转移。
后缀个数为 \(n\),显然长度为 \(1\) 的后缀不对应非树上转移,则非树上转移数 \(\le n - 1\)

两者相加,得到转移数的上界为 \(3n-3\)
但状态数为 \(2n-1\) 时,\(S\) 形如 \(\operatorname{abb\cdots bb}\),此时转移数 \(< 3n-3\)
转移数实际的上界为 \(3n-4\),在 \(S\) 形如 \(\operatorname{abb\cdots bbc}\) 时取到。


SAM 的空间复杂度

写成这样 int ch[kMaxn << 1][kMaxm];,空间 \(O(n|\sum|)\),查询时间 \(O(1)\)
字符集较大时,可写成这样 map<int,int> ch[kMaxn << 1],空间 \(O(n)\),查询时间 \(O(\log |\sum|)\)


构建 SAM 的时间复杂度

均摊 \(O(n)\),知道就好。
写这个挺浪费时间的也没什么用,跑路了。


写在最后

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

posted @ 2021-01-08 19:26  Luckyblock  阅读(140)  评论(1编辑  收藏  举报