作为菜鸡 OIer,本文尽量用通俗的语言去讲解后缀自动机。
当然学习后缀自动机前是有一点点前置知识的,我一块给说了。

有限状态自动机(DFA)

定义

确定优先状态自动机 是由:

  • 一个非空有限的状态集合
  • 一个输入字母表 (非空有限的字符集合)
  • 一个转移函数 (例如:
  • 一个开始状态
  • 一个接受状态的集合
    所组成的5-元组,可写成形式

    工作方式

    确定有限状态自动机从起始状态开始,一个字符接一个字符地读入一个字符串 (这里的指示Kleene星号算子。),并根据给定的转移函数一步一步地转移至下一个状态。在读完该字符串后,如果该自动机停在一个属于F的接受状态,那么它就接受该字符串,反之则拒绝该字符串。
    (以上摘自Wiki)

    后缀自动机(SAM)

    定义

    字符串 的后缀自动机是一个接受字符串所有后缀的最小 DFA。(实际上显然可以看出它接受所有 的子串)
    相信有了上面的铺垫大家定义都懂了,接下来我们学习如何构造 SAM

    构造

    如果不需要保证是最小 DFA,我们直接把所有后缀插入 trie 就可以了。但是我们实际上是有一个线性构造出 SAM 的方法的。为了介绍这个方法,我们需要引入一些定义。
    定义 1:对于一个子串 ,我们将其在原串中所有结束位置构成的集合称之为 。例如

    引理 1.1: 如果两个子串的 相同,那么一个一定是另一个的后缀

证明是显然的。设这两个字符串为 ,令,那么命题不成立时,两个子串在后 位上一定有一位不同,但是因为 endpos 相同,那么后 一定都相同,自己画个图就明白了。

引理 1.2: 对于任意两个子串 ,令 ,要么 ,要么

如果 的后缀,那么每次 出现一定伴随着 出现,所以 ,否则交集一定为空(交集不为空表示存在一个位置他们同时出现,即 后缀)

引理 1.3: 对于 相同的子串我们将其归为一个 等价类,将字符串按照长度从大到小排序,则一个字符串总是比它上一个字符串的长度少 ,且是上一个的后缀

连续运用引理 1.1 即可。

引理 1.4: 等价类的大小为

对于一个类,根据引理1.3,我们找到最长的一个子串 ,在前面添加一个字符(满足新字符串是原串子串),得到的字符串一定不属于此类,并且由于 是新串的后缀,一定有 ,并且添加两个不同的字符,它们的 也不会有交。所以我们认为向前加入一个字符就是对 进行分割。所以我们可以按照分割结构建出一个树,可以称为 parents数(fail 树)。
例:
图片说明

引理 1.5: 一个类 中,令最长子串的长度为 ,最短子串长度为 ,令这个类的父亲为 ,则有

显然。加上一个新字符后 缩小,可能会有新的字符串加入。(想象这些字符串原来因为只是 的子集而未能加入)

现在我们回顾定义:发现我们的 SAM 中的状态只需要由这些等价类来构成就好了。(因为这个可以接受且仅接受所有的子串)我们只需要合理安排一些转移边让这些状态之间可以转化,就可以构造出 SAM。

引理1.4 已经证明了 SAM 的状态数为 ,接下来我们证边数为

引理1.6 SAM 的转移边数为

假设我们现在有了一个 SAM 的所有边,我们求出任意一棵生成树。
我们对于每个终止节点,考虑按照后缀自动机的边的反向跑,尝试跑出它能接受的所有串。如果这次能顺利跑回源点,那么我们尝试跑下一个子串;如果这次在中间卡掉了,那么我们链上没有的那条边继续跑回源点。这样即使增加后跑出的串不是你想要的那个,但一定是能接受的串中还没跑过的。发现每个结束状态跑的时候加入的边不会超过 的大小。所以边增加后缀的的个数 ,即数量级为

正式开始构造

考虑每个节点存储的信息应该有: 存储转移边, 存储这个等价类最长长度的大小, 存储这个等价类在 parent 树(依个人习惯 以后将其称之为 fail 树)上的祖先。
我们的构造是一个在线算法,比较好用。
还是感觉这个东西对着程序讲比较简单。

int ch[MAXN][26],fail[MAXN],len[MAXN];

int las = 1,tot = 1;

inline void copy(int x,int y){
    FOR(i,0,25) ch[x][i] = ch[y][i];
    fail[x] = fail[y];len[x] = len[y];
}

inline void insert(int c){
    int p = las;int np = las = ++tot;
    len[np] = len[p]+1;
    while(p&&!ch[p][c]) ch[p][c] = np,p = fail[p];
    if(!p) fail[np] = 1;
    else{
        int q = ch[p][c];
        if(len[p]+1 == len[q]) fail[np] = q;
        else{
            int nq = ++tot;copy(nq,q);// split
            len[nq] = len[p]+1;
            fail[q] = fail[np] = nq;
            while(p&&ch[p][c]==q) ch[p][c] = nq,p = fail[p];
        }
    }
}
char str[MAXN];
int main(){
    scanf("%s",str+1);int n = strlen(str+1);
    FOR(i,1,n) insert(str[i]);
    return 0;
}

我们来记住下 SAM 中两类边的用途:

  1. SAM 上的边,走一步相当于在字符串后面加上字符后的最大等价类,加入字符之后需要尽量向前扩展(向前扩展的另一个解释详见引理1.5)
  2. fail 树上的边,走一步相当于找最大的不同的后缀。
    我们开始 分 步 讲 解 insert 函数。
    int p = las;int np = las = ++tot;
    len[np] = len[p]+1;
    存插入前 SAM 中原串组成的等价类的点, 存插入后的。 的转移显然。
    while(p&&!ch[p][c]) ch[p][c] = np,p = fail[p];
    这个时候 所有后缀 并且无法通过 字符转移的可以通过现在新加入的 转移了,我们更新一下边。我们会找到第一个节点满足它已经有能转移加 的边为止。
    if(!p) fail[np] = 1;
    一直都没有找到,说明 没有出现过,直接更新 fail 为空串即可。
    else{
     int q = ch[p][c];
     if(len[p]+1 == len[q]) fail[np] = q;
    找到了这样的点,于是我们找到按照 直接转移下去的点。
    如果 ,说明加入 后向前扩展失败, 的所有子串都会是 的子串的后缀,更新 fail 即可。
    else{
     int nq = ++tot;copy(nq,q);// split
     len[nq] = len[p]+1;
     fail[q] = fail[np] = nq;
     while(p&&ch[p][c]==q) ch[p][c] = nq,p = fail[p];
    }
    如果扩展出来了新的就麻烦了。我们考虑将这个点拆成两个点:一个是我们想要的没有扩展出新的字符串的点,另一个是只包含扩展出新的字符串的点,这样不影响 SAM 的性质。
    我们设我们拆出的子集想要的点为 ,首先 的所有转移边和 都一样(原来就是 q 的一部分),然后我们更新 的 len 为我们想要的len。这样 的 fail 就可以更新了,不过注意只包含扩展出新的字符串的点的 fail 需要更新成 (更长的的不同状态的后缀)。
    最后那个循环:考虑之前所有转移到未拆的点上的边,现在只能转移到那个我们想要的点上,不能转移到只包含扩展出新的字符串的点(否则会出现新的字符串)。所以我们需要更新一下这些字符串的 fail。
    构造过程大概就是这样,如果想手动模拟可以看一下咕咕日报链接 后面专门有一部分带你手动模拟建立 SAM,博主由于太咕加上本来很好懂就不写了。

    简单运用

    判断一个字符串是否出现

    使用 DFA 的定义来判断是否接受该字符串。

    不同子串个数

    后缀自动机不会存相同的字符串,所以答案是

    第k大子串

    首先 SAM 是个 DAG,可以 dp 出 表示 i 到结束节点包含的子串的个数:
    然后类似于权值线段树查询 大的写法即可。

    最小表示法

    设字符串为,我们对 建立 SAM,只需要找到长度为 字典序最小的串。可以贪心走最小的边实现。

reference:

  1. 洛谷日报链接
  2. OI WIKI链接
    还有一些找不到来源的东西