字符串算法

日志(?)

2026.3.21:更新了部分算法的描述和一系列细节问题。

目录

  • PART I AC 自动机
  • PART II 后缀自动机 SAM
  • PART III 其他自动机以及后缀数组

PART I AC 自动机 ACAM

引入

我们引入一个多模匹配问题:给定一个文本串 \(T\) 和多个模式串 \(S\),求每个 \(S\)\(T\) 出现的次数。

注:之前使用 KMP 进行引入,但想了想 KMP 与 ACAM 没啥关系,用 KMP 的匹配方式形成的自动机应当叫 KMP 自动机(?)两者的转移函数一个是前后缀,一个是后缀。

使用 KMP 算法只能通过多次反复匹配进行求解,记模式串总长为 \(m\),匹配串总长为 \(n\),则时间复杂度为 \(O(nm)\)。但是我们知道,模式串之间可能也存在重叠关系,直接反复匹配会浪费一定的时间。我们想到可以使用字典树去将这些模式串串起来,充分利用其中的相同性质。AC 自动机(Aho-Corasick automaton,ACAM)就是利用字典树和 \(fail\) 失配指针进行高效匹配的一个字符串数据结构。

符号以及定义

默认字符串编号从 \(0\) 开始。

我们假设我们在字典树的节点 \(v\),其中 \(v\) 的父节点是 \(u\)。我们记 \(trie(u,c)\) 表示在字典树中,\(u\) 通过一条边上转移字符为 \(c\) 的边指向 \(v\)。即:\(trie(u,c)=v\)

首先我们先假定 AC 自动机中只包含着若干模式串构成的字典树。此时 AC 自动机上一个状态对应着一条从根节点到该状态的路径,也就是一个字符串。我们称该状态的 \(fail\) 指针是一条有向边,从该状态指向字符串满足最长且存在的后缀对应字符串的状态。如插入 abc,bc 两个字符所建成的 AC 自动机,对应字符串为 abc 的状态就指向 bc 对应的状态。若不存在指向根节点。

我们可以明确的发现:\(fail\) 指针构成一棵树,我们称其为 \(fail\) 树。求解完 \(fail\) 指针也就是建立完了 ACAM。为方便,下文我们用 \(fail(x)\) 描述 \(x\) 状态的 \(fail\) 指针指向的状态。

构建算法

基本思想是:我们先建完浅层的 \(fail\) 指针,再通过这些指针求出深层的。现在我们已经求出了所有深度小于状态 \(v\) 的状态 \(u\)\(fail\) 指针已经求完,现在我们需要求 \(fail(v)\)

在构建算法的讨论中 \(u\) 是状态 \(v\) 的父亲状态(在字典树意义下)。

朴素算法

如果 \(fail(u)\) 可以通过 \(c\) 转移到一个非空节点。此时我们令 \(fail(v)=trie(fail(u),c)\)。若不可以,令 \(u=fail(u)\),回到上一步。如果跳到根节点也不存在,\(fail(v)\) 指向根节点。

理由:比起父亲节点我们只增加了一个 c,前面的最优 \(+c\) 一定是当前最优(如果存在)不然就直接跳后缀,因为中间其他后缀都不存在,下一个最长后缀自然就是我们寻找的目标。

从字典树到字典图

我们已经发现了一种计算的方法。但是我们发现暴力跳时间复杂度不可接受,我们需要更快。

我们类比路径压缩的思想,其实我们只关心我们最终指向哪里,却不关心中途怎么跳。

同时我们的做法需要分类讨论,并不是那么容易压缩,有什么办法让他变得简洁呢?

我们可以对于每个节点的每个儿子,若 \(trie(p,c)\) 不存在,我们建一个虚点表示,同时令 \(fail(trie(p,c))=trie(fail(p),c)\)

用处:如果 \(trie(fail(p),c)\) 实际不存在的话,这个值是 \(trie(fail(fail(p)),c)...\) 直到第一个存在的位置,也就是我们希望连向的点。同时下文匹配时这样子可以保证始终我们都会在 AC 自动机有用的节点上查询。

此时我们每个转移都是存在的,可以 \(O(1)\) 直接转移,缺点是在建立自动机时复杂度就是 \(O(nm)\)。当然如果 \(m\) 过大我们可以使用倍增(注意到如果一个后缀存在,这个后缀的所有后缀肯定也是存在的,也就是后缀具有单调性)复杂度 \(O(n \log n)\)

