ac自动机 匹配最长前缀_回文自动机入门

缘起

回文自动机(Palindrome auto machine PAM,有些地方称之为回文树)是回文问题的大杀器~  本文使用一道很简单的题目入门这个精巧的数据结构. hdu 2163 Palindromes

分析

写个程序判定一个字符串是不是回文?

【输入】
多样例. 每个样例占据一行, 每行至多52个字符. 你的程序需要在输入"STOP"的时候停止, 输入保证都是
大写英文字母.

【输出】
YES或者NO

【样例输入】
ABCCBA
A
HELLO
ABAB
AMA
ABAB
PPA
STOP

【样例输出】
#1: YES
#2: YES
#3: NO
#4: NO
#5: YES
#6: NO
#7: NO

【限制】
1s

本题其实用回文自动机来做着实小题大做~ 因为任何一个学了一点c语言的人都会写下如下O(n)的代码

//#include "stdafx.h"
#include
#include
//#define LOCAL

char s[100];

bool kk() {
int len = strlen(s);
int i = 0, j = len - 1;
while (i < j && s[i] == s[j])
{
++i;
--j;
}
return i >= j;
}

int main() {
#ifdef LOCAL
freopen("d:\\data.in", "r", stdin);
// freopen("d:\\my.out", "w", stdout);
#endif
int kase = 0;
while(gets(s), strcmp(s, "STOP"))
{
++kase;
printf("#%d: %s\n", kase, kk()?"YES":"NO");
}
return 0;
}

ac情况

StatusAccepted
Time31ms
Memory1184kB
Length454
LangG++
Submitted2019-12-16 21:49:39
Shared
RemoteRunId31885462

但是本文只是想借这么简单的一个题引入回文自动机这种优雅处理回文问题的数据结构。

回想一下, 我们处理字符串的数据结构很多——kmp、扩展kmp、ac自动机、后缀自动机、后缀数组、后缀树、trie、各种字符串哈希.... 但是迄今为止没有专门处理回文的数据结构!

于是, 就有了manacher算法. manacher代码短、效率高(O(n))、不区分奇偶回文,非常完美的算法! manacher可以预处理出字符串每个索引处的最大回文半径.  所以manacher对于最长回文子串问题是十分有效的! 但是如果要统计字符串有多少不同的回文子串这类问题的话,manacher就无能为力了~

因此神犇救世——

回文自动机,又称回文树,是俄罗斯神犇 MikhailRubinchik 于2014年发明的.

首先, 回文自动机(Palindrome Auto Machine 下面简称pam)中的状态节点是字符串中不同的回文子串(也即回文自动机中仅仅保存回文子串). 例如 aaabbaaa 中的节点是

空串、a、aa、aaa、b、bb、abba、aabbaa、aaabbaaa

既然是自动机,辣么我们肯定要考虑一下回文自动机中的转移函数. 既然回文自动机的节点仅仅是刻画回文子串的. 所以回文自动机的转移函数肯定是从一个回文子串到达另一个回文子串. 而怎么从一个回文子串A变成另一个回文子串B呢? 显然唯一的方法就是在A的两头分别拼接上一个相同的字符(注意, 可能有读者注意到了, 'aaa'可以拼接上一个字符就可以变成'aaaa', 但是在pam的处理中, 我们认为'aaaa'是'aa'两头分别拼接上一个'a'变成的. 这样统一观点进行处理). 所以我们知道了pam的转移函数就是

trans[i][c] = j 表示从i节点代表的回文子串两头拼接上相同的字符c, 变成了j节点代表的回文子串.

如果是这样的话, 是不是大家会有疑惑——这样的话, 能通过trans转移的两个节点代表的回文子串的长度一定是同奇偶的——因为字符个数总是加2嘛~  没错, 因为回文也分为奇回文与偶回文, 所以trans[i][c]=j能够推出i和j代表的回文子串长度同奇偶这是符合直观的. 那么一个自然的疑问就来了——那岂不是在pam中, 奇回文和偶回文老死不相往来了,也就是奇(偶)回文节点永远不可能转移到偶(奇)回文节点了? 不急, 我们先来看看奇回文节点和偶回文节点分别构成什么? 显然, 奇回文和偶回文节点一定分别构成树.

偶回文的树根代表空串, 奇回文的树根也代表空串. 分别记做0号顶点和1号顶点.  所以pam本质上是两棵树构成的森林(更是一个dag). 那么现在来看这2棵树之间有什么联系吗? 站在trans的角度,的确是老死不相往来的.

但是要想往来, 就不得不提到pam的线性时间构造算法. 但是为了提及pam的线性时间构造算法, 就不得不提及下面一个有趣而重要的事实.

