后缀自动机解析:本质不同子串与最长重复片段统计

引言

2026 南昌市中小学生信息学奥林匹克竞赛报名正在火热进行(9 月 29 日---10 月 16 日,10 月 25 日开赛),提高组考纲把"特殊树""哈希表""搜索与图论"都列了进去,而字符串处理作为信奥进阶的硬骨头,几乎贯穿从普及组到省选的每一场实战。

提到字符串,很多同学的第一反应是 KMP 或者哈希,但当你要回答"一个串里本质不同的子串有多少个 ""最长的重复片段 有多长""某个片段出现过几次 "这类问题时,暴力枚举会瞬间退化成指数级。本期我们请出字符串算法的"终极大杀器"------后缀自动机(Suffix Automaton,简称 SAM),用一份呼号库的小项目,带你一次性吃透它的构造与三大高频应用。


一、题目目标:校园广播台呼号库

广播台每天会播报一长串由字母组成的呼号串 S(例如 ababa)。运营同学想做一个检索系统,需要支持三个功能:

  1. 本质不同子串统计 :S 中一共有多少个互不相同的子串(空串不算)。
  2. 最长重复片段:找出出现次数 ≥ 2 的最长子串的长度(即被"重复播报"过的最长呼号片段)。
  3. 片段出现次数查询 :给定一个查询串 t,返回它在 S 中作为子串出现的次数(每处起止位置都算一次)。

直接枚举所有子串再丢进 set 去重,时间复杂度是 O(|S|²)(还要乘上插入 set 的代价),一旦 |S| 到 10⁵ 就彻底爆掉。后缀自动机能在 O(|S|) 的时间和空间内建好结构,把上面三个问题都压到近乎线性。


二、核心考点拆解

后缀自动机之所以强,是因为它用一个最简状态自动机 存下了原串的所有子串,且每个状态恰好对应一族"结尾位置集合(endpos)相同"的子串。吃透下面 7 个点,SAM 就不再是黑盒:

  1. endpos 等价类 :所有在原串中"结束位置集合相同"的子串,被压缩进同一个状态。SAM 的状态数 ≤ 2|S|−1。
  2. len 数组 :每个状态 v 代表的一族子串里,最长的那条长度 就是 len[v];最短的是 len[link[v]] + 1。
  3. 后缀链接 link(parent 树) :link[v] 指向"v 代表子串的最长真后缀、且与 v 的 endpos 不同"的那个状态。所有 link 连起来是一棵以根(空串)为祖先的树。
  4. 增量构造 extend(c) :从空串开始,逐字符往右"接",每次只新建/调整常数个状态,保证 O(|S|)。
  5. 转移 next :状态 v 经过字符 c 到达的状态,表示"在 v 代表子串后追加 c"。
  6. 克隆节点(clone) :当某个状态 q 被"半路截胡"时,必须把 q 拆出一个克隆点,保证自动机仍然最小------这是 SAM 最容易写错的地方。
  7. cnt 累加(endpos 大小) :每个"实点"(真正由某次 extend 新建的整串前缀)初始 cnt = 1;按 len 从大到小 沿 link 向上汇总,就得到每个状态子串的出现次数。build_cnt 写成幂等(先重置再累加),多次调用也不会把计数翻倍。

三、解法与逐步实现

3.1 本质不同子串个数

每个状态 v 贡献的"新"子串数 = len[v] − len[link[v]](从最短那条到最长那条,刚好这段长度区间里的子串都只在 v 里首次出现)。把所有 v ≠ 根 加起来即可,复杂度 O(|S|)。

3.2 最长重复片段

先跑一遍 cnt 累加,然后扫描所有状态:只要某个状态的 cnt ≥ 2(说明它代表的子串至少出现了两次),就用它的 len 去更新最大值。

3.3 片段出现次数查询

把查询串 t 在自动机上顺着 next 一位位走;走不动(某字符无转移)说明 t 不是子串,返回 0;否则停在哪状态 v,返回 cnt[v] 即可(同一状态的所有子串出现次数相同)。

3.4 Python 完整实现