查询算法

我们已经详细介绍了构建自动机的方法。现在我们要考虑使用我们这个进行了一系列处理的自动机进行匹配。

匹配 \(1\)

我们只需要知道是否出现过。

首先我们先给出做法:令 \(pos=0\),即字典树根,然后做以下操作(称字符串为 \(S\)\(S\) 的第 \(i\) 个字符为 \(S_i\)\(i\)\(0\) 循环到 \(|S|-1\)):

  • 循环遍历每个字符串,令 \(pos=trie(pos,S_i)\)
  • 在循环中:令 \(u=pos\),记 \(vis_u=1\),若原本 \(vis_u\)\(1\) 则结束,否则令 \(u=vis_u\)

易得时间复杂度为 \(O(nm)\),其中 \(n\) 为模式串字符数总和,\(m\) 为字符集大小,一般有 \(m=26\)

正确性说明:虚拟节点的存在只是为了传递 \(fail\),那么其实我们将子节点直接连向对应节点效果相同。用符号表达,就是我们直接令 \(trie(p,c)=trie(fail(p),c)\)。一直传递下去,(可自行模拟)可知道最终这个指向的就是一个实际存在的节点,也就是 \(trie(p,c)\) 对应字符串的最长后缀字符串对应的状态。暴力跳的过程就本质上就是每一次找以 \(i\) 为结尾的字符串的过程。易知找到所有这样的字符串对应的就是答案。

匹配 \(2\)

我们通过拓扑排序计算出 \(fail\) 树子树和即可。后面 ACAM 的大部分题,都基于一个事情:在字典图中跑匹配,在 \(fail\) 树上搜答案。一定记住 AC 自动机就是由字典树和 \(fail\) 树组成的。

实现

#include<bits/stdc++.h>
using namespace std;
constexpr int MAXN = 2e6 + 5; 
int n;
string S[200005]; // 字符串
int Trie[MAXN][26], Fail[MAXN], num = 1; // Trie 树和 Fail 指针
int vis[MAXN], ind[MAXN], Ans[MAXN]; // 统计答案数组
vector<int> v[MAXN]; 
void Insert(string s, int u) { // 建 Trie
  int pos = 1, x = s.size();
  for(int i = 0; i < x; ++i) {
    if(!Trie[pos][s[i] - 'a']) {
      Trie[pos][s[i] - 'a'] = ++num;
    }
    pos = Trie[pos][s[i] - 'a'];
  }
  v[pos].push_back(u);
}
void Aho_Corasick() {
  queue<int> q;
  for(int i = 0; i < 26; ++i)
    if(Trie[1][i]) q.push(Trie[1][i]), Fail[Trie[1][i]] = 1; // 所有子节点
  while(!q.empty()) {
    int p = q.front();
    ++ind[Fail[p]]; // 计算入度
    q.pop();
    for(int i = 0; i < 26; ++i) {
      if(Trie[p][i]) { // 真节点
        Fail[Trie[p][i]] = (Trie[Fail[p]][i] == 0 ? 1 : Trie[Fail[p]][i]); // 注意:0 要使他回到 1
        q.push(Trie[p][i]);
      } else { // 虚节点
        Trie[p][i] = (Trie[Fail[p]][i] == 0 ? 1 : Trie[Fail[p]][i]);
      }
    }
  }
}
void Query(string s) {
  int p = 1, x = s.size();
  for(int i = 0; i < x; ++i) {
    p = (Trie[p][s[i] - 'a'] == 0 ? 1 : Trie[p][s[i] - 'a']);
    ++vis[p]; // 加入答案
  } 
} 
void TopoSort() { // 拓扑排序
  queue<int> q; 
  for(int i = 1; i <= num; ++i)
    if(ind[i] == 0) {
      q.push(i); 
    }
  while(!q.empty()) {
    int p = q.front();
    q.pop();
    --ind[Fail[p]], vis[Fail[p]] += vis[p];
    if(ind[Fail[p]] == 0) q.push(Fail[p]);
  }
}
int main() {
  cin >> n;
  for(int i = 1; i <= n; ++i) {
    cin >> S[i];
    Insert(S[i], i); 
  }
  Aho_Corasick();
  cin >> S[0];
  Query(S[0]);
  TopoSort();
  for(int i = 2; i <= num; ++i) {
    if(v[i].size() == 0) continue;
    for(auto x : v[i]) 
      Ans[x] = vis[i];
  }
  for(int i = 1; i <= n; ++i)
    cout << Ans[i] << endl; 
  return 0; 
}

