字符串 --- KMP Eentend-Kmp 自动机 trie图 trie树 后缀树 后缀数组

时间:2021-09-05 14:41:12

涉及到字符串的问题,无外乎这样一些算法和数据结构:自动机 KMP算法 Extend-KMP 后缀树 后缀数组 trie树 trie图及其应用。当然这些都是比较高级的数据结构和算法,而这里面最常用和最熟悉的大概是kmp,即使如此还是有相当一部分人也不理解kmp,更别说其他的了。当然一般的字符串问题中,我们只要用简单的暴力算法就可以解决了,然后如果暴力效率太低,就用个hash。当然hash也是一个面试中经常被用到的方法。这样看来,这样的一些算法和数据结构实际上很少会被问到,不过如果使用它们一般可以得到很好的线性复杂度的算法。

老实说,我也一直觉得字符串问题挺复杂的,出来一个如果用暴力,hash搞不定,就很难再想其他的方法,当然有些可以用动态规划。不过为了解决这个老大难问题,还是仔细对这些算法和数据结构研读了一番。做个笔记,免得忘了还得重新思考老长时间。如果碰到字符串问题,也一般不会超过这些方法的范围了。先看一张图吧,主要说明下这些算法数据结构之间的关系。图中黄色部分主要写明了这些算法和数据结构的一些关键点。

字符串 --- KMP Eentend-Kmp 自动机 trie图 trie树 后缀树 后缀数组

图中可以看到这样一些关系:extend-kmp 是kmp的扩展;ac自动机是kmp的多串形式;它是一个有限自动机;而trie图实际上是一个确定性有限自动机;ac自动机,trie图,后缀树实际上都是一种trie;后缀数组和后缀树都是与字符串的后缀集合有关的数据结构;trie图中的后缀指针和后缀树中的后缀链接这两个概念及其一致。

下面我们来分别说明这些算法和数据结构,并对其涉及的关键问题进行分析和解释。

kmp

首先这个匹配算法,主要思想就是要充分利用上一次的匹配结果,找到匹配失败时,模式串可以向前移动的最大距离。这个最大距离,必须要保证不会错过可能的匹配位置,因此这个最大距离实际上就是模式串当前匹配位置的next数组值。也就是max{Aj 是 Pi 的后缀  j < i},pi表示字符串A[1...i],Aj表示A[1...j]。模式串的next数组计算则是一个自匹配的过程。也是利用已有值next[1...i-1]计算next[i]的过程。我们可以看到,如果A[i] = A[next[i-1]+1] 那么next[i] = next[i-1],否则,就可以将模式串继续前移了。
整个过程是这样的:
void next_comp(char * str){
   int next[N+1];
   int k = 0;
   next[1] = 0;
   //循环不变性,每次循环的开始,k = next[i-1] 
   for(int i = 2 ; i <= N ; i++){
      //如果当前位置不匹配,或者还推进到字符串开始,则继续推进
      while(A[k+1] != A[i] && k != 0){
           k = next[k];
      }     
      if(A[k+1] == A[i]) k++;
      next[i] = k;
   } 
}
复杂度分析:从上面的过程可以看出,内部循环再不断的执行k = next[k],而这个值必然是在缩小,也就是是没执行一次k至少减少1;另一方面k的初值是0,而最多++ N次,而k始终保持非负,很明显减少的不可能大于增加的那些,所以整个过程的复杂度是O(N)。

上面是next数组的计算过程,而整个kmp的匹配过程与此类似。

extend-kmp

为什么叫做扩展-kmp呢,首先我们看它计算的内容,它是要求出字符串B的后缀与字符串A的最长公共前缀。extend[i]表示B[i...B_len] 与A的最长公共前缀长度,也就是要计算这个数组。

观察这个数组可以知道,kmp可以判断A是否是B的一个子串,并且找到第一个匹配位置?而对于extend[]数组来说,则可以利用它直接解决匹配问题,只要看extend[]数组元素是否有一个等于len_A即可。显然这个数组保存了更多更丰富的信息,即B的每个位置与A的匹配长度。