python 复制代码
class SAM:
    """后缀自动机:支持本质不同子串计数、最长重复子串、子串出现次数查询。"""

    def __init__(self, s: str):
        self.len = [0]          # 每个状态的 len
        self.link = [-1]        # 后缀链接,根节点为 -1
        self.next = [dict()]    # 转移:字符 -> 状态编号
        self.cnt = [0]          # endpos 集合大小(出现次数)
        self.real = [False]     # 实点(真实前缀状态)才初始 cnt=1,克隆点为 False
        self.last = 0
        for ch in s:
            self._extend(ch)

    def _extend(self, c: str):
        cur = len(self.len)
        self.len.append(0)
        self.link.append(0)
        self.next.append(dict())
        self.cnt.append(0)
        self.real.append(True)                 # 新整串前缀 = 实点
        self.len[cur] = self.len[self.last] + 1   # 新状态长度 = 上一整串长度 + 1
        p = self.last
        while p != -1 and c not in self.next[p]:
            self.next[p][c] = cur
            p = self.link[p]
        if p == -1:
            self.link[cur] = 0
        else:
            q = self.next[p][c]
            if self.len[p] + 1 == self.len[q]:
                self.link[cur] = q             # 不用拆,直接接上
            else:
                # 克隆 q:克隆点长度 = len[p] + 1(关键!不是 len[q])
                clone = len(self.len)
                self.len.append(0)
                self.link.append(0)
                self.next.append(dict(self.next[q]))
                self.cnt.append(0)
                self.real.append(False)         # 克隆点不是实点,cnt 初值 0
                self.len[clone] = self.len[p] + 1
                self.link[clone] = self.link[q]
                while p != -1 and self.next[p].get(c) == q:
                    self.next[p][c] = clone
                    p = self.link[p]
                self.link[q] = clone
                self.link[cur] = clone
        self.last = cur

    def build_cnt(self):
        """幂等:先重置 cnt,再按 len 从大到小(parent 树拓扑序)累加。"""
        self.cnt = [1 if r else 0 for r in self.real]
        order = sorted(range(len(self.len)), key=lambda x: -self.len[x])
        for v in order:
            if self.link[v] != -1:
                self.cnt[self.link[v]] += self.cnt[v]

    def distinct_substrings(self) -> int:
        """本质不同子串个数(不需要 cnt)。"""
        return sum(self.len[v] - self.len[self.link[v]]
                   for v in range(1, len(self.len)))

    def longest_repeat(self) -> int:
        """最长出现 ≥2 次的子串长度。"""
        self.build_cnt()
        return max((self.len[v] for v in range(1, len(self.len))
                    if self.cnt[v] >= 2), default=0)

    def occur(self, t: str) -> int:
        """查询子串 t 的出现次数(每处起止位置都算一次)。"""
        v = 0
        for c in t:
            if c not in self.next[v]:
                return 0
            v = self.next[v][c]
        self.build_cnt()
        return self.cnt[v]


if __name__ == "__main__":
    S = "ababa"
    sam = SAM(S)
    print("呼号串:", S)
    print("本质不同子串个数:", sam.distinct_substrings())   # 9
    print("最长重复片段长度:", sam.longest_repeat())         # 3 ("aba")
    print('出现次数 occur("aba"):', sam.occur("aba"))       # 2
    print('出现次数 occur("abab"):', sam.occur("abab"))     # 1
    print('出现次数 occur("abc"):', sam.occur("abc"))       # 0

3.5 C++ 完整实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <map>
#include <algorithm>
using namespace std;

struct State {
    int len;                 // 该状态代表的最长子串长度
    int link;                // 后缀链接(parent 树父节点),根节点为 -1
    map<char, int> next;     // 转移:字符 -> 状态编号
    int cnt;                 // endpos 集合大小(出现次数)
    bool real;               // 实点(真实前缀状态)才初始 cnt=1
    State(int l = 0, int ln = -1, bool r = false)
        : len(l), link(ln), cnt(0), real(r) {}
};