代码不详说,看注释即可。大概分为几步走:

  • 建字典树。
  • \(fail\) 指针。
  • 加入答案。
  • 拓扑排序。

必须说明的一个注意点是,如果令树根为 \(1\),则一定要注意默认为 \(0\) 时跳回 \(0\) 的情况。

题目

我们将会根据“在字典图中跑匹配,在 \(fail\) 树上搜答案”这条基本准则进行做题。

文本生成器

一道经典的 ACAM DP 问题。首先我们需要将“了解”的字符串建成 ACAM,然后试图求解。至少包含一个并不好做,我们将其转化为总共的个数(\(26^m\))减去一个都不存在的个数。定义 \(dp_{i,j}\) 表示考虑达到 \(i\) 节点长度为 \(j\) 的“一个都不出现”的方案数,然后这东西根据字典图上跑即可简单转移,唯一难点在于如何判断是否合法。

合法性根据我们的基本准则不难想到就是该节点沿着 \(fail\) 树上往上跳不能存在一个结尾节点,这个显然是可以预处理出来的。复杂度与匹配一致。

病毒

首先考虑无限长文本串的匹配。我们不可能实际上去匹配,但是我们考虑这个过程实际上就是从根节点出发一直走然后进入一个环上始终打转,并且环+路径不会带有任何病毒片段(即某个字符串的终止节点)那么我们考虑建出 ACAM,然后考虑如何找环。

容易考虑到使用 \(dfs\) 得到一棵 \(dfs\) 树,返祖边就可以形成环。然后判定是否是危险节点跟上题一样进行预处理即可。

Video Game G

套路的建出 ACAM,然后定义 \(dp_{i,j}\) 为到达节点 \(i\) 长度为 \(j\) 的最大分数,套路的转移即可。唯一问题在于如何算一个节点的分数,这个拓扑排序预处理即可。

阿狸的打字机

首先字符串总长可能超级长,所以说我们不能直接把所有串建出来。但是发现串之间是有前后缀关系的,重复的我们没必要重复建,直接利用即可。

然后建完 ACAM 考虑如何做。根据 \(fail\) 树和 \(Trie\) 树的性质,原问题等价于求 \(x\)\(Fail\) 树上的子树与 \(y\) 从根节点到该节点(\(Trie\))的公共节点个数。直接维护不易,考虑离线 \(y\) 处理。也就是说我们每次遍历 \(Trie\),计算对应节点的答案。每经过一个节点标记该节点,对于每一个 \(x\) 求的就是 \(dfs\) 序范围内的节点,树状数组容易维护。


PART II 后缀自动机 SAM

通俗的说,SAM 是接受一个字符串所有子串的自动机,也就是说 SAM 压缩了一个子串的所有信息,并且可以做到线性大小。SAM 的延伸品是 parent 树。

符号以及定义

默认字符串编号从 \(0\) 开始。

对于字符串 \(s\) 的子串 \(t\),记 \(endpos\{t\}\) 代表 \(t\) 每一次出现的末位置的集合。例如 abcbc\(endpos\{bc\}=\{2,4\}\)

我们在 SAM 中,一个状态存的是所有 \(endpos\{t\}\) 相等的子串。例如 abcb 包含子串以及其 \(endpos\) 如下:

  • \(endpos\{a\}=\{0\}\)
  • \(endpos\{ab\}=\{1\}\)
  • \(endpos\{abc\}=\{2\}\)
  • \(endpos\{abcb\}=\{3\}\)
  • \(endpos\{b\}=\{1,3\}\)
  • \(endpos\{bc\}=\{2\}\)
  • \(endpos\{bcb\}=\{3\}\)
  • \(endpos\{c\}=\{2\}\)
  • \(endpos\{cb\}=\{3\}\)

然后我们认为当前具有 \(5\) 个状态,分别是 \(\{a\},\{ab\},\{bc,abc,c\},\{bcb,abcb,cb\},\{b\}\)。我们令 \(len_i\) 表示一个状态所代表等价类最长的长度,上面 \(5\) 个状态的 \(len\) 分别是 \(\{1,2,3,4,1\}\)。这是 SAM 中重要的概念,务必理解后再去理解 SAM 的构造过程。

