对于文本T,设T’是T的逆序文本,若T‘与T相同,那么称T为回文。比如aba、abba都是回文。
回文树是用于组织和统计文本T中所有回文的数据结构,可以优雅地解决大量回文有关的问题。如同AC自动机,后缀自动机等处理文本的数据结构一样,回文树的建立也拥有着线性的时间复杂度,并且其建立过程是在线的。
下面我们来描述回文树的定义和建立过程。
定义
在文本T上建立的回文树中的结点表示文本T的某段子回文串,且文本T中的每个子回文串都对应回文树中的一个结点,我们将结点n所代表的回文记作n.repr。结点与其孩子之间通过轨迹联系,若某个结点u通过字符c标记的轨迹抵达到结点v(即存在c标记的边(u,v)),那么v所代表的回文等同于u所代表的回文的两端加上c,即v.repr = c + u.repr + c。
对于每个结点,我们记录结点对应回文的长度。对于结点n,我们记n.len为其回文长度,即n.len=|n.repr|。
如同AC自动机和后缀自动机一样,回文树结点也有失败指针fail。对于结点x,设y=x.fail,那么y.repr是x.repr的后缀,且是最长后缀(|y.len|<|x.len|)。对于从某个结点x出发,沿着fail链能访问到的结点序列,我们称为x的fail链。显然所有x的回文后缀都出现在了x的fail链上(根据定义)。
回文树中初始有两个结点,even和odd。其中even.len = 0,即even表示的是空串(按照定义空串当然也是回文)。而odd.len=-1,这里可能会感觉比较奇怪,但是看到后面就知道用处了。我们顺便将even.fail设为odd,而odd.fail设为空NIL即可。实际上我们考虑到孩子的存在,而孩子是在回文两端加上相同的字符,即对于父亲father和孩子child,一定有father.len+2=child.len,那么even的孩子是偶数长度的回文串,而odd的孩子均为奇数长度的回文串,将even.len设为0,可以保证T中所有偶数串都可以挂在even或even的后代结点上,而将odd设为-1,可以帮助所有奇数长度的回文挂在其下。并且由于空串even的存在,我们能保证每个非空回文的fail指针均能指向一个结点(空串是所有串的后缀,且是回文,因此满足fail指针的要求)。
回文树中还需要有一个指针last,初始时其指向even和odd均可。last会在每次向树中加入新的字符后,指向当前读取的文本(T的某段前缀)的能形成最长回文的后缀(由于单个字符也是文本,因此last始终能指向一个有效结点)。
建立
好的,上面就是回文树中的定义,下面我们要讲述回文树的建立过程。假设我们已经利用文本T的前缀T[1...k-1]建立了一株合法的回文树(此时所有的上面的定义都对于文本T[1...k-1]是满足的)。那么现在读入新的字符c=T[k]。
现在我们需要做的工作是将在T[1...k-1]建立的回文树Tree[k-1]转变为在T[1...k]上建立的回文树Tree[k]。先观察两株回文树有什么区别,区别在于Tree[k]可能拥有比Tree[k-1]更多的回文,而这个回文一定是以新加入的字符T[k]作为结尾的。我们考虑需要加入多少个结点,等同于考虑T[1...k]较T[1...k-1]多了哪些回文。设T[l...k]是T[1...k]所有回文后缀中最长的回文,那么我们可以保证T[1...k]只可能比T[1...k-1]多了回文T[l...k](当然也可能二者拥有相同的回文子串)。那么对于r>l,若T[r...k]也是回文,我们如何保证T[1...k-1]中包含回文T[r...k]呢?这源于回文的性质,由于T[r...k]是T[l...k]的后缀,且二者都是回文,因此T[l...(l+k-r)]=T[r...k],而(l+k-r)<k,因此T[r...k]是T[1...k-1]的某个子串。
好了,根据上一段的说明,我们了解到最多只需要向Tree[k-1]中加入一个结点就可以使得与Tree[k]有相同的结点。当然也有可能T[l...k]已经存在于T[1...k-1],这时候我们就不需要追加结点。无论哪种情况,我们都需要先找到T[l...k]在Tree[k-1]中的父结点。T[l...k]的父结点必然是T[l+1...k-1](如果l=k,那么代表的就是odd)。而我们注意到last记录了T[1...k-1]中的最长后缀回文,而T[l+1...k-1]也是T[1...k-1]的某个回文后缀,因此T[l+1...k-1]必然出现在了last的fail链上。而根据T[l...k]是T[1...k]的最长回文后缀,因此T[l+1...k-1]必然是last的fail链上最长的某个符合下面条件的结点x:T[k-x.len-1]=T[k]。由于fail链上的结点的长度是递减的,因此T[l+1...k-1]是last的fail链上首个满足该条件的结点。写作代码就如下:
x = last; for(; k - x.len >= 0; x = x.fail); for(; T[k - x.len - 1] != T[k]; x = x.fail);
我们已经成功找到了父亲,之后利用父亲是否存在c标记的轨迹,就可以判断T[l...k]是否已经存在于Tree[k-1]中了,如果已经存在自然就不需要增加了。但是根据last的定义,我们需要将last调整为x.next[c]才能保证上面的定义不被破坏,即last指向文本的最长回文后缀。
if(x.next[c] != NIL) last = x return
当然还有一种是T[l...k]不存在于Tree[k-1]。我们需要加入新的结点now,来保证能创建出Tree[k]。很显然now.len = x.len + 2。并且我们还需要建立now与x的父子联系:
now = new-node now.len = x.len + 2 x.next[c] = now
但是做完了这些就OK了吗,看看我们新创建的Tree[k]还违反了上面提出的哪些定义。是的,now的fail指针还没有正确设置。由于我们说过结点的fail指针指向的是该结点的最长回文后缀,而由于now和now.fail均为回文,因此now.fail也是now的回文前缀,即在now加入之前,now.fail已经存在于原来的回文树中了,这也说明了now.fail永远能指向正确的结点,并且不会因为后面新的字符的加入而改变。我们接下来聊一聊如何找到now.fail。
由于now=c+x+c,而now.fail是now的后缀,因此now.fail在剔除两端的c之后得到的结点y(即y是now.fail的父亲),必然是x的后缀回文(注意由于x没有c标记的轨迹,因此x不可能是y,故y只可能是x的后缀回文)。而x的后缀回文均落在x的fail链上,所以我们可以在fail链上找到y,而y也就是x的fail链上最长(换言之首个)满足下面条件的结点:T[k-y.len-1]=T[k]。当然这个过程中,若x是odd,那么now实际上只有一个字符c,我们上面所说的寻找fail指针指向的y.next[c]的算法找到的fail长度至少为1,因此无法找到x的fail,我们可以特判,并将其fail指针设置为空串,即even。
if(x.len == 1) now.fail = even else y = x.fail for(; T[k-y.len-1] != T[k]; y = y.fail) now.fail = y
将上面三部分代码合并起来,我们就得到了从Tree[k-1]到Tree[k]的转移函数。
分析
时间复杂度的分析如下:
按照算法,每次读入一个新的结点,我们最多将循环结束后的last的len增加2,同时每次沿着fail链寻找新结点的父亲x的时候,每次循环都将会使得last的len减少1。在下一次读入新字符后,我们先找到x(last的某个后缀),之后必然会从x.fail开始沿着其fail链移动寻找y,而x.fail的len是必然要不可能大于last.fail.len的。因此我们发现now.fail.len<=last.fail.len+2。每次从x.fail开始沿着其fail链移动寻找y,都会使得last.fail.len减少至少1,而每读入一个字符最多使得last.fail.len增加2,由last.fail.len>=0可以推出最多沿着从x.fail开始沿着其fail链移动寻找y共计2|T|次。其它每次读入一个字符的时间复杂度均为常数O(1),因此时间复杂度为O(|T|)+O(|T|)+|T|*O(1)=O(|T|)。
空间复杂度的分析如下:
每次读入一个字符最多创建1个结点,加上初始时创建的even和odd,总计最多创建|T|+2个结点,因此空间复杂度为O(|T|)。同时这也说明一段文本T中最多有|T|种不同的回文。