计算这个数组extend也采用了于kmp类似的过程。首先也是需要计算字符串A与自身后缀的最长公共前缀长度。我们设为next[]数组。当然这里next数组的含义与kmp里的有所过程。但它的计算,也是利用了已经计算出来的next[1...i-1]来找到next[i]的大小,整体的思路是一样的。

具体是这样的:观察下图可以发现

字符串 --- KMP Eentend-Kmp 自动机 trie图 trie树 后缀树 后缀数组

首先在1...i-1,要找到一个k,使得它满足k+next[k]-1最大,也就是说,让k加上next[k]长度尽量长。

实际上下面的证明过程中就是利用了每次计算后k+next[k]始终只增不减,而它很明显有个上界,来证明整个计算过程复杂度是线性的。如下图所示,假设我们已经找到这样的k,然后看怎么计算next[i]的值。设len = k+next[k]-1(图中我们用Ak代表next[k]),分情况讨论:

如果len < i 也就是说,len的长度还未覆盖到Ai,这样我们只要从头开始比较A[i...n]与A的最长公共前缀即可,这种情况下很明显的,每比较一次,必然就会让i+next[i]-1增加一.
如果len >= i,就是我们在图中表达的情形,这时我们可以看到i这个位置现在等于i-k+1这个位置的元素,这样又分两种情况
如果 L = next[i-k+1] >= len-i+1,也就是说L处在第二条虚线的位置,这样我们可以看到next[i]的大小,至少是len-i+1,然后我们再从此处开始比较后面的还能否匹配,显然如果多比较一次,也会让i+A[i]-1多增加1.
如果 L < len-i+1 也就是说L处在第一条虚线位置,我们知道A与Ak在这个位置匹配,但Ak与Ai-k+1在这个位置不匹配,显然A与与Ai-k+1在这个位置也不会匹配,故next[i]的值就是L。

这样next[i]的值就被计算出来了,从上面的过程中我们可以看到,next[i]要么可以直接由k这个位置计算出来,要么需要在逐个比较,但是如果需要比较,则每次比较会让k+next[k]-1的最大值加1.而整个过程中这个值只增不减,而且它有一个很明显的上界k+next[k]-1 < 2*len_A,可见比较的次数要被限制到这个数值之内,因此总的复杂度将是O(N)的。

trie树

首先trie树实际上就是一些字符串组成的一个字符查找树,边由代表组成字符串的字符代表,这样我们就可以在O(len(str))时间里判断某个字符串是否属于该集合。trie树的节点内分支可以用链表也可以用数组实现,各有优劣。

简单的trie树每条边由一个字符代表,但是为了节省空间,可以让边代表一段字符,这就是trie的压缩表示。通过压缩表示可以使得trie的空间复杂度与单词节点数目成正比。

AC自动机

ac自动机,可以看成是kmp在多字符串情况下扩展形式,可以用来处理多模式串匹配。只要为这些模式串建立一个trie树,然后再为每个节点建立一个失败指针,也就是类似与kmp的next函数,让我们知道如果匹配失败,可以再从哪个位置重新开始匹配。ac实际上两个人的名字的首字母,Aho-Corasick。

应该还记得,在kmp构造next数组时,我们是从前往后构造,即先构造1...i-1,然后再利用它们计算next[i],这里也是类似。不过这个先后,是通过bfs的顺序来体现的。AC自动机的失败指针具有同样的功能,也就是说当我们的模式串在Tire上进行匹配时,如果与当前节点的关键字不能继续匹配的时候,就应该去当前节点的失败指针所指向的节点继续进行匹配。而从根到这个失败指针指向的节点组成的字符串,实际上就是跟当前节点的后缀的匹配最长的字符串。

过程如下:

--------------引用:AC(Aho-Corasick)自动机算法http://hi.baidu.com/luyade1987/blog/item/5ba280828dcb9eb96d811972.html

如同KMP中模式串得自我匹配一样.从根节点开始,对于每个结点:设该结点上得字符为k,沿着其父亲结点得失败指针走,直到到达根节点或者当前失败指针结点也存在字符为k得儿子结点,
那么前一种情况当然是把失败指针设为根节点,而后一种情况则设为当前失败指针结点得字符为k得儿子结点.