parent 树是也即后缀链接组成的树,其定义为一个状态里所代表的最长字符串的后缀中最长的满足于该字符串不属于一个等价类的字符串所属的状态。例如上文状态 \(\{bc,abc,c\}\) 最长的是 \(abc\),其后缀中 \(c,bc\) 都与它在同一个状态,因此我们在 parent 树中连向根节点;\(\{ab\}\) 的后缀中 \(b\) 与其不在同一个等价类,因此我们将这个状态在 parent 树中连向 \(\{b\}\)

我们发现 \(endpos\) 有两个个性质,若两个字符串 \(endpos\) 相同或一个字符串的 \(endpos\) 属于另一个字符串,则他们一定有后缀关系,即一个是另一个的后缀。第二个性质就是随着状态 \(p\) 跳到其在 parent 树上父节点的状态,那么 \(len\) 单调递减。这些非常重要。

构建算法

SAM 构建的基本思想是已经构建完字符串 \(s\) 的 SAM,接着构造 \(s+c\) 的 SAM,其中 \(c\) 是一个字符。我们记字符串 \(s\) 所代表的等价类位置为 \(lst\),当前状态为 \(cur\)。我们初始时连一条 \(lst\)\(cur\) 转移为 \(c\) 的边。

与我们新加节点有关的子串一定是 \(s\)\(s\) 的后缀。为了不重复跳我们会在 parent 树上从 \(lst\) 节点往上跳。如果读者有详细阅读上文 parent 树的定义的话,这一步是自然的。

情况 \(1\)

我们跳的过程中没有遇到任何状态 \(p\),满足 \(p\) 有向 \(c\) 的转移。也就是说路径上所有的子串 \(+c\) 所出现的位置都是一致的,也就是当前的结尾位置。那么让所有状态在 SAM 上都连向 \(cur\)。在 parent 树上这个新节点直接连向根节点,因为不存在任意一个它的后缀满足与他不是一个等价类。

理由:之前都没有这样的转移说明这样的转移是唯一的,遍历所有后缀已经遍历所有可能的新状态,因此这些状态 \(endpos\) 全是结尾位置,都等价,因此全都连向 \(cur\),并且所有可能的后缀都与它等价,那么没有不等价的那么就连向根节点。

情况 \(2\)

