从零掌握后缀自动机:OIer必备字符串处理神器

从零掌握后缀自动机:OIer必备字符串处理神器

【免费下载链接】OI-wiki :star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法) 【免费下载链接】OI-wiki 项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki

你还在为字符串匹配效率低而烦恼?面对重复子串查找、不同子串计数等问题时束手无策?本文将带你一文掌握后缀自动机(Suffix Automaton, SAM)的核心原理、实现步骤及实战应用,让你轻松突破字符串处理瓶颈,成为OI赛场上的字符串高手。读完本文,你将能够独立实现SAM、理解其线性复杂度的底层逻辑,并解决各类字符串经典问题。

什么是后缀自动机?

后缀自动机(SAM)是一种能高效表示字符串所有子串的数据结构,它以线性空间复杂度存储字符串的全部子串信息,并支持多种字符串操作的快速查询。对于长度为n的字符串,SAM仅需O(n)的空间和时间即可构造完成,这使得它在处理大规模文本时表现卓越。

SAM的核心价值在于其最小性——它是接受字符串所有后缀的最小确定性有限自动机(DFA)。与直接构建所有后缀的AC自动机相比,SAM通过合并等价状态将复杂度从O(n²)降至O(n),堪称字符串处理的"压缩神器"。

空字符串的后缀自动机结构

核心原理:从endpos集合到后缀链接树

endpos集合:状态的本质

SAM的每个状态对应一组具有相同结束位置集合(endpos) 的子串。例如对于字符串"abcbc",子串"bc"的endpos为{2,4},表示它在原字符串中结束于位置2和4。

关键性质

  • 两个子串的endpos相同当且仅当较短子串是较长子串的后缀
  • 不同状态的endpos集合要么完全包含,要么互不相交
  • 同一状态的子串长度构成连续区间[minlen, len]

后缀链接:状态间的父子关系

每个状态(除初始状态)都有一条后缀链接(link),指向对应最长子串的后缀中endpos不同的最长子串所在的状态。所有后缀链接构成一棵根向树,称为后缀链接树(parent树)。

后缀链接树结构示例

后缀链接树性质

  • 祖先节点的endpos集合包含子孙节点
  • 节点v的minlen = len(link(v)) + 1
  • 从任意节点沿后缀链接遍历终将到达初始状态

线性构造算法:增量扩展的艺术

SAM采用在线增量构造,每次添加一个字符并更新自动机。核心步骤包括创建新状态、更新转移关系和调整后缀链接,整个过程保持线性复杂度。

核心步骤解析

  1. 创建新状态:添加字符c时,创建对应整个新字符串的状态cur
  2. 更新转移:从last状态沿后缀链接回溯,为缺失c转移的状态添加指向cur的转移
  3. 处理冲突状态
    • 情况1:未找到冲突状态,cur的link指向初始状态
    • 情况2:找到的冲突状态q满足len(p)+1=len(q),cur的link指向q
    • 情况3:否则分裂q为clone状态,调整相关转移和链接

增量构造过程图示

实现代码示例

struct state {
  int len, link;
  std::map<char, int> next;
};

constexpr int MAXLEN = 100000;
state st[MAXLEN * 2];
int sz, last;

void sam_init() {
  st[0].len = 0;
  st[0].link = -1;
  sz++;
  last = 0;
}

void sam_extend(char c) {
  int cur = sz++;
  st[cur].len = st[last].len + 1;
  int p = last;
  while (p != -1 && !st[p].next.count(c)) {
    st[p].next[c] = cur;
    p = st[p].link;
  }
  if (p == -1) {
    st[cur].link = 0;
  } else {
    int q = st[p].next[c];
    if (st[p].len + 1 == st[q].len) {
      st[cur].link = q;
    } else {
      int clone = sz++;
      st[clone].len = st[p].len + 1;
      st[clone].next = st[q].next;
      st[clone].link = st[q].link;
      while (p != -1 && st[p].next[c] == q) {
        st[p].next[c] = clone;
        p = st[p].link;
      }
      st[q].link = st[cur].link = clone;
    }
  }
  last = cur;
}

完整实现代码可参考OI-Wiki官方代码库

实战应用:解决字符串经典问题

1. 不同子串计数

问题:计算字符串中不同子串的个数。

解法:利用后缀链接树的性质,每个状态贡献的子串数为len(v) - len(link(v)),求和即可得到总个数。

long long count_distinct_substrings() {
  long long ans = 0;
  for (int i = 1; i < sz; ++i) {
    ans += st[i].len - st[st[i].link].len;
  }
  return ans;
}

2. 子串出现次数统计

问题:查询模式串在文本中出现的次数。

解法:通过对后缀链接树进行拓扑排序,计算每个状态的endpos集合大小:

void calc_occurrences() {
  vector<int> order(sz), cnt(sz);
  iota(order.begin(), order.end(), 0);
  sort(order.begin(), order.end(), [&](int a, int b) {
    return st[a].len > st[b].len;
  });
  
  for (int v : order) {
    if (st[v].link != -1) {
      cnt[st[v].link] += cnt[v];
    }
  }
}

3. 最长重复子串

问题:找出字符串中最长的重复子串。

解法:遍历所有状态,寻找len(v)最大且cnt[v] ≥ 2的状态,其len(v)即为答案。

不同字符串的SAM结构对比

性能分析:为何SAM如此高效?

SAM的高效性源于其精妙的状态设计:

  • 状态数:最多2n-1个(n为字符串长度)
  • 转移数:最多3n-4个
  • 构造时间:O(n)(假设字符集大小为常数)

这种线性复杂度使得SAM能够轻松处理百万级长度的字符串,远超后缀数组等传统结构。

学习资源与进阶方向

掌握SAM不仅能解决字符串问题,更能培养对复杂数据结构的设计思维。从endpos集合的等价类划分到后缀链接树的层级关系,SAM的每个细节都闪耀着算法设计的智慧光芒。现在就动手实现你的第一个SAM,开启字符串处理的高效之旅吧!

点赞收藏本文,关注后续字符串专题,解锁更多OI算法技巧!下一篇我们将深入探讨SAM与后缀树的关系,敬请期待。

【免费下载链接】OI-wiki :star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法) 【免费下载链接】OI-wiki 项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值