加载中...

后缀自动机 SAM

定义与性质

pmwkxsK.png

构建过程

pmwAVQP.png

算法正确性证明

pmwAzpn.png

算法线性性质证明

pmwEQ0O.png

参考博客

blog1

blog2

blog3 (夯)

blog4

可视化

video

模板

字符集为小写字母的版本

struct State{
    int len, link;
    int nxt[26];
};

const int N = 100005;
State st[N*2];
int tot, last;

void sam_init(){ 
    st[0].len = 0;
    st[0].link = -1;
    tot = last = 0; // 初始状态编号为 0
}

// 每次新加入一个字符,动态构建 SAM
void sam_extend(int c){
    int cur = ++tot;
    st[cur].len = st[last].len + 1;
    int p = last;
    while(p != -1 && !st[p].nxt[c]){
        st[p].nxt[c] = cur;
        p = st[p].link;
    }
    if(p == -1){
        st[cur].link = 0;
    }
    else{
        int q = st[p].nxt[c];
        if(st[p].len + 1 == st[q].len){
            st[cur].link = q; 
        }
        else{
            int clone = ++tot;
            st[clone].len = st[p].len + 1;
            for(int i = 0; i < 26; i ++) st[clone].nxt[i] = st[q].nxt[i];
            st[clone].link = st[q].link;
            while(p != -1 && st[p].nxt[c] == q){
                st[p].nxt[c] = clone;
                p = st[p].link;
            }
            st[q].link = st[cur].link = clone;
        }
    }
    last = cur;
}

map 版本

struct State{
    int len, link;
    map<int,int> nxt;
};

const int N = 100005;
State st[N*2];
int tot, last;

void sam_init(){ 
    st[0].len = 0;
    st[0].link = -1;
    tot = last = 0; // 初始状态编号为 0
}

// 每次新加入一个字符,动态构建 SAM
void sam_extend(int c){
    int cur = ++tot;
    st[cur].len = st[last].len + 1;
    int p = last;
    while(p != -1 && !st[p].nxt.count(c)){
        st[p].nxt[c] = cur;
        p = st[p].link;
    }
    if(p == -1){
        st[cur].link = 0;
    }
    else{
        int q = st[p].nxt[c];
        if(st[p].len + 1 == st[q].len){
            st[cur].link = q; 
        }
        else{
            int clone = ++tot;
            st[clone].len = st[p].len + 1;
            // for(int i = 0; i < 26; i ++) st[clone].nxt[i] = st[q].nxt[i];
            st[clone].nxt = st[q].nxt;
            st[clone].link = st[q].link;
            while(p != -1 && st[p].nxt.count(c) && st[p].nxt[c] == q){
                st[p].nxt[c] = clone;
                p = st[p].link;
            }
            st[q].link = st[cur].link = clone;
        }
    }
    last = cur;
}

应用

是否作为子串出现

pmw1KT1.png

子串出现次数

pmw1Bff.png

子串第一次出现位置

pmw1R7n.png

本质不同子串个数

pmw11fK.png

所有本质不同子串的总长度

pmw1J6e.png

字典序第 \(k\) 大子串

pmw1YOH.png

最小循环移位

pmw1wkt.png

最长公共子串长度

pmw3XrQ.png

例题

P3804

Q:给定一个字符串,求出现的所有子串中,子串出现次数与子串长度乘积的最大值。

先由后缀链接 \(link\) 汇总,得到每个结点代表子串的出现次数;显然对于每个 \(endpos\) 等价类,贪心选取最长后缀是最优的,而每个结点对应子串集合的最长长度恰好为 \(len_{v}\),故取所有结点 \(v\)\(cnt_{v} * len_{v}\) 的最大值即为答案。

code

P4070

Q:文本串初始为空,多次操作,每次往文本串末尾加入一个字符,求每次操作后本质不同子串的数量。

考虑在动态构建 SAM 的过程中统计增量。每次加入一个字符后(原串为 \(S\),新加字符为 \(c\)),新产生的子串一定以刚加入的字符结尾,故累计增量时,我们只需要关注 SAM 内插入字符后新产生的状态带来的影响。设新产生的状态为 \(cur\),考虑后缀链接 \(link\) 的性质,不难发现 \(link[cur]\) 内的串均是 \(S + c\) 的后缀,且已经在 \(S\) 中出现过,不需要再重复统计,进而增量为 \(len[cur] - len[link[cur]]\)

code

P3975

Q:给定一个字符串与两个整数 \(op,k\),求:

  • \(op=0\) 时,求不同位置的相同子串视作相同时,字典序第 \(k\) 小的子串
  • \(op=1\) 时,求不同位置的相同子串视作不同时,字典序第 \(k\) 小的子串

先讨论 \(op = 1\) 时的情况:此时不需要考虑子串 “本质不同” 的问题,可以直接由后缀链接 \(link\) 汇总经过每个结点的子串个数(每个结点的 \(cnt\) 代表属于对应 \(endpos\) 等价类的子串在原串中的出现次数,即 \(endpos\) 集合大小);

再讨论 \(op = 0\) 时的情况:此时对于出现在不同位置上的相同子串无需作区分,那么也就无需像前面那样将所有子串按照后缀链接 \(link\) 进行汇总,而是将每个结点的 \(cnt\) 直接赋值为 \(1\) 即可(初始结点除外,因为空串不参与)。

