回文串算法小结

发布时间 2023-07-14 00:10:59作者: ckain

为什么说回文是字符串原神.

Manacher 算法

功能

求出字符串每一处的回文半径,记为 \(p_i\)

实现方法

manacher 只能处理存在回文中心(长度为奇数)的回文串.故需要在待处理串 \(T\) 的字符空隙和开头结尾添加 相同 的特殊字符 \(ch_1\) 得到 \(S_1\).并且为了防止算法运行溢出边界,再在 \(S_1\) 的开头结尾分别添上特殊字符 \(ch_2,ch_3\) 得到最终可以进行 manacher 算法的字符串 \(S\)
注意需保证 \(ch_1,ch_2,ch_3\) 各不相同.

顺序扫描到位置 \(i\),记录回文中心 \(< i\) 的右端点最大的回文串右端点 \(r\) 和其回文中心 \(d\)
分情况讨论求解 \(p_i\)

  • \(r<i\).令 \(p_i \leftarrow i\)
  • \(r \ge i\).令 \(p_i \leftarrow \min(r-i+1,p_{d*2-i})\)

在第二种情况中,由于 \(S[d*2-r, r]\) 是回文串,所以 \(i\) 沿 \(d\) 对称的位置 \(d*2-i\) 作为回文中心,在 \(S[d*2-r, r]\) 内的回文串对称到 \(i\) 上不变,同样是回文串.

然后我们在这个基础上扩展 \(p_i\) 直到两端的字符不相等(这里就发挥了 \(S\) 两端限制字符 \(ch_1,ch_2\) 的防止溢出作用).

在求解出 \(p_i\) 后,用 \(i+p_i-1,i\) 尝试更新 \(r,d\)

时间复杂度分析

考虑求解 \(p_i\) 时:

  • \(r<p_i\)\(p_i\) 每扩展一步,\(r\) 都会增加.
  • \(r \ge p_i\)
    • \(p_{d*2-i} \le r-i+1\)\(p_i\) 不会扩展.
    • \(p_{d*2-i} > r-i+1\)\(p_i\) 每扩展一步,\(r\) 都会增加.

这说明,\(p\) 数组扩展的总次数等价于 \(r\) 增加的总次数.而 \(r\) 至多从 \(0\) 增加到 \(|S|\).共算法总时间复杂度为 \(O(|S|)\).非常的有力啊.


PAM 回文自动机

回文自动机又被称为回文树.据说是科学家受到 SAM 的启发研究出来的.但我感觉它和 ACAM 更像.

回文串的折叠表示

考虑一个回文串 \(S[1, 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} \]

显然,我们只要知道了回文串长度的奇偶性就可以用折叠表示唯一对应一个回文串.

前置:PAM 的结构

PAM 和其他自动机一样,由转移边 \(\delta\) 和后缀链接 \(Fail\) 构成.

约定:
\(\overline{S}\)\(S\) 的折叠表示.
\(T(p)\)\(p\) 节点表达的回文串.
\(Fail(p)\)\(p\) 节点的后缀链接.
\(\delta(p, c)\)\(p\) 经过 \(c\) 转移到的节点.
\(len(p)\)\(|T(p)|\)

转移边 \(\delta\):PAM 的转移边形成两棵 Trie 树,根节点编号为 \(0\)\(1\),从 \(0\) 号节点出发,沿转移边到达 Trie 树上的任意一个节点,路径形成的字符串为一个 长度为偶数的回文串 的折叠表示.同理,从 \(1\) 号节点出发,路径形成的字符串为一个 长度为奇数的回文串 的折叠表示.
特别的,规定 \(len(0)=0, len(1)=-1\)

后缀链接 \(Fail\):PAM 的后缀链接同样和其他自动机的后缀链接很像.都是满足某一条件的最长真后缀.比如 ACAM 是满足和该节点字符串前缀匹配,SAM 是满足和该节点的 \(endpos\) 不同.
PAM 的后缀连接的定义形如:对于节点 \(p\)\(Fail(p)=q\)\(q\) 是满足 \(T(q)\)\(T(p)\) 的真后缀的 \(len(q)\) 最大的节点.
特别的,规定 \(Fail(0)=1, Fail(1)=1\)

构造过程

增量构造法,考虑加入字符 \(c\)
新增的回文串(原来没出现过)只可能是新串的极长回文后缀 \(suf\).考虑 \(suf\) 的一个回文后缀沿 \(suf\) 的对称中心翻折,其肯定包含在旧串里面.
显然有 \(suf\) 是旧串的极长回文后缀 \(suf'\) 或其回文后缀首尾各添加 \(c\) 形成的.于是我们可以跳 \(suf'\)\(Fail\) 寻找 \(suf\).找到后,若发现没有 \(suf\) 对应的节点,我们需要考虑新建一个节点 \(np\) 对应 \(suf\)
\(np\) 在 Trie 上的父亲就是我们跳 \(suf'\)\(Fail\) 得到的节点.其首尾添加 \(c\) 就是 \(suf\).我们设其是 \(p\).有 \(\delta(p, c)=np, len(np)=len(p)+2\)
再考虑 \(Fail(np)\).显然 \(Fail(np)\)\(\delta(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];
}

回文划分

不会.有没有人能教教我啊.