struct SAM {
    vector<State> st;
    int last;
    SAM() {
        st.emplace_back(0, -1, false);   // 状态 0:空串(根),非实点
        last = 0;
    }
    void extend(char c) {
        int cur = (int)st.size();
        st.emplace_back(0, 0, true);     // 新整串前缀 = 实点
        st[cur].len = st[last].len + 1;  // 新状态长度 = 上一整串长度 + 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 = (int)st.size();
                st.emplace_back(st[p].len + 1, st[q].link, false);  // 克隆点:非实点
                st[clone].next = st[q].next;
                while (p != -1 && st[p].next.count(c) && st[p].next[c] == q) {
                    st[p].next[c] = clone;
                    p = st[p].link;
                }
                st[q].link = clone;
                st[cur].link = clone;
            }
        }
        last = cur;
    }
    void build_cnt() {
        // 幂等:先重置 cnt,再按 len 从大到小累加
        for (auto& s : st) s.cnt = s.real ? 1 : 0;
        vector<int> order(st.size());
        for (int i = 0; i < (int)st.size(); i++) order[i] = i;
        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) st[st[v].link].cnt += st[v].cnt;
    }
    long long distinct_substrings() {
        long long ans = 0;
        for (int v = 1; v < (int)st.size(); v++)
            ans += st[v].len - st[st[v].link].len;
        return ans;
    }
    int longest_repeat() {
        build_cnt();
        int best = 0;
        for (int v = 1; v < (int)st.size(); v++)
            if (st[v].cnt >= 2) best = max(best, st[v].len);
        return best;
    }
    int occur(const string& t) {
        int v = 0;
        for (char c : t) {
            if (!st[v].next.count(c)) return 0;
            v = st[v].next[c];
        }
        build_cnt();
        return st[v].cnt;
    }
};

int main() {
    string S = "ababa";
    SAM sam;
    for (char c : S) sam.extend(c);
    cout << "呼号串: " << S << endl;
    cout << "本质不同子串个数: " << sam.distinct_substrings() << endl; // 9
    cout << "最长重复片段长度: " << sam.longest_repeat() << endl;      // 3
    cout << "occur(\"aba\"): " << sam.occur("aba") << endl;            // 2
    cout << "occur(\"abab\"): " << sam.occur("abab") << endl;          // 1
    cout << "occur(\"abc\"): " << sam.occur("abc") << endl;            // 0
    return 0;
}

3.6 样例运行结果

以呼号串 S = "ababa" 为例:

复制代码
本质不同子串个数: 9
最长重复片段长度: 3        ("aba" 出现 2 次)
occur("aba"): 2
occur("abab"): 1
occur("abc"): 0

验证一下:长度为 1 的不同子串有 a,b(2 个);长度 2 有 ab,ba(2 个);长度 3 有 aba,bab(2 个);长度 4 有 abab,baba(2 个);长度 5 有 ababa(1 个)。合计 2+2+2+2+1 = 9,与程序输出一致。重复片段里 "aba" 在第 0--2 位和第 2--4 位各出现一次,长度 3 是最长的,完美吻合。


四、易错点提醒

  1. 新状态长度 = len[last] + 1,绝不是 len 数组的当前下标 cur。一旦把长度误写成"状态编号",只要出现过克隆节点,编号就不再等于前缀长度,整棵结构会错位、计数全错------这是最隐蔽也最致命的坑。
  2. 克隆点长度 = len[p] + 1,不是 len[q] 。写成 len[q] 会让 link 树不再满足"子节点 len 严格大于父节点",cnt 累加顺序直接乱掉。
  3. 克隆点必须标记为"非实点" (cnt 初值 0),只有真正由 extend 新建的整串前缀才是实点(cnt = 1)。漏标会让出现次数整体虚高。
  4. cnt 累加必须按 len 从大到小 (即 parent 树的拓扑序)。子节点先于父节点汇总,父节点才能拿到全部子孙的贡献。把 build_cnt 写成幂等(先重置再累加),多次调用也不必担心计数被重复叠加。
  5. 根节点的 link 设为 -1 ,循环条件用 while p != -1。若误设成 0 当终止标志,会让 p=0 提前退出、转移没接全。
  6. 字符集用 map/dict 而非大数组。题目若限定小写字母可开 26 大小数组提速;但多字符集(中文、Unicode)必须用映射,否则爆内存。

