欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

初探回文树/回文自动机

程序员文章站 2022-06-10 22:20:08
...

概述

回文树(Palindromic TreePalindromic\ Tree,又称回文自动机)是一种用于存储字符串所有回文子串的数据结构,通过对这些回文子串建立回文树,从而高效便捷地解决一系列设计回文串的问题。

前置姿势

  1. 字典树
  2. AC自动机

实现

由于本人太懒,所有图片来源于网络

先来明确回文树与普通字典树的区别所在:

  1. 和字典树不同的第一个地方在于字典树只有一个根结点,而回文树有两个根节点,这是因为回文串的长度分奇数和偶数两种情况。将偶数串的根置为  0  \;0\;,奇数串的根置为  1  \;-1\;(理由下面再说)。
  2. 字典树的每个结点表示字符串中的一个字符,而回文树中的每个结点定义为一个回文子串,例如对于字符串  abba  \;abba\;而言,建立回文树如下图所示:
    初探回文树/回文自动机

这样就表示了一个回文树,先暂且不用理会  len  \;len\;  fail  \;fail\;指针的含义。观察该图,  0  \;0\;号结点连接了所有偶数长度的回文子串,每个结点为一个字符,如何来理解呢?比如  4  \;4\;号结点,其值为  b  \;b\;,那么就表示原串中有一个  bb  \;bb\;的回文子串,对于  4  \;4\;号结点的子结点  5  \;5\;号结点,其值为  a  \;a\;,表示在其父结点的基础上首位各扩展一个该字符,也就是说,  5  \;5\;号结点表示原串中存在  abba  \;abba\;这样一个回文子串。

有了这个思想以后,我们来考虑如何实现这种结构。同样,首先先明确几个定义:

  1.   last  \;last\;表示每次添加完一个字符后该回文串的位置;
  2.   cnt  \;cnt\;表示回文树总共的结点个数;
  3.   sam[maxn][26]  \;sam[maxn][26]\;类比于字典树;
  4.   len[i]  \;len[i]\;表示当前结点  i  \;i\;的回文串长度;
  5.   fail[i]  \;fail[i]\;表示指向当前结点形成的回文串的最大回文后缀。

先给出源码:

#include<bits/stdc++.h>
using namespace std;
const int maxn = 5e5 + 5;
int n, last, cnt;
char s[maxn];
int sam[maxn][26], fail[maxn], len[maxn];
inline void init()
{
	s[0] = '#';
	cnt = fail[0] = 1;
	len[1] = -1;
}
inline void extend()
{
	for (int i = 1; i <= n; i++)
	{
		int x = s[i] - 'a';
		while (s[i - len[last] - 1] != s[i])
		{
			last = fail[last];
		}
		if (!sam[last][x])
		{
			len[++cnt] = len[last] + 2;
			int j = fail[last];
			while (s[i - len[j] - 1] != s[i])
			{
				j = fail[j];
			}
			fail[cnt] = trie[j][x];
			sam[last][x] = cnt;
		}
		last = sam[last][x];
	}
}
int main()
{
	scanf("%s", s + 1);
	n = strlen(s + 1);
	init();
	extend();
	return 0;
}

我们来逐步分析,首先主函数就没什么好说的了,就是读入字符串并获取字符串的长度。首先看初始化部分:

inline void init()
{
	s[0] = '#';
	cnt = fail[0] = 1;
	len[1] = -1;
}

这里将  s[0]  \;s[0]\;设置为任意一个字符串中不会出现的字符即可,这样最后匹配跳转就一定会终止,然后将  fail[0]  \;fail[0]\;指向  1  \;1\;号结点,这是因为任意一个字符都是可以构成长度为  1  \;1\;的回文串,所以在不断跳  fail  \;fail\;的过程中最终都会落到  1  \;1\;号结点(当然在此之前它都无法构成回文串),所以我们将  0  \;0\;号结点的  fail  \;fail\;指向  1  \;1\;,而  1  \;1\;号结点的  fail  \;fail\;则无关紧要,默认为  0  \;0\;即可。

	int x = s[i] - 'a';
	while (s[i - len[last] - 1] != s[i])
	{
		last = fail[last];
	}

先来理解循环开始的第一部分,这个循环的作用就是在跳  fail  \;fail\;指针以找到一个合适的插入位置。根据我们前面的定义,  last  \;last\;表示的上一次结点的位置,它所构成的回文长度为  len[last]  \;len[last]\;,而每个结点表示的是一个回文子串,所以我们要判断新加入的这个字符是否可以放在上一次所构成的回文串两端,比如我们已经构建好了回文串  baab  \;baab\;,此时此时新加入的字符假设为  a  \;a\;,那么我们就需要判断一下  baab  \;baab\;的上一个字符是否为  a  \;a\;,如果是那么就可以插入在这个回文串的两端,如果不是则需要不断跳转  fail  \;fail\;找到合适的插入位置。
初探回文树/回文自动机
在上图中,原串为  abbaabba  \;abbaabba\;,考虑插入  6  \;6\;号结点  a  \;a\;时,首先很明显我们可以观察出加入  a  \;a\;后构成的  abbaa  \;abbaa\;不是回文串,那么计算机如何判断呢?首先它将加入的字符  a  \;a\;  abba  \;abba\;的上一个字符KaTeX parse error: Expected 'EOF', got '#' at position 3: \;#̲\;进行比较,发现不等,此时根据上个结点的  fail  \;fail\;指针跳向最大回文后缀的位置,再判断对于该回文串的前一个字符是否与该字符相等。比如说,当前字符串为  aaabbaa  \;aaabbaa\;,回文串为  aabbaa  \;aabbaa\;,此时加入  b  \;b\;判断该回文串的前一个字符  a  \;a\;  b  \;b\;,那么根据  fail  \;fail\;指针会跳向最大回文后缀也就是表示  aa  \;aa\;的结点,这时再判断前一位字符  b  \;b\;与待插入字符  b  \;b\;,二者相等,因此在该位置下生成新结点。

	if (!sam[last][x])
	{
		len[++cnt] = len[last] + 2;
		int j = fail[last];
		while (s[i - len[j] - 1] != s[i])
		{
			j = fail[j];
		}
		fail[cnt] = sam[j][x];
		sam[last][x] = cnt;
	}
	last = sam[last][x];

在上一步中我们找到了新结点所要插入的位置,接下来就是插入的过程了。如果当前结点是空的,那么就开始插入。第一步很好理解,扩建一个新结点,然后该结点的长度自然是其父结点长度+2+2(回文串插入新字符首位都要插入),接下来就是要构造新结点的  fail  \;fail\;指针,(如果你已掌握ACAC自动机  fail  \;fail\;指针的构造,可以跳过),详情请自行学习AC自动机中fail指针的建立,再讲一遍太累了,篇头有链接  AC  \;AC\;自动机类似,我们根据父结点的  fail  \;fail\;指针来转移,判断每次跳转后该结点是否为最大后缀,判断方式和循环开始的方式相同,在找到合适位置后将  fail  \;fail\;指针指向该结点,同时修改  last  \;last\;,含义就是之前提到的加入新字符后所形成的回文串的结点位置。

在构建好回文树后,可以得到几个有用的信息:

  1.   cnt  \;cnt\;表示的就是字符串中回文子串的个数,这里是不重复的。
  2. 如果想要得到每种回文子串出现的次数,只需要再设置一个数组,在每次插入新字符后,该结点的数量  +1  \;+1\;即可。

至于例题,想都不要想了我懒得很, 有机会的话会补充的。

完结撒花!!!