本文的一些定义见附录A
1,回文树与其构造
1.1结构
对于s的回文树是一个森林,它由两棵树构成。他们的根分别定义为odd和even。树上的每个节点i对应s的一个回文子串(设其为si)。每条边对应一个字符c且满足每个点的出边的字符各不相同(Trie?)。
则回文树满足si=csfic。另外根even对应的字符串为空串,根odd对应的字符串为一个长度为-1的实际不存在的串,odd儿子对应的字符串为其出边对应的字符。
对于回文树上的每个节点有三个值:字符串,go[c],fail。even和odd的fail指针为odd。
下面有一个例子。
1.2构造
定理1 一个串的不同回文子串的个数一定不超过|s|
我们考虑用数学归纳法来证明
证明:
1,当|s|=1时,s有且只有一个回文子串,显然成立。
2,设s的最后一位字符为c即s=s'c,且s'满足条件。
考虑以末位字符c为结尾的回文子串,设它们的左端点从左到右依次为l1,l2……lk,
那么由于s[l1…|s|]是回文子串,所以对于任意的1<=p<=|s|,有s[p…|s|]=s[l1…l1+|s|-p]的翻转。
所以所有其他的回文子串s[li…|s|]都等于s[l1…l1+|s|-li]的翻转,又因为它是回文的所以s[l1…l1+|s|-li]=s[l1…l1+|s|-li]的翻转
所以s[li…|s|]=s[l1…l1+|s|-li],对于所有的i≠1,都有了l1+|s|-li<|s|,所以它已经在[1…|s|-1]中出现了。所以每次只会增加一个新的回文串,即[l1…|s|]。
因此此结论显然对s成立。由数学归纳法得定理1成立
证毕
因此回文树的状态是O(|s|)的,考虑每条转移边只连向一个节点,所以边的状态也是O(|s|)的。同理fail指针的状态也是O(|s|)的。
定理2 若在一个串s后添加一个字符c,构成一个新的串sc,则不同的回文子串只会增加一个,且为其包含末位的最长回文串(可以=sc)
证明
可以根据定理1的证明直接得出
证毕
我们在构造回文树的时候考虑用增量构造,设已经构造好了字符串s的回文树,现在要在s的后面新增一个字符c构成新的回文串sc。根据定理2,新增的回文子串是sc 的最长回文后缀(包含sc),设其为s[i…|s|]。则根据回文串的定义,要么i>|s|,即只有c这一个字符,要么s[i+1…|s|]也是回文子串且s[i]=c。
也就是说,s[i+1…|s|]必须是s的回文后缀。现在要在s的最长回文后缀,也就是上次插入的字符串的fail链上找到一个长度最大的字符串t,使得s[|s|-lent]=c。则sc的最长回文后缀就是ctc。
若t已经有了go[c]这个节点(已经有ctc这个字符串),则不用更新fail指针;若没有,则还需要求出新节点的fail指针。相当于在fail[t]的fail链上再找一个 长度最大的字符串v,使得s[|s|-lenv]=c,将其fail指向v的go[c]。我们发现若lent=-1,则其fail将指向even。
我们称这种插入算法为基础插入算法。
代码
#include <iostream> #include <cstdio> #include <cstring> using namespace std; const int N = 400000; char s[N]; int lens; namespace Plalindromic_Tree{ struct node{ int go[26]; int fail, len; }pt[N]; int lst = 0, tot = 0; int cnt[N]; void build() { s[0] = -1; pt[++tot].len = -1;//odd节点 pt[0].fail = pt[1].fail = 1; } void add(int c, int n) { //在位置n添加字符c int p = lst;//最长后缀为上次更新的节点 while (s[n - pt[p].len - 1] != s[n]) p = pt[p].fail;//找到当前位置应该插到哪的后面 if (!pt[p].go[c]) {//如果没有此节点 //创建一个新的节点 //查询此节点fail指向的节点(必定在pt[p].fail的fail链上) int v = ++tot, k = pt[p].fail; pt[v].len = pt[p].len + 2;//在两边添加字符c所以长度增加2 while (s[n - pt[k].len - 1] != s[n]) k = pt[k].fail;//找到fail pt[v].fail = pt[k].go[c];//若长度为-1,则pt[k].go[c]==0,自动指向even节点 pt[p].go[c] = v;//指向v } lst = pt[p].go[c]; cnt[pt[p].go[c]]++;//记录有多少的该回文字符串 } }using namespace Plalindromic_Tree; int main() { scanf("%s", s + 1); lens = strlen(s + 1); build(); for (int i = 1; i <= lens; i++) add(s[i] - 'a', i); return 0; }
时间和空间复杂度的证明。
首先我们先考虑在fail链上跳的次数。其可以分为两部分:找sc的最长回文后缀和找新节点的fail指针。因为两部分是相同的,所以我们只考虑一部分即可。
考虑找最长回文后缀。设势能函数φ(s)代表s的最长回文后缀长度。则φ(sc)<=φ(s)+1,又因为φ(sc)>=0,每在fail链上跳一次,φ就会减一,最多+|s|次,最多-|s|次,所以最多跳|s|次。所以复杂度是O(|s|)级别的。
在考虑每次找go[c]可以用treap,时间复杂度为O(|s|log(字符集大小)),空间复杂度O(|s|)。
在常见题目中,字符集大小一般很小,我们可以直接用数组存起来(邻接矩阵),时间复杂度O(|s|),空间复杂度O(|s|X(字符集大小))。
2,回文树的扩展
2.1 前端插入
在之前回文树的构造中,我们在字符串后面插入一个并维护回文树,现在我们考虑在前面插入一个字符。
类似的,在前端插入我们需要找到一个最长的回文前缀,同时有一个fail',指向一个节点对应的串的最长回文前缀对应的点。
我们考虑一个回文串s,设它的最长回文前缀为s[1…i]。又应为它是回文的所以s[1…i]=s[|s|-i+1…|s|]的翻转。
又因为s[1…i]又是回文的,所以s[1…i]=s[1…i]的翻转=s[|s|-i+1…|s|]即为s的最长回文后缀,所以fail'和fail指向的是同一个节点。
唯一不同的是我们要维护当前串的最长回文前缀和最长回文后缀,同时如果你插入一个字符后你的最长回文前缀或最长回文后缀变成了整个字符串,则需把最长回文前后缀都更新为当前插入的节点。
2.2 一种不基于势能分析的插入算法
之前我介绍了基础插入算法,是一种基于势能分析的算法,现在我再介绍一种不基于势能分析的算法。
我们发现基础插入算法在待删除字符的情况下复杂度是不对的,这是因为每次插入一个字符c都要在最长回文后缀的fail上面找到第一个v使得v在s中的前驱是c。而v一定在最长回文后缀中,这只与最长回文后缀有关与c无关。
我们在回文树上的每个节点在多存储一个失配转移数组quick[c],存储树上一个节点t最长的满足前驱为c的回文后缀。
那么在插入时,我们只用判断当前最长回文后缀的前驱是否为c,若不是则合法的节点就是quik[c]的go[c]。
我们考虑如何维护quick。
对于一个节点t和fail[t]的quick大部分都是相同的,可以直接复制fail[t]的quick。若c为t在s中的前驱,则用fail[t]更新t的quick[c]。
插入的时空复杂度都是O(∑)。
用不基于势能分析的插入算法写例一:
#include <iostream> #include <cstdio> #include <cstring> using namespace std; typedef long long ll; const int N = 400000; char s[N]; int lens; namespace Plalindromic_Tree{ struct node{ int go[26], quick[26]; int fail, len; ll fail_len; }pt[N]; int lst = 0, tot = 0; int cnt[N]; void build() { s[0] = -1; pt[++tot].len = -1; pt[0].fail = pt[1].fail = 1; for (int i = 0; i < 26; i++) pt[0].quick[i] = pt[1].quick[i] = 1; pt[0].fail_len = 2; pt[1].fail_len = 1; } int add(int c, int n) { int p = lst; if (s[n - pt[p].len - 1] != s[n]) p = pt[p].quick[c]; if (!pt[p].go[c]) { int v = ++tot; pt[v].len = pt[p].len + 2; pt[v].fail = pt[pt[p].quick[c]].go[c]; pt[v].fail_len = pt[pt[v].fail].fail_len + 1; memcpy(pt[v].quick, pt[pt[v].fail].quick, sizeof(pt[pt[v].fail].quick)); pt[v].quick[s[n - pt[pt[v].fail].len] - 'a'] = pt[v].fail; pt[p].go[c] = v; } return lst = pt[p].go[c]; } }using namespace Plalindromic_Tree; ll ans; int main() { scanf("%s", s + 1); lens = strlen(s + 1); build(); for (int i = 1; i <= lens; i++) ans += pt[add(s[i] - 'a', i)].fail_len - 2; cout << tot - 1 << " " << ans; return 0; }
2.3在trie树上建立回文树
给定一个trie,求所有根节点到某节点的边连起来所形成的的字符串的回文树的并集。
类似于一个串s的回文树的规模是O(|s|)的,对于一个有n个节点的trie的回文树是O(n)的。考虑一个节点对应的字符串,它相当于在他的父亲节点后又添加了一个字符。当然回文串最多只会增加一个且为当前串的最长回文子串。
我们可以在每个节点都多记录一个值表示当前节点代表的字符串的最长回文后缀在回文树上对应的节点,每次扩展到儿子时只需要直接插入即可。
我们现在来分析这种算法的时空复杂度。这种算法相当于把每个叶子节点所代表的字符串的回文树的并集,如果使用基础插入算法,时间复杂度是O(所有叶节点的深度和),显然会被卡O(n^2),所以我们只能用不基于势能分析的插入算法,时间复杂度为O(n∑)或O(nlog∑)。
2.4 前端和后端的插入与删除
接下来尝试做到前后插入删除。
首先如果用最基础的插入算法的话时间复杂度肯定是不对的。这里我新引入一个概念。
对于一个s中的子串s[l…r]被称为重要的当且仅当s[l…r]是会问的且不存在一个r'>r,使得s[l…r']也是回文的、不存在一个l'<l使得s[l'…r]是回文的。
显然,对于每个r最多只有一个l<=r使得s[l…r]是重要的,称其为r的重要后缀,r可以没有重要后缀。同样的可以对l定义重要前缀。
接下来考虑4中操作的影响
在末端插入新的字符
设r表示原来末端的位置,那么r必然会有一个重要后缀,而这个后缀就是当前s的最长回文后缀,直接套用末端插入的算法求出新的回文树并得到新的最长回文后缀,
令l为新最长回文后缀的左端点,则s[l…r+1]是重要的。注意可能会让s[l…r +1]的最长回文前缀变得不重要。
另外维护一个桶cnt[l][r]表示s[l…r]被标记了多少次“不重要”,cnt[l][r]可以用哈希来实现。
在末端删除字符
还是设r表示原来末端的位置,令l为r的重要后缀的左端点,将s[l…r]的最长回文前缀对应的cnt减1,假如被减为0,意味着他再次变成重要的。
现在还有一个问题是,我们需要知道s[l…r]对应的回文树上的节点是否需要被删掉。
对回文树上每个节点t再定义两个权值impt和childt。impt表示有多少1<=l'<=r'<=|s|,s[l'…r']=t且s[l'…r']是重要的,childt表示在fail树上t的儿子 的imp值。
当改变一个串的重要情况时,需要同时改变其回文树上对应点的imp值。
假如一个点的imp与child都是0了,这个点就不可能在s中出现,直接把他删掉。注意每次最多只可能删掉一个点,同时在删掉一个点时需要注意对其fail的child也减1。
在前端插入新的字符
与在末端插入类似的,只需要找到新的最长回文前缀,并对其最长回文后缀的重要情况与cnt做出修改即可。
在前端删除字符
与在末端删除类似,只需要处理好重要情况的变化与回文树的变化即可。
2.5 可持久化回文树
若用最基础的插入算法构建可持久化回文树的复杂度是O(n^2),所以我们要用不基于势能分析的插入算法构建。
附录A 一些记号与定义:
记号1,∑表示字符集大小。
记号2,|s|代表字符串的长度。
记号3,s[i]代表字符串s的第i位。
记号4,s[l…r](1 ≤ l ≤ r ≤ |s|)代表s的字串,s[l]s[l+1]s[l+2]……s[r-1]s[r]。
记号5,记pre[s][i]代表字符串s的前缀s[1…i],suf[s][i]代表字符串s的后缀s[i…|s|]。
记号6,设有字符串s和字符c,sc代表在s后面接上一个字符c,cs代表在s前面接一个字符c。
记号7,fi为i号节点的父亲节点。
记号8,fail为i号节点对应的字符串最长回文后缀所对应的节点。
记号9,go[c]以c为出边到达的节点
定义1,对于两个字符串s、t,定义它们两个不同当且仅当满足下述两个条件的一个
1,|s| ≠ |t|
2,存在一个位置i,s[i] ≠ t[i]
定义2,对于一个串s(s1s2s3…sn),定义将其反转为sn…s3s2s1
定义3,对于两个串t、s,定义t是s的字串当且仅当存在(1≤l≤r≤|s|)使得s[l…r] = t。
定义4,一个串s是回文的当且仅当s=s的翻转。
定义5,t是s的回文子串当且仅当t是s的字串且t是回文的。
定义6,一个串t是s的回文后缀当且仅当t≠s且t是s的后缀且t是回文的。s的最长回文后缀为其最长的回文后缀,及max{|t|}的t。
定义7,fail树为一个字符串s以fail指针连边形成的树。
定义8,fail链为一个集合,等于faili的fail链并上i。odd的fail链为其本身。