我们也可以动手操作一下,如果我们的ac自动机只包含一个模式串,这个过程实际上就是kmp的计算过程。

接下来要做的就是进行文本匹配:
首先,Trie-(模式串集合)中有一个指针p1指向root,而文本串中有一个指针p2指向串头。下面的操作和KMP很类似:如果设k为p2指向的字母 ,而在Trie中p1指向的节点存在字符为k的儿子,那么p2++,p1

则改为指向那个字符为k的儿子,否则p1顺着当前节点的失败指针向上找,直到p1存在一个字符为k的儿子,或者p1指向根结点。如果p1路过一个标记为模式串终点的结点,那么以这个点为终点的的模式
串就已经匹配过了.或者如果p1所在的点可以顺着失败指针走到一个模式串的终结结点,那么以那个结点结尾的模式串也已经匹配过了。
在下面的链接中可以找到相关的资料:
www.cs.uku.fi/~kilpelai/BSA05/lectures/slides04.pdf

主要是根据模式串构造三个函数goto fail和output.

q := 0; // initial state (root)
for i := 1 to m do
while g(q, T[i]) = 0 do
q := f(q); // follow a fail
q := g(q, T[i]); // follow a goto
if out(q) != 0; then print i, out(q);
endfor;
-----------------------------------------引用结束-------------------------------------------------------------------------------------------
以ababa为例,我们可以得到它的kmp next数组值为 0 0 1 2 3,ac自动机和trie图如下:

字符串 --- KMP Eentend-Kmp 自动机 trie图 trie树 后缀树 后缀数组

trie图

trie图实际上一个确定性自动机,比ac增加了确定性这个属性,对于ac自动机来说,当碰到一个不匹配的节点后可能要进行好几次回溯才能进行下一次匹配。但是对于trie图来说,可以每一步进行一次匹配,每碰到一个输入字符都有一个确定的状态节点。

从上面的图中我们也可以看到trie图的后缀节点跟ac自动机的后缀指针基本一致,区别在于trie图的根添加了了所有字符集的边。另外trie图还会为每个节点补上所有字符集中的字符的边,而这个补边的过程实际上也是一个求节点的后缀节点的过程,不过这些节点都是虚的,我们不把它们加到图中,而是找到它们的等价节点即它们的后缀节点,从而让这些边指向后缀节点就可以了。(比如上图中的黑节点c,它实际上并未出现在我们的初始tire里,但我们可以把它作为一个虚节点处理,把指向它的边指向它的后缀节点)

trie图主要利用两个概念实现这种目的。一个是后缀节点,也就是每个节点的路径字符串去掉第一个字符后的字符串对应的节点。计算这个节点的方法,是通过它父亲节点的后缀节点,很明显它父亲的后缀节点与它的后缀节点的区别就是还少一个尾字符,设为c。所以节点的父节点的指针的c孩子就是该节点的后缀节点。但是因为有时候它父亲不一定有c孩子,所以还得找一个与父亲的c孩子等价的节点。于是就碰到一个寻找等价节点的问题。

而trie图还有一个补边的操作,不存在的那个字符对应的边指向的节点实际上可以看成一个虚节点,我们要找一个现有的并且与它等价的节点,将这个边指向它。这样也实际上是要寻找等价节点。

我们看怎么找到一个节点的等价节点,我们所谓的等价是指它们的危险性一致。那我们再看一个节点是危险节点的充要条件是:它的路径字符串本身就是一个危险单词,或者它的路径字符串的后缀对应的节点是一个危险节点。因此我们可以看到,如果这个节点对应的路径字符串本身不是一个危险单词,那它就与它的后缀节点是等价的。所以我们补边的时候,实际指向的是节点的后缀节点就可以了。

trie图实际上对trie树进行了改进,添加了额外的信息。使得可以利用它方便的解决多模式串的匹配问题。跟kmp的思想一样,trie图也是希望利用现在已经匹配的信息,对未来的匹配提出指导。提出了一些新的概念。定义trie树上,从根到某个节点的路径上所有边上的字符连起来形成的字符串称为这个节点的路径字符串。如果某个节点的路径字符串以一个危险字符串结尾,那么这个节点就是危险节点:也就是说如果到达这个点代表是匹配的状态;否则就是安全节点。 那么如何判断某个节点是否危险呢?