两种不同情况均会处理出每个结点的 \(cnt\),表示子串出现次数;然后再对 \(cnt\) 进行关于转移边的汇总,得到经过每个结点 \(u\) 的子串数量 \(f_{u}\)

最后为了找到字典序第 \(k\) 小的子串,我们从 SAM 的根节点出发,按字符字典序升序依次遍历每个字符,根据上述求得的 \(f_{u}\)\(k\) 的大小关系来判定应该走哪个字符。这个过程与线段树上二分求第 \(k\) 小比较类似,递归实现即可。

code

P5341

Q:给定一个字符串与整数 \(k\),求出现次数恰好 \(k\) 次的所有子串中,出现次数最多的长度。

通过后缀链接 \(link\) 汇总 \(cnt\),得到每个结点所属 \(endpos\) 等价类内所有子串的出现次数。查找所有 \(cnt_{v} = k\) 的结点,并统计所有出现过的长度。由 \(endpos\) 等价类的性质,里面的所有子串呈连续的后缀包含形式,且长度区间范围 \([len_{link_{v}} + 1, len_{v}]\),差分加速计数即可。

code

P4248

Q:给定一个长度为 \(n\) 的字符串,令 \(T_{i}\) 表示它从第 \(i\) 个字符开始的后缀,求:

\[\sum_{1\le i<j \le n} len(T_{i}) + len(T_{j}) - 2 * lcp(T_{i}, T_{j}) \]

前面两个增量的贡献为定值 \(\frac{n(n+1)(n-1)}{2}\),问题等价于求:

\[\sum_{1\le i<j \le n} lcp(T_{i}, T_{j}) \]

求某个字符串任意一对后缀的最长公共前缀长度之和,等价于求该字符串反串的任意一对前缀的最长公共后缀之和。对原串的反串建 SAM,考虑后缀链接树,存在性质:原串任意两个不同前缀的最长公共后缀长度等于这两个前缀所在 SAM 结点的 \(lca\)\(len\)。于是,我们只需要在每个结点 \(u\) 统计有多少对子串(仍按照传统的后缀链接汇总 \(cnt\) 的方式,因为每个新建结点最终会成为 SAM 的叶节点,也代表了原串每个前缀的结尾位置)对应的一对结点的 \(lca\)\(u\),类似树上 \(dp\) 计数即可。具体细节见 code。

code

ABC433G

在 SAM 的 DAG 上进行博弈 dp。

code

2021ICPC沈阳M

Q:给定一个字符串,对于该字符串的每个前缀,求字典序最大的子串所在区间。

显然对于每个前缀,字典序最大的子串一定是该前缀的某个后缀,因此右端点不需要考虑,只需要考虑左端点。

一个暴力的想法是:每次加入新字符、动态更新 SAM 后,从根节点开始,贪心地走存在的、字符字典序最大的边。这样需要跑 \(O(n)\) 次暴力。

考虑优化:想办法只跑一次暴力,并沿途更新所有前缀位置的答案。

暴力过程仍然是每次贪心地走存在的、字符字典序最大的边,配合后序遍历,可以达到访问的所有子串按字典序逆序访问的效果。因此第一次访问到某个结点时(这样每个结点只需要经过一次,保证复杂度),经过路径形成的子串一定是关于该结点对应 \(endpos\) 等价类内的所有结尾位置,字典序最大的后缀。我们只需要将 \(endpos\) 集合内的所有位置都用该后缀的长度更新一遍答案即可。

然而,所有结点的 \(endpos\) 集合大小总和是巨大的,不能直接暴力地更新每个位置。考虑用线段树优化:在后缀链接树中,\(endpos\) 集合是关于子树呈包含关系的,故考虑给所有位置分配 \(dfn\) 序,这样每个结点的 \(endpos\) 集合内的元素映射成 \(dfn\) 序后,是一段连续的区间,更新答案时可以直接区间赋值

这里需要取第一次分配的值,而不是覆盖,所以需要将赋值操作倒序处理

复杂度分析:

  • 暴力跑 SAM 转移边(DAG)时,由于每个结点只会经过一次,故复杂度是 \(O(26n)\) 的;
  • 采用的是区间赋值,单点查询的线段树,每次只需要在经过某个结点时进行一次区间赋值,故复杂度为 \(O(n\log n)\)

综上,总复杂度为 \(O(n\log n + 26n)\)

code

牛客 SAM 专题

1

Q:给出一个只包含小写字母的字符串 \(s\)。有 \(q\) 次询问,每次给出一个区间 \([l,r]\),询问子串 \(s[l…r]\)\(s\) 中的出现次数。

由于总字符数最多可达 \(O(nq)\),不能在建立 SAM 后直接暴力跑转移边以得到所有查询的答案。

由于查询对象一定是原串某个前缀的后缀,根据右端点 \(r\),就可以定位到 SAM 中代表前缀 \(s[1…r]\) 的结点 \(u\)。结合后缀链接树的性质,显然 \(s[l…r]\) 一定在根节点到 \(u\) 的某个结点内,且该路径上的每个结点代表子串集合的长度区间已知,因此可以树上倍增加速查找 \(s[l…r]\) 所在结点。

posted @ 2026-07-04 19:48  小橘奏  阅读(8)  评论(0)    收藏  举报