zoukankan      html  css  js  c++  java
  • 后缀自动机

    后缀自动机

    0x00 前言

    • 纠结了⑤天终于大概理解了后缀自动机
    • 这个东西实在是太要命了,虽然代码很短,但是理解起来太困难
    • 趁着理解还新鲜赶紧写一发笔记
    • 我会尽量写得详细且便于理解,以供他人参考(我以后忘记了回头来看

    • 做一些符号规定
    • S:我们建立后缀自动机的字符串
    • S[i]:S的第i位
    • prefix[i]:S长为i的前缀
    • suffix[i]:你意会一下
    • parent[p]:p这个状态在后缀链(parent tree)中的前驱(父亲)
    • max[p]:p这个状态的最长子串长度
    • min[p]:p这个状态的最短子串长度
    • R[p]:p这个状态的右端点集合
    • Sub[p]:p这个状态的子串集合
    • root:后缀自动机的根,表示空串
    • last:最后一个插入的节点
    • 一般用i,j,k表示位置,p,q表示状态

    0x01 后缀自动机是啥

    • 这显然有个基础的问题
    • 什么叫自动机(AutoMaton)?
    • 有限状态自动机(Finite-state machine, FSM)是一种表现状态和转移的结构
    • 可以这么理解:一棵trie就是一个自动机,她能接受所有插入过的单词并转移到终止状态,且她不能接受任何没有插入过的单词(不能转移到终止状态)。如果我们只考虑转移合法,不一定要求终止,她还能接受所有插入过的单词的前缀。
    • 一个字符串S的后缀自动机(SuffixAutoMaton,SAM)是一个自动机(废话),她能接受S的所有后缀(拓展为所有后缀的前缀,即所有子串)

    0x02 怎么搞呢

    • 哇,看上去好高大上啊
    • 大概和后缀数组是差不多的东西吧?
    • 那要怎么构造它呢?
    • 有一个粗暴而低效的办法:直接构造这个字符串的一棵全裸后缀树,显然这是一个合法的后缀自动机
    • 但这太低效了,我们需要一个$ O(nlog_2{n}) $ 或更优的办法才能和后缀数组齐头并进不分高低,幸运的是,我们有一个线性的作法,比后缀数组不知道高到哪里去了
    • 我们称这个办法为增量法,即假设我们已经得到了S的一个前缀prefix[i],我们向已得到的SAM中插入S[i+1]并维护SAM的性质
    • 如此从空串开始依次插入S中所有字符,我们就得到了S的SAM

    0x03 注意事项

    • 下面大概正式开始
    • 注意如果哪里不理解了,回忆一下SAM的目的是什么
    • 识别所有S的后缀,子串只是附带功能
    • 这是一个重点

    0x04 状态的表示

    • 既然我们需要线性的构造方式,那么显然状态数和转移数都要是线性的
    • 转移显然是用字符转移
    • 状态如何去表示呢?
    • 假如你有一个文本编辑器,你要输入S的某一个后缀,这里假设S是"ABCBBABC"
    • 你第一次输入了一个A
    • 你有可能输入的是那些后缀?显然只有"ABCBBABC"和"ABC"
    • 为什么?因为A是这些后缀的前缀
    • 同样,如果你第一次输入"BC",那么你可能的输入只有"BCBBABC"和"BC"
    • 我们发现,每个子串能拓展到的后缀完全是由这个子串在S中的出现位置决定的
    • 进一步说,是由每一次出现的右端点位置决定的
    • 假设我们已经得到了一个子串所有的出现右端点位置集合R $ {R_{1},R_{2}...R_{n} } $
    • 如果往这个子串前面加上一些字符或者从前面删掉一些字符,出现右端点位置集合没变,那显然加上或者删去这些字符对于状态没有影响
    • 那么这些没有影响的操作能得到的所有子串就有着相同的情况
    • 我们称这些子串是一个等价类(equivalent class),他们属于一个集合Sub
    • 具体的来说,我们的状态表示是:一个状态是一个右端点的集合R $ { R_{1},R_{2}...R_{n} } $
    • 更进一步,我们发现一个状态所包含的子串的长度是一个连续的区间
    • 记为[min[p],max[p]]
    • 不同状态的Sub之间没有交集(正确性是显然的,有交集就可以合并两个状态)

    • 现在我们能表示状态了,我们要考虑一下状态之间的关系
    • 假设两个状态p,q的R集合有交集(没有交集是可能的)
    • 考虑交集中的某一个位置i,由于Sub不能相交,所以[min,max]也不能相交(不然i这个位置就能产生相同的子串)
    • 不妨设max[p]<min[q]
    • 那么Sub[p]中所有子串长度都比Sub[q]中的要短
    • 两者出现的右端点还是一样的
    • 那不是说明Sub[p]都是Sub[q]的后缀么
    • 那么R[q]出现的位置,R[p]肯定也出现了
    • 所以R[q]是R[p]的真子集(显然不能相等吧)
    • 这样就说明,两个状态的R要么交集为空,要么一个是另一个的真子集
    • 那我们考虑转移到终点的状态,显然R大小为1,这样的状态有|S|那么多个
    • 每一个不是终点的状态起码能把R分为两个非空部分,很容易证明状态是O(|S|)的
    • 这样我们就证明了状态的线性,以及明确了状态的表示
    • 转移的线性证明详见陈立杰讲课PPT(提示:考虑后缀自动机的树形图)

    • 令一个状态p的parent[p]表示一个状态q,q满足R[p]是R[q]的真子集且R[q]尽可能小
    • 显然有max[q]=min[p]-1,这很显然,把一个等价类中最短的子串头上在砍去一个字符,R集合就会不可避免的扩大,且这次扩大的范围是最小的
    • 这样parent就形成了一个树的结构,称之为parent tree,并把一个状态到另一个状态的parent路径称为后缀链(suffix link)
    • 充分理解后缀链这个名字的含义有助于我们理解这个概念(详见《组合数学》,以下仅简略介绍)
    • 是一种 子集 的 集合,集合中任何两个子集都是包含关系
    • 一条后缀链上的任意两个点的R集合都是包含关系

    • 其实我们并不需要真的记录R,R集合其实是使用parent tree结构表示的
    • 最终得到的SAM转移既不需要parent tree,也不需要R集合,这两个东西的用处是帮我们一步步构造SAM
    • 这是第二个重点

    • 我们来粗略说明一下这样的状态表示也能识别所有子串
    • 考虑以i位置结尾的子串,首先找到i这个位置的终止状态p
    • 不断地通过parent[p]向上爬,从p到root的[min,max]区间的交集显然是[1,i],这样它就表示出了所有以i结尾的子串

    0x05 状态的转移

    • 转移相对好理解,就是往R中的每个位置i后面尝试拼一个字符,如果能拼上就在转移后的R中加入i+1
    • 比如S还是"ABCBBABC"
    • 状态{3,8}用字符'B'转移得到状态{8}(想想为啥?)

    0x06 线性构造

    • 现在我们既会表示状态,又会表示转移了
    • 该开始讨论如何构造了
    • 痛苦从这里开始

    • 我们现在已经得到了prefix[i]的SAM
    • 如何插入S[i+1]?
    • 牢记我们的目的,我们要让SAM识别prefix[i+1]的所有后缀
    • 我们其实只要在prefix[i]的所有后缀(包括一个空串)后面插入一个S[i+1]就行了
    • 我们如何找到prefix[i]的所有后缀?我们首先要找到终点i,这不就是我们上一次插入的那个么
    • 用last表示S[i]的终止状态,不断地在parent tree中向上爬就能找到prefix[i]的所有后缀
    • 我们新建一个点np,这个点的R只有{i+1},显然max[np]=max[last]+1(后面多加了S[i+1]这个字符)
    • 这时我们需要分类讨论
    • 设当前爬到的这个状态为p
    • 如果p没有S[i+1]这个字符的转移,显然加上S[i+1]后会转移到np,那么给他加上这个转移就行了
    • 如果p有S[i+1]这个字符的转移呢?
    • 回顾我们的目的:识别prefix[i+1]的所有后缀
    • 如果一个状态p有S[i+1]这个字符的转移,那么它的parent,grandparent......肯定都有
    • 其实现在这个SAM已经能识别prefix[i+1]的所有后缀,可是我们不知道np的parent是谁,这导致所有状态的R集合中都没有i+1,这会给我们之后的添加字符造成问题
    • 我们现在需要往合适的R[sp]集合中加入i+1这个位置(即让parent[np]=sp)
    • 假设q是p用S[i+1]转移后的状态
    • 我们强行给R[q]加入{L+1}这个位置?(即让parent[np]=q)
    • 不太行,虽然R[p]中有L这个位置,但是R[q]中不一定有。强行插入会导致max[q]变小
    • 考虑"AAAAXBAA"
    • R[p]={4,8}
    • 用X转移,得到R[q]={5},max[q]=5(AAAAXBAA)
    • 如果我们从后面插入一个X,并且强行把9这个位置塞进R[q],会导致max[q]=3(AAAAXBAAX),从而引发一系列问题
    • 当然如果max[q]==max[p]+1就不会有这样的问题,直接让parent[np]=q就行了
    • 这个时候,我们发现有两种情况
    • 第一种:AAAAXBAAX 这个就是q这个状态(这里讨论的是max[q]!=max[p]+1的情况)
    • 第二种:AAAAXBAAX 我们给这种情况新建一个状态nq
    • 显然max[nq]=max[p]+1(后面多了一个S[i+1])
    • 显然R[q]是R[nq]的真子集,而且有parent[q]=parent[nq]
    • 而且有parent[np]=parent[nq]
    • 或者说,nq就是由q和np组成的
    • 而parent[nq]=parent[q](原来的)
    • 这是因为min[q]现在在min[nq]里,这样保持了原来长度的连贯性
    • 然后对于q所有的转移,显然nq也同样可以使用(R[nq]新加的S[i+1]在转移上不起作用),所以直接拷贝即可
    • 新建状态的部分就做完了,对于所有会转移到q的p的祖先(包括p),把转移重新指向nq即可

    • 这里似乎有一个遗留问题,那些转移不到q的祖先怎么办?
    • 要时刻记得SAM此时的目的,我们要让SAM识别prefix[i+1]的所有后缀
    • 现在识别了吗?识别了
    • 那还要考虑parent tree
    • 转移不到q的p的祖先,转移到的一定是q的祖先,而我们已经处理完了q(在q或者nq的R集合里想办法加入了i+1这个位置),即我们已经在转移到的q的祖先的R集合中加入了i+1这个位置
    • R集合其实是使用parent tree结构表示的

    0x07 总结

    • 对于插入一个字符i+1
    • 新建一个节点np
    • 找到上一次插入的终止位置last
    • 对于last及其祖先(统称为p)
    • 没有S[i+1]转移:插入新的转移到np
    • 有S[i+1]转移:设转移到的状态为q,若max[q]=max[p]+1,parent[np]=q,结束这一阶段,否则
    • 新建节点nq
    • 把q的转移拷贝给nq
    • parent[nq]=parent[q],parent[q]=nq,parent[np]=nq
    • 把所有转移到q的p改成转移到nq
    • 搞完啦!

    0x08 感受

    • 彻底地理解R集合的表示方式和SAM的目的对于理解SAM太重要了

    0x09 代码

    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #include 
    #define eps 10e-7
    #define INF 0x3f3f3f3f
    #define MOD 1000000007
    #define rep0(j,n) for(int j=0;jval + 1 );
    for ( ; p && p->son[w] == 0; p = p->parent ) p->son[w] = np;
    if ( p == 0 ) np->parent = root;
    else {
    State *q = p->son[w];
    if ( p->val + 1 == q->val ) {
    np->parent = q;
    } else {
    State *nq = new State ( p->val + 1 );
    memcpy ( nq->son, q->son, sizeof ( q->son ) );
    nq->parent = q->parent;
    q->parent = nq;
    np->parent = nq;
    for ( ; p && p->son[w] == q; p = p->parent ) p->son[w] = nq;
    }
    }
    last = np;
    }
    bool transit(char c){
    int w = c - 'a';
    if(present->son[w]==NULL) return false;
    else present=present->son[w];
    return true;
    }
    };
    int main() {
    return 0;
    }
    

    很短嘛!SAM部分也就20+

    括弧,代码在wordpress上显示不出来,我也不知道为什么

    0x10 一个例题

    求两个字符串A,B的最长公共子串

    • SA水题
    • 考虑怎么用SAM做
    • 对A建SAM,用B在SAM上面跑
    • 如果当前状态为p,B中的一个字符在SAM上没有相应的转移,就让p=parent[p]直到有转移为止
    • 用max[p]+1更新答案

    0x11 一些额外问题

    • 维护一个点是不是终止节点(转移到了后缀):只有最后一步的last到root的后缀链上的点才是终止节点
    • 维护一个点的R集合中的最小值:每次新建的点显然不会使这个值变小,所有只要对新建的点初始化一下就行了
    • 维护一个点的min:不就是parent的max+1么

    0x12 一个遗留问题

    • 细心的读者可能发现了,我们证明了状态数和转移数是线性的,但是还有一步的复杂度线性我们未予证明
    • 即构造SAM过程中修改last到root的后缀链这一部分
    • 这是可证的,详见
    • http://www.aiuxian.com/article/p-2504420.html
    • 最终结论是后缀自动机的复杂度是线性的

    0x13 后记

    许愿弥生改二
  • 相关阅读:
    【PHP】算法: 获取满足给定值的最优组合
    @程序员,你还记得当年高考时的样子吗?
    教妹学 Java:难以驾驭的多线程
    二十九岁,刚读完了财富启蒙读物《小狗钱钱》
    蓦然回首,Java 已经 24 岁了!
    @程序员,你需要点金融常识
    教妹学 Java:大有可为的集合
    @程序员,你需要点财商
    教妹学 Java:晦涩难懂的泛型
    大量阅读,并不等同于“走马观花”
  • 原文地址:https://www.cnblogs.com/LoveYayoi/p/6745495.html
Copyright © 2011-2022 走看看