任何一个字符串的不同回文子串的个数一定不会超过它的长度. 例如 aabbaa, 则不同回文子串有 a、aa、b、bb、abba、aabbaa 六个.

这是为什么呢?  这只需要注意到下图

73aa21471dbc6ac474995a995d35ad70.png
image

我们新增一个字符c(红椭圆). 我们考虑它能导致新增的不同的回文子串的个数. 如果超过1个的话, 则考虑最长的那个, 即A. 然后对于任何一个比A短的回文子串, 例如B, 则B一定和C(蓝矩形)对称, 而因为B是回文, 所以C也是回文, 而且B和C是完全相同的回文子串. 所以B并不是新的回文子串. 即我们证明了每次新读入一个字符c, 它能带来的不同的回文子串的个数<=1. 所以一个字符串的回文子串的个数不会超过字符串的长度.

注意到上面的事实对我们的pam构造是有好处的——至少我们知道2点

  1. 采用和后缀自动机一样的增量构造法. 即采用 新读入一个字符造成pam的异动这种观点看待pam的构造过程
  2. 每次新增, 至多产生一个新的节点.

上面的观察对pam的理解是很重要的: 因为我们就知道pam的构造过程大概是怎么回事了——其实就是每次增量读入一个字符, 然后这个字符至多可能带来一个新的回文子串. 如果这个回文子串在既有的pam中不存在, 我们就新增, 如果存在就不新增. 因为pam的节点个数不会超过字符串长度, 所以我们有理由要求pam的构造算法是O(n)的——哪怕 O(nlogn)都不能令我们满意的.

我们对上面第二点比较感兴趣.  如果新产生了一个节点的话, 那么这个节点对应的回文子串一定是最长的. 什么意思? 举个例子来说, aaaa 再读入s[5]='a', 则新产生的回文子串一定是'aaaaa' 这个最长的. 因为根据上图的论证就知道不可能是短的(例如上图的B),短的(例如上图的B)一定都是既存的回文子串(即已经存在了,例如上图的C). 也就是我们知道新产生的节点具备某种最长性.  那么我们怎么保证这种最长性呢? 显然, 如果s[1,..,n-1]拼上了s[n]之后能出现以s[n]为结尾的回文(以s[1,...,n-1]为结尾的所有不同回文都已经在pam中了)那么一定是某个以s[n-1]为结尾的回文——假设是s[i+1,..,n-1],并且有s[i]=s[n]才使得s[i,...,n]是回文. 所以我们必须知道以s[n-1]为结尾的回文子串(即读入s[n-1]之后pam停留在的顶点)的所有回文后缀. 然后按照长度从大到小(因为刚刚说了,我们需要保证s[n]为结尾的回文子串的最长性——因为短的都已经存在了,最长的那个才可能是此次增量读入导致新增的)考察每个回文后缀——类似于s[i+1,...,n-1], 去考察s[i]是否等于s[n], 一旦发现相等, 例如s[i]=s[n], 我们令s[i+1,...,n-1]这个回文后缀是pam的v节点, 我们就知道了pam读入s[n]之后, 将跑到 trans[v][s[n]]去.

所以我们就要考察 trans[v][s[n]]是否存在了? 如果不存在的话, 则就说明读入s[n]导致pam新增了新的节点, 如果存在的话, 则说明此次读入s[n]将不会新产生新的节点(注意,根据上图的论证, 不可能还有以s[n-1]为结尾更短的后缀能拼上s[n]形成新的回文子串).

找到了 u=trans[v][s[n]]之后, 我们还要确定它的所有回文后缀——因为我们要为增量读入s[n+1]字符做准备. 但是我们不需要找到所有的u的回文后缀, 因为u的短的回文后缀都已经存在了——我们只需要确定u的最长的回文后缀(注意, 这个回文后缀我们并不考虑u代表的回文子串自身)即可. 而如果我们遍历u的所有后缀一一去考察他们是否是回文的话, 则pam的构造算法就会膨胀到O(N^2)去. 所以我们遇到的境遇就和【2】一毛一样了~ 我们必须要用类似于【2】中slink后缀那般快速维护这里的一个回文子串的非自身最长回文后缀. 我们用fail[v]表示pam中的顶点v代表的回文子串的非自身最长回文后缀. 那么既然u是v两头拼接上 s[n]之后形成的, 那么 fail[u] 就是fail[v]两头拼接上s[n]之后形成的, 当然,前提是fail[v]的左侧第一个字符是s[n],不然右边拼接上s[n], 左边拼接上的不是s[n], 形成的自然不会是回文子串, 我们把这个寻找的过程称作getfail,  那么 fail[u] = trans[getfail(fail[v])][s[n]]  注意,完全不必担心trans[getfail(fail[v])][s[n]]不存在, 因为根据上图的论证, 它一定是会存在的——因为getfail(fail[v])两头拼接上s[n]之后得到的回文子串v两头拼接上s[n]之后得到的回文子串的较短的回文后缀. 既然 v两头拼接上s[n]之后得到的回文子串已经存在了(有可能是此次读入s[n]导致新增的),所以较短的这个回文后缀一定是会存在的(大不了就是 v两头拼上s[n]产生的回文子串).

