「笔记」后缀自动机

写在前面

遇到某些看做很扯的定义时暂时不要考虑正确性,应用之类,只需记住就好,这些东西在下文中一般都有详细介绍。

前置知识

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

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

DFA 可接受一个字符串,并对其进行判定。一个 DFA 读入一个字符串后,会从起始状态开始,按照转移函数一个一个字符进行 转移,转移函数的字符与字符串对应位置字符相同。
读入完成后,若字符串位于一个接受状态,则称 DFA 接受 这个字符串,反之称 DFA 不接受 这个字符串。若转移过程中不存在对应某字符的转移函数,也称 DFA不接受这个字符串。

Trie, KMP, AC自动机,SAM,广义SAM 都是自动机。

引入

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

能识别所有子串,则该 DFA 需要包含所有子串的信息。最直观的思路是用 \(S\) 所有子串建立一棵 Trie,将每个子串的结尾作为接受状态。

但这棵 Trie 有两个问题,一是大量子串是其他子串的前缀,Trie 的节点数量实际上只有 \(O(n^2)\) 级别。二是除起始状态外的所有状态都是接受状态,一个字符串不是 \(S\) 的子串,等价于转移时不存在对应串中某字符的 转移函数
可以发现,构建这棵 Trie 时,仅将所有后缀插入进去,将后缀的结尾作为 接受状态 即可。使用时仅需判断最终状态是否在 Trie 上。

这东西长得怎么这么熟悉?发现这是一棵支持 接受 后缀的后缀树。
后缀自动机就是一种具有这样的性质和功能的更强大数据结构。

SAM 的定义

