后缀自动机学习笔记


定义和一些记号

定义

自动机由 \(5\) 个模块组成:

  1. \(\Sigma\) :字符集
  2. \(state\) : 状态集合
  3. \(init\) : 初始状态(只有一个)
  4. \(end\) : 终止状态集合
  5. \(Trans(s,c)\) : 转移函数,状态 \(s\) 在接受字符 \(c\) 之后,返回下一个状态

一个字符串 \(S\) 的后缀自动机(\(SAM\),Suffix Automaton)是其最小的确定有限状态自动机(\(DFA\) ,Deterministic Finite Automaton),仅接受\(S\) 的所有后缀,即只在输入后缀后,自动机会达到终止状态。

记号

\(null\) : 不存在状态,若 \(s\) 无法接受字符 \(c\),则 \(Trans(s,c)=null\)。同时定义 \(Trans(null,*)=null\)

\(ST(str)\)\(Trans(init,str)\),即从初始状态开始输入 \(str\) 之后得到的状态。

\(str[l,r]\) : 字符串 \(str\) 中下标 \(\in[l,r]\) 的子串

\(Fac\) : 子串集合

\(Right(s)\) : 状态 \(s\) 在字符串中出现的所有位置的集合,即 \(Right(s)=\{r\;|ST(str[l,r])=s\}\)

\(Reg(A)\) : 自动机 \(A\) 能够识别的字符串的集合

\(Reg(s)\) : 从状态 \(s\) 开始能够识别的字符串的集合,即 \(Reg(s)=\{str\;|Trans(s,str)\in end\}\)

前置性质

如果用朴素的想法去实现 \(SAM\) 的功能,其实只需将字符串 \(str\) 的所有后缀拿出来建一棵 \(Trie\) 即可,不过这样的状态数是 \(O(n^2)\) 级别的,我们需要将自动机的状态数简化。观察两个串 \(a\)\(b\),如果 \(Reg(a)=Reg(b)\),可以发现两者在自动机上被处理的方式完全相同,从而我们将这些串合并起来,储存在同一个状态 \(s\) 中。

在进一步讨论之前,我们先分析一些性质。

  1. 达到状态 \(s\) 的字符串其长度构成一段区间,我们设其为 \([Min(s),Max(s)]\)

这些字符串达到状态 \(s\),说明它们在原串中的出现次数等于 \(|Right(s)|\),而这些串之间为后缀关系,一个串在原串中的出现次数随着其长度增加而减小,且其母串的出现位置为自己的子集,所以出现次数为 \(|Right(s)|\) 这一值的串长构成一段区间。

  1. 对于两个状态 \(a\)\(b\),要么 \(Right(a)\bigcap Right(b)=\emptyset\),要么 \(Right(a)\subset Right(b)\) 或者 \(Right(a)\supset Right(b)\)

考虑 \(Right(a)\bigcap Right(b)\neq \emptyset\) 的情况,设 \(r\in Right(a)\bigcap Right(b)\),对于这个位置,两者的 \(len\) 区间不能有交,否则就存在一个字符串同时能够到达两个状态了,由于 \(SAM\)\(DFA\),这种情况不被允许。那么我们设 \(Max(a) < Min(b)\),则 \(a\) 中所包含的字符串均为 \(b\) 中字符串的后缀,\(b\) 中字符串是由 \(a\) 中延伸出来的,从而有 \(Right(a)\supset Right(b)\)

有了性质 \(2\),我们可以用树的方式将状态之间联系起来,引入父亲的概念:

\(par(s)\) : 最小的满足 \(Right(s)\) 为其 \(Right\) 子集的状态

我们将建出的这棵树称为 \(Parent\;\;Tree\),再分析 \(Parent\;\;Tree\) 的性质:

  1. 每一个非叶子节点都有至少 \(2\) 个儿子。

由于父亲儿子之间的 \(Right\) 是真子集关系,而每一个位置都要被一个状态认领,性质成立。

  1. \(Parent\;\;Tree\) 至多有 \(n\) 个叶子节点,总共至多有 \(2n-1\) 个节点。

叶子节点就是字符串匹配到最后的情况,仅剩下一个可能位置,显然至多有 \(n\) 种这样的情况,比如 \(str=aaaaa\) 的时候,叶子节点就只有 \(1\) 个。由于至少是二叉树,显然至多有 \(2n+1\) 个节点。

由于 \(Parent\;\;Tree\) 是由所有状态构成的,性质 \(4\) 说明了 \(SAM\) 的状态数至多有 \(2n-1\) 种。

我们再来证明 \(SAM\) 状态之间的转移数量是 \(O(n)\) 的。

  1. \(SAM\) 的状态转移数量 \(\leq 3n-4\)