注意到fail的作用, 它完全类似于【2】中的slink——正是因为fail这个大杀器, 所以我们的pam的构造算法才是O(n)的,很优秀的构造算法.

回到本题,  如何使用pam这把牛刀来杀鸡呢?  显然, 根据上面的描述,字符串S的pam是一部怎样的自动机呢? 它是一部能识别S的所有回文子串的自动机! 也就是构造好s的pam之后, 顺次将s的字符喂入pam, 则将在pam的节点之间跳来跳去. 而每个节点都是s的回文子串。所以我们首先构造s的pam, 然后只需要维护一下每个节点代表的回文子串的长度len即可.

下面代码将会在后面写注释

//#include "stdafx.h"
#include
#include
//#define LOCAL
const int SZ = 26, maxn = 100;
char s[maxn];

int tot, lz[maxn], n;
struct PamNode
{
int fail, trans[SZ], len;
int num, size;
}pam[maxn];

void init() {
tot = 1;
pam[0].fail = 1;
pam[0].len = 0;
pam[1].fail = 0;
pam[1].len = -1;
memset(pam[0].trans, 0, SZ*sizeof(int));
memset(pam[1].trans, 0, SZ*sizeof(int));
}

int newnode(int len) {
++tot;
memset(pam[tot].trans, 0, SZ*sizeof(int));
pam[tot].len = len;
pam[tot].fail = 0;

pam[tot].num = 0;
pam[tot].size = 0;
return tot;
}

int getfail(int i, int u) {
while(s[i - pam[u].len - 1] ^ s[i])
{
u = pam[u].fail;
}
return u;
}

int insert(int i, int u) {
char c = s[i];
int v = getfail(i, u);
u = pam[v].trans[c - 'A'];
if (!u)
{
int z = newnode(pam[v].len + 2);
int w = getfail(i, pam[v].fail);
pam[z].fail = pam[w].trans[c - 'A'];
u = pam[v].trans[c - 'A'] = z;
pam[z].num = pam[pam[z].fail].num + 1;
}
++pam[u].size;
return u;
}

bool kk() {
n = 0;
init();
for (int i = 1, u = 0; s[i]; i++)
{
++n;
u = insert(i, u);
lz[i] = pam[u].len;
}
return lz[n] == n;
}

void count() {
for (int i = tot, fail; i>1; i--)
{
fail = pam[i].fail;
pam[fail].size += pam[i].size;
}
}

int main() {
#ifdef LOCAL
freopen("d:\\data.in", "r", stdin);
// freopen("d:\\my.out", "w", stdout);
#endif
int kase = 0;
while(gets(s+1), strcmp(s+1, "STOP"))
{
++kase;
printf("#%d: %s\n", kase, kk()?"YES":"NO");
}
return 0;
}

ac情况(0ms~)

StatusAccepted
Memory1196kB
Length1373
LangG++
Submitted2019-12-17 10:25:03
Shared
RemoteRunId31888544

关于上面回文自动机的代码注释如下

line 8  
tot是pam节点的个数. pam[0]是偶回文树的根. pam[1]是奇回文树的根. 最终pam[2,...,tot] 就是非根
的所有节点. n是字符串s的长度, 根据上图的论证, tot-1<=n, lz数组并不是pam的一部分. 即lz[i] 中的i
并不代表pam的节点. 仅仅代表字符串s的索引下标. pam的板子中, 字符串的索引下标从1开始而不是从0开始
lz[i]的意思是s[1,...,i]的最长回文后缀的长度.

line 9
给出pam的节点数据结构. fail域等于x表明当前pam节点的最长的非自身回文后缀对应的pam节点是pam[x]
trans[c]=i 表明当前pam节点读入字符c之后将跳转到pam[i]. 其中所谓读入字符c的含义是在当前节点对应的
回文子串两头拼上字符c, len是当前节点对应回文子串的长度, num是当前pam节点(即读入s[i]之
后,s[1,..,i]的最长回文后缀对应节点u(即下面47行的insert方法中的u))对应回文子串的所有不同
回文后缀的个数(包括自身), size是当前pam节点表示的回文子串在s中出现的次数.
注意, 就pam而言, fail、trans、len 才是有关的. num和size只是业务域(甚至这两个业务域和本题无关
只是为了展示pam比manacher具有更强的统计功能而列出的)