我们路径上第一个遇到了一个状态 \(p\),满足 \(p\) 有向 \(c\) 的转移,设转移到的状态为 \(q\)。同时满足 \(len_p+1=len_q\)这是情况 \(2\) 区别与情况 \(3\) 的核心,下文将说明产生的区别。我们将 parent 树上 \(cur\) 的父节点指向 \(q\),剩下中途经过路径指向 \(cur\) 即可(转移都是 \(c\)

理由:第一个跳到的就一定是最长的后缀,满足该条件说明该等价类所有的子串 \(+c\) 完仍然保持等价(\(endpos\) 的性质)同时中途路径上不等价(注意到中途路径上没有额外多出的前面位置)因此中途路径连向 \(cur\)。再往上跳与 \(q\) 有关,因此我们不必再连。那么自然 parent 树上第一个 \(endpos\) 集合更大的后缀就是 \(q\),连接即可。

情况 \(3\)

我们路径上第一个遇到了一个状态 \(p\),满足 \(p\) 有向 \(c\) 的转移,设转移到的状态为 \(q\)。同时满足 \(len_p+1=len_q\)

区别:不满足说明 \(q\) 代表的等价类中有更长的字符串满足去掉结尾 \(c\) 以后的字符串不是 \(s\) 的后缀,那么加完 \(c\) 以后这两类是后缀的会更多而不是的没有增加那么就不等价了,那么我们等价类就要分裂。

具体而言我们将 \(q\) 复制一份得到状态 \(q'\),并做如下操作:

  • \((1)\) \(q'\) 的所有后继直接与 \(q\) 的后继相同。
  • \((2)\) 所有满足 \(p\) 父亲经过 \(c\) 指向 \(q\) 的节点(一定是连续的一段)全部改完指向 \(q'\)
  • \((3)\) \(len_{q'}=len_p+1\)
  • \((4)\) parent 树上 \(q'\) 的父亲直接等于 \(q\) 的父亲,\(cur,q\) 的父亲改为 \(q'\)
  • \((5)\) 中途所有状态在 SAM 上正常连向 \(cur\)

理由:首先 \((5)\) 正常路径没有增加全部等价连向 \(cur\) 是明显的,解释同前面。本质上我们分裂出来的 \(q'\) 事实上代表的是由 \(s\) 的后缀 \(+c\) 组成的部分,由前面等价可知他们的后继仍然等价(我们只加了一个字符,\(+c\) 再后面那部分没有增加),这样就解释了 \((1)\)。由 \((1)\) 的解释可知现在 \(s\) 的后缀 \(+c\) 已经变成 \(q'\) 了,那么接过来也是明显的。同理 \((3)\) 也是自然的。\(cur\) 的父亲指向 \(q'\) 的解释与情况 \(2\) 相同,\(q\) 的父亲就是更短 \(len\) 更长的后缀 \(q'\)(原本等价,分裂后一定在 parent 树上连续)\(q'\) 的父亲就是 \(q\) 的父亲也是因为原本就是一个节点。

当然我们还要最后说明一个事情,就是为什么情况 \(3\) 不用继续往上跳找到其他需要分裂的节点呢?事实上是因为 \(endpos/len\) 和后缀具有优美的单调特性,让它不可能出现情况 \(3\),读者可以自证。

那我们就简单清晰(?)的介绍完了 SAM 的构建。时间复杂度是线性,证明可以看 OI-WIKI。为了方便理解,下面将配上 abcbc 的完整构建过程插图。

WPS拼图0

上面是 parent 树随着插入的变化。

WPS拼图1

上面是 SAM 随着插入产生的变化。

总结

理论上来说想要灵活使用 SAM 只需要理解其中 \(endpos\)\(link\) 存的是什么即可,具体的建立方法其实并没有什么大用。下面给出一些性质。

性质 \(1\)

SAM 每个状态所代表的最长子串就是从根节点沿着最长路走到该节点的路径形成的字符串。

解释:根据定义可知连接就是从一个等价类经过一个字符转移到另一个等价类。取最长即可归纳证明。(这个可能不算性质,仅仅是解释如何找到最长子串)

性质 \(2\)

parent 树上每个状态到根节点路径形成一个前缀集合。

解释:parent 树本质上是后缀连接树,而过程中我们加的点事实上是某个前缀,所有每个状态都是前缀的后缀,这样很好解释为什么包含所有子串的信息。

性质 \(3\)

parent 树上每个状态的 \(endpos\) 集合一定包含其子状态。

解释:首先肯定是没有交集的,然后根据“后缀连接”的定义不难理解。

性质 \(4\)

parent 树上叶子节点的 \(endpos\) 集合大小为 \(1\)

证明:考虑反证法,假设出现出现次数不为 \(1\),则一定有更长的子串满足该串是子串的后缀。一直延长总会有到达 \(1\) 的时候,否则字符串无穷长。只要有到达 \(1\) 的时候,就有子节点,与假设矛盾,因此原命题成立。

实现

struct SAM {
  int link, len, nxt[M];
}sam[N << 1]; 
void Init() { // 初始化
  sam[0].link = -1, sam[0].len = 0, lst = 0;
} 
void sam_extend(int x) {
  int cur = ++num, p = lst; //新节点
  sam[cur].len = sam[p].len + 1;
  while(p != -1 && !sam[p].nxt[x])
    sam[p].nxt[x] = cur, p = sam[p].link; //找 p,q
  if(p == -1) { // 情况 1
    sam[cur].link = 0;
  } else { // 情况 2
    int q = sam[p].nxt[x];
    if(sam[p].len + 1 == sam[q].len) {
      sam[cur].link = q;
    } else { // 情况 3
      int New = ++num;
      sam[New] = sam[q];
      sam[New].len = sam[p].len + 1;
      while(p != -1 && sam[p].nxt[x] == q)
        sam[p].nxt[x] = New, p = sam[p].link;
      sam[q].link = sam[cur].link = New;
    }
  }
  lst = cur; // 更新 lst
} 

题目

\(endpos\) 的计算

计算出每一个状态的 \(endpos\) 大小。

对于一个等价类,我们发现他在 parent 树上的所有子节点都是其的一次出现,而且这些出现位置应当是没有重合的(若重合,则一定有前后缀关系)然后字符串所有前缀对应的节点再加即可。

本质不同子串个数

首先对字符串建立 SAM。然后我们发现一个集合已经不重不漏的包含所有本质不同的子串了,那么我们计算出每个状态所包含的子串个数即可。这个就是当前状态 \(len\) 减去 parent 树上父节点 \(len\) 即可。

从 SAM 的视角就是我们要算出每个状态的子串个数,自动机是一个 DAG,我们可以 \(dp\)。我们定义 \(dp_i\) 表示状态 \(i\) 的子串个数。那么显然当 \(j \rarr i\) 时,\(dp_j \rarr dp_i\)。也可以简单计算。

[模板]后缀自动机

首先依旧建立 SAM。

然后我们注意到一个等价类里出现次数是相同的,我们在 parent 树上算出每个状态的出现次数,然后乘以最长子串长度计算即可。复杂度线性。

最长公共子串

记两个串分别为 \(s,t\),对 \(t\) 建立 SAM。

从根节点上开始跑 SAM,我们注意到每次我们访问到的是一个前缀,此时我们延长长度。当我们无法访问时,我们可以直接跳 parent 树,因为等价类相同的显然都没有出现过。

等价类全没有出现过:注意到等价类是取结尾位置,结尾位置相同其中一个找不到对应位置则全部找不到对应位置。这显然是正确的。

然后实时维护当前的长度即可。复杂度线性。

复杂度线性:参照 KMP。

[模板] AC 自动机

没错 SAM 也可以做 AC 自动机模板题。

对文本串 \(T\) 建 SAM,然后更前面一样差不多的跑模式串 \(S_i\)。注意到我们只要按照转移一直走若能找到对应的状态则答案就是其 \(endpos\),参照求 \(endpos\) 方法即可。

\(k\) 小字典序

我们发现假设我们可以求出来从原点往 a 走包含的子串个数,我们就可以判断走 a 能不能得到第 \(k\) 小,同理我们只要算出一个状态包含所有后继子串个数(注意,可能计重)。

  • 如果不计重:这类复杂情况是有利于 SAM 的。SAM 本质就是将所有子串去重以后丢到一个个等价类里,那么直接 \(dp\) 预处理一下直接查询就是严格线性。

  • 如果计重:没关系我们有 parent 树,简单求出一个状态所包含子串个数,\(endpos\) 大小然后同样直接查询也是严格线性。

我们可以发现 SAM 强大之处体现在:你想要的他全都有,你可以在这里找到一个字符串几乎所有的信息。

最短的没有出现的字符串

给定你一个字符串 \(s(|s|=n)\),字符集大小为 \(m\),求最短的没有出现的字符串。


首先先对 \(s\) 建 SAM。

然后我们注意到我们可以二分,这部分复杂度是 \(\log n\) 的,考虑如何快速查询一个长度为 \(k\) 的字符串是否可以做到不在 \(s\) 中出现。

我们发现具体说一个字符串不是很好做,但是我们可以求出 \(s\) 中所有长度为 \(k\) 的字符串个数(去重)这个计算方式就是根据 parent 树上的 \(len\) 来计算出一个节点的长度范围,然后即可计算出来。时间复杂度是 \(O(nm \log n)\)\(O(n \log^2 n)\)

注:时间复杂度是因为建立 SAM 的复杂度有两种:\(O(nm)\)\(O(n \log n)\),遍历都是线性的。这点跟前面 ACAM 有点像。

与 SA 的关系

upd by 2026/3/21:修改了这题非常神秘的表述。

SA 将字符串所有的后缀排序,理论上说子串=后缀的前缀,加上 \(lcp\) 可以达到与 SAM 类似的功能。不过其实我们知道一点就是 SA 的代码一点都不简洁,我们希望可以使用 SAM 达到一样的功能。

建立 SAM 后将字符串 swap 查询所有后缀,并标记。然后正着遍历即可后缀排序。对于这些后缀的 \(lcp\),我们知道 SAM 的 parent 树有一个东西,就是两个节点的 \(lca\) 正好就是最长公共后缀。那么你把 swap 后的串也建 SAM 就完事了。

Substrings in a string

注:该链接并不能用来提交题目,仅用来指示题面,若要提交请去 Codeforces。

我们发现这道题带修,非常的不好,那我们能不能扩展一下 SAM 让他可以支持带修呢?目前是不能的,我们考虑做一些改进使它可以做这道题。

不能的可能原因:SAM 构建是线性的,如果你从中间插入一个那么后面所有的基础全被破坏,SAM 信息的极致压缩也就意味着他不是很能支持常见的修改操作。

我们被修改的 SAM 就算是报废了,那么 SAM 就要重构。我们需要平衡重构的复杂度,这让我们想到了分块。具体而言我们分 \(B\) 块,每块建立 SAM,遇到修改重构即可。查询时我们发现长度如果大于 \(B\) 则暴力 KMP,这个过程只会发生 \(\dfrac{n}{B}\) 次,然后 \(<B\) 就在 SAM 上跑就可以了。

CSP-S 2025 谐音替换

upd by 2026/3/21:修改了这题非常神秘的表述。

首先我们长度不同显然是不可能的,然后若长度相同我们将把不同的部分弄出来。

然后字符串问题一个基本的思路就是把若干个东西当做一个字符,这题就是将两个字符压成一个字符。然后我们需要找到一个前缀和后缀,满足这东西在前面一大堆替换中存在。使用 Hash 可以获得大约 40pts 的高分。考虑枚举后缀。试图求有多少个前缀出现过。

对于多串有个东西叫广义 SAM 不过我们不用管他因为在这题我们把一大堆东西组一起建一个 SAM 就够了,考虑一个字符串前缀那不就是 parant 树上的子树吗直接子树求和预处理就做完了。

[NOI 2018] 你的名字

upd by 2026/3/21:之前的是假算。现在的为口胡,建议谨慎对待,假了私信作者。

首先我们思考两个串如何做。我们发现这是一个经典问题:首先还是对 \(s\) 建 SAM,然后 \(t\) 在上面跑前缀,然后在 parent 树上覆盖后缀并动态维护个数。显然可以用树剖维护。

现在 \(s\) 总和可能很长。我们试图倒着建立 SAM,并将 parent 树可持久化一下。我们需要支持插入以及在中间插入,这个树剖可以简单修改一下完成,做一个可持久化数组。然后我们发现我们找到 \(l\) 对应的那一棵树,然后还是继续跑(但是我们需要忽略所有 \(>r\) 的信息,这是容易的)然后继续上述做法可以做到 \(O((n+q) \log^2 n)\)。然后事实上你可以离线,所以不可持久化其实也行。

[CTSC2012] 熟悉的文章

首先这里看似有多串,实际上没关系,我们对于每个串建 SAM,建完一个串以后令 \(lst=0\) 把一堆串建一起即可。当然更标准的做法是广义 SAM。

然后扫每一篇作文我们在扫 SAM 过程中维护每一个位置 \(i\) 的最长后缀满足出现过,记左端点为 \(x_i\)。然后我们进行 二分+DP。定义 \(dp_{i}\) 表示考虑前 \(i\) 个位置的最长熟悉长度。分类讨论:

  • 不选 \(i\)(舍弃)\(dp_i=dp_{i-1}\)
  • \(i\)。我们发现我们可以枚举上一个端点,范围在 \(i-x_i\)\(i-len\) 之间。我们发现 \(i-x_i\) 的性质是单调不减,因此我们可以单调队列优化这个 DP。当然选择这个的前提是我们二分长度 \(L \le i-x_i+1\)

复杂度 \(O(n \log n)\)

PART III 其他自动机以及后缀数组

SA(后缀数组)

这边提一嘴吧。

首先 SA 需要后缀排序。你可以用 SAM 搞定也可以使用一个倍增方法我们注意到我们可以将相邻 \(2^k\) 项进行排序,并将其 rk 压成一个数,然后合并再排序。朴素实现该思路复杂度为 \(O(n \log^2 n)\)

然后注意到值域只有 \(n\),那么可以桶排,做到 \(O(n \log n)\),不过不好写。

然后让 SA 发挥大作用还需要一个 \(height\)。定义 \(height_i=lcp(sa_i,sa_{i-1})\),然后有定理 \(lcp(sa_i,sa_j)=\min_{k=i+1}^j height_k\),感兴趣可以学习 OI-WIKI \(height\) 的求法。

其他自动机

常见的 PAM(回文自动机),序列自动机,以及 DP 套 DP,KMP 自动机等。

posted @ 2026-02-08 20:34  Onlog2n  阅读(67)  评论(0)    收藏  举报