我们取出 \(SAM\) 状态转移关系构成的图的最长路径生成树。

对于树边,根据前面的结论其数量 \(\leq 2n-2\)

对于非树边,考虑一条非树边 \(a\rightarrow b\),我们先从 \(init\) 沿着树边走到 \(a\),然后经过 \(a\rightarrow b\) 走到 \(b\),令这条非树边对应所有 \(b\) 继续沿着非树边最终可以到达的后缀,显然至少有一个。对于每一个后缀,如果它匹配的过程中经过了非树边,则让其对应经过的第一条非树边,从而每个后缀最多对应一条非树边。所以非树边的数量 \(\leq\) 后缀的数量,由于这里是最长路径生成树,\(str\) 本身不经过非树边,所以非树边数量 \(\leq n-1\)

但是两种情况无法同时取等,可以说明总共的转移数量 \(\leq 3n-4\)

在愉快地构建 \(SAM\) 之前,再观察一些性质,记 \(t=Trans(s,c), \;fa=par(s)\)

  1. \(Right(t)=\{r+1\;|r\in Right(s),s[r+1]=c\}\)\(SAM\) 是一个 \(DAG\)

前者根据定义显然,由于 \(r\) 一直在增加,不会出现环的情况,所以 \(SAM\) 是一个 \(DAG\)

  1. \(Max(t)>Max(s)\)

\(Max(s)\) 对应的那个字符串多增加了 \(c\) 字符,显然要变大了。

  1. \(Trans(s,c)\neq null \Rightarrow Trans(fa,c)\neq null\)

\(Right(fa)\supset Right(s)\),显然

  1. \(Max(fa)=Min(s)-1\)

根据合法的 \(len\) 的连续性可证。

  1. \(Right(Trans(s,c))\subseteq Right(Trans(fa,c))\)

\(Parent\;\;Tree\) 的包含关系所保证。

  1. \(ST(str)\neq null\iff str\in Fac\)

这些性质不会在构造中全部用到,但是在实际应用 \(SAM\) 的时候很重要。

构建 \(SAM\)