line 15
pam的初始化. 这里pam[0].fail=1, pam[1].fail=0, pam[1].len=-1的设置是比较精妙的. 后面会再说.
至于 pam[0].len = 0是因为 pam[0]是空串, 所以长度为0. 然后两个memset也是很容易理解的.

line 26
新建节点. 这种写法防止了我们每次都暴力取memset整棵pam. 注意这里的写法, 第32行有一个空行表示pam核心
域与业务域的初始化分开了.

line 38
getfail返回pam[u]的最长的回文后缀(可以是pam[u]自身), 这个回文后缀v满足它左侧的第一个字符
s[n-len[v]-1]=c, 所以v左边拼上s[n-len[v]-1], 右边拼上c之后就是一个回文.

ling 47
入参i表示当前读入字符s[i], u表示上次读入s[i-1]完毕之后,s[1,..,i-1]的最长回文后缀所在的pam
节点. 函数返回读入s[i]完毕之后, 以s[1,...,i]的最长回文后缀所在的pam节点. 其实就是更新了u嘛~

line 50
找到最长的那个pam顶点v, 然后读入c, 我们知道v读入c就是v代表的回文子串的两头拼接上c, 而根据getfail
的解释, 拼接完成之后得到的就是一个回文子串. 而这个回文子串未必已经在pam中出现过了.
所以就要分情况——如果没出现过, 那就表明此次读入c=s[i]新诞生了一个回文子串, 否则就不需要新增pam节点
即51行之后要判断u是不是为空, 如果是的话,则表明pam中尚未出现此节点. 就要新增, 否则就不需要新增pam
节点

line52
需要新增pam节点.

line 54
新建节点注意, 这里就体现了上面line 15说的精妙之处. 为什么? 因为我们来分析一下读入第一个字符的情况
, 即i=1,c=s[1]. 首先来看getfail. u伊始是0, pam[u].len = 0, 所以 s[i-pam[u].len-1]=s[0] =0
这就是为什么我们字符串的索引要从1开始, 因为s[0]=0肯定不会和字符串中任何一个字符相等, 字符串索引
从1开始可以减少特判. 回归正题哈~ 既然u=0满足 s[i - pam[u].len - 1] ^ c 不为0, 所以
u = pam[u].fail= pam[0].fail=1, 而u=1是否满足s[i - pam[u].len - 1] ^ c 不为0呢?
s[i-pam[1].len-1] = s[1-(-1)-1]=s[1]=c, 所以不满足, 就break了.所以getfail返回1, 即50行
返回的v=1, 所以54行的pam[v].len+2=-1+2=1, 即新建的节点对应的回文子串的长度是1. 这不是刚好么?
你读入一个字符的时候, 不就是形成了单个字符构成的回文吗?
还有一点我们可以指出, 这里属于z代表的回文子串第一次出现, 之前读入s[1,..,i-1]都是没有出现过该回文子串
的, 所以可以维护出每种回文子串第一次出现的索引.只是这里没做而已.

line 55
为了line 56找新建节点z的fail(即确定fail[z]),根据上面的论述, pam[z].fail就是
getfail(pam[v].fail) 读入 c之后跳转的节点. 也就是55~56行做的事情.

line 57
此次读入s[i]之后, pam将停留在z, 注意, 本行代码不能放到55行代码前面去.否则会陷入死循环. 你试一下,
本题第一组数据就是这样.

line 58
z的不同回文后缀个数显然=它的fail节点的不同后缀个数+1, 这个1就是z自身. 因为前面说了, num域所描述的
回文后缀是包含自身的.

line 60
u这个回文子串的个数+1

line 77
注意, 仅仅通过insert方法是不能求出完整的每个节点的size域的. 因为假设回文子串v是回文子串u的后缀.
那么line 60 统计 u这个回文子串的个数+1的时候, 按道理 v的size域也是需要+1的. 因为有u出现的地方
v也一定出现了. 幸运的是, pam是dag, 而且2,...,tot-1,tot 恰好已经就是拓扑排好了序的. 所以直接倒向
遍历tot,tot-1,...,2 进行dp就可以得到正确的size域了. 即跑完insert方法并不足以获取正确的size
域,还需要调用一次count方法进行dag上的dp才行.
507b1b237b1a1664b01a580c55154074.gif

温馨提示

如果你喜欢本文,请分享到朋友圈,想要获得更多信息,请关注ACM算法日常

0e35dc9579dfe1a4f27b59848f5ea960.png

点赞的时候,请宠溺一点
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值