根节点显然是安全节点。一个节点是危险节点的充要条件是:它的路径字符串本身就是一个危险单词,或者它的路径字符串的后缀(这里特指一个字符串去掉第一个字符后剩余的部分)对应的节点(一个字符串对应的节点,是指从trie图中的根节点开始,依次沿某个字符指定的边到达的节点)是一个危险节点。

那么如何求每一个节点的后缀节点呢?这里就可以里利用以前的计算信息,得到了。具体来说就是利用父亲节点的后缀节点,我们只要记住当前节点的最后一个字符设为C,那么父亲节点的后缀节点的C分支节点就是要求的后缀节点了。首先我们限定,根节点的后缀节点是根本身,第一层节点的后缀节点是根节点。这样我们可以逐层求出所有节点的后缀节点。但是这个过程中,可能出现一个问题:父亲节点的后缀节点可能没有c分支。这时候该怎么办呢?

如下图所示如果设当前节点的父亲节点的后缀节点为w,我们假设w具有c孩子为,我们可以看到对于w的整个c子树来说,因为根本不存在通向它们的边c,它们也就不可能是不良字符串,这样这些节点的危险性也就等价与它们的后缀节点的危险性了,而它们的后缀节点,实际上就是w的后缀节点的c孩子,如此回溯下去,最后就能找到。

字符串 --- KMP Eentend-Kmp 自动机 trie图 trie树 后缀树 后缀数组

--------------------引用:http://huangwei.host7.meyu.net/?paged=7

其实Trie图所起到的作用就是建立一个确定性有限自动机DFA,图中的每点都是一个状态,状态之间的转换用有向边来表示。Trie图是在Tire的基础上补边过来的,其实他应该算是AC自动机的衍生,AC自动机只保存其后缀节点,在使用时再利用后缀节点进行跳转,并一直迭代到找到相应的状态转移为止,这个应该算是KMP的思想。这篇文章可以参考。

而Trie图直接将AC自动机在状态转移计算后的值保存在当前节点,使得不必再对后缀节点进行迭代。所以Trie图的每个节点都会有|&sum;|个状态转移(&sum;指字符集)。构造具体方法可见WC2006《Trie图的构建、活用与改进》。我简单叙述下流程:
(1)构建Trie,并保证根节点一定有|&sum;|个儿子。
(2)层次遍历Trie,计算后缀节点,节点标记,没有|&sum;|个儿子的对其进行补边。
后缀节点的计算:
(1)根结点的后缀节点是它本身。
(2)处于Trie树第二层的节点的后缀结点也是根结点。
(3)其余节点的后缀节点,是其父节点的后缀节点中有相应状态转移的节点(这里类似AC自动机的迭代过程)。
节点标记:
(1)本身就有标记。
(2)其后缀节点有标记。
补边:
用其后缀节点相应的状态转移来填补当前节点的空白。
最后Trie图中任意一个节点均有相应的状态转移,我们就用这个状态转移做动态规划。
设dp[i][j]表示第i个状态产生j个字符时,与DNA序列最小的改变值。
假设Tire图中根节点是0,则初始化dp[0][0]=1。
其后,对图进行BFS遍历,可知处于第j层时,就说明以产生了j长度的字符串。
dp[0][0] = 1;for i = 1 to m do  for 图中每条边(s1,ch,s2) do    dp[s2][i] = min{dp[s1][i-1] + (txt[i-1] != ch)};    for 图中每个结点x do  ans = min{dp[x][m]};

-----------------------------------------------引用结束-----------------------------------------------------------------------------------

后缀树

后缀树,实际上就是字符串的所有后缀组成的字符串集合构成的trie树。如果采用不压缩方式的trie存储,这样整个内部节点和外部节点的总和就可能达到O(n^2).所以不能利用这种存储方式,因为如果采用它那么构建的复杂度下界就是O(n^2),不会再低了。所以必须使用压缩方式,才有可能降到O(n)。

