『常见字符串算法概要』
常见字符串算法概要
字符串Hash
通常使用多项式\(\mathrm{Hash}\)赋权的方法,将字符串映射到一个正整数。
可以支持\(O(1)\)末端插入字符,\(O(1)\)提取一段字串的\(\mathrm{Hash}\)值。
每次查询的冲突率大概在\(\frac{1}{p}\)左右,如果查询次数较多,可以采用双模数\(\mathrm{Hash}\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20 , Mod = 998244353 , P = 131;
inline int inc(int a,int b) { return a + b >= Mod ? a + b - Mod : a + b; }
inline int mul(int a,int b) { return 1LL * a * b % Mod; }
inline int dec(int a,int b) { return a - b < 0 ? a - b + Mod : a - b; }
inline void Inc(int &a,int b) { a = inc( a , b ); }
inline void Mul(int &a,int b) { a = mul( a , b ); }
inline void Dec(int &a,int b) { a = dec( a , b ); }
int Pow[N],val[N],n,m; char s[N];
inline int GetHash(int l,int r) { return dec( val[r] , mul( val[l-1] , Pow[r-l+1] ) ); }
int main(void)
{
scanf( "%s" , s+1 );
n = strlen( s + 1 );
Pow[0] = 1;
for (int i = 1; i <= n; i++)
Pow[i] = mul( Pow[i-1] , P ) , val[i] = inc( s[i] - 'a' , mul( val[i-1] , P ) );
scanf( "%d" , &m );
for (int i = 1; i <= m; i++)
{
int l1,l2,r1,r2;
scanf( "%d%d%d%d" , &l1 , &r1 , &l2 , &r2 );
GetHash(l1,r1) == GetHash(l2,r2) ? puts("Yes") : puts("No");
}
return 0;
}
Trie树
确定性有限状态自动机,识别且仅识别字符串集合\(S\)中的所有字符串。
支持\(O(|s|)\)插入字符串,\(O(|s|)\)检索字符串。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20;
struct Trie
{
int e[N][26],end[N],tot;
Trie(void) { tot = 1; }
inline void Insert(char *s)
{
int n = strlen( s + 1 ) , p = 1;
for (int i = 1; i <= n; i++)
{
int c = s[i] - 'a';
if ( !e[p][c] ) e[p][c] = ++tot;
p = e[p][c];
}
end[p] = true;
}
inline bool Query(char *s)
{
int n = strlen( s + 1 ) , p = 1;
for (int i = 1; i <= n; i++)
{
int c = s[i] - 'a';
if ( !e[p][c] ) return false;
p = e[p][c];
}
return end[p];
}
};
Knuth-Morris-Pratt 算法
定义一个字符串的\(\mathrm{Border}\)为其公共前后缀。
定义字符串的前缀函数$$\pi(p)=\max_{s(1,t)=s(p-t+1,p)}{t}$$
含义即为字符串\(s\)的前缀\(s_p\)最长\(\mathrm{Border}\)的长度。遍历字符串,每次从上一个位置的最长\(\mathrm{Border}\)处开始向后匹配,如果匹配失败则再跳\(\mathrm{Border}\),直至匹配成功即可求出一个字符串的所有前缀函数。
定义势能函数\(\Phi(p)\)为前缀字符串\(s_p\)的最长\(\mathrm{Border}\)长度,根据\(\mathrm{Knuth-Morris-Pratt}\)算法,有\(\Phi(p)\leq\Phi(p-1)+1\),若暴力跳\(\mathrm{Border}\),则势能降低,可知总时间复杂度为\(O(n)\)。
若求出了一个字符串的前缀函数,则可以实现单模式串的字符串匹配,失配就从最长的\(\mathrm{Border}\)处开始重新匹配即可,时间复杂度为\(O(n+m)\),分析方法类似。
#include <bits/stdc++.h>
using namespace std;
const int N = 1000020;
int n,m,fail[N]; char s[N],t[N];
int main(void)
{
scanf( "%s\n%s" , s+1 , t+1 );
n = strlen( s + 1 ) , m = strlen( t + 1 );
for (int i = 2 , j = 0; i <= m; i++)
{
while ( j && t[j+1] != t[i] ) j = fail[j];
j += ( t[j+1] == t[i] ) , fail[i] = j;
}
for (int i = 1 , j = 0; i <= n; i++)
{
while ( j && ( t[j+1] != s[i] || j == m ) ) j = fail[j];
j += ( t[j+1] == s[i] );
if ( j == m ) printf( "%d\n" , i - m + 1 );
}
for (int i = 1; i <= m; i++)
printf( "%d%c" , fail[i] , " \n"[ i == m ] );
return 0;
}
Knuth-Morris-Pratt 自动机
对于一个字符串\(s\),定义其\(\mathrm{KMP}\)自动机满足:
\(1.\) 状态数为\(n+1\)。
\(2.\) 识别所有前缀。
\(3.\) 转移函数\(\delta(p,c)\)为状态\(p\)所对应前缀接上字符\(c\)后最长\(\mathrm{Border}\)位置前缀对应的状态。
构造方法与\(\mathrm{Knuth-Morris-Pratt}\)算法类似,时间复杂度为\(O(n\Sigma)\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20;
struct KMPAutomaton
{
int trans[N][26],n;
inline void Build(char *s)
{
n = strlen( s + 1 ) , trans[0][s[1]-'a'] = 1;
for (int i = 1 , j = 0; i <= n; i++)
{
for (int k = 0; k < 26; k++)
trans[i][k] = trans[j][k];
trans[i][s[i]-'a'] = i + 1;
j = trans[j][ s[i] - 'a' ];
}
}
};
Aho-Corasick 自动机
确定性有限状态自动机,识别所有后缀在指定字符串集合\(S\)中的字符串。
首先,我们初始化\(\mathrm{Aho-Corasick}\)自动机为指定字符串集合\(S\)的\(\mathrm{Trie}\)树,然后按照\(\mathrm{bfs}\)序构造转移函数\(\delta\)。
我们定义每一个状态有一个\(\mathrm{fail}\)指针,\(\mathrm{fail}(x)=y\)当且仅当状态\(y\)代表的字符串是状态\(x\)代表字符串的后缀,且\(y\)代表字符串的长度最长。
我们只需\(\mathrm{bfs}\)原\(\mathrm{Trie}\)树,当节点\(x\)在\(\mathrm{Trie}\)上存在字符为\(c\)的转移边时,我们令\(\delta(x,c)=\mathrm{Trie}(x,c)\),并更新其\(\mathrm{fail}\)指针为\(\delta(\mathrm{fail}(x),c)\),反之,则可以令\(\delta(x,c)=\delta(\mathrm{fail}(x),c)\),易知其正确性。
\(\mathrm{Aho-Corasick}\)自动机可以实现多模式串的文本匹配,构造和匹配的时间复杂度均为线性(值得注意的是,计算贡献如果选择暴跳\(\mathrm{fail}\),则时间复杂度无法保证)。
\(\mathrm{Knuth-Morris-Pratt}\)自动机就是只有一个串\(\mathrm{Aho-Corasick}\)自动机。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 20;
struct AhoCorasickautomaton
{
int trans[N][26],fail[N],end[N],q[N],tot,head,tail;
inline void insert(char *s,int id)
{
int len = strlen( s + 1 ) , now = 0;
for (int i = 1; i <= len; i++)
{
int c = s[i] - 'a';
if ( !trans[now][c] ) trans[now][c] = ++tot;
now = trans[now][c];
}
end[id] = now;
}
inline void build(void)
{
head = 1 , tail = 0;
for (int i = 0; i < 26; i++)
if ( trans[0][i] ) q[++tail] = trans[0][i];
while ( head <= tail )
{
int x = q[head++];
for (int i = 0; i < 26; i++)
if ( !trans[x][i] )
trans[x][i] = trans[fail[x]][i];
else {
fail[trans[x][i]] = trans[fail[x]][i];
q[++tail] = trans[x][i];
}
}
}
};
Z - algorithm
又称\(z\)函数,\(z-\mathrm{box}\)算法或拓展\(\mathrm{Knuth-Morris-Pratt}\)算法,用于计算一个模式串\(s\)与其他字符串所有后缀的\(\mathrm{LCP}\)长度。
定义\(z\)函数$$z(p)=|\mathrm{LCP}(\mathrm{Suffix_{s,p}},s)|$$
增量求解\(z\)函数时,维护最靠右的与\(s\)匹配的字串\(s[l:r]\),根据\(z\)函数的定义,不难得知\(z_i\geq \min(r-i+1,z[i-l+1])\)。在此基础上继续向右拓展即可。
当\(z_i\)初值为\(r-i+1\)时,每次向右拓展匹配子串的右端点\(r\)单增,当\(z_i\)初值小于\(r-i+1\)时,根据\(\mathrm{LCP}\)的最长性可知无法向右拓展。所以该算法的时间复杂度为\(O(n)\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e7 + 20;
int z[N],p[N],n,m; char s[N],t[N];
int main(void)
{
scanf( "%s\n%s" , s+1 , t+1 );
n = strlen( s + 1 ) , m = strlen( t + 1 );
for (int i = 1; i <= m; i++) z[i] = 0;
z[1] = m;
for (int i = 2 , l = 0 , r = 0; i <= m; i++)
{
if ( i <= r ) z[i] = min( z[i-l+1] , r-i+1 );
while ( i + z[i] <= m && t[i+z[i]] == t[z[i]+1] ) ++z[i];
if ( i + z[i] - 1 > r ) l = i , r = i + z[i] - 1;
}
for (int i = 1; i <= n; i++) p[i] = 0;
for (int i = 1 , l = 0 , r = 0; i <= n; i++)
{
if ( i <= r ) p[i] = min( z[i-l+1] , r-i+1 );
while ( i + p[i] <= n && s[i+p[i]] == t[p[i]+1] ) ++p[i];
if ( i + p[i] - 1 > r ) l = i , r = i + p[i] - 1;
}
long long Ans = 0;
for (int i = 1; i <= m; i++) Ans ^= 1LL * i * ( z[i] + 1 );
printf( "%lld\n" , Ans );
Ans = 0;
for (int i = 1; i <= n; i++) Ans ^= 1LL * i * ( p[i] + 1 );
printf( "%lld\n" , Ans );
return 0;
}
序列自动机
确定性有限状态自动机,识别且仅识别一个序列的所有子序列。
根据定义,可以构造一个\(|s|+1\)个状态的自动机,然后倒序连边即可,每一个状态都可以作为终止状态,时间复杂度\(O(n\Sigma)\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6;
struct SequenceAutomaton
{
int trans[N][26],next[26];
inline void Build(char *s)
{
int n = strlen( s + 1 );
memset( next , 0 , sizeof next );
for (int i = n; i >= 1; i--)
{
next[ s[i] - 'a' ] = i;
for (int j = 0; j < 26; j++)
trans[i-1][j] = next[j];
}
}
};
最小表示法
求出一个字符串\(s\)所有循环表示中字典序最小的一个。
可以用两个指针\(i,j\)扫描,表示比较\(i,j\)两个位置开头的循环同构串,并暴力依次向下比较,直到发现长度\(k\),使得\(s_{i+k}>s_{j+k}\),那么我们可以直接令\(i=i+k+1\),因为对于任意的\(p\in[0,k]\),同构串\(s_{i+p}\)都比同构串\(s_{j+p}\)劣,所以不用再比较。
易知其时间复杂度为\(O(n)\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 20;
int n,s[N<<1];
int main(void)
{
scanf( "%d" , &n );
for (int i = 1; i <= n; i++)
scanf( "%d" , &s[i] ) , s[i+n] = s[i];
int i = 1 , j = 2 , k;
while ( i <= n && j <= n )
{
for (k = 0; k < n && s[i+k] == s[j+k]; k++);
if ( k == n ) break;
if ( s[i+k] > s[j+k] ) ( i += k + 1 ) += ( i == j );
if ( s[i+k] < s[j+k] ) ( j += k + 1 ) += ( i == j );
}
i = min( i , j ) , j = i + n - 1;
for (int p = i; p <= j; p++) printf( "%d " , s[p] );
return puts("") , 0;
}
后缀自动机
确定性有限状态自动机,识别且仅识别一个字符串的所有后缀。
采用增量法构造,详见『后缀自动机入门 SuffixAutomaton』。
使用静态数组存转移边,时空复杂度\(O(n\Sigma)\),用链表可以将时间复杂度优化到\(O(n)\)。用平衡树存转移边,时间复杂度\(O(n\log \Sigma)\),空间复杂度\(O(n)\)。
struct SuffixAutomaton
{
int trans[N][26],link[N],maxlen[N],tot,last;
// trans为转移函数,link为后缀链接,maxlen为状态内的最长后缀长度
// tot为总结点数,last为终止状态编号
SuffixAutomaton () { last = tot = 1; } // 初始化:1号节点为S
inline void Extend(int c)
{
int cur = ++tot , p;
maxlen[cur] = maxlen[last] + 1;
// 创建节点cur
for ( p = last; p && !trans[p][c]; p = link[p] ) // 遍历后缀链接路径
trans[p][c] = cur; // 没有字符c转移边的链接转移边
if ( p == 0 ) link[cur] = 1; // 情况1
else {
int q = trans[p][c];
if ( maxlen[q] == maxlen[p] + 1 ) link[cur] = q; // 情况2
else {
int cl = ++tot; maxlen[cl] = maxlen[p] + 1; // 情况3
memcpy( trans[cl] , trans[q] , sizeof trans[q] );
while ( p && trans[p][c] == q )
trans[p][c] = cl , p = link[p];
link[cl] = link[q] , link[q] = link[cur] = cl;
}
}
last = cur;
}
};
广义后缀自动机
确定性有限状态自动机,识别且仅识别字符串集合\(S\)中所有字符串的所有后缀。
构造方法与狭义后缀自动机类似,只需在转移边产生冲突时分裂节点即可。
时空复杂度均与后缀自动机相同。
值得一提的是,广义后缀自动机如果采用线段树合并来维护\(\mathrm{endpos}\)集合,则需\(\mathrm{dfs}\)遍历\(\mathrm{Parent}\)树来合并,不可以按照基数排序的拓扑序来合并。
struct SuffixAutomaton
{
int trans[N][26],link[N],maxlen[N],tot;
SuffixAutomaton () { tot = 1; }
inline int Extend(int c,int pre)
{
if ( trans[pre][c] == 0 )
{
int cur = ++tot , p;
maxlen[cur] = maxlen[pre] + 1;
for ( p = pre; p && !trans[p][c]; p = link[p] )
trans[p][c] = cur;
if ( p == 0 ) link[cur] = 1;
else {
int q = trans[p][c];
if ( maxlen[q] == maxlen[p] + 1 ) link[cur] = q;
else {
int cl = ++tot; maxlen[cl] = maxlen[p] + 1;
memcpy( trans[cl] , trans[q] , sizeof trans[q] );
while ( p && trans[p][c] == q )
trans[p][c] = cl , p = link[p];
link[cl] = link[q] , link[q] = link[cur] = cl;
}
}
return cur;
}
else {
int q = trans[pre][c];
if ( maxlen[q] == maxlen[pre] + 1 ) return q;
else {
int cl = ++tot; maxlen[cl] = maxlen[pre] + 1;
memcpy( trans[cl] , trans[q] , sizeof trans[q] );
while ( pre && trans[pre][c] == q )
trans[pre][c] = cl , pre = link[pre];
return link[cl] = link[q] , link[q] = cl;
}
}
}
};
后缀树
将一个字符串\(s\)的所有后缀插入到一个\(\mathrm{Trie}\)树中,我们称这棵\(\mathrm{Trie}\)树所有叶子节点的虚树为这个字符串的后缀树。
根据\(\mathrm{endpos}\)等价类的定义及性质,容易得知原串倒序插入后缀自动机后的\(\mathrm{Parent}\)树就是该串的后缀树,所以可以用后缀自动机的构造方法求后缀树。
时间复杂度和后缀自动机的时间复杂度相同,可以\(O(n)\)顺带求后缀数组。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5+20;
struct SuffixAutomaton
{
int trans[N][26],link[N],maxlen[N],tot,last;
int id[N],flag[N],trie[N][26],sa[N],rk[N],hei[N],cnt;
// id 代表这个状态是几号后缀 , flag 代表这个状态是否对应了一个真实存在的后缀
SuffixAutomaton () { tot = last = 1; }
inline void Extend(int c,int pos)
{
int cur = ++tot , p;
id[cur] = pos , flag[cur] = true;
maxlen[cur] = maxlen[last] + 1;
for ( p = last; p && !trans[p][c]; p = link[p] )
trans[p][c] = cur;
if ( p == 0 ) link[cur] = 1;
else {
int q = trans[p][c];
if ( maxlen[q] == maxlen[p] + 1 ) link[cur] = q;
else {
int cl = ++tot; maxlen[cl] = maxlen[p] + 1;
memcpy( trans[cl] , trans[q] , sizeof trans[q] );
while ( p && trans[p][c] == q )
trans[p][c] = cl , p = link[p];
link[cl] = link[q] , id[cl] = id[q] , link[q] = link[cur] = cl;
}
}
last = cur;
}
inline void insert(int x,int y,char c) { trie[x][c-'a'] = y; }
inline void Build(char *s,int n)
{
for (int i = n; i >= 1; i--)
Extend( s[i]-'a' , i );
for (int i = 2; i <= tot; i++)
insert( link[i] , i , s[ id[i] + maxlen[link[i]] ] );
}
inline void Dfs(int x)
{
if ( flag[x] ) sa[ rk[id[x]] = ++cnt ] = id[x];
for (int i = 0 , y; i < 26; i++)
if ( y = trie[x][i] ) Dfs(y);
}
inline void Calcheight(char *s,int n)
{
for (int i = 1 , k = 0 , j; i <= n; i++)
{
if (k) --k; j = sa[ rk[i]-1 ];
while ( s[ i+k ] == s[ j+k ] ) ++k;
hei[ rk[i] ] = k;
}
}
};
SuffixAutomaton T; char s[N];
int main(void)
{
scanf( "%s" , s+1 );
int n = strlen( s+1 );
T.Build( s , n ) , T.Dfs(1);
T.Calcheight( s , n );
for (int i = 1; i <= n; i++)
printf( "%d%c" , T.sa[i] , " \n"[ i == n ] );
for (int i = 2; i <= n; i++)
printf( "%d%c" , T.hei[i] , " \n"[ i == n ] );
return 0;
}
回文自动机
确定性有限状态自动机,识别且仅识别一个字符串\(s\)的所有回文字串的右半部分。
由于回文串分奇偶,所以回文自动机有两个初始状态,分别代表奇回文串和偶回文串。
可以使用数学归纳法证明,字符串\(s\)最多只有\(|s|\)个本质不同的回文字串,所以回文自动机的一个状态就代表一个回文字串。而回文自动机的一条转移边就代表在原串的两边各加一个字符,这样转移后的字符串仍然是回文串,同时也解释了为什么回文自动机只识别回文串的右半部分。
回文自动机同样采用增量法构造。对于每一个状态,我们额外记录其最长回文后缀所对应的状态,称为\(\mathrm{link}\)函数。当我们在字符串末尾插入一个字符时,我们从原串最后的状态开始跳\(\mathrm{link}\),直至可以构成回文串,并确定新的状态。
对于新的状态,仍然可以继续跳\(\mathrm{link}\),找到其最长回文后缀。
可以把回文自动机看作两棵树,也称为回文树。对于\(\mathrm{link}\)指针,也构成了一棵树,可以称之为回文后缀树。定义势能函数\(\Phi(p)\)表示状态\(p\)在回文后缀树中的深度,根据构造算法,易知\(\Phi(p)\leq\Phi(\mathrm{link}(p))+1\),而跳\(\mathrm{link}\)则势函数减小。又因为回文自动机的状态数是\(O(n)\)的,回文后缀树的最大深度也就是\(n\),可以得知构造算法的时间复杂度不超过\(O(n)\)。
其空间复杂度为\(O(n\Sigma)\),使用邻接表存边,时间复杂度升至\(O(n\Sigma)\),空间复杂度降至\(O(n)\)。
由于一个回文串的最长回文后缀必然是它的一个\(\mathrm{Border}\),所以回文树\(\mathrm{dp}\)可能用到\(\mathrm{Border\ Series}\)的等差性质。回文自动机中就会额外记录两个参量\(\mathrm{dif}\)和\(\mathrm{slink}\),\(\mathrm{dif}(x)=\mathrm{len}(x)-\mathrm{len}(\mathrm{link}(x))\),\(\mathrm{slink}(x)\)记录了回文后缀树上\(x\)最深的一个祖先,满足\(\mathrm{dif}(\mathrm{slink}(x))\not=\mathrm{dif}(x)\),这些都可以在构造过程中顺带维护。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 20 , Mod = 1e9 + 7;
struct PalindromesAutomaton
{
int n,tot,last,link[N],slink[N],trans[N][26],len[N],dif[N],s[N];
PalindromesAutomaton(void)
{
len[ last = 0 ] = 0 , link[0] = 1;
len[1] = -1 , tot = 1 , s[0] = -1;
}
inline void Extend(int c)
{
int p = last; s[++n] = c;
while ( s[n] != s[ n - len[p] - 1 ] ) p = link[p];
if ( trans[p][c] == 0 )
{
int cur = ++tot , q = link[p];
len[cur] = len[p] + 2;
while ( s[n] != s[ n - len[q] - 1 ] ) q = link[q];
link[cur] = trans[q][c] , trans[p][c] = cur;
dif[cur] = len[cur] - len[ link[cur] ];
if ( dif[cur] != dif[ link[cur] ] ) slink[cur] = link[cur];
else slink[cur] = slink[ link[cur] ];
}
last = trans[p][c];
}
};
回文自动机的转移函数如果用\(\mathrm{Hash}\)表来存的话,可以做到时空复杂度均为\(O(n)\),一种常数较小的实现方式如下。
#include <bits/stdc++.h>
using namespace std;
const int N = 11e6 + 10 , Mod = 1<<21;
struct PalindromesAutomaton
{
int tot,last,n,link[N],len[N],s[N],Ans;
int t,Head[1<<21],next[N],fr[N],to[N];
PalindromesAutomaton(void)
{
len[ last = 0 ] = 0 , link[0] = 1;
s[0] = len[1] = -1 , tot = 1 , t = 0;
}
inline void Insert(int a,int b,int c)
{
int val = ( a * 27 + b ) & ( Mod - 1 );
next[++t] = Head[val] , Head[val] = t;
fr[t] = a , to[t] = c;
}
inline int trans(int a,int b)
{
int val = ( a * 27 + b ) & ( Mod - 1 );
for (int i = Head[val]; i; i = next[i])
if ( fr[i] == a ) return to[i];
return 0;
}
inline void Extend(int c)
{
int p = last , tmp; s[++n] = c;
while ( s[n] != s[ n - len[p] - 1 ] ) p = link[p];
if ( ( tmp = trans(p,c) ) == 0 )
{
int cur = ++tot , q = link[p];
len[cur] = len[p] + 2;
while ( s[n] != s[ n - len[q] - 1 ] ) q = link[q];
link[cur] = trans(q,c) , Insert(p,c,cur);
last = cur , Ans = max( Ans , len[last] );
}
else last = tmp;
}
};
PalindromesAutomaton T;
char s[N]; int n;
int main(void)
{
scanf( "%s" , s+1 );
n = strlen( s + 1 );
for (register int i = 1; i <= n; i++)
T.Extend( s[i] - 'a' );
printf( "%d\n" , T.Ans );
return 0;
}
事实上,回文自动机还可以支持双端插入,只需要维护两个\(\mathrm{last}\)指针即可。一个\(\mathrm{last}\)指针会影响到另一个\(\mathrm{last}\)指针,当且仅当整个串都变成回文串了,只需要特判一下即可。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 20;
struct PalindromesAutomaton
{
int tot,pre,suf,l,r,trans[N][26],link[N],len[N],dep[N],s[N]; long long Ans;
PalindromesAutomaton(void) { memset( s , 0xff , sizeof s ); }
inline void Init(void)
{
memset( trans , 0 , (tot+1) * 4 * 26 );
memset( link , 0 , (tot+1) * 4 );
memset( len , 0 , (tot+1) * 4 );
for (int i = l; i <= r; i++) s[i] = -1;
r = 1e5 , l = r + 1 , tot = 1 , pre = suf = 0;
len[0] = 0 , len[1] = -1 , link[0] = 1 , Ans = 0;
}
inline void Extend(int c,int &n,int &last,int op)
{
int p = last; s[ n += op ] = c;
while ( s[n] != s[ n - op * len[p] - op ] ) p = link[p];
if ( trans[p][c] == 0 )
{
int cur = ++tot , q = link[p];
len[cur] = len[p] + 2;
while ( s[n] != s[ n - op * len[q] - op ] ) q = link[q];
link[cur] = trans[q][c] , trans[p][c] = cur;
dep[cur] = dep[ link[cur] ] + 1;
}
last = trans[p][c] , Ans += dep[last];
if ( len[last] == r - l + 1 ) pre = suf = last;
}
};
PalindromesAutomaton T; int n;
int main(void)
{
while ( ~scanf( "%d" , &n ) )
{
T.Init();
for (int i = 1; i <= n; i++)
{
int op; char c = ' ';
scanf( "%d" , &op );
if ( op == 1 )
{
while ( isspace(c) ) c = getchar();
T.Extend( c - 'a' , T.l , T.pre , -1 );
}
if ( op == 2 )
{
while ( isspace(c) ) c = getchar();
T.Extend( c - 'a' , T.r , T.suf , 1 );
}
if ( op == 3 ) printf( "%d\n" , T.tot - 1 );
if ( op == 4 ) printf( "%lld\n" , T.Ans );
}
}
return 0;
}