为什么说回文是字符串原神.
Manacher 算法
功能
求出字符串每一处的回文半径,记为 pip_ipi.
实现方法
manacher 只能处理存在回文中心(长度为奇数)的回文串.故需要在待处理串 TTT 的字符空隙和开头结尾添加 相同 的特殊字符 ch1ch_1ch1 得到 S1S_1S1.并且为了防止算法运行溢出边界,再在 S1S_1S1 的开头结尾分别添上特殊字符 ch2,ch3ch_2,ch_3ch2,ch3 得到最终可以进行 manacher 算法的字符串 SSS.
注意需保证 ch1,ch2,ch3ch_1,ch_2,ch_3ch1,ch2,ch3 各不相同.
顺序扫描到位置 iii,记录回文中心 <i< i<i 的右端点最大的回文串右端点 rrr 和其回文中心 ddd.
分情况讨论求解 pip_ipi.
- r<ir<ir<i.令 pi←ip_i \leftarrow ipi←i.
- r≥ir \ge ir≥i.令 pi←min(r−i+1,pd∗2−i)p_i \leftarrow \min(r-i+1,p_{d*2-i})pi←min(r−i+1,pd∗2−i).
在第二种情况中,由于 S[d∗2−r,r]S[d*2-r, r]S[d∗2−r,r] 是回文串,所以 iii 沿 ddd 对称的位置 d∗2−id*2-id∗2−i 作为回文中心,在 S[d∗2−r,r]S[d*2-r, r]S[d∗2−r,r] 内的回文串对称到 iii 上不变,同样是回文串.
然后我们在这个基础上扩展 pip_ipi 直到两端的字符不相等(这里就发挥了 SSS 两端限制字符 ch1,ch2ch_1,ch_2ch1,ch2 的防止溢出作用).
在求解出 pip_ipi 后,用 i+pi−1,ii+p_i-1,ii+pi−1,i 尝试更新 r,dr,dr,d.
时间复杂度分析
考虑求解 pip_ipi 时:
- r<pir<p_ir<pi:pip_ipi 每扩展一步,rrr 都会增加.
- r≥pir \ge p_ir≥pi:
- pd∗2−i≤r−i+1p_{d*2-i} \le r-i+1pd∗2−i≤r−i+1:pip_ipi 不会扩展.
- pd∗2−i>r−i+1p_{d*2-i} > r-i+1pd∗2−i>r−i+1:pip_ipi 每扩展一步,rrr 都会增加.
这说明,ppp 数组扩展的总次数等价于 rrr 增加的总次数.而 rrr 至多从 000 增加到 ∣S∣|S|∣S∣.共算法总时间复杂度为 O(∣S∣)O(|S|)O(∣S∣).非常的有力啊.
PAM 回文自动机
回文自动机又被称为回文树.据说是科学家受到 SAM 的启发研究出来的.但我感觉它和 ACAM 更像.
回文串的折叠表示
考虑一个回文串 S[1,n]S[1, n]S[1,n].它的折叠表示为
{S[n+12,n](2∤n)S[n2+1,n](2∣n)
\begin{cases}
&S[\frac{n+1}{2}, n]\quad(2 \nmid n)\\
&S[\frac{n}{2}+1,n]\quad(2 \mid n)
\end{cases}
{S[2n+1,n](2∤n)S[2n+1,n](2∣n)
显然,我们只要知道了回文串长度的奇偶性就可以用折叠表示唯一对应一个回文串.
前置:PAM 的结构
PAM 和其他自动机一样,由转移边 δ\deltaδ 和后缀链接 FailFailFail 构成.
约定:
S‾\overline{S}S:SSS 的折叠表示.
T(p)T(p)T(p):ppp 节点表达的回文串.
Fail(p)Fail(p)Fail(p):ppp 节点的后缀链接.
δ(p,c)\delta(p, c)δ(p,c):ppp 经过 ccc 转移到的节点.
len(p)len(p)len(p):∣T(p)∣|T(p)|∣T(p)∣
转移边 δ\deltaδ:PAM 的转移边形成两棵 Trie 树,根节点编号为 000 和 111,从 000 号节点出发,沿转移边到达 Trie 树上的任意一个节点,路径形成的字符串为一个 长度为偶数的回文串 的折叠表示.同理,从 111 号节点出发,路径形成的字符串为一个 长度为奇数的回文串 的折叠表示.
特别的,规定 len(0)=0,len(1)=−1len(0)=0, len(1)=-1len(0)=0,len(1)=−1.
后缀链接 FailFailFail:PAM 的后缀链接同样和其他自动机的后缀链接很像.都是满足某一条件的最长真后缀.比如 ACAM 是满足和该节点字符串前缀匹配,SAM 是满足和该节点的 endposendposendpos 不同.
PAM 的后缀连接的定义形如:对于节点 ppp,Fail(p)=qFail(p)=qFail(p)=q.qqq 是满足 T(q)T(q)T(q)是 T(p)T(p)T(p) 的真后缀的 len(q)len(q)len(q) 最大的节点.
特别的,规定 Fail(0)=1,Fail(1)=1Fail(0)=1, Fail(1)=1Fail(0)=1,Fail(1)=1.
构造过程
增量构造法,考虑加入字符 ccc.
新增的回文串(原来没出现过)只可能是新串的极长回文后缀 sufsufsuf.考虑 sufsufsuf 的一个回文后缀沿 sufsufsuf 的对称中心翻折,其肯定包含在旧串里面.
显然有 sufsufsuf 是旧串的极长回文后缀 suf′suf'suf′ 或其回文后缀首尾各添加 ccc 形成的.于是我们可以跳 suf′suf'suf′ 的 FailFailFail 寻找 sufsufsuf.找到后,若发现没有 sufsufsuf 对应的节点,我们需要考虑新建一个节点 npnpnp 对应 sufsufsuf.
npnpnp 在 Trie 上的父亲就是我们跳 suf′suf'suf′ 的 FailFailFail 得到的节点.其首尾添加 ccc 就是 sufsufsuf.我们设其是 ppp.有 δ(p,c)=np,len(np)=len(p)+2\delta(p, c)=np, len(np)=len(p)+2δ(p,c)=np,len(np)=len(p)+2.
再考虑 Fail(np)Fail(np)Fail(np).显然 Fail(np)Fail(np)Fail(np) 是 δ(p在Fail树的一个祖先,c)\delta(p在 Fail 树的一个祖先, c)δ(p在Fail树的一个祖先,c).
主体流程是比较清晰的.
展出一下 PAM 的代码,其中的一些小细节会在代码中解释:
int get_fail(int u, int pos){
while(s[pos-t[u].len-1]!=s[pos]) u=t[u].fail;
//这里的循环一定会 break:当 u 来到 1 号节点时,一定有 s[pos]==s[pos].
return u;
}
void insert(int c, int pos){
int p=get_fail(las, pos);
if(!t[p].ch[c]){
t[++tot].len=t[p].len+2;
t[tot].fail=t[get_fail(t[p].fail, pos)].ch[c];
t[p].ch[c]=tot;
/*
11 行要放在 12 行前面.
get_fail 只会判断转移的可行性,并没有检查实际上是否有这种转移.
在 t[tot].len>1 时,可以保证 get_fail(t[p].fail, pos) 找到正确的节点.
而在 t[tot].len=1 时,有 p=1,这时 get_fail(t[p].fail, pos) 其实找到的是 1 号节点.
在这种特殊情况中,若提前令 t[p].ch[c]=tot,则可能使 fail 边连出自环.
*/
}
las=t[p].ch[c];
}
当然,如果不喜欢上面代码中 insert
部分的奇怪特判,可以这样写:
void insert(int c, int pos){
int p=get_fail(las, pos);
if(!t[p].ch[c]){
t[t[p].ch[c]=++tot].len=t[p].len+2;
t[tot].fail=t[tot].len==1? 0:t[get_fail(t[p].fail, pos)].ch[c];
}
las=t[p].ch[c];
}
回文划分
不会.有没有人能教教我啊.