构建之前,我们首先给字符串加上一个未在字符串中出现过的单词,比如"$",为什么这样做呢?是为了避免后缀节点出现在内部,如果我们加上"$",很明显就不会有后缀出现在内部了,可以用反证法证明:假设出现了一个这样的后缀是内部节点,那么意味着这条字符串路径上会有两个"$",但这是不可能的,因为我们的"$"只在结尾出现,之前没有出现过。

构建过程中,我们看如果采用普通的构建过程是怎样的?普通的构建,假设字符串为A[1....N],我们从以A[1]开头的后缀开始插入trie树,插入的时候,逐步比对,直到找到不匹配的分支,在这个节点将原来的节点分裂,并加入这个新的节点。可以这个过程关键是寻找,之前sufix[1]...sufix[i-1]这些已经插入的字符串与sufix[i]的最长公共前缀。之后插入的时间O(1)就可以完成,因此主要的时间花在这个最长公共前缀(称为head[i])的寻找上。Headi是W(i,n)和W(j,n)的最长公共前缀,其中j是小于i的任意正整数,Taili使得Headi + Taili = W(i,n)。

那我们看到现在关键是这个最长公共前缀head[i]的计算了。我们再次考虑如何利用head[1]...head[i-1]来计算head[i],为加快寻找hi的速度我们需要使用辅助结构&mdash;&mdash;后缀链接。

后缀链接的定义(McCreight Arithmetic):
令Head[i-1] = az,其中a是字符串W的第i-1位字符。由于z在范围i内出现过至少两次(因为az也是A[i-1...N]与之前某后缀的最长公共前缀,也就是说另外的那个后缀也是一az开头的一个串,这样就意味着它的后继者,就比然是以z为前缀的,这样A[i...N]与它的公共前缀就是z。{实际上这个性质在我们计算后缀数组的lcp时也会利用到}),所以一定有|Head[i]| >=  |z|,z是Head[i]的前缀。所谓hi-1的后缀链接(Suffix Link)实际是由hi-1指向z对应节点d的指针Link h[i-1]。当然,z有可能是空串,此时Link hi-1由hi-1指向根节点Root。

和前面 ac自动机的失败指针 trie树的后缀指针比较,我们可以发现这里的z它刚好就是head[i-1]去掉第一个字符后的那个后缀,所谓的后缀链接,实际上是指向head[i]自身的后缀的链接,这个定义也就跟我们trie树里的后缀指针所指向的那个位置一致了。这样这个head[i]的后缀链接怎样建立就很清楚了。

创建方法:
1)根节点Root的后缀链接指向它自身
2)任何一个非叶节点的后缀链接都在该节点出现以后被立即创建

算法主框架如下:
For i = 1 -> n do
    步骤1、函数Find从Link hi-1开始向下搜索找到节点hi
    步骤2、增添叶子节点Leafi
    步骤3、函数Down创建hi的后缀链接Link hi      
End for

后缀树性能分析:
接着刚才文本框内的伪代码来谈论。对于给定的i,步骤2的复杂度为O(1),但由于无法确定Link hi-1到hi之间的节点个数,所以不能保证步骤1总是线性的。局部估算失败,不妨从整体入手。有一点是肯定的,那就是i + |Headi|总随着i的递增而递增。因此,W中的每个字符只会被Find函数遍历1次,总体复杂度是O(n)的。

这个分析就与extend-kmp的复杂度分析很类似了。

后缀数组

后缀数组实际上就是对字符串的后缀按照字典序进行排序,然后把排好序后的顺序放到一个数组sa[]里保存,数组元素代表了后缀在原串里的起始索引。通过这个我们可以很容易得到另一个数组rank[],rank[i]代表了原来的后缀A[i...N]在sa数组里的排名。

这个数据结构,主要涉及两个方面的内容,一个是如何快速的对这些后缀排序,有很多方法,这里只说明倍增算法,这个方法比较好理解,思路也比较巧妙。