五、进阶方向

  • 广义后缀自动机(多串) :把多份呼号合并管理时,在 Trie 上建 SAM(或用分隔符拼接),可同时统计"跨串"的子串信息,注意多串 cnt 初始要按"贡献来自哪几个串"分别打标记。
  • 两串最长公共子串 :对串 S 建 SAM,让 T 在上面跑------走不动就沿 link 回跳并缩短当前匹配长度,全程 O(|T|),经典模板题。
  • 字典序第 k 小子串 :SAM 本质是一张 DAG,先统计从每个状态出发的路径总数,再按字符序贪心走 k 步,对应「弦论」类题目。
  • 与后缀数组 / 后缀树对比 :后缀数组靠 rank 和 height 也能做很多子串题,但 SAM 在建图后查询更"在线";理解两者关系能让你在考场上灵活选型。
  • 结合 LCP / 子串统计综合题:如"本质不同子串的总长度""第 k 小子串的具体串内容"等,都是在今天骨架上的自然延伸。

六、小结与互动

后缀自动机用"状态最少化"的思想,把 O(|S|²) 的子串世界压缩进了 O(|S|) 的状态空间。记住三句话就能上手:extend 增量建机、clone 保最小、cnt 沿 link 倒序汇总。它既是子串计数、重复检测、模式串查询的瑞士军刀,也是通往广义 SAM、最长公共子串、字典序第 k 子串等省选真题的必经之路。

互动时间 :如果广播台的呼号串是 S = "aabaa",请你手算或跑一下代码,看看"本质不同子串个数"和"最长重复片段长度"分别是多少?把你的答案留在评论区,我们一起对一对!也欢迎说说你在字符串题上踩过的最离谱的坑。


📚 免费少儿编程资料(夸克网盘领取)

以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:

  1. 全国青少年信息素养大赛复赛集训题目Python&C++.docx
    https://pan.quark.cn/s/93995d3cb150
  2. 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
    https://pan.quark.cn/s/da97b5dbf75d
  3. Python背记手册.pdf
    https://pan.quark.cn/s/7568ae9ca92b
  4. Python课程
    https://pan.quark.cn/s/a94bf02d00c6
  5. 2024信息素养大赛图形化复赛集训题答案3-9
    https://pan.quark.cn/s/6ccab7ec3cbc
  6. 2025年03月份电子学会考级真题
    https://pan.quark.cn/s/4403c4228912
  7. 2025全国青少年信息素养大赛赛项说明
    https://pan.quark.cn/s/d9d0df4a9f29
  8. 青少儿信息素养大赛编程资料
    https://pan.quark.cn/s/4ab6bd83be8a

资料持续更新,关注获取最新分享。

相关推荐
朝朝辞暮i40 分钟前
C++ 第 12 课:局部变量、作用域、变量生命周期
开发语言·c++·算法
怕浪猫1 小时前
LLM 面试必问的 8 个问题,答不上来直接淘汰
人工智能·python·面试
老歌老听老掉牙2 小时前
从三维旋转的深度解析到Python实现:绕任意轴旋转的奥秘
python·三维旋转
乐迪信息2 小时前
港口船舶逆行怎么管?AI防爆摄像机搭载智能检测算法
大数据·人工智能·深度学习·算法·计算机视觉
打工仔折腾 AI2 小时前
工业场景下时序库与实时计算一体化架构选型实践对比
java·开发语言·后端·python·性能优化·架构·ai agent 实战
无名猿3 小时前
shared_ptr 完全指南:引用计数、控制块与开销
c++·性能优化·内存管理·标准库·现代c++
梦幻精灵_cq3 小时前
sumdir——一个伪python.built-in的“术法”打造
python
Sirens.3 小时前
Java并发锁详解:六类锁策略与 synchronized 底层原理
java·前端·算法