• 后缀自动机学习笔记


    本篇博客部分内容及图片引用自KesdiaelKen的博客

    一·、概述

    后缀自动机(suffix automaton,SAM)是一个能够解决许多字符串问题的自动机。它可以说是常考的字符串算法中最困难的一个,解决的问题也非常多。

    想要学会SAM,首先得先学会(trie),较高深的内容也需要后缀数组SA相关的知识。

    后缀自动机,实质上就是一种用一个DAG表示一个字符串的所有子串的方法,表示一个字符串所有子串的方法我们并不是没有学过,那就是(trie):即将原串的所有后缀都加入(trie)树中,得到的(trie)树存在一个根以及若干终止点,满足重要性质:

    • 从根到任意节点的任意路径对应原串的一个子串,原串的每个子串对应从根开始的某条路径。
    • 从根到任意终止节点的任意路径对应原串的一个后缀

    但是,(trie)树的节点数达到了(mathcal O(n^2))的级别,这是我们不能接受的,我们需要一种更好的表示所有子串信息的方法,这就是SAM了。

    以下的所有内容中我们都认为原字符串为(s)

    SAM是一个能识别(s)的所有后缀的最小DFA,不理解什么是DFA也没关系,总之就是它满足以下性质:

    • SAM是一个DAG,节点被称为状态,边被称为转移,每个转移上标有一些字母
    • 存在一个初始状态,从初始状态出发能到达任意节点,并且将从初始状态出发的任意路径上的所有转移的字母写下来就是(s)的一个子串,(s)的每一个子串均能被这样的路径表示出来
    • 存在若干个终止状态,从初始状态到终止状态的任意路径都是(s)的一个后缀,(s)的每一个后缀都可以由这样的方式表示出来。
    • SAM的状态数、转移数都是线性的

    二、SAM的结构

    首先,我们引入一个新定义:结束位置(endpos),对于字符串(s)的任意子串(t)(endpos(t))表示(t)(s)中的所有结束位置组成的集合,例如对于(s=“dabda”,endpos(“da”)={2,5},endpos(“b”)={3})

    (endpos)满足许多很好的性质,这是SAM的关键:

    引理1:对于字符串的任意非空子串(u)(v),如果(endpos(u)=endpos(v)),且(|u|le|v|),那么(u)(v)的后缀。

    证明:还是比较显然的。

    引理2:对于字符串的任意非空子串(u)(v),如果(|u|le|v|),那么(endpos(v)in endpos(u))或者$endpos(u)cap endpos(v)=varnothing $。

    证明:如果(u)(v)的后缀,那么(v)出现时(u)一定出现,于是(endpos(v)in endpos(u)),否则(u)(v)一定不会同时出现。

    于是我们按照不同的(endpos)将原串的所有子串分为若干个(endpos)等价类。

    引理3:对于任意一个(endpos)等价类,其中的所有子串将它们按长度从大到小排序,那么每一个子串都是前一个子串的后缀,长度(=)前者(-1),等价类的长度值域恰好覆盖连续的一段。

    证明:如果等价类仅包含一个字符串那么引理显然成立,否则设(u)为该等价类最短的字符串,(v)为最长的字符串,那么(v)的所有长度(ge |u|)的后缀,根据引理(1),它们也一定属于这一等价类中。

    对于任意一个等价类,我们设(v)为该等价类最长的子串,那么在(v)前加另一个字符,如果依然得到原串的子串,那么它一定属于另一个等价类,并且由引理2,这个等价类的(endpos)一定(in endpos(v)),并且在(v)前加不同的字符会得到不同的等价类,它们的(endpos)一定不相交,于是我们相当于将(endpos(v))拆分为了若干个新的集合并保留原来的集合。我们将从(A)分割得到(B)看作是父子关系,借此我们就能建出一棵树:

    这是以(aababa)为例建出的树,图片来自开头提到的博客。

    通过这样的例子,根据分割关系显然可以发现树的节点个数是不超过(mathcal O(2|s|))的,这棵树我们称之为(parent tree)。我们将基于(parent tree)来建出SAM,所有(endpos)等价类就是SAM中的节点,根就是SAM的初始节点。

    我们认为任意等价类(x)中的最长字符串的长度为(len(x)),最短字符串长度为(minlen(x)),它在(parent tree)上的父亲为(link(x)),那么显然有(minlen(x)=len(link(x))+1),因为(x)正是由(link(x))增加一个字符得来的。

    但SAM的转移并不是(parent tree)上的边,因为这些边是在字符串前面加一个字符,而SAM中的转移边应该是在最后加一个字符,我们希望能在(parent tree)的节点之间连边,使起点出发到任意点的路径都对应属于该点的一个字符串。

    具体建出来的结果是这样的:

    接下来,我们将介绍如何在线性时间内建出SAM:

    三、SAM的构造

    和PAM一样,我们也采用增量法构造SAM,即依次将(s)的每一个字母加入SAM中并使SAM维护新出现的子串。

    我们记录(c)为要加入的这个字母,它是第(i)个字母,(last)为旧串对应的节点编号,用(ch[i][c])表示(i)的各条转移连向的点,设(1)为初始节点。

    • 首先,我们新建一个节点(cur)表示新串,显然它的(endpos={i})是全新的,应该是一个新的节点,有(len[cur]=len[last]+1)

      int cur=++tot;len[cur]=len[last]+1;
      
    • 接下来我们考虑哪些子串的(endpos)需要被修改——新串的后缀,它们都是原串的一个后缀通过转移(c)得来的,我们通过遍历原串的后缀来找到它们,遍历后缀的方法则是从(last)出发,不断让(last=link[last])直至(last=1)即根节点,这个结合(link)的含义不难发现。

    • 显然我们是按后缀的长度从大到小遍历的,一开始的几个后缀可能没有(c)这个转移,也就是说它们对应的新串后缀从未在旧串中出现过,因此它们的(endpos={i}),应该属于(cur)

      while(p&&!ch[p][c]){//p=0表明我们已经遍历完所有后缀了
      	ch[p][c]=cur;
      	p=link[p];	
      }
      if(!p) link[cur]=1;
      
    • 如果(p=0),那么所有新串后缀都已并入(cur),这样的节点在(parent tree)上只能从初始节点转移而来了,因此将(link[cur])设为(1)

    • 假设现在来到了节点(p)(ch[p][c]=q)是已经出现过的子串,它的(endpos)应该增加一个({i})进去,如果(q)中只有这一个子串,也就是说(len[q]=len[p]+1),(这一点是因为(minlen[q]=len[p]+1=len[q]),所以(q)仅包含这一个子串),于是我们不用修改它,因为(cur)就是(q)前加一个字母得到的,我们将(link[cur])指向(q)即可。

      int q=ch[p][c];
      if(len[q]==len[p]+1) link[cur]=q;
      
    • 否则,意味着我们要将(q)中代表新串后缀的这一个子串单独提出来修改它的(endpos),于是我们新建一个(np)表示这个单独提出来的子串的(endpos),用(t)来表示这个子串。于是(len[np]=len[p]+1),且(np)(ch)数组应当与(q)相同。考虑(link[np]),之前的(link[q])表示什么?表示(link[q])通过加一个字符得到了(q)中最短的一个子串,因为(t)满足(len[np]=len[p]+1=minlen[q]),因此它就是之前的最短子串,于是(link[np]=link[q])(link[q])自然就应该更换为(np)(link[cur])也应该指向(np)

    • 拆分完后,我们不仅要考虑(q)(np)的转移,还要考虑原先连向(np)(q)的转移,原先在字符串最后增加一个字符能得到(t)的点,我们要将它们的转移边改为连向(np),然后我们继续跳(link[p]),接下来的点也应该在(endpos)中增加(i),而如果它们本来连向的是(q),那么直接改为连向(np),当(ch[p][c] ot=q)时,意味着现在(p)连向的是(q)的祖先了,(q)的父亲(np)(endpos)中已经包含(i)了,因此我们就不需要再修改它们了。

      else{
      	int np=++tot;
      	len[np]=len[p]+1;
      	link[np]=link[q];
      	ch[np]=ch[q];//实际上ch[np]作为一个数组它不能这么用,我们这里只是表示这个意思
      	while(p&&ch[p][c]==q){
      		ch[p][c]=np;
      		p=link[p];
      	}
      	link[cur]=link[q]=np;
      }
      
    • 最后将(last)更新为(cur),我们就完成了整个构造的过程。依次加入每个字符,我们就完成了SAM的构造:

    • 完整代码如下:代码中也有一些注释,不过我认为我前面的讲解会更好理解一些。

      inline void insert(int c){
      	int cur=++tot;
      	len[cur]=len[last]+1;//开新节点 
      	int p=last;
      	while(p&&(!ch[p][c])){
      		ch[p][c]=cur;
      		p=link[p];
      	}//原s的所有后缀都应启动新转移c 
      	if(!p) link[cur]=0;//c没出现过,直接连向初始状态 
      	else{
      		int q=ch[p][c];
      		if(len[q]==len[p]+1) link[cur]=q;//是连续的则直接link 
      		else{
      			int np=++tot;
      			len[np]=len[p]+1;
      			link[np]=link[q];
      			ch[np]=ch[q]//否则将状态q分成两部分q与np,让q连向np 
      			while(p!=-1&&ch[p][c]==q){
      				ch[p][c]=np;
      				p=link[p];
      			}//修改所有连向q的转移 
      			link[q]=link[cur]=np;
      		}
      	}
      	siz[cur]=1;
      	last=cur;//更新last 
      }
      

      还有一个问题,为什么这样的构造复杂度是正确的?

      四、复杂度证明

      首先我们先证明SAM的状态数与转移数是(mathcal O(n))的,状态数我们通过(parent tree)已经证明,接下来考虑转移数:

      • 我们先找出SAM的一个生成树,将其他边舍去,然后我们从每一个后缀对应的终止状态出发沿SAM上起始状态到它的路径(这个是唯一的)往起始状态跑,然后如果遇见了一条非树边,就把这条边加入SAM中,然后沿这条边到达点(u),然后我们这个时候不走之前的路径了,改沿生成树上(u)到起点的路径跑回起点。这样的一条路径得到的字符串不一定是这个后缀,但它依然是从初始状态到终止状态的一条边,对应的也是一个后缀,并且没有被跑过(不然不会现在才连上)

      • 此时,我们将这个后缀划掉不跑了,继续回到(u)跑之前的后缀,重复这样的流程。

      • 整个流程中,我们每增加的一条非树边都唯一对应一个后缀,因此非树边的数量是(mathcal O(n))的,而树边数量等于状态数也是(mathcal O(n))的,因此总转移数是(mathcal O(n))的。

      回来考虑构造的复杂度:构造中看起来不是线性的应该就是以下几部分:

      • 遍历旧串后缀,增加到(cur)的转移
      • 增加新节点后复制之前(q)的转移到(np)上去((ch[np]=ch[q]))
      • 修改之前到(q)的转移到(np)

      对于前两个,它们每操作一次,就意味着新增一条转移,因此总复杂度是(mathcal O(n))的,(补充一点:字符集较大时我们用(unordered\_map)维护(ch)这样每次取出会带有一些常数,较小时(大部分情况下)我们直接把(ch)开为数组暴力转移)

      对于第三个,我们修改的转移就是连向新节点的所有转移,这个新节点不会再被操作三遍历到了,因此每条边最多只会被修改一次,操作三的总复杂度依然是线性的。

      至此,我们就证明了后缀自动机构造的线性复杂度。

    五、应用及例题

    本篇博客篇幅已经很大了,所以这部分我们下篇博客再说(咕咕咕)

  • 相关阅读:
    Hermite插值是牛顿插值的极限情形
    An introduction to numerical analysis theorem 6.3 :Hermite Interpolation Theorem
    matrix theory_basic results and techniques_exercise_1.2.10
    Hermite插值是牛顿插值的极限情形
    用带余除法可以解决一切部分分式的题目
    matrix theory_basic results and techniques_exercise_1.2.10
    详解物联网Modbus通讯协议
    手把手带你做LiteOS的树莓派移植
    在Vue中使用JSX,很easy的
    解读鸿蒙轻内核的监控器:异常钩子函数
  • 原文地址:https://www.cnblogs.com/tqxboomzero/p/14321693.html
Copyright © 2020-2023  润新知