还有就是后缀数组求出来后,如果要发挥比较强的作用,还需要求出各个后缀的最长公共前缀lcs。所以lcs的计算也是一个重点。

首先看排序,如果我们采用普通的排序算法,那么需要nlogn次比较,但是每次比较需要O(n),这样总的复杂度将是O(n*nlogn).

倍增算法是这样的,主要是第i次排序,比较时的大小时利用了第i-1次的排序结果,这样可以让比较在O(1)时间里完成:
我们首先对所有从原字符串各个位置开始的长度为1的字符进行排序,然后再对从这些位置开始的长度为2的排序,之后是长度为2^i的排序,直到2^i >= N.可以看到这中间,总共需要log N次排序。然后我们看第i次排序,比较大小时怎样利用了第i-1次的排序结果。

比如在第i次排序时,我们需要比较A[j]和A[k]开始的长度为2^i的串,那么我们可以将它们分成两块:
A[j]开始的长度为2^i的串 = A[j]开始的2^(i-1)长 + A[j+2^(i-1)]开始的2^(i-1)长
A[k]开始的长度为2^i的串 = A[k]开始的2^(i-1)长 + A[k+2^(i-1)]开始的2^(i-1)长
要比较A[j]开始的长度为2^i的串 和 A[k]开始的长度为2^i的串,我们只要先比较第一部分,如相等再比较第2部分,而这两部分大小因为之前已经排好序了,我们完全可以给它们一个rank值,只比较它们的rank值就可以得到大小关系,这样比较就可以在O(1)时间内完成了。另外如果我们的排序算法是O(n)的,这样整个算法的复杂度就是O(nlogn)的了。

再看lcs的计算,如果要计算任意两个后缀的lcs[i][j],我们有一个结论:

设 i<j  LCP(i,j)=min{LCP(k-1,k)|i+1 =< k <= j}  LCP Theorem 这里的i,j指而是sa[i] sa[j]

如果要证明上面那个结论,首先要证明这个:对任意的 1=<i<j<k<=n, LCP(i,k)=min{LCP(i,j),LCP(j,k)},这里不再证明。

上面那个结论实际上说:如果要找i j的最长公共前缀长度,只需要找到i j之间相邻后缀的最小lcs长度即可。这样我们只需要求出sa数组中相邻后缀的lcs长度,就转化成了一个rmq问题,即区间内的最小值问题。这个可以O(1)解决。这样问题就变成:如何在O(n)时间里,计算sa数组中相邻后缀的lcs长度。

这个问题如果要O(n),又利用了下面这样一个结论:定义一个一维数组 height,令height[i]=LCP(i-1,i) 1<i<=n 并设 height[1]=0。如何尽量高效的算出height数组呢?

为了描述方便,设h[i]=height[Rank[i]],即height[i]=h[SA[i]],而h数组满足一个性质:

对于 i>1 且 Rank[i]>1  一定有 h[i] >= h[i-1]-1.

为什么会有这个结论呢?实际上就与上面后缀树的后缀链接那部分提到的想呼应了。h[i]=height[Rank[i]],实际上就是我们的原来的后缀A[i...N]与某个串的最长公共前缀,而h[i-1]就是A[i-1...N]与某个串的最长公共前缀。而我们可以看到如果把A[i-1...N]去掉第一个字符后,就变成了A[i...N],我们假设A[i-1...N]相邻的那个后缀串是XYYYYYY,在这里它们的lcs长度是h[i]。后缀串XYYYYYY,去掉x之后就是YYYYYYY,这样如果没有比它更接近A[i...N]的,那么h[i]=h[i-1]-1,如果A[i...N]的邻居不是它,那么h[i]只可能比h[i-1]-1大不可能比它小。

这样利用这个结论,我们在O(n)时间内就可以把h[i]计算出来了。因为h[i]最大不超过N,而它每次最多减少不超过1.

计算出来之后,再根据height[i]=h[SA[i]],就可以计算出height数组,这样就求出了sa中相邻后缀的lcs长度。

总结:
实际上我们可以看到上面的算法思想,都有一个共同点:利用已经得到的计算结果得到下一次计算的结果,尽量利用现有信息,减少计算量。