假设当前已经有了 \(T\)\(SAM\),考虑构建 \(T+x\)\(SAM\),设 \(L=|T|\)

  • 找到状态 \(u\) 满足 \(Right(u)=\{L\}\)\(u\)\(Parent\;\;Tree\) 的一个叶子节点。

  • 构造状态 \(u'\)\(Right(u')=\{L+1\}\)\(Max(u')=L+1\)

  • 现在考虑从 \(u\)\(init\) 的这一段链,将节点依次记为 \(v_1,v_2,...,v_k\)\(v_k=init\)),找到 \(Trans(v,x)=null\) 的所有状态,它们应为 \(\{v\}\) 的一段前缀,令 \(Trans(v,x)=u'\)

  • 找到第一个 \(Trans(v,x)\neq null\) 的状态 \(v_p\)

    \(v_p\) 不存在,则令 \(par(u')=init\) (因为链上除了 \(init\) 没有节点的 \(Right\) 能包含 \(u'\)

    \(v_p\) 存在,设 \(q=Trans(p,x)\),有 \(Max(q)\geq Max(v_p)+1\),分两种情况:

    (图中的横线为 \([r_i-Max(s)+1, r_i]\)

  1. \(Max(q)=Max(v_p)+1\)

    此时 \(v_p\) 中的所有字符串都可以直接拓展到 \(L+1\),因为红线和蓝线左端对齐了,那么红线区域中的字符串可以,蓝色的也可以。令 \(par(u')=q\) 即可。

  2. \(Max(q)>Max(v_p)+1\)

    这个时候 \(q\) 中的串不止 \(vp\) 中的,有一些更长的可能匹配不了 \(x\),如右边蓝色虚线部分,如果强行转过去,\(Max(q)\) 就反而变小了,这可能会导致一系列问题。为了解决这种情况,我们新建一个结点 \(nq\),将 \(q\)\(len\leq Max(v_p)+1\) 的字符串给 \(nq\)\(L+1\) 那一块转移也交给 \(nq\) 处理。

    由于 \(L+1\) 以外的位置还没法转移,所以 \(nq\) 的转移情况是和 \(q\) 完全一样的,\(Trans(nq,*)=Trans(q,*)\)。而 \(par(nq)\) 即为先前 \(SAM\) 中的 \(par(q)\),由图,可以知道有这样的变化:

    \[par(q)\leftarrow nq,\; par(u')\leftarrow nq \]

    然后我们找到所有满足 \(Trans(v,x)=q\) 的结点(是 \(v_p\)\(v_e\) 的一段区间),现在 \(q\) 已经满足不了 \(L+1\) 的转移需求了,但是 \(nq\) 可以,所以将 \(Trans(v,x)\) 改为 \(nq\)

然后就没了。初始是 \(SAM\) 是单独一个结点 \(init\),每次增量构造即可。

时间复杂度

  1. 对于原先 \(Trans(v,x)=null\),后来改为 \(u'\) 的结点,这个操作增加了一个转移,先前证转移数是 \(O(n)\) 的,从而该操作是 \(O(n)\) 的。
  2. 增加新结点 \(nq\) 的操作是和总结点数挂钩的,总节点数 \(O(n)\),从而此操作 \(O(n)\)
  3. 对于将 \(Trans(v,x)\) 改为 \(nq\) 这个操作,时间复杂度证明:

\(lst\) 为上一次的 \(u\),每一次构造后它变为 \(u'\),观察 \(Min(par(lst))\) 的变化情况:

\[Min(u)=Min(v_1)>Min(v_2)>...>Min(v_e)\geq Min(q)-1\geq Min(nq)-1 \]

倒数第二个大于等于是因为 \(q\) 向右拓展了一个 \(x\),所以会增加,最后一个大于等于即继承关系。可以发现在链上往上走的过程中,\(Min\) 一直减小,到了 \(q\) 处才可能会加 \(1\),而下一次往上跳的时候经过 \(nq\) 就直接到 \(v_{e-1}\) 处了,\(Min\) 会减少至少 \(1\),而 \(Min\) 最大为 \(|S|\),从而总时间复杂度为 \(O(n)\) 的。

综上,\(SAM\) 的构造时间复杂度为 \(O(n)\)

代码实现

话说着很多,但是代码还挺短的,这里的 \(len\)\(Max(s)\)\(link\)\(par(s)\)

struct Suffix_Automaton{
    struct state{
        int len, link;
        int next[26];
    } t[N<<1];
    int cnt, lst;

    void init(){
        t[1].len = 0, t[1].link = 0;
        mem(t[1].next, 0);
        cnt = 1, lst = 1;
    }

    void extend(int c){
        int cur = ++cnt;
        t[cur].len = t[lst].len+1;
        mem(t[cur].next, 0);
        int u = lst;
        while(u && !t[u].next[c])
            t[u].next[c] = cur, u = t[u].link;
        if(u == 0) t[cur].link = 1;
        else{
            int q = t[u].next[c];
            if(t[q].len == t[u].len+1) t[cur].link = q;
            else{
                int nq = ++cnt;
                memcpy(t[nq].next, t[q].next, sizeof(t[nq].next));
                t[nq].link = t[q].link;
                t[q].link = t[cur].link = nq;

                while(u && t[u].next[c] == q)
                    t[u].next[c] = nq, u = t[u].link;
            }
        }
        lst = cur;
    }
} SAM;

应用

\(SAM\) 其特殊之处在于它维护的是后缀信息,但是读取串却是从前往后读的,所以它能够解决众多子串类型的问题,其中最典型的一个,即 \(ST(T)\neq null \iff T\in Fac\)

CF123D String

给定一个串 \(S\),记 \(f(t)\) 为串 \(t\)\(S\) 的出现次数,求 \(\sum_{t\subseteq S}\frac{f(t)(f(t)-1)}{2}\)\(|S|\leq 10^5\)

我们只需要维护每个子串的出现次数即可,即该串对应的状态 \(s\)\(|Right(s)|\),注意到 \(Right(s)\) 即为其子树中所有节点 \(Right\) 的并集,那么只需在建 \(SAM\) 的时候,在第一个 \(Right\) 中包含 \(L+1\) 的节点,即新添的 \(u'\) 上记录一下即可。具体来说,维护状态的一个属性 \(siz=|Right(s)|\),每次在 \(u'\) 的地方 \(siz\) 设为 \(1\),表示 \(L+1\) 的出现,然后自底向上贡献到父亲的 \(siz\) 中。一个节点中的字符串数量即为 \(Max(s)-Min(s)+1=Max(s)-Max(par(s))\),将它们的值加入答案中即可。

时间复杂度 \(O(n)\)

Code

#include
#include
#define mem(a,b) memset(a, b, sizeof(a))
#define rep(i,a,b) for(int i = (a); i <= (b); i++)
#define per(i,b,a) for(int i = (b); i >= (a); i--)
#define N 101000
#define ll long long
#define t(x) SAM.t[x]
using namespace std;

struct Suffix_Automaton{
    struct state{
        int len, link, siz;
        int next[26];
    } t[N<<1];
    int cnt, lst;

    void init(){ cnt = lst = 1; }

    void extend(int c){
        int cur = ++cnt;
        t[cur].len = t[lst].len+1, t[cur].siz = 1;
        int u = lst;
        while(u && !t[u].next[c])
            t[u].next[c] = cur, u = t[u].link;
        
        if(u == 0) t[cur].link = 1;
        else{
            int q = t[u].next[c];
            if(t[q].len == t[u].len+1) t[cur].link = q;
            else{
                int nq = ++cnt;
                t[nq].len = t[u].len+1; 
                memcpy(t[nq].next, t[q].next, sizeof(t[nq].next));
                t[nq].link = t[q].link;
                t[q].link = t[cur].link = nq;
                
                while(u && t[u].next[c] == q)
                    t[u].next[c] = nq, u = t[u].link;
            }
        }
        lst = cur;
    }
} SAM;

int head[2*N], to[2*N], nxt[2*N];
int cnt;
string s;
ll ans;

void init(){ mem(head, -1), cnt = -1; }
void add_e(int a, int b){
    nxt[++cnt] = head[a], head[a] = cnt, to[cnt] = b;
}

void dfs(int x){
    for(int i = head[x]; ~i; i = nxt[i])
        dfs(to[i]), t(x).siz += t(to[i]).siz;
    ans += (ll)(t(x).len-t(t(x).link).len) * t(x).siz * (t(x).siz+1) / 2;
}

int main(){
    ios::sync_with_stdio(false);
    cin>>s;
    SAM.init();
    for(char c : s) SAM.extend(c-'a');
    init();
    rep(i,2,SAM.cnt) add_e(t(i).link, i);
    dfs(1);
    cout<

CF235C Cyclical Quest

给定一个主串 \(S\)\(n\) 个串 \(x\),求每个串 \(x\) 的循环同构串在 \(S\) 中的出现次数。

\(|S|\leq 10^6,\;n\leq 10^5,\;\sum|x|\leq 10^6\)

先通过和上面相同的方式将每个状态对应的字符串数量求出来,要求循环同构串的出现次数,考虑将 \(x_i\) 重复两遍作为一个串,在 \(SAM\) 上找所有匹配长度 \(\geq |x_i|\) 的位置数量。

在匹配的过程中,如果当前 \(trans(st,c)\neq null\),则直接走,匹配长度 \(L++\) ,否则一直跳 \(par(st)\) 直到存在转移,如果最终不存在,则 \(st\) 回到 \(init\)\(L=0\),否则 \(L=Max(st)+1\) (这肯定是变小了),\(st=Trans(st,c)\) 。由于 \(|x_i|\) 可能还有循环节,我们直接匹配的时候还要注意去重,若 \(L\geq |x_i|\),则一直跳 \(par(st)\) 直到 \(Max(par(st)),此时的 \(st\) 即为该循环节真正匹配上的状态,\(ans\leftarrow siz_{st}\),在这个节点上记录此次匹配,防止一个状态被重复匹配。

时间复杂度 \(O(n+\sum|x|)\)

Code

Automaton 中的内容和上一份代码完全一样,就不贴了。

#include
#include
#define mem(a,b) memset(a, b, sizeof(a))
#define rep(i,a,b) for(int i = (a); i <= (b); i++)
#define per(i,b,a) for(int i = (b); i >= (a); i--)
#define N 1001000
#define t(x) SAM.t[x]
using namespace std;

struct Suffix_Automaton{ ... } SAM;

int head[N<<1], to[N<<1], nxt[N<<1];
int cnt;
string s, x;

void init(){ mem(head, -1), cnt = -1; }
void add_e(int a, int b){
    nxt[++cnt] = head[a], head[a] = cnt, to[cnt] = b;
}
void dfs(int x){
    for(int i = head[x]; ~i; i = nxt[i])
        dfs(to[i]), t(x).siz += t(to[i]).siz;
}

int main(){
    ios::sync_with_stdio(false);
    cin>>s;
    SAM.init();
    for(char c : s) SAM.extend(c-'a');
    init();
    rep(i,2,SAM.cnt) add_e(t(i).link, i);
    dfs(1);

    int T; cin>>T;
    rep(o,1,T){
        cin>>x;
        int m = x.size();
        x += x;
        int st = 1, l = 0, ans = 0;
        rep(i,0,2*m-2){
            int c = x[i]-'a';
            if(t(st).next[c]) l++, st = t(st).next[c];
            else{
                while(st && t(st).next[c] == 0) st = t(st).link;
                if(st == 0) st = 1, l = 0;
                else l = t(st).len+1, st = t(st).next[c];
            }
            if(i >= m-1 && l >= m){
                while(t(t(st).link).len >= m) st = t(st).link;
                if(t(st).vis < o) ans += t(st).siz, t(st).vis = o;
                l = m;
            }
        }
        cout<

UOJ#395. 【NOI2018】你的名字

待更。