字符串 \(S\) 的后缀自动机 (suffix automaton, SAM) 是一个可以且尽可以接受 \(S\) 所有后缀的 最小的 DFA。
更形式化的定义:

  • 字符串 \(S\) 的 SAM 是一张 DAWG(有向单词无环图)。节点被称作 状态,边被称作状态间的 转移
  • 存在一个起始节点,称作 起始状态。其它节点均可从起始节点出发到达。
  • 每个 转移 都标有一些字母。从一个节点出发的所有转移均 不同
  • 存在数个 终止状态。若从 \(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=\texttt{114514}, \operatorname{endpos}(\texttt{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}\]

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

三个引理正确性显然,读者感性理解即可。

DAWG

DAWG 指有向单词无环图。

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

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

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


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

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


再引入一些概念:

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

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

对于字符串 \(S=\texttt{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. \(\operatorname{link}(u)\) 代表的子串均为 \(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)\) 的次长后缀(最长为 \(\operatorname{shortest}(u)\) 本身)。

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

正确性显然,由性质 2 可知,\(\operatorname{link}(u)\) 所对应的字符串严格短于 \(u\) 所表示的字符串,沿着后缀链接移动,最终总能到达空串,即起始状态。则通过 \(\operatorname{link}\) 构成的图中不会出现环。
这棵树被称为字符串 \(S\) 的 parent 树。

parent 树

所有后缀链接构成的一棵根节点为 起始状态 的树,它有如下性质:

  1. 结合后缀链接的性质 3,在这棵树上总有 \(\operatorname{endpos}(son)\subsetneq \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=\texttt{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))\) 基础上,在头部添加一个字符得到。
在正串的头部添加字符构成正串的后缀,等价于在反串尾部添加字符构成反串的后缀,构建正串的 SAM 的过程,与构建反串后缀树的过程一致。

小结

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

  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。

加入字符 \(c\) 后,子串只新增加了 \(S + c\) 的后缀,已有的子串不受影响。但 \(S + c\) 的某些后缀可能在 \(S\) 出现过,在 SAM 中有其对应的状态。SAM 中一个串只能对应一个状态,需考虑将它们对应到相应状态上。

void Insert(int ch_) { //原串 S -> 新串 S + ch

初始化 与 判断

\(last\) 是代表字符串 \(S\) 的状态。显然串 \(S + c\)\(S\) 中不可能出现,它一定被对应到新状态上。
设新状态为 \(now\),则必有 \(\operatorname{len}(now) = |S+c| = \operatorname{len}(last) + 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 && !tr[p][ch_]; p = link[p]) tr[p][ch_] = now;

需要找到第一个这样的状态 \(p\)
parent 树上 \(p\) 的祖先代表的串,均为 \(p\) 代表的串的后缀。它们短于 \(p\) 代表的串,并且一定也有转移边 \(c\),也一定为 \(S+c\) 的后缀。换言之,状态 \(p\)代表的串最长的、满足有转移边 \(c\) 的、代表了 \(S\) 的某个后缀的状态。

讨论

为得到 \(\operatorname{link}(now)\),并更新转移函数,下面将对\(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{link}(now) = q\)
此时 \(now\) 代表 \(S + c\) 的后缀 \([\operatorname{len}(q) + 1, |S + c|]\)

int q = tr[p][ch_];
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\) 中,一些较长的串不为 \(S+c\) 的后缀,但 \(q\) 中长度小于等于 \(\operatorname{len}(p) + 1\) 的串,及 parent 树上它的祖先代表的串,为 \(S + c\) 的后缀。
此时需要将 \(q\) 拆成 \(S + c\) 的后缀部分,和非 \(S + c\) 的部分,用 \(S+c\) 的后缀部分更新。

设将 \(q\)\(S + c\) 的后缀部分放入状态 \(newq\) 中,其余的保留在 \(q\) 中。
由于 \(q\) 中长度小于等于 \(\operatorname{len}(p) + 1\) 的串,及 parent 树上它的祖先代表的串,为 \(S + c\) 的后缀,则 \(\operatorname{len}(newq) = \operatorname{len}(p) + 1\)
并且 \(newq\) 应继承 \(q\) 的转移,因为 \(newq\) 中的串与新的 \(q\) 表示的串为后缀关系。转移同样字符后也为后缀关系,应位于同一状态中

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

\(newq\) 的串长度小于 \(q\) 的串,且均为 \(q\) 的后缀,则 \(\operatorname{link}(newq) = \operatorname{link}(q)\)
\(\operatorname{len}(newq) + 1 = \operatorname{minlen}(q)\),则 \(\operatorname{link}(q) = newq\)

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

link[newq] = link[q], link[q] = link[now] = newq;

最后枚举所有可以转移到原来的 \(q\)\(S\) 的后缀,即 \(p\) 的所有后缀,将其指向 \(newq\)
因为有 \(\operatorname{len}(newq) = \operatorname{len}(p) + 1\)\(p\) 应转移到 \(newq\),则 \(p\) 的后缀也应转移到 \(newq\)
这样实现的话,应转移到新的 \(q\) 的后缀的转移也不会被修改,保证了正确性。

for (; p && tr[p][ch_] == q; p = link[p]) tr[p][ch_] = newq;

模板代码

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

给定一只由小写字母构成的字符串 \(S\),求 \(S\) 所有出现次数不为 1 的子串的出现次数乘上该子串的最大值。
\(1\le |S|\le 10^6\)
2S,512MB。

考虑对 \(S\) 建立 SAM,在插入字符的同时记每个前缀状态的 \(\operatorname{size} = 1\)。由 \(\operatorname{endpos}\) 的性质可知,状态 \(u\) 对应的子串的出现次数即为 parent 树中该状态子树前缀状态的个数,即有:

\[\operatorname{size}(u) = \sum_{v\in \operatorname{son}(u)} \operatorname{size}(v) \]

在 parent 树上 DP 求解即可。总复杂度 \(O(|S|)\) 级别。

//知识点:SAM
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
//=============================================================
int n;
LL ans;
char S[kN];
//=============================================================
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 Chkmax(LL &fir, LL sec) {
  if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
namespace SAM {
  const int kNode = kN << 1;
  int node_num = 1, last = 1, tr[kNode][26], len[kNode], link[kNode]; //注意 1 为起始状态,其他状态的标号从 1 开始。 
  int sz[kNode]; //每个状态代表的串出现次数
  int e_num, head[kNode], v[kNode], ne[kNode];
  void Insert(int ch_) { //原串 S -> 新串 S + ch
    int p = last, now = last = ++ node_num;
    sz[now] = 1, len[now] = len[p] + 1; //仅为终止状态 size 赋值。
    for (; p && !tr[p][ch_]; p = link[p]) tr[p][ch_] = now;

    //case 1:
    if (!p) { 
      link[now] = 1;
      return ;
    }
    
    //case 2:
    int q = tr[p][ch_];
    if (len[q] == len[p] + 1) {
      link[now] = q;
      return ;
    }

    //case 3:
    int newq = ++ node_num;
    memcpy(tr[newq], tr[q], sizeof (tr[q]));
    len[newq] = len[p] + 1;
    link[newq] = link[q], link[q] = link[now] = newq;
    for (; p && tr[p][ch_] == q; p = link[p]) tr[p][ch_] = newq;
  }
  void Add(int u_, int v_) {
    v[++ e_num] = v_, ne[e_num] = head[u_], head[u_] = e_num;
  }
  void Dfs(int u_) {
    for (int i = head[u_]; i; i = ne[i]) {
      int v_ = v[i];
      Dfs(v_);
      sz[u_] += sz[v_];
    }
    if (sz[u_] > 1) Chkmax(ans, 1ll * sz[u_] * len[u_]);
  }
  LL Solve() {
    for (int i = 1; i <= node_num; ++ i) Add(link[i], i);
    Dfs(1);
    return ans;
  }
}
//=============================================================
int main() { 
  scanf("%s", S + 1);
  n = strlen(S + 1);
  for (int i = 1; i <= n; ++ i) SAM::Insert(S[i] - 'a');
  printf("%lld\n", SAM::Solve());
  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\) 形如 \(\texttt{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\)

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

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

两者相加,得到转移数的上界为 \(3n-3\)
但状态数为 \(2n-1\) 时,\(S\) 形如 \(\texttt{abb}\cdots \texttt{bb}\),此时转移数 \(< 3n-3\)。转移数实际的上界为 \(3n-4\),在 \(S\) 形如 \(\texttt{abb}\cdots \texttt{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)\)
写这个挺浪费时间的,也没什么用,跑路了。证明可以看这里:后缀自动机学习笔记 _ Menci's Blog

例题

SP705 SUBST1 - New Distinct Substrings

\(T\) 组数据,每次给定一个字符串,求该字符串本质不同的子串数量。
两个子串本质不同,当且仅当两个子串长度不等,或长度相等但有任意一位不同。
280ms,1.46GB。

显然 SAM 中每个状态表示的字符串都是本质不同的。状态 \(i\) 代表的本质不同子串数为 \(\operatorname{len}(i)-\operatorname{len}(\operatorname{link}(i))\),则答案即:

\[\sum\limits_i{\operatorname{len}(i)-\operatorname{len}(\operatorname{link}(i))} \]

//知识点:SAM 
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
const int kMaxn = 5e4 + 10;
const int kMaxm = 300 + 10;
//=============================================================
int n, last = 1, node_num = 1, ans;
int ch[kMaxn << 1][kMaxm], link[kMaxn << 1], len[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(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void GetMin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
void Insert(int c_) {
  int p = last, now = last = ++ node_num;
  len[now] = len[p] + 1;
  for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
  if (! p) {link[now] = 1; return ;}  
  int q = ch[p][c_];
  if (len[q] == len[p] + 1) {link[now] = q; return ;}
  int newq = ++ node_num;
  memcpy(ch[newq], ch[q], sizeof (ch[q]));
  link[newq] = link[q], len[newq] = len[p] + 1;
  link[q] = link[now] = newq;
  for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
}

void Init() {
  last = node_num = 1;
  ans = 0;
  memset(ch, 0, sizeof (ch));
  memset(link, 0, sizeof (link));
  memset(len, 0, sizeof (len));
}
//=============================================================
int main() { 
  int T = read();
  while (T --) {
    Init();
    scanf("%s", S + 1);
    n = strlen(S + 1);
    for (int i = 1; i <= n; ++ i) Insert(S[i]);
    for (int i = 2; i <= node_num; ++ i) ans += len[i] - len[link[i]];
    printf("%d\n", ans);
  } 
  return 0; 
}

「SDOI2016」生成魔咒

给定字符串 \(S\),求 \(S\) 的所有前缀的本质不同的子串的个数。
\(1\le S\le 10^5\)\(|\sum|\le 10^9\)
1S,256MB。

考察对 SAM 构建过程的理解。

对于一个确定的字符串 \(S\),其本质不同子串的个数,等于所有状态所表示子串的个数之和。
即有下式:

\[ans = \sum_{u\in \operatorname{DAWG}}{\operatorname{len(u)} - \operatorname{len(\operatorname{link}(u))}} \]

对于字符串 \(S\),考虑新加入字符 \(c\) 的影响。
加入 \(c\) 后,显然答案增加 不在 \(S\) 中出现的 \(S+c\) 后缀的个数
设表示 \(S+c\) 的状态为 \(a\),考虑第一个在 \(S\) 中出现的 \(S+c\) 的后缀,会在 SAM 构建中赋值给 \(\operatorname{link}(a)\) 上。
则新字符的贡献即为 \(\operatorname{len}(a) - \operatorname{len}(\operatorname{link}(a))\),累计到答案中即可。

总复杂度 \(O(|S|\log |\sum|)\)

//知识点:SAM
/*
By:Luckyblock
*/
#include <map>
#include <cstdio>
#include <ctype.h>
#include <cstring>
#include <algorithm>
#define ll long long
const int kMaxn = 1e5 + 10;
//=============================================================
int n, last = 1, node_num = 1, link[kMaxn << 1];
std :: map <int, int> ch[kMaxn << 1]; 
ll ans, len[kMaxn << 1];
//=============================================================
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(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void GetMin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
int Insert(int c_) {
  int p = last, now = last = ++ node_num;
  len[now] = len[p] + 1ll;
  for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
  if (! p) {link[now] = 1; return now;}
  int q = ch[p][c_];
  if (len[q] == len[p] + 1ll) {link[now] = q; return now;}
  int newq = ++ node_num;
  ch[newq] = ch[q];
  link[newq] = link[q], len[newq] = len[p] + 1ll;
  link[q] = link[now] = newq;
  for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
  return now;
}
//=============================================================
int main() {
  int n = read();
  for (int i = 1; i <= n; ++ i) {
    int x = read(), now = Insert(x);
    printf("%lld\n", ans += (len[now] - len[link[now]]));
  }
  return 0;
}

SP1811 LCS - Longest Common Substring

给定两字符串 \(S_1, S_2\),求它们的最长公共子串长度。
\(|S_1|,|S_2|\le 2.5\times 10^5\)
294ms,1.46GB。

对第一个串建 SAM,用第二个串从起始节点开始,在 SAM 上进行匹配。

若当前状态为 \(x\),如果有对应字符 \(s_i\) 的转移,直接转移即可,匹配长度 \(+1\)
如果没有对应转移,转移到 \(\operatorname{link}(x)\),匹配长度 \(=\operatorname{len}(x)+1\) 检查有无对应转移,若没有则继续转移到 \(\operatorname{link}(\operatorname{link}(x))\),直到存在对应转移。
若始终找不到对应转移,则从根开始重新匹配。

跳 parnet 树相当于失配指针,继续利用了已匹配的部分。
匹配过程中匹配的最长长度即为答案。

复杂度 \(O(n)\),实际运行效率也非常高。

//知识点:SAM
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
const int kMaxn = 3e5 + 10;
const int kMaxm = 26;
//=============================================================
char S[kMaxn];
int n, k, ans, last = 1, node_num = 1;
int ch[kMaxn << 1][kMaxm], len[kMaxn <<1], link[kMaxn << 1];
//=============================================================
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(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void Insert(int c_) {
  int p = last, now = last = ++ node_num;
  len[now] = len[p] + 1;
  for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
  if (! p) {link[now] = 1; return ;} 
  int q = ch[p][c_];
  if (len[q] == len[p] + 1) {link[now] = q; return ;}
  int newq = ++ node_num;
  memcpy(ch[newq], ch[q], sizeof(ch[q]));  
  link[newq] = link[q], len[newq] = len[p] + 1; 
  link[q] = link[now] = newq; 
  for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
}
void Work() {
  scanf("%s", S + 1);
  int n = strlen(S + 1), now = 1, l = 0;
  for (int i = 1; i <= n; ++ i) {
    while (now && ! ch[now][S[i] - 'a']) {
      now = link[now];
      l = len[now]; 
    }
    if (! now) {
      now = 1;
      l = 0;
      continue ;
    }
    ++ l;
    now = ch[now][S[i] - 'a'];
    if (l > k) GetMax(ans, l);
  }
}
//=============================================================
int main() {
//  k = read();
  scanf("%s", S + 1); n = strlen(S + 1);
  for (int i = 1; i <= n; ++ i) Insert(S[i] - 'a');
  Work();
  printf("%d", ans);
  return 0; 
}

SP1812 LCS2 - Longest Common Substring II

给定不超过 \(10\) 个字符串 \(S_1, S_2, \cdots\),求它们最长公共子串的长度。
\(|S_i|\le 10^5\)
236ms,1.46GB。

多串最长公共子串问题,考虑 SAM。

如果只有两个串:SP1811 LCS - Longest Common Substring,可以对第一个串建 SAM,用第二个串从起始节点开始,在 SAM 上进行匹配。

若当前状态为 \(x\),如果有对应字符 \(s_i\) 的转移,直接转移即可,匹配长度 \(+1\)
如果没有对应转移,转移到 \(\operatorname{link}(x)\),匹配长度 \(=\operatorname{len}(x)+1\) 检查有无对应转移,若没有则继续转移到 \(\operatorname{link}(\operatorname{link}(x))\),直到存在对应转移。
若始终找不到对应转移,则从根开始重新匹配。

跳 parnet 树相当于失配指针,继续利用了已匹配的部分。
匹配过程中匹配的最长长度即为答案。


再考虑多串,对第一个串 \(S_1\) 建 SAM,用其他串在 SAM 上匹配,设当前匹配到串 \(S_i\)
对于状态 \(u\),维护转移到它时最大的匹配长度 \(mx_u\),即以该状态作为后缀时的公共子串的最长长度。
在匹配过程中进行维护即可。

考虑一个状态 parent 树上的所有祖先,若该状态可被匹配到,则祖先也可被匹配到。
祖先的 \(mx\) 应为 其子树中 \(mx\) 的最大值。
\(S_i\) 匹配完成后按拓扑序对祖先的信息进行更新。

对于一个状态 \(u\),将 \(S_2\cdots S_n\) 匹配时的 \(mx_u\)\(\min\),得到在所有字符串中 转移到 \(u\) 最长的匹配长度,即以 \(u\) 为后缀时 \(S_2\cdots S_n\) 公共子串的长度,设为 \(mi_u\)
所有的 \(mi_u\) 取最大值,即为答案。


小细节

最长公共子串不会超过最短串的长度,应对最短的串建 SAM,以保证复杂度。
注意每次匹配完一个字串时,都将 \(mx\) 清空。
更新祖先信息时 应对祖先的 \(\operatorname{len}\)\(\min\)

//知识点:SAM
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
const int kMaxn = 3e5 + 10;
const int kMaxm = 26;
//=============================================================
char S[11][kMaxn];
int n, T, last = 1, node_num = 1, ans;
int cnt[kMaxn], id[kMaxn];
int ch[kMaxn << 1][kMaxm], len[kMaxn <<1], link[kMaxn << 1];
int minn[kMaxn << 1], maxx[kMaxn << 1];
//=============================================================
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(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void GetMin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
void Insert(int c_) {
  int p = last, now = last = ++ node_num;
  len[now] = len[p] + 1;
  for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
  if (! p) {link[now] = 1; return ;} 
  int q = ch[p][c_];
  if (len[q] == len[p] + 1) {link[now] = q; return ;}
  int newq = ++ node_num;
  memcpy(ch[newq], ch[q], sizeof(ch[q]));  
  link[newq] = link[q], len[newq] = len[p] + 1; 
  link[q] = link[now] = newq; 
  for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
}
void TopSort() {
  for (int i = 1; i <= node_num; ++ i) cnt[len[i]] ++;
  for (int i = 1; i <= node_num; ++ i) cnt[i] += cnt[i - 1];
  for (int i = 1; i <= node_num; ++ i) id[cnt[len[i]] --] = i;
}
void Work(char *S_) {
  int n = strlen(S_ + 1), now = 1, l = 0;
  for (int i = 1; i <= n; ++ i) {
    while (now && ! ch[now][S_[i] - 'a']) {
      now = link[now];
      l = len[now]; 
    }
    if (! now) {
      now = 1;
      l = 0;
      continue ;
    }
    ++ l;
    now = ch[now][S_[i] - 'a'];
    GetMax(maxx[now], l);
  }
  for (int i = node_num; i; -- i) {
    int u = id[i], fa = link[u];
    GetMax(maxx[fa], std :: min(maxx[u], len[fa]));
    GetMin(minn[u], maxx[u]); 
    maxx[u] = 0; //注意清空,准备下一次匹配。 
  }
}
//=============================================================
int main() {
  int min_len = 1e9 + 2077, min_pos;
  while (~ scanf("%s", S[++ T] + 1)) {
    int now_len = strlen(S[T] + 1);
    if (now_len < min_len) {
      min_len = now_len;
      min_pos = T;
    }
  }
  std :: swap(S[1], S[min_pos]);
  for (int i = 1; i <= min_len; ++ i) Insert(S[1][i] - 'a');
  TopSort();
  memset(minn, 63, sizeof (minn));
  for (int i = 2; i < T; ++ i) Work(S[i]);
  for (int i = 1; i <= node_num; ++ i) GetMax(ans, minn[i]);
  printf("%d", ans);
  return 0; 
}

「TJOI2019」甲苯先生和大中锋的字符串

\(T\) 组数据,每次给定一字符串 \(s\) 与参数 \(k\)
对于 \(s\) 中出现次数为 \(k\) 的子串,求它们的长度中出现次数最多的长度数(如果有多个输出最长长度)。
\(1\le T\le 100\)\(1\le |s|\le 10^5\)\(\sum |s|\le 3\times 10^6\)
1S,128MB。

根据 \(\operatorname{endpos}\) 的性质,一个状态代表的所有子串的出现次数即为 \(\operatorname{endpos}\) 的大小,可以在 parent 树上 DP 维护子树内终止状态个数求得。
之后枚举每个 \(\operatorname{endpos}\) 的大小等于 \(k\) 的状态,状态表示的子串长度连续,问题可以抽象成数轴上被线段覆盖次数最多的点的位置,差分即可。
复杂度 \(O(\sum |s|)\) 级别。

代码中并没有直接 dfs,而是通过拓扑序进行了更新。为保证复杂度使用了计数排序,详见代码。

//知识点:SAM,差分
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 2e5 + 10;
//=============================================================
int n, k;
char s[kN];
//=============================================================
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 Chkmax(int &fir_, int sec_) {
  if (sec_ > fir_) fir_ = sec_;
}
void Chkmin(int &fir_, int sec_) {
  if (sec_ < fir_) fir_ = sec_;
}
namespace SAM {
  int node_num, last;
  int tr[kN][26], len[kN], link[kN];
  int cnt[kN], id[kN], size[kN];
  int diff[kN];
  void Init() {
    node_num = last = 1;
    memset(cnt, 0, sizeof (cnt));
    memset(size, 0, sizeof (cnt));
    memset(tr, 0, sizeof (tr));
    memset(diff, 0, sizeof (diff));
  }
  void Insert(int ch_) {
    int p = last, now = last = ++ node_num;
    size[now] = 1;
    len[now] = len[p] + 1;
    for (; p && ! tr[p][ch_]; p = link[p]) tr[p][ch_] = now;
    
    if (! p) {
      link[now] = 1;
      return ;
    }
    
    int q = tr[p][ch_];
    if (len[q] == len[p] + 1) {
      link[now] = q;
      return ;
    }
    
    int newq = ++ node_num;
    len[newq] = len[p] + 1;
    memcpy(tr[newq], tr[q], sizeof (tr[q]));
    link[newq] = link[q];
    link[q] = link[now] = newq;
    for (; p && tr[p][ch_] == q; p = link[p]) tr[p][ch_] = newq;
  }
  void Build() {
    for (int i = 1; i <= node_num; ++ i) cnt[len[i]] ++;
    for (int i = 1; i <= node_num; ++ i) cnt[i] += cnt[i - 1];
    for (int i = 1; i <= node_num; ++ i) id[cnt[len[i]] --] = i;
    for (int i = node_num; i; -- i) size[link[id[i]]] += size[id[i]];
  }
  void Solve() {
    int maxcnt = 1, ans = -1;
    for (int i = 1; i <= node_num; ++ i) {
      if (size[i] == k) {
        diff[len[link[i]] + 1] ++;
        diff[len[i] + 1] --; 
      }
    }
    for (int i = 1; i <= n; ++ i) {
      diff[i] += diff[i - 1];
      if (diff[i] >= maxcnt) {
        maxcnt = diff[i];
        ans = i;
      }
    }
    printf("%d\n", ans);
  }
}
//=============================================================
int main() {
  int T = read();
  while (T --) {
    SAM::Init();
    scanf("%s", s + 1);
    n = strlen(s + 1), k = read();
    for (int i = 1; i <= n; ++ i) SAM::Insert(s[i] - 'a');
    SAM::Build(); SAM::Solve();
  }
  return 0;
}

「TJOI2015」弦论

而莲子则是超统一物理学。最近在做弦论方面的研究,还顺利吗?
———— 《梦违科学世纪》永夜の報い ~ Imperishable Night

给定一长度为 \(n\) 的字符串和参数 \(t\)
\(t=0\),求它的第 \(k\) 小本质不同子串。
\(t=1\),求它的第 \(k\) 小子串。
\(1\le n\le 5\times 10^5,\ t<2,\ k\le 10^9\)
1S,256MB。

先考虑 \(t=1\),建 SAM,在 parent 树上 DP 求每个状态的出现次数 \(size\),表示该状态表示的字符串的出现次数。
再在 DAWG 上按照字典序跑,跑到一个节点就令 \(k- size_i\),表示减去跑出的字符串的出现次数,并转移。没有转移可走时递归回溯。
\(k=0\) 时,当前跑到的字符串即为所求。

\(t=0\) 时,直接赋 \(size_i = 1\),即钦定每个子串仅出现 \(1\) 次,再按上述过程跑即可。


然后 T 了。发现这玩意复杂度上限是 \(O(n+k)\) 的,\(t=0\) 时必定达到上限。

在上述算法中,没有转移可走时会回溯,某些状态在遍历过一遍后又回溯到上一状态,不可能作为答案。
考虑预处理经过某一状态的路径条数 \(sum\),转移到某状态前先判断 \(sum\) 是否小于 \(k\)。若满足条件,则令 \(k-sum\),并直接考虑下一个转移。
注意预处理路径条数时按照拓扑序 DP,用 \(u\) 可转移到的节点更新 \(u\)。这需要先将各状态按照 \(len\) 进行排序,为保证复杂度使用了计数排序。

总复杂度 \(O(|S| + |ans|\cdot |\sum|)\)

//知识点:SAM
/*
By:Luckyblock
*/
#include <cstdio>
#include <ctype.h>
#include <cstring>
#include <algorithm>
#define ll long long
const int kMaxn = 1e6 + 10;
const int kMaxm = 26;
//=============================================================
int n, m, t, k, last = 1, node_num = 1;
int ch[kMaxn][kMaxm], link[kMaxn], len[kMaxn], size[kMaxn], sum[kMaxn];
int cnt[kMaxn], id[kMaxn];
char S[kMaxn >> 1];
//=============================================================
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(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void GetMin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
void Insert(int c_) {
  int p = last, now = last = ++ node_num;
  size[now] = 1, len[now] = len[p] + 1;
  for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
  if (! p) {link[now] = 1; return ;}
  int q = ch[p][c_];
  if (len[q] == len[p] + 1) {link[now] = q; return ;}
  int newq = ++ node_num;
  memcpy(ch[newq], ch[q], sizeof (ch[q]));
  link[newq] = link[q], len[newq] = len[p] + 1;
  link[q] = link[now] = newq;
  for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
}
void Prepare() {
  for (int i = 1; i <= node_num; ++ i) cnt[len[i]] ++;
  for (int i = 1; i <= node_num; ++ i) cnt[i] += cnt[i - 1];
  for (int i = 1; i <= node_num; ++ i) id[cnt[len[i]] --] = i;
  for (int i = node_num; i; -- i) {
    if (t) {
      size[link[id[i]]] += size[id[i]];
    } else {
      size[id[i]] = 1;
    }
  }
  size[1] = 0;
  for (int i = node_num; i; -- i) {
    sum[id[i]] = size[id[i]];
    for (int j = 0; j < kMaxm; ++ j) {
      if (ch[id[i]][j]) sum[id[i]] += sum[ch[id[i]][j]];
    }
  }
}
void Query() {
  if (k > sum[1]) {
    printf("-1");
    return ;
  }
  for (int now = 1; k > 0; k -= size[now]) {
    int p = 0;
    for (; k > sum[ch[now][p]]; ++ p) k -= sum[ch[now][p]];
    printf("%c", 'a' + p);
    now = ch[now][p];
  }
}
//=============================================================
int main() {
  scanf("%s", S + 1);
  n = strlen(S + 1);
  t = read(), k = read();
  for (int i = 1; i <= n; ++ i) Insert(S[i] - 'a');
  Prepare();
  Query();
  return 0;
}

「TJOI / HEOI2016」字符串

给定一长度为 \(n\) 的字符串,\(m\) 个询问。
每次询问给定参数 \(a,b,c,d\),求子串 \(S[a:b]\)所有子串,与子串 \(S[c:d]\) 的最长公共前缀的最大值。
\(1\le n,m\le 10^5, 1\le a\le b\le n, 1\le c\le d\le n\)
2S,256MB。

对于每一个询问,答案满足单调性,考虑二分答案。
\(l\) 为当前二分到的最长的,子串 \(S[a:b]\)所有子串,与子串 \(S[c:d]\) 的最长公共前缀。
\(l\) 合法,则 \(S[c:c+l-1]\)\(S[a:b]\) 中出现过,则其结束位置位于 \(S[a+l-1:b]\) 中。

考虑对原串建 SAM,跑出子串 \(S[c:c+l-1]\) 的状态 \(u\),检查状态 \(u\) 是否维护了 \(S[a:b]\) 的信息。
具体地,检查该状态的子树中的状态 \(v\),是否有 \(\exist x\in [a, b-l+1], x\in\operatorname{endpos}(v)\) 成立。


这样为什么是对的?
子树中的状态 \(v\),其 \(\operatorname{len}(v)>\operatorname{len}(u)\ge l\),且均以状态 \(u\) 代表的子串,即 \(S[c:c+l-1]\) 作为后缀。
\(\exist x\in [a+l-1, b], x\in\operatorname{endpos}(v)\),则存在一个子串 \(S[?:x]\),其长度 \(\ge l\),且以 \(S[c:c+l-1]\) 为后缀。
显然,\(S[a:x]\) 也以 \(S[c:c+l-1]\) 为后缀,从而证明了 \(l\) 的合法性。

维护 \(\operatorname{endpos}\) 的信息可对每个状态都维护一棵权值线段树。类似维护集合信息,线段树合并自底向上更新信息即可。


然后 T 了。暴力跑 \(S[c:c+l-1]\) 的状态太慢啦!
考虑套路,在建 SAM 的时候记录每一个前缀 \(S[1:x]\) 的对应状态。
二分时先找到 \(S[1:c+l-1]\) 对应状态 \(u\)
发现 \(S[c:c+l-1]\) 为该前缀的一个后缀,对应状态是 \(u\) parent 树上的祖先。
倍增上跳到最后一个 \(\operatorname{len} \le l\) 的位置,即为 \(S[c:c+l-1]\) 对应状态。

先进行线段树合并预处理 \(\operatorname{endpos}\) 信息,查询时二分套倍增再线段树查询,复杂度 \(O(n\log n +n\log^2 n)\) 级别。

这题用 SA 过了,所以代码咕了(

「NOI2018」你的名字

抄着题解水过去了。这就是 noi 吗/fad

给定模式串 \(S\),有 \(Q\) 次询问。
每次询问给定询问串 \(T\),参数 \(l,r\)
\(T\) 的本质不同子串中 没有在 \(S[l:r]\) 中出现过的个数。
\(1\le |S|\le 5\times 10^5\)\(1\le l\le r\le |S|\)\(1\le Q\le 10^5\)\(1\le \sum |T|\le 10^6\)
特殊性质:有 \(68\%\) 的测试点满足询问的 \(l=1, r=|S|\)
4S,1GB。

\(T\) 的本质不同子串 没有\(S[l:r]\) 中出现过的个数,等价于 总个数减去出现过的个数。

建出 \(T\) 的 SAM,本质不同子串的总个数 = \(\sum\operatorname{len}(i) - \operatorname{len}(\operatorname{link}(i))\)
考虑如何快速求得出现过的子串个数。


先考虑特殊性质 \(l=1, r=|S|\)。即需要求得 \(T\) 的本质不同子串 在 \(S\) 中出现的次数。
考虑对于 \(T\) 的每一个前缀 \(T[:i]\),求得其在 \(S\) 中出现的最长的后缀 \(\operatorname{match}_i\),可在 \(S\) 的 SAM 上匹配 \(T\) 求得。
具体怎么求详见:如何用 SAM 过 SP1811

对于每一个 SAM 的状态,其维护的所有串的 \(\operatorname{endpos}\) 相同。
维护 \(T\) 的 SAM 的每个状态 \(i\)\(\operatorname{endpos}\) 中最小的值,设为 \(\operatorname{firend}_i\)

考虑 \(T\) 的 SAM 的状态 \(i\) 维护的 \(T\) 的子串,它们互为后缀,且其长度在 \([\operatorname{len}(\operatorname{link}(i)) + 1, \operatorname{len}(i)]\) 内。
\(\operatorname{match}_{{\operatorname{firend}}_i}\ge \operatorname{len}(\operatorname{link}(i)) + 1\),表示状态 \(i\) 维护的串中小于等于 \(\operatorname{match}_{{\operatorname{firend}}_i}\) 的在 \(S\) 中出现过。则 \([\operatorname{len}(\operatorname{link}(i)) + 1, \operatorname{match}_{\operatorname{firend}_i}]\) 内的串,均在 \(S\) 中出现过,其它串均在 \(S\) 内未出现。

考虑枚举 \(T\) 的 SAM 的状态,则 \(T\) 的在 \(S\) 中出现过的,本质不同的子串个数,为:

\[\sum_{i=2}{\large{\{}}\max\{0, \operatorname{match}_{\operatorname{firend}_i}-\operatorname{len}(\operatorname{link}(i))\}{\large{\}}} \]

再考虑一开始的补集转化,由于可能存在 \(\operatorname{match}_{\operatorname{firpos}_i}>\operatorname{len}(i)\),则答案为:

\[\sum_{i=2}{\large{\{}} \max\left\{0, \operatorname{len}(i) -\max\left[\operatorname{match}_{\operatorname{firpos}_i}, \operatorname{len}(\operatorname{0link}(i))\right]\right\}{\large{\}}} \]


再考虑 \(l,r\) 任意的情况。
发现 \(l,r\) 的改变,只影响上述过程中对 \(\operatorname{match}\) 的预处理。
如果仍按照上述过程进行匹配,可能会匹配到一个不属于 \(S[l:r]\) 的子串。

考虑使用权值线段树合并维护 \(\operatorname{endpos}\) 即可。
合并后的 \(\operatorname{endpos}\) 意义与 SAM 中的原定义不同,代表以该状态代表的串 为后缀的 所有 位置。

设匹配的长度为 \(len\)。若当前匹配到的状态属于 \(S[l:r]\),等价于匹配到的状态的 \(\operatorname{endpos}\) 中存在 \([l+len-1,r]\) 中的数。

考虑在匹配时增加判断:
设当前状态匹配的长度为 \(len\),若应该按照对应字符转移到的下一个状态 的 \(\operatorname{endpos}\) 中存在 \([l+len,r]\),则可转移。否则令 \(len-1\),再进行检查。
\(len = \operatorname{len}(\operatorname{link}(i))\),令当前匹配的状态转移到 \(\operatorname{link}(i)\),再重复进行上述检查过程。

复杂度 \(O(n\log n)\) 级别。

//知识点:SAM,线段树合并
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
#define max std::max
const int kMaxn = 1e6 + 10;
const int kMaxm = 26;
//=============================================================
char S[kMaxn], T[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;
}
struct SuffixAutomaton {
  int n, last = 1, node_num = 1;
  int firend[kMaxn << 1];
  int ch[kMaxn << 1][kMaxm], len[kMaxn << 1], link[kMaxn << 1];
  void Clear() {
    for (int i = 1; i <= node_num; ++ i) {
      link[i] = len[i] = firend[i] = 0;
      memset(ch[i], 0, sizeof (ch[i]));
    }
    last = node_num = 1;
  }
  void Insert(int c_, int pos_) {
    int p = last, now = last = ++ node_num;
    firend[now] = pos_; len[now] = len[p] + 1;
    for (; p && ! ch[p][c_]; p = link[p]) ch[p][c_] = now;
    if (! p) {link[now] = 1; return ;} 
    int q = ch[p][c_];
    if (len[q] == len[p] + 1) {link[now] = q; return ;}
    int newq = ++ node_num;
    memcpy(ch[newq], ch[q], sizeof(ch[q])); 
    link[newq] = link[q];
    len[newq] = len[p] + 1; 
    firend[newq] = firend[q]; //注意继承
    link[q] = link[now] = newq; 
    for (; p && ch[p][c_] == q; p = link[p]) ch[p][c_] = newq;
  }
};
struct SuffixAutomaton1 {
  #define ls lson[now_]
  #define rs rson[now_]
  SuffixAutomaton sam;
  int edge_num, head[kMaxn], v[kMaxn << 1], ne[kMaxn << 1];
  int node_num, root[kMaxn << 1];
  int lson[kMaxn << 5], rson[kMaxn << 5], sum[kMaxn << 5];
  void TreeInsert(int &now_, int L_, int R_, int pos_) {
    if (! now_) now_ = ++ node_num;
    sum[now_] ++;
    if (L_ == R_) return ;
    int mid = (L_ + R_) >> 1;
    if (pos_ <= mid) TreeInsert(ls, L_, mid, pos_);
    else TreeInsert(rs, mid + 1, R_, pos_);
  }
  int Merge(int x_, int y_, int L_, int R_) {
    if (! x_ || ! y_) return x_ + y_;
    int now_ = ++ node_num;
    sum[now_] = sum[x_] + sum[y_];
    if (L_ == R_) return now_;
    int mid = (L_ + R_) >> 1;
    ls = Merge(lson[x_], lson[y_], L_, mid);
    rs = Merge(rson[x_], rson[y_], mid + 1, R_);
    return now_;
  }
  int Query(int now_, int L_, int R_, int ql_, int qr_) {
    if (! now_) return 0;
    if (ql_ <= L_ && R_ <= qr_) return sum[now_];
    int mid = (L_ + R_) >> 1, ret = 0;
    if (ql_ <= mid) ret += Query(ls, L_, mid, ql_, qr_);
    if (qr_ > mid) ret += Query(rs, mid + 1, R_, ql_, qr_);
    return ret;
  }
  void AddEdge(int u_, int v_) {
    v[++ edge_num] = v_, ne[edge_num] = head[u_], head[u_] = edge_num;
  }
  void Dfs(int u_) {
    for (int i = head[u_]; i; i = ne[i]) {
      int v_ = v[i];
      Dfs(v_);
      root[u_] = Merge(root[u_], root[v_], 1, sam.n);
    }
  }
  void Build() {
    sam.n = strlen(S + 1);
    for (int i = 1; i <= sam.n; ++ i) {
      sam.Insert(S[i] - 'a', i);
      TreeInsert(root[sam.last], 1, sam.n, i);
    }
    for (int i = 2; i <= sam.node_num; ++ i) AddEdge(sam.link[i], i);
    Dfs(1);
  }
  #undef ls
  #undef rs
} sam1;

struct SuffixAutomaton2 {
  SuffixAutomaton sam;
  int match[kMaxn << 1];
  void GetMatch(int l_, int r_) {
    int now = 1, nowlen = 0;
    for (int i = 1; i <= sam.n; ++ i) {
      while (true) {
        if (sam1.sam.ch[now][T[i] - 'a'] && 
            sam1.Query(sam1.root[sam1.sam.ch[now][T[i] - 'a']], 
                            1, sam1.sam.n, l_ + nowlen, r_)) {
          now = sam1.sam.ch[now][T[i] - 'a'];
          nowlen ++;
          break;
        }
        if (! nowlen) break;
        nowlen --;
        if (nowlen == sam1.sam.len[sam1.sam.link[now]]) {
          now = sam1.sam.link[now];
        }
      }
      match[i] = nowlen;
    }
  }
  void Build() {
    sam.Clear();
    sam.n = strlen(T + 1);
    for (int i = 1; i <= sam.n; ++ i) {
      sam.Insert(T[i] - 'a', i);
    }
  }
  void Solve() {
    int l = read(), r = read();
    ll ans = 0ll;
    GetMatch(l, r);
    for (int i = 2; i <= sam.node_num; ++ i) {
      ans += max(0, sam.len[i] - max(match[sam.firend[i]], sam.len[sam.link[i]]));
    }
    printf("%lld\n", ans);
  }
} sam2;
//=============================================================
int main() {
  freopen("name.in", "r", stdin);
  freopen("name.out", "w", stdout);
  scanf("%s", S + 1);
  sam1.Build();
  int q = read();
  while (q --) {
    scanf("%s", T + 1);
    sam2.Build(); sam2.Solve();
  }
  return 0; 
}

P6292 区间本质不同子串个数

SAM,LCT,线段树

之前好像说过无限期停更来着……不管了!

给定一长度为 \(n\) 的字符串 \(S\),给定 \(m\) 次询问。每次询问给定参数 \(l,r\),求由 \(s\) 的第 \(l\) 到第 \(r\) 个字符组成的字符串包含多少个本质不同的子串。
定义两个字符串 \(a,b\) 相同当且仅当 \(|a|=|b|\) 且对于 \(i\in[1,|a|]\) 都有 \(a_i=b_i\)
\(1\le n\le 10^5\)\(1\le m\le 2\times 10^5\)
1S,500MB。

一些约定:

\(s\) 的第 \(l\) 到第 \(r\) 个字符组成的子串为 \(s[l:r]\)
SAM 的状态 \(u\) 的后缀链接为 \(\operatorname{link}(u)\)。其维护的字符串的终止集合为 \(\operatorname{endpos}(u)\),其中最长串的长度为 \(\operatorname{len}(u)\),。

先考虑最简单的暴力。

套路地考虑此类区间无重问题(P1972 [SDOI2009]HH的项链),离线询问并按右端点排序。之后枚举右端点,考虑新加入新字符的影响,并回答以枚举位置为右端点的询问。设当前枚举到的右端点为 \(r\),某次询问的区间为 \([l,r]\)。对于前缀 \(s[1:r]\) 中的一个子串 \(t\),当且仅当其最后一次出现位置的左端点 \(p\) 满足 \(p\ge l\) 时,它会对这次询问做出 1 的贡献。
由上,考虑维护一个权值数列。对于前缀 \(s[1:r]\) 中的每种本质不同子串 \(t\),记其最后一次出现位置的左端点为 \(p\),令权值数列位置 \(p\) 加 1。询问区间 \([l,r]\) 的答案即为权值数列对应区间的和。

考虑右端点 \(r\) 右移一位的影响。发现仅会影响作为前缀 \(s[1:r+1]\) 的后缀的子串的最后一次出现位置。又发现这些子串对应的状态恰好就是前缀 \(s[1:r + 1]\) 的 SAM 上从 \(s[1:r+1]\) 对应状态到根的链上的所有状态,于是考虑对每个 SAM 的状态 \(u\) 维护其 \(\operatorname{endpos}\) 集合中的最大值,即其中所有串最后一次出现位置的右端点,记为 \(\operatorname{end}_u\)。SAM 的每个状态对应子串的 \(\operatorname{endpos}\) 集合相同,则同一状态所有串最后一次出现位置的左端点也构成了一段区间,即为 \([\operatorname{end}_u - \operatorname{len}(u) + 1, \operatorname{end}_u - \operatorname{len}(\operatorname{link} (u))]\)。并且可以发现这些区间的并即为 \([1,r + 1]\)

考虑动态维护 SAM,在加入新字符后暴力跳 parent 树枚举所有被影响的串对应状态,更新它们最后一次出现位置的左端点对权值区间的贡献即可。权值数列可以使用线段树维护,单次右端点移动复杂度 \(O(n\log n)\) 级别,总复杂度 \(O((n^2 + m)\log n)\) 级别。


考虑上述算法中在 parent 树上进行了什么操作:

  • 从链底暴力上跳,并对每个状态上对应区间进行区间减。
  • 将链上每个节点的 \(\operatorname{end}_u\) 都修改为 \(r+1\)

瓶颈在于操作 1 中每个状态对应的区间不同,必须暴力上跳。但可以发现操作 1 类似 LCT 的 access 操作,操作 2 是以根为端点的链覆盖,考虑使用 LCT 维护 parent 树。

发现 parent 树是一棵有根树,且链覆盖操作一端点为根,仅需对被修改节点 access 成实链后即可直接进行覆盖。且根据此过程可知,一条实链所有状态的 \(\operatorname{end}\) 均相同,它们影响的位置构成了一段连续区间,又 LCT 上一个点到根最多有 \(\log n\) 级别个 splay,区间减的次数变为 \(\log n\) 次,顺便削除了操作 1 造成的瓶颈。

但上述算法中存在一个漏洞。在动态维护 SAM 的过程中需要进行加边删边操作。在此过程中需要进行 access,破坏了上述链覆盖得到的“一条实链所有状态的 \(\operatorname{end}\) 均相同”的优美性质。
但是可以发现根本没有必要动态维护 SAM。可以预先建立 SAM,并维护每个前缀对应的状态。令初始 LCT 中的边全为虚边,LCT 操作时对维护的前缀状态进行操作即可。由于每次链覆盖的对象,都是一段前缀的后缀,显然这样不会使得没有出现过的串做出贡献,可以保证正确性。

单次右端点移动复杂度变为 \(O(\log^2 n)\) 级别,总复杂度 \(O(n\log^2 n + m\log n)\) 级别。

只需要 access 的 LCT 真是太好写辣!

//知识点:SAM,LCT,线段树
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
struct Que {
  int l, r, id;
} q[kN << 1];
int n, m, pos[kN];
LL ans[kN << 1];
char S[kN];
//=============================================================
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 Chkmax(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
bool CMP(Que fir_, Que sec_) {
  return fir_.r < sec_.r;
}
namespace Seg {
  #define ls (now_<<1)
  #define rs (now_<<1|1)
  #define mid ((L_+R_)>>1)
  const int kNode = kN << 2;
  LL sum[kNode], tag[kNode];
  void Pushup(int now_) {
    sum[now_] = sum[ls] + sum[rs];
  }
  void Pushdown(int now_, int L_, int R_) {
    sum[ls] += 1ll * tag[now_] * (mid - L_ + 1);
    sum[rs] += 1ll * tag[now_] * (R_ - mid);
    tag[ls] += tag[now_];
    tag[rs] += tag[now_];
    tag[now_] = 0ll;
  }
  void Modify(int now_, int L_, int R_, int l_, int r_, LL val_) {
    if (l_ <= L_ and R_ <= r_) {
      sum[now_] += 1ll * (R_ - L_ + 1) * val_;
      tag[now_] += val_;
      return ;
    }
    Pushdown(now_, L_, R_);
    if (l_ <= mid) Modify(ls, L_, mid, l_, r_, val_);
    if (r_ > mid) Modify(rs, mid + 1, R_, l_, r_, val_);
    Pushup(now_);
  }
  LL Query(int now_, int L_, int R_, int l_, int r_) {
    if (l_ <= L_ and R_ <= r_) return sum[now_];
    Pushdown(now_, L_, R_);
    LL ret = 0;
    if (l_ <= mid) ret += Query(ls, L_, mid, l_, r_);
    if (r_ > mid) ret += Query(rs, mid + 1, R_, l_, r_);
    return ret;
  }
  #undef ls
  #undef rs
  #undef mid
}
namespace SAM {
  const int kNode = kN << 2;
  int node_num = 1, last = 1, tr[kNode][26], len[kNode], link[kNode];
  int end[kNode];
  void Insert(int ch_, int pos_) {
    int p = last, now = last = ++ node_num;
    pos[pos_] = now;
    len[now] = len[p] + 1;
    for (; p && !tr[p][ch_]; p = link[p]) tr[p][ch_] = now;
    if (!p) {
      link[now] = 1;
      return ;
    }
    
    int q = tr[p][ch_];
    if (len[q] == len[p] + 1) {
      link[now] = q;
      return ;
    }

    int newq = ++ node_num;
    memcpy(tr[newq], tr[q], sizeof (tr[q]));
    len[newq] = len[p] + 1;
    end[newq] = end[q];
    link[newq] = link[q], link[q] = link[now] = newq;
    for (; p && tr[p][ch_] == q; p = link[p]) tr[p][ch_] = newq;
  }
}
namespace LCT {
  #define f fa[now_]
  #define ls son[now_][0]
  #define rs son[now_][1]
  const int kNode = kN << 2;
  int fa[kNode], son[kNode][2], end[kNode], tag[kNode];
  void Modify(int now_, int val_) {
    if (!now_) return;
    end[now_] = tag[now_] = val_;
  }
  void Pushdown(int now_) {
    if (tag[now_]) Modify(ls, tag[now_]), Modify(rs, tag[now_]);
    tag[now_] = 0;
  }
  bool IsRoot(int now_) {
    return son[f][0] != now_ && son[f][1] != now_;
  }
  bool WhichSon(int now_) {
    return son[f][1] == now_;
  }
  void Rotate(int now_) {
    int fa_ = f, w = WhichSon(now_);
    if (!IsRoot(f)) son[fa[f]][WhichSon(f)] = now_;
    f = fa[f];

    son[fa_][w] = son[now_][w ^ 1];
    fa[son[fa_][w]] = fa_;

    son[now_][w ^ 1] = fa_;
    fa[fa_] = now_;
  }
  void Update(int now_) {
    if (!IsRoot(now_)) Update(f);
    Pushdown(now_);
  }
  void Splay(int now_) {
    Update(now_);
    for (; !IsRoot(now_); Rotate(now_)) {
      if (!IsRoot(f)) Rotate(WhichSon(f) == WhichSon(now_) ? f : now_);
    }
  }
  void Access(int pos_) {
    int last_ = 0, now_ = pos[pos_];
    for (; now_; last_ = now_, now_ = f) {
      Splay(now_), rs = last_;
      if (end[now_]) { //减去之前 end 的贡献
        Seg::Modify(1, 1, n, end[now_] - SAM::len[now_] + 1,
                             end[now_] - SAM::len[f], -1); //注意被修改区间
      }
    }
    Seg::Modify(1, 1, n, 1, pos_, 1); //
    Modify(last_, pos_); //链覆盖,更新 end
  }
}

void Init() {
  scanf("%s", S + 1);
  n = strlen(S + 1);
  m = read();
  for (int i = 1; i <= m; ++ i) q[i] = (Que) {read(), read(), i};
  std::sort(q + 1, q + m + 1, CMP);
  for (int i = 1; i <= n; ++ i) SAM::Insert(S[i] - 'a', i);
  for (int i = 1; i <= SAM::node_num; ++ i) LCT::fa[i] = SAM::link[i]; //初始时全为虚边
}
//=============================================================
int main() { 
  Init();
  for (int r = 1, i = 1; r <= n; ++ r) {
    LCT::Access(r);
    for (; q[i].r <= r && i <= m; ++ i) {
      ans[q[i].id] = Seg::Query(1, 1, n, q[i].l, q[i].r);
    }
  }
  for (int i = 1; i <= m; ++ i) printf("%lld\n", ans[i]);
  return 0; 
}

写在最后

鸣谢:

OI-wiki SAM
后缀自动机学习笔记 _ Menci's Blog
博文 Суффиксный автомат 版权协议为 Public Domain + Leave a link
博文 Суффиксный автомат 的英文翻译版 Suffix Automaton 版权协议为 CC-BY-SA 4.0
史上最通俗的后缀自动机详解 - KesdiaelKen 的博客
后缀自动机多图详解(代码实现) - maomao9173
《后缀自动机》 - 陈立杰

maoyiting 的查错。

posted @ 2021-01-02 22:26  Luckyblock  阅读(265)  评论(0编辑  收藏  举报