Loading

基础字符串

目录

Change Log

  • 5.27:修复了文章里没有图片的问题。更新了若干题目的题解,修改了少量 typo。移除了《喵星球上的点名》一题。
  • 5.28 :重新添加了《JOJO》一题。修改了少量 typo。补充了部分题目的题解。

符号与约定

对于字符串 \(s\),用 \(s_i,s(i),s[i]\) 表示 \(s\) 中第 \(i\) 个字符。如无特殊说明,默认字符串下标从 \(1\) 开始。

对于字符串 \(s\),用 \(s[l...r],s[l,r]\) 表示 \(s\) 中第 \(l\) 个字符开始到第 \(r\) 个字符结束的子串。特殊地,若 \(l>r\),则认为其为空串。

对于字符串 \(s,t\),用 \(st\)\(s+t\) 来表示 \(s\)\(t\) 的拼接。

对于字符串 \(s\) 和非负整数 \(l\),用 \(\operatorname{pre}(s,l),\operatorname{suf}(s,l)\) 分别表示 \(s\) 中长度长度为 \(l\) 的前缀和后缀。特殊地,若 \(l = 0\),则认为二者皆为空串。

\(\Sigma\) 表示字符集。字符集 \(\Sigma\) 是一个有限全序集,字符串中仅含字符集中的字符。

匹配、周期和 Border

定义

对于字符串 \(s\),若 \(0 \leq r < |s|\)\(r\) 使得 \(s[1,r] = s[|s|-r+1,|s|]\),则称 \(s[1,r]\)\(s\)border

对于字符串 \(s\),若 \(0 < p \leq |s|\)\(p\) 使得 \(\forall i\in\{1,2,\cdots,|s|-p\}\)\(s_i = s_{i+p}\),则称 \(p\)\(s\)周期

按照上述定义,我们不能说 \(s\) 本身是它的 border,也不能说 \(0\)\(s\) 的周期。上述对于不等式符号的精心选取是为了导出以下结论:

Period-Border Lemma

\(s[1,r]\)\(s\) 的 border 当且仅当 \(|s|-r\)\(s\) 的周期。

对于长度为 \(n\) 的字符串 \(s\),定义其前缀数组(一别称为 \(\operatorname{next}\) 数组)\(\pi = [\pi_0,\pi_1,\pi_2,\cdots,\pi_n]\),其中 \(\pi_i\) 表示 \(s[1,i]\) 的最长 border 长度。特殊地,规定 \(\pi_0 = 0\)

对于字符串 \(s,t\)\(1 \leq l \leq r \leq |s|\),若 \(s[l,r] = t\),则称 \(s[l,r]\)\(t\) 匹配。对于所有满足前述条件的 \(r\),称 \(r\) 构成的集合为 \(t\)\(s\) 中的匹配位置

基础性质

  • 性质 1:border 的 border 仍然是原串的 border。

  • 性质 2:每次令 \(s\) 变为 \(s\) 的最长 border,直到 \(s\) 变为空串,则经过的所有 \(s\) 恰好构成原串的 border 集合。

    证明可以考虑反证法,结合 border 的定义即可。

  • 性质 3:若 \(p\)\(s\) 的周期,且 \(kp \leq |s|\),则 \(kp\) 也是 \(s\) 的周期。

    只需要考虑周期定义。

周期引理

弱周期引理 Weak Periodicity Lemma

对于字符串 \(s\),若 \(p,q\)\(s\) 的周期,且 \(p+q \leq |s|\),则 \(\gcd(p,q)\) 也是 \(s\) 的周期。

周期引理 Periodicity Lemma

对于字符串 \(s\),若 \(p,q\)\(s\) 的周期,且 \(p+q -\gcd(p,q) \leq |s|\),则 \(\gcd(p,q)\) 也是 \(s\) 的周期。

接下来使用较为直观的生成函数方法来证明周期引理。

以下证明中默认字符串的下标从 \(0\) 开始,且长度为 \(n\)

考虑给每种字符分配一个互不相同的正整数权值,即建立映射 \(f:\Sigma \to \mathbb N_+\),然后将字符串 \(s = s_0s_1\cdots s_{n-1}\) 看作数列 \(\lang f(s_0),f(s_1),\cdots, f(s_{n-1})\rang\)

对于周期 \(p,q\),构造多项式 \(P(x) = \sum \limits_{i=0}^{p-1} f(s_i)x^i,Q(x) = \sum \limits_{i=0}^{q-1} f(s_i)x^i\),这便是 \(s[0,p-1],s[0,q-1]\) 的生成函数。

定义字符串 \(s[0,p-1],s[0,q-1]\) 复制无穷多遍的生成函数为 \(S_p(x),S_q(X)\),则有 \(S_p(x)= \sum \limits_{i \geq 0} f(s_{i \bmod p})x^i\)\(S_q(x)= \sum \limits_{i \geq 0} f(s_{i \bmod q})x^i\)\(S_p(x),S_q(x)\) 的系数可以分别看作 \(P(x),Q(x)\) 的系数复制无穷多遍产生的,可以表示为 \(S_p(x) = P(x) + x^pP(x)+x^{2p}P(x)+\cdots+x^{kp}P(x)+\cdots\),对于 \(S_q(x)\) 同理。根据生成函数的运算规则,\(S_p(x) = \dfrac{P(x)}{1-x^p},S_q(x) = \dfrac{Q(x)}{1-x^q}\)

不难发现字符串 \(s\) 的生成函数 \(S(x)\)\(S_p(x),S_q(x)\) 对前 \(n\) 项的截断,即 \(S(x) = S_p(x) \bmod x^n = S_q(x) \bmod x^n\),从而 \([x^k]S_p(x) = [x^k]S_q(x)\),其中 \(k = 0,1,\cdots,n-1\)

考虑对 \(S_p(x)\)\(S_q(x)\) 作差,得到

\[\dfrac{P(x)}{1-x^p}-\dfrac{Q(x)}{1-x^q} = \dfrac{1-x^{\gcd(p,q)}}{(1-x^p)(1-x^q)}\left(\dfrac{1-x^q}{1-x^{\gcd(p,q)}} P(x)+ \dfrac{1-x^p}{1-x^{\gcd(p,q)}}Q(x) \right) \]

长除法容易证明 \((1-x^a),(1-x^b)\) 都是 \((1-x^{ab})\) 的因式,因此 \(\frac{1-x^q}{1-x^{\gcd(p,q)}},\frac{1-x^p}{1-x^{\gcd(p,q)}}\) 是整式,从而 \(H(x) = \dfrac{1-x^q}{1-x^{\gcd(p,q)}} P(x)+ \dfrac{1-x^p}{1-x^{\gcd(p,q)}}Q(x)\) 是次数不超过 \(p+q-1-\gcd(p,q)\) 的多项式。同时,\(\dfrac{1-x^{\gcd(p,q)}}{(1-x^p)(1-x^q)}\) 是一个常数项不为 \(0\) 的形式幂级数。若 \(H(x) \neq 0\),则取左侧幂级数的常数项和 \(H(x)\) 相乘,最终的结果中必然会得到不为 \(0\) 的一个 \(x^i\),其中 \(i \leq p+q-1-\gcd(p,q)\)。根据上面的讨论,在 \(k = 0,1,\cdots,n-1\) 处,我们都有 \([x^k](S_p(x)-S_q(x)) = 0\),又因为 \(p+q-\gcd(p,q) \leq n\),从而 \(i \leq n-1\),这样我们同时有 \(x^i\) 项的系数不为 \(0\)\(x^i\) 项的系数必须为 \(0\),出现了矛盾,因此 \(H(x) = 0\)

这样我们就有 \(S_p(x) - S_q(x) = 0\),从而 \(S_p(x)\)\(S_q(x)\) 每一项的系数都相等。根据裴蜀定理,存在整数 \(a,b\) 使得 \(ap+bq=\gcd(p,q)\)。这样就有 \([x^i]S_p(x) = [x^{i+ap}]S_p(x) = [x^{i+ap}]S_q(x) = [x^{i+ap+bq}] S_q(x) = [x^{i+\gcd(p,q)}]S_p(x)\),从而 \(s_i = s_{i+\gcd(p,q)}\)。如果 \(ap\) 是负数并使得 \(i-ap\) 是负数,那么我们可以先让 \(i\) 变为 \(i+bq\),因为此时 \(bq\) 一定是正整数,从而我们可以避免取负数项的系数。

这里,尽管我们直接证明了周期引理,但是大部分时候 WPL 就足够了。

匹配引理

引理

若字符串 \(u,v\) 满足 \(2|u| \geq v\),则 \(u\)\(v\) 中的所有匹配位置构成一个等差数列。

对平凡情况进行考察后,我们只需要考虑 \(u\)\(v\) 中匹配了至少 \(3\) 次的情况。

image

如上图,设 \(u\)\(v\) 中的前两次匹配在 \(v\) 中间隔为 \(d\),另外某次匹配距离第二次匹配的间隔为 \(q\),并记 \(u\)\(v\) 中的前两次匹配分别为 \(u_1,u_2\)。因为 \(2|u| \geq v\),因此任意两次相邻匹配都会产生重叠位置,从而 \(d+q \leq |u|\),根据 Period Lemma 得到 \(r = \gcd(d,q)\) 也是 \(u\) 的周期。

\(u\) 的最小周期为 \(p \leq r\)。仅根据周期定义,\(u_1\)\(v\) 中匹配的最后 \(p\) 个位置不一定满足 \(v(x) = v(x+p)\)。因为 \(u_1\)\(u_2\) 是相同的,且 \(p \leq r =\gcd(d,q) \leq d\),因此 \(|u_1 \cap u_2|\) 中的 \(|u|-d\) 个位置必然有 \(v(x) = v(x+p)\)。如果 \(|u_1 \cap u_2| \geq p\),那么 \(u_1\) 中最后 \(p\) 个位置也可以借助 \(u_2\) 中对应位置提供的信息来满足 \(v(x) = v(x+p)\)(因为 \(d \leq p\),所以这确实成立),从而 \(p\) 也是 \(u_1 \cup u_2\) 的周期。因为 \(p\)\(u\) 的最小周期,且根据上图有 \(|u_1 \cap u_2| \geq q\),因此 \(p \leq q \leq |u_1 \cap u_2|\),从而 \(|u_1 \cap u_2| \geq p\) 确实成立。

此时,若 \(p <d\),则 \(u_1\) 向右移动 \(p\) 的距离就会产生一次匹配,和 \(u_2\)\(u\)\(v\) 中第二次匹配矛盾。于是 \(d \leq p \leq r = \gcd(d,q) \leq d\) 成立,从而 \(p =d = r = \gcd(d,q)\)

推论

若字符串 \(u,v\) 满足 \(2|u| \geq v\),则 \(u\)\(v\) 中的所有匹配位置构成一个等差数列。若该等差数列项数不小于 \(3\),则其公差 \(d\)\(u\) 的最小周期 \(\text{per}(u)\),且此时易知 \(\text{per}(u) \leq |u|/2\)

我们上面的证明可以立刻得到该推论。注意到 \(d+q \leq |u|\),因此两个和为 \(|u|\) 的正整数的 \(\gcd\) 必然不会超过 \(|u|\) 的一半。

当等差数列仅含 \(2\) 项时不一定有 \(\text{per}(u) = d\),这是因为存在 \(u =\texttt{aabaa}, v =\texttt{aabaaabaa}, \text{per}(u) = 3, d = 4\) 的反例。

Border 的结构

引理 1

字符串 \(s\) 所有长度不小于 \(|s|/2\) 的 border 长度组成一个等差数列。

笔者注:此处不取整。

image

证明:设 \(s\) 的最大 border 长度为 \(|s|-p\),另外某个 border 长度为 \(|s| - q\),其中 \(p,q \leq |s|/2\)。那么 \(p+q \leq |s|\),从而 \(\gcd(p,q)\)\(s\) 的周期。注意到 \(|s| -p\)\(s\) 的最大 border 长度,因此 \(p\)\(s\) 的最小周期,因此 \(p \leq \gcd(p,q)\)。根据 \(\gcd\) 的定义有 \(p \geq \gcd(p,q)\),因此 \(p = \gcd(p,q)\),从而 \(s\) 所有大小不超过 \(|s|/2\) 的周期恰为 \(p,2p,\cdots,kp,\cdots(kp \leq |s|/2)\),同时 \(s\) 所有大小不小于 \(|s|/2\) 的周期恰为 \(|s|-p,|s|-2p,\cdots,|s|-kp\),它们构成一个等差数列。

接下来对 \(s\) 的所有 border 考虑如下的引理 2:

引理 2

\(s\) 的所有 border 长度构成 \(O(\log|s|)\) 个值域上不交的等差数列。

我们将 border 按照长度 \(x\) 分类:\(x \in [1,2),[2,4),\cdots,[2^{k-1},2^k),[2^k,n)\)

\(x \in [2^k,n)\),其中 \(2^k \geq n/2\),那么使用引理 \(1\) 可证。接下来讨论 \(x \in [2^{i-1},2^i)\) 的情形。

对于两个长度相等的串 \(u,v\),仿照 border 的定义,定义 \(u,v\)PS 集合 \(\text{PS}(u,v) =\{k\mid\text{pre}(u,k) = \text{suf}(v,k)\}\)

\(\text{LargePS}(u,v) = \{k \mid k \in \text{PS}(u,v),k \geq |u|/2\}\)

引理 3

\(\text{LargePS}(u,v)\) 构成一个等差数列。

证明:若 \(u = v\) 则只需要考察 \(|u|\) 是否和 \(u\) 所有长度不小于 \(|u|/2\) 的 border 构成一个等差数列。根据对引理 1 的证明,这事实上是显然的,因为后者是 \(|u|-p,|u|-2p,\cdots,|u|-kp\)

\(u \neq v\),考察 \(\text{LargePS}(u,v)\) 中的最大元素 \(x\)

image

那么将 \(u,v\) 按照上图方式叠放后,它们长度为 \(x\) 的交集应当是相等的。\(\text{LargePS}(u,v)\) 中任意一个更小的元素(图中深蓝色部分)必然是 \(\text{pre}(u,x)\) 的 border,因此利用引理 1 得到这些元素构成一个等差数列。和 \(u=v\) 时类似,\(x\) 也可以加入到这个等差数列中。因此引理 3 成立。

现在回到引理 2。根据 border 的定义,\(\text{LargePS}(\text{pre}(s,2^i),\text{suf}(s,2^i))\) 恰好包含 \(x \in [2^{i-1},2^i)\) 的长度为 \(x\) 的 border。利用引理 3,\(\text{LargePS}(\text{pre}(s,2^i),\text{suf}(s,2^i))\) 构成一个等差数列,因此引理 2 立刻得证。

通过引理 2 的证明,当 \(|s|>1\) 时,这里的 \(O(\log|s|)\) 可以变成一个更紧的界 \(\lceil \log_2 |s|\rceil\)。如果要严谨一点,我们还应该考察长度为 \(0\) 的 border,但这不影响结论成立,因为我们完全可以把它和 \(1\) 放到同一个等差数列中,不过我们一般也不会需要它。

小结

通过探讨关于周期和 border 的几个引理,我们最终得到了最重要的引理 \(2\)\(s\) 的所有 border 长度构成 \(O(\log|s|)\) 个值域上不交的等差数列。

在具体应用到题目中时,通常会考虑所有非空 border 构成的长度序列 \(b_1 \leq b_2 \leq \cdots \leq b_k\),同时令 \(b_0 = 0,b_{k+1} = |s|\)。一种较为常用的划分方法是,从后往前考虑每个 \(b_i(1 \leq i \leq k)\),并判定:

  • \(b_{i+1} - b_i = b_{i} - b_{i-1}\),则将 \(b_i\) 划分进当前等差数列;
  • 否则,将 \(b_i\) 划分进下一个等差数列。

例如,我们有 border 长度序列 \(b = [3,5,7,9,11,33,55]\)(很拙劣的例子。此处只是为了演示划分方式,可能并不存在这样的 border 序列),那么我们的划分方式为 \([3] / [5,7,9,11] / [33,55]\)

这样做的好处是,因为 \(b_{i-1} \geq 0\),因此对于同一个等差数列中的相邻元素 \(b_i,b_{i+1}\),都有 \(b_{i+1} \geq 2b_i\)。这个性质和匹配引理的条件颇为相似,在某些题目中会派上用场。

最后给出一条性质作为结尾:若满足 \(k>1\) 的等差数列 \(b'=[b'_1,\cdots,b'_{k}]\) 存在,那么根据周期的定义,\(s\) 存在大小为公差 \(b'_2 - b'_1\) 的周期,因此,除了 \(b'_k\),其余的 \(b'_i\) 都满足 \(s[b'_i+1]\) 相同。

前缀数组、KMP 与字符串匹配

前缀数组的求法

KMP 算法支持在 \(O(n)\) 的时间内在线计算出前缀数组 \(\pi\)。根据 border 的定义,若 \(s[1,i]\) 存在长度为 \(x(x > 0)\) 的 border,则 \(s[1,i-1]\) 存在长度为 \(x-1\) 的 border。考虑以下计算流程:

  • \(\pi_1 = 0\)
  • 枚举 \(i = 2,\cdots,n\),依次执行如下算法:
    1. 初始化指针 \(j \leftarrow \pi_{i-1}\)
    2. \(s[j+1] = s[i]\),令 \(\pi_{i} \leftarrow j+1\),结束算法;
    3. \(j = 0\),令 \(\pi_i \leftarrow 0\),结束算法。
    4. \(j \leftarrow \pi_{j}\),回到 2。

在每次执行算法的时候,\(j\) 不断变为 \(s[1,j]\) 的最长 border 的长度(\(\pi\) 数组的定义),根据性质 2,我们本质上从长到短遍历了 \(s[1,i-1]\) 的所有 border,然后依次判断该 border 是否是能在后方接上一个字符 \(s[i]\) 变成 \(s[1,i]\) 的 border。

上述流程可以写作如下代码:

for(int i=2;i<=n;++i){
	int j=len[i-1];
	while(s[i]!=s[j+1]){
	    if(!j) break;
	    j=len[j];
	}
	len[i]=j+(s[i]==s[j+1]);
}

考虑复杂度分析。首先,显然有 \(\pi_i \leq \pi_{i-1} + 1\),其次,在每次算法执行的过程中,每当 \(j\) 变为 \(\pi_j\) 时,\(j\) 至少变小 \(1\)。因为 \(0 \leq \pi_i < i\),运用势能分析可以得知该算法的复杂度为 \(O(n)\)

前缀数组与字符串匹配

对于字符串 \(s,t(|s| = n,|t| = m)\),利用字符串 \(t\) 的前缀数组 \(\pi\) 可以求出 \(t\)\(s\) 中的所有匹配位置。考虑以下计算流程:

  • 枚举 \(i = 1,2,\cdots,n\)。同时维护指针 \(j\),表示 \(s[1,i]\) 的某个后缀和 \(t[1,j]\) 相等。初始令 \(j \leftarrow 0\)

    依次执行如下算法:

    1. 检查是否有 \(s[i] = s[j+1]\)

    2. \(s[i] = s[j+1]\) 成立,则令 \(j \leftarrow j+1\)。此时若 \(j = m\),则找到一个出现位置,标记该位置,并令 \(j \leftarrow \pi_j\)

      结束算法。

    3. \(s[i] = s[j+1]\) 不成立,则判断:若 \(j = 0\),则结束算法。否则令 \(j \leftarrow \pi_{j}\),转到 1。

该算法的正确性证明和时间复杂度证明与求前缀数组是类似的,此处不再赘述。可以分析出时间复杂度为 \(O(n+m)\),其中 \(O(m)\) 的部分为求 \(\pi\) 的复杂度,\(O(n)\) 的部分为执行匹配算法的复杂度。

例题:【模板】KMP

正常写法。

# include <bits/stdc++.h>
const int N=1000010,INF=0x3f3f3f3f;
char s[N],t[N];
int n,m;
int len[N];
int main(void){
	scanf("%s",s+1),scanf("%s",t+1),n=strlen(s+1),m=strlen(t+1);
	for(int i=2;i<=m;++i){
		int j=len[i-1];
		while(t[i]!=t[j+1]){
		    if(!j) break;
		    j=len[j];
		}
		len[i]=j+(t[i]==t[j+1]);
	}
	for(int i=1,j=0;i<=n;){
		if(s[i]==t[j+1]){
			++i,++j;
			if(j==m) printf("%d\n",i-m),j=len[j];
		}else if(j) j=len[j]; else ++i;
	}
	for(int i=1;i<=m;++i) printf("%d ",len[i]);
	return 0;
}

或者偷懒:将 \(s\)\(t\) 用分隔符连接,每次只需要查询某一位的 \(\pi\) 值是否为 \(|s|\)

# include <bits/stdc++.h>
const int N=2000010,INF=0x3f3f3f3f;
char s[N],t[N];
int n,m;
int len[N];
int main(void){
	scanf("%s",s+1),scanf("%s",t+1),n=strlen(s+1),m=strlen(t+1);
	int q=m;
	t[++q]='#';
	for(int i=1;i<=n;++i) t[++q]=s[i];
	for(int i=2;i<=q;++i){
		int j=len[i-1];
		while(t[i]!=t[j+1]){
		    if(!j) break;
		    j=len[j];
		}
		len[i]=j+(t[i]==t[j+1]);
	}
	for(int i=1;i<=n;++i) if(len[m+1+i]==m) printf("%d\n",i-m+1);
	for(int i=1;i<=m;++i) printf("%d ",len[i]);
	return 0;
}

结合周期引理 / UVA1328

UVA 1328 Period

对于某个字符串,当且仅当某个周期的大小整除字符串长度时,这个周期是该字符串的循环节。

注意到如果一个长度为 \(n\) 的字符串 \(s\) 有最短非平凡(大小不为 \(n\) 本身)循环节 \(c\),那么一定有 \(c \leq n/2\)。若 \(s\) 的最小周期 \(p\) 不为 \(c\),则 \(p+c \leq n\),根据周期引理,\(\gcd(p,c) \leq p < c\)\(s\) 的周期。因为 \(c\)\(n\) 的非平凡循环节,因此 \(\gcd(p,c) \mid c \mid n\),同时 \(\gcd(p,c) < c\),推出矛盾。

因此,对于本题,只需要求出前缀数组 \(\pi\),对于每个前缀 \(i\),检查是否有 \((i - \pi_i) \mid i\) 即可。同样根据周期引理,我们可以得到,任何循环节长度都是最短循环节长度的整倍数,因此,前缀 \(i\) 的循环节数量恰为 \(i/(i- \pi_i)\)

KMP 算法的可持久化

接下来介绍一种 KMP 算法的变种,时空复杂度可做到 \(O(n\Sigma)\),或利用可持久化数据结构做到 \(O(n \log \Sigma)\)

具体来说,记 \(nex(i,c)\) 表示 \(s[1,i]\) 所有满足 \(s[j+1] = c\) 的 border \(j\)\(j = 0\) 亦考虑在内)的最大长度,不存在则 \(nex(i,c) = -1\)。那么显然有 \(\pi_i = nex(i-1,s[i])\)

接下来只需考虑求出 \(nex(i)\)。不难发现 \(nex(i)\) 比起 \(nex(\pi_i)\),仅有 \(c = s[\pi_i + 1]\) 时可能发生改变。因此每次从 \(nex(\pi_i)\) 处复制后修改即可。

该变种的优势是,复杂度不依赖均摊,每添加一个字符,需要的复杂度都是 \(O(\Sigma)\)\(O(\log \Sigma)\),因此支持可持久化。

例题 Codeforces 1721E

给定长度为 \(n\) 的字符串 \(s\)\(m\) 次询问,每次给出一个字符串 \(t\),询问字符串 \(s+t\) 的前缀数组 \(\pi\) 中,最后 \(|t|\) 位的值。

字符集为小写字母集。

\(1 \leq n \leq 10^6,1 \leq m \leq 10^5,1 \leq |t| \leq 10\)

不难发现可持久化 KMP 只需要保证 \(\sum |t|\),因此 \(|t| \leq 10\) 无用,可加强到与 \(n\) 同阶。

以下给出代码。

# include <bits/stdc++.h>

const int N=1000110,INF=0x3f3f3f3f;

char s[N],t[N];
int n,m;
int len[N];
int nex[N][26];

inline void extend(int i,int c){	
	len[i]=nex[i-1][c]+1;
	memcpy(nex[i],nex[len[i]],sizeof(nex[i]));
	nex[i][s[len[i]+1]-'a']=len[i];
	return;
}
int main(void){
	for(int i=0;i<26;++i) nex[0][i]=-1;
	scanf("%s",s+1),n=strlen(s+1);
	for(int i=1;i<=n;++i){
		extend(i,s[i]-'a');
	}
	int m; std::cin>>m;
	while(m--){
		scanf("%s",t+1);
		int k=strlen(t+1);
		for(int i=1;i<=k;++i) s[n+i]=t[i],extend(n+i,s[n+i]-'a'),printf("%d ",len[n+i]);
		puts("");
	}
	return 0;
}

Luogu P4156 [WC2016] 论战捆竹竿

给定长度为 \(n\) 的小写字母串 \(s\) 和正整数 \(w\)。考虑 \(s\) 的所有非空 border 长度与正整数 \(n\) 构成的集合 \(B\),求:有多少个小于等于 \(w-n\) 的非负整数可以被集合 \(B\) 中若干个(可以为 \(0\) 个)元素的和所表示。集合中元素可重复使用。

多测,\(T \leq 5,1 \leq n \leq 5\times 10^5,1 \leq w \leq 10^{18}\)

求出 \(B\) 是平凡的。不难发现这是元素较小,且值域较大的完全背包问题,可以使用转圈法(或同余最短路)解决,详见 [THUPC 2023 初赛] 背包

但事实上,元素种类仍然较多,难以直接通过。接下来的做法需要考虑到 border 可以划分为若干个等差数列的性质。

考虑当前模数 \(M\),并设 \(f(i)\) 表示只使用之前加入的元素时,能够凑出来的最小的模 \(M\) 等于 \(i\) 的非负整数。初始令 \(M = n,f(0) = 0,f(i) = +\infty ( i \neq 0)\)

对于一个长度为 \(l+1\) 的等差数列,设其为 \(x,x+d,\cdots,x+ld\)。先实现 \(f(i)\) 从模 \(M\) 意义下到模 \(M' = x\) 意义下的转换,那么对于一个旧的 \(f(i)\),其贡献显然为:令 \(f'(i \bmod y)\)\(f(i)\) 取 min。

此时注意到,因为原来的背包基准元素为 \(M\),因此旧的 \(f(i)\) 中并没有考虑加入元素 \(M\) 的贡献。因此需要在模 \(M'\) 意义下的背包中,加入元素 \(M\),跑一遍背包。

现在考虑加入等差数列中的元素。将每个点 \(i\)\((i+d) \bmod x\) 连边,此时形成了 \(\gcd(d,x)\) 个环。每个环上的任务形如:找到环上使得 \(f(i)\) 最小的 \(i\) 作为起始点,然后对于每个 \(f(i+kd)\),有 \(f(i+kd) = \min \limits_{j = 1}^{l} \{f(i+(k-j)d) + jd\}\)。使用单调队列优化 DP 即可。

Codeforces 1286E Fedya the Potter Strikes Back

本题需要在加入每个字符后,求出当前字符串每个非空 border 的可疑度之和。

考察 \(s[1,i] (i>1)\) border 的来源:要么是一个空 border,要么是 \(s[1,i-1]\) 的某个 border 在后面拼上一个和 \(s[i]\) 相等的字符变来的。

\(S(i,c)\) 表示 \(s[1,i]\) 所有满足下一位字符为 \(c\) 的 border 构成的集合,那么 \(S(i)\) 中只有一个位置和 \(S(\pi_i)\) 不同:\(S(i,s[\pi_i + 1])\) 中添加了 \(\pi_i\) 这一元素。

不难发现,加入 \(s[i]\) 后,需要删除 \(S(i-1,c) (c \neq s_i)\) 中 border 的贡献。没有被删除的 border 贡献需要对 \(i\) 这一位的权值取 min,可以使用 map 简单维护。另外,若 \(s[i] = s[1]\),则加入该 border 的贡献。

考虑如何求出需要删除的 border。记 \(nex(i,c)\) 表示 \(S(i,c)\) 中的最长 border 长度,那么依次遍历 \(nex(i-1,c),nex(nex(i-1,c),c),\cdots\) 即可。

贡献可以使用线段树计算。因为 border 的加入删除数量是均摊 \(O(1)\) 的,因此时间复杂度 \(O(n \log n)\)

# include <bits/stdc++.h>

const int N=600010,INF=0x3f3f3f3f;

typedef long long ll;
int nex[N][26],fail[N],n,w[N]; // nex[i,x] = pos 表示 [1,pos] 是 [1,i] 的 border 且 s[pos+1] = x 
int s[N];
__int128 ans;
ll curv; // 记录下 border 的和(即不包含 [1,x] 的所有相等前缀) 
int minx[N<<2];

std::map <int,int> S;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-')f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}
inline void print(__int128 x){
	if(x<0) putchar('-'),x-=x;
	if(x>9) print(x/10);
	putchar(x%10+'0');
	return; 
}
inline int lc(int x){
	return x<<1;
}
inline int rc(int x){
	return x<<1|1;
}
inline void pushup(int k){
	minx[k]=std::min(minx[lc(k)],minx[rc(k)]);
	return;
}
void change(int k,int l,int r,int x,int v){
	if(l==r) return minx[k]=v,void();
	int mid=(l+r)>>1;
	if(x<=mid) change(lc(k),l,mid,x,v);
	else change(rc(k),mid+1,r,x,v);
	pushup(k);
	return;
}
int query(int k,int l,int r,int L,int R){
	if(L<=l&&r<=R) return minx[k];
	int mid=(l+r)>>1,res=2e9;
	if(L<=mid) res=std::min(res,query(lc(k),l,mid,L,R));
	if(mid<R) res=std::min(res,query(rc(k),mid+1,r,L,R));
	return res;
}


int main(void){
	n=read();
	char in[2];
	scanf("%s",in);
	s[1]=in[0]-'a',printf("%d\n",w[1]=read()),ans+=w[1],change(1,1,n,1,w[1]);
	for(int i=2,val,j=0;i<=n;++i){
		scanf("%s",in),val=read();
		s[i]=(in[0]-'a'+ans%26)%26,w[i]=val^(ans%(1<<30));
		change(1,1,n,i,w[i]);
		ans+=query(1,1,n,1,i);
		while(j&&s[j+1]!=s[i]) j=fail[j];
		if(s[j+1]==s[i]) ++j;
		fail[i]=j;
		for(int k=0;k<26;++k) nex[i][k]=nex[fail[i]][k];
		nex[i][s[fail[i]+1]]=fail[i];
		for(int k=0;k<26;++k){
			if(k!=s[i]){
				for(int j=nex[i-1][k];j;j=nex[j][k]){
					int res=query(1,1,n,(i-1)-j+1,i-1);
					curv-=res,--S[res];
				}
			}
		}
		std::vector <int> delv;
		for(std::map <int,int>::iterator it=S.upper_bound(w[i]);it!=S.end();++it){
			std::pair <int,int> now=*it;
			curv+=1ll*(w[i]-now.first)*now.second,S[w[i]]+=now.second,delv.push_back(now.first);
		}
		for(auto v:delv) S.erase(v);
		if(s[i]==s[1]) curv+=w[i],++S[w[i]];
		ans+=curv,print(ans),puts("");
	}
	return 0;
}

Luogu P6080 [USACO05DEC]Cow Patterns G

考虑对于两个序列,如何判定其等价。我们发现重复的数非常麻烦,因此可以把序列中的某个数 \(x\) 看作一个二元组 \((x,c)\),其中 \(c\) 表示在 \(x\) 所在位置之前大小等于 \(x\) 的数的数量。

两个序列 \(a,b\) 相同,当且仅当对于每一位 \(i\)\(a[1,i-1]\) 中小于 \(a_i\) 的数的数量和 \(b[1,i-1]\) 中小于 \(b_i\) 的数的数量相等。同时,对于这一位,\(a[1,i-1]\) 中等于 \(a_i\) 的数的数量也要和 \(b[1,i-1]\) 中等于 \(b_i\) 的数的数量相等。这些要求本质上确定了插入第 \(i\) 位时这个数要插在的位置。

等价仍然是具有传递性的,因此这不影响 KMP 的过程。因此我们只需要按照上面的方法修改两个字符相等的判定即可。

# include <bits/stdc++.h>

const int N=200010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,k,s;

int T[N];
int S[N];

int occ[N][30];
int pi[N];

inline int g(int l,int r,int p){
	return occ[r][p]-occ[l-1][p];
}

inline bool mat(int x,int y){
	if(x>k) return false;
	return (g(1,x-1,S[x]-1)==g(y-x+1,y-1,S[y]-1))&&(g(1,x-1,S[x])==g(y-x+1,y-1,S[y]));
}

int main(void){
	n=read(),k=read(),s=read();
	
	for(int i=1;i<=n;++i) T[i]=read();
	for(int i=1;i<=k;++i) S[i]=read();
	for(int i=1;i<=n;++i) S[k+i+1]=T[i];
	
	int len=n+k+1;
	
	for(int i=1;i<=len;++i){
		memcpy(occ[i],occ[i-1],sizeof(occ[i]));
		for(int j=S[i];j<=s;++j) ++occ[i][j];
	}
	
	std::vector <int> vec;
	
	for(int i=2;i<=len;++i){
		if(i==k+1) continue;
		int j=pi[i-1];
		while(!mat(j+1,i)){
			if(!j) break;
			j=pi[j];
		}
		pi[i]=j+1;
		if(i>k&&pi[i]==k) vec.push_back(i-(k+1)-k+1);
	}
	printf("%llu\n",vec.size());
	for(auto v:vec) printf("%d\n",v);
	return 0;
}

Luogu P5287 [HNOI2019] JOJO

不难发现询问离线,因此可持久化无用,可以建出操作树后 DFS。

将一个字符串看作若干个二元组 \((x,c)\) 构成的序列,其中 \(x\) 描述这段字符的数量,\(c\) 描述这段字符的种类。例如,\(\texttt{aaaabbbbaa}\) 可以被描述为 \([(4,\texttt a),(4,\texttt b),(2,\texttt a)]\)

如何判定这个字符串的某个前缀和后缀相等?事实上,题目保证了相邻两次插入的字符种类不同,因此,分别将该前缀和该后缀所在的二元组序列取出(它们也分别是原二元组序列的一段前缀和后缀。另外,如果某个二元组不被完全包含,我们也会取出它),那么这两个序列需要满足以下条件:

  • 两个序列的长度相同;
  • 序列中每个位置的字符对应相等;
  • 除了头尾,中间的二元组必须完全相等;
  • 对于第一个二元组,前缀序列的字符数量不超过后缀序列的字符数量;
  • 对于最后一个二元组,后缀序列的字符数量不超过前缀序列的字符数量。

\(\texttt{aabbcccdaaabbcc}\) 为例。它可以被描述为 \([(2,\texttt a),(2,\texttt b),(3,\texttt c),(1,\texttt d),(3,\texttt a),(2,\texttt b),(2,\texttt c)]\),取出长度为 \(6\) 的前缀和长度为 \(6\) 的后缀的二元组序列,它们分别是 \([(2,\texttt a),(2,\texttt b),(3,\texttt c)],[(3,\texttt a),(2,\texttt b),(2,\texttt c)]\)。按照上述规则,我们可以判定长度为 \(6\) 的后缀和长度为 \(6\) 的前缀相等。

注意到,中间的二元组必须完全相等,因此 KMP 的过程不会发生太大变化。我们只需要略微修改二元组序列中,两个前后缀相等的定义:前后缀长度必须相等;除了第一个二元组,其余二元组必须完全相等;第一个二元组的字符必须相等,前缀中该二元组的字符数量必须不超过后缀中该二元组的字符数量。

在序列中加入第 \(i\) 个二元组 \((x_i,c_i)\) 的时候,我们需要关心新加入的这 \(x_i\) 个字符对应前缀的最长 border 长度。我们可以从长到短遍历二元组序列中前缀 \(i-1\) 的所有 border​ \(b\),并检查该 border 的下一个位置 \(b+1\) 是否有 \(c_{b+1} = c_i\)。如果是,那么新加入的第 \(k(1 \leq k \leq \min(x_i,x_{b+1}))\) 个字符就存在一个长度为 \((\sum \limits_{j=1}^{b} x_j)+k\) 的 border 了。

注意到我们的操作是在操作树上,因此不能再用带势能的 KMP 了。考虑 WPL,对于一个长度为 \(n\) 的字符串 \(s\),设其最长的 border 长度为 \(n-d\),那么 \(d\) 是其最小周期。若周期 \(d'\) 满足 \(d' +d \leq n\),则 \(\gcd(d',d)\) 也是 \(s\) 的周期,因此 \(d'\) 只能是 \(d\) 的倍数。从而所有大小在 \([d,n-d]\) 之间的周期都形如 \(kd\)

因此,长度为 \(n-d,n-2d,\cdots,n \bmod d + d\) 的 border 构成了一个等差数列,根据结论,这些 border 中除了最长的那个,剩下的 border 的下一个字符都是相同的。因此对于该等差数列,只需要检查该等差数列的最长 border 和次长 border 即可。

具体地,我们维护两个指针 \(cl,cr\),其中 \(cl\) 表示当前位于的 border,\(cr\) 表示上一个 border。如果 \(cr - cl = cl - \pi_{cl}\),则说明 \(cl\) 是等差数列中的次长 border,我们直接跳到等差数列中的第一个 border,并将 \(cr\) 置为 \(-1\)(这样下一次判断一定不会成立)。否则,我们只向前跳一步,即将 \(cr\) 置为 \(cl\)\(cl\) 置为 \(\pi_{cl}\)

# include <bits/stdc++.h>

const int N=100010,INF=0x3f3f3f3f,mod=998244353;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

struct Edge{
	int x,to;
	char c;
};
std::vector <Edge> G[N];
int n;
int cnt=1;
int ver[N];

inline int sum(int l,int r){
	if(l>r) return 0;
	return 1ll*(l+r)*(r-l+1)/2ll%mod;
}
int ans[N];

namespace KM{
	int top,pi[N],len[N];
	int ans[N];
	struct Ele{
		int x;
		char c;
		bool operator == (const Ele &rhs) const{
			return x==rhs.x&&c==rhs.c;
		}
	}v[N];
	
	inline bool chk(int pos,Ele ele){
		if(!pos) return (v[1].c==ele.c)&&(v[1].x<=ele.x);
		return v[pos+1]==ele;
	}
	inline void add(int &a,int b){
		a=(a+b)%mod;
		return;
	}
	
	inline void ins(int x,char c){
		Ele ele=(Ele){x,c};
		
		++top,pi[top]=0,v[top]=ele,len[top]=len[top-1]+x;
		int &res=ans[top];res=ans[top-1];
		
		if(top==1) return res=sum(1,x-1),void(); // 细节 1: 第一次插入

		int cl=pi[top-1],cr=top-1,mx=0;
        // mx 表示当前插入的前 mx 个字符已经找到了 border.
        // 因此只有 > mx 的部分有可能造成贡献
		while(cl&&!chk(cl,ele)){		
			if(v[cl+1].c==c&&v[cl+1].x>mx) // 有贡献
				add(res,sum(len[cl]+mx+1,len[cl]+std::min(x,v[cl+1].x))),mx=std::min(x,v[cl+1].x);
			if(cr-cl==cl-pi[cl]){ // 位于等差数列的次长 border
				int d=cr-cl;
				cr=-1,cl=cl%d+d;
			}else cr=cl,cl=pi[cl];
		}
				
		if(chk(cl,ele)){
			pi[top]=cl+1,add(res,sum(len[cl]+mx+1,len[cl]+v[cl+1].x));
			add(res,1ll*(len[cl]+v[cl+1].x)*(x-std::max(mx,v[cl+1].x)%mod));
            // 此处注意细节:cl = 0 时,v[cl+1].x 未必和 x 相等
		}
		else{ // 边界:cl = 0 时仍有可能会造成贡献 (此时 v[1].x > x,可以有贡献但不能被 border 匹配)
			if(v[cl+1].c==c&&v[cl+1].x>mx)
				add(res,sum(len[cl]+mx+1,len[cl]+std::min(x,v[cl+1].x))),mx=std::min(x,v[cl+1].x);
		}
		return;
	}
	inline void del(void){
		--top;
		return;
	}
	inline int get(void){
		return ans[top];
	}
}

void dfs(int u){
	ans[u]=KM::get();
	for(auto edge:G[u]){
		int v=edge.to;
		KM::ins(edge.x,edge.c),dfs(v),KM::del();
	}
	return;
}

int main(void){
	n=read();
	int cur=1;
	ver[0]=1;
	for(int i=1;i<=n;++i){
		int op=read(),x;
		if(op==1){
			x=read();
			char s[2];
			scanf("%s",s);
			++cnt,G[cur].push_back((Edge){x,cnt,s[0]}),cur=cnt,ver[i]=cnt;
		}else x=read(),ver[i]=cur=ver[x];
	}
	dfs(1);
	for(int i=1;i<=n;++i) printf("%d\n",ans[ver[i]]);

	return 0;
}

Z 函数

对于长度为 \(n\) 的字符串 \(s\),定义其 Z 函数数组 \(z = [z_1,z_2,\cdots,z_n]\),其中 \(z_i\)\(s[1,n]\)\(s[i,n]\) 的 LCP 长度。

与前缀函数 \(\pi\) 类似,\(z\) 也可以在线性时间内求出。算法如下:

  • \(z_1 = n\)

  • 遍历 \(i =2,3,\cdots,n\),并维护 Z-box \([l,r]\),表示最靠右的一段区间,使得它可以和 \(s\) 的某个前缀完全匹配。初始 \(l = r = 0\)。执行以下流程:

    1. \(i \leq r\),说明 \(i\) 位于 Z-box 中。此时根据定义,有 \(s[l,r] = s[1,r-l+1]\),从而 \(s[i,r] = s[i-l+1,r-l+1]\)。因此 \(z_i \geq \min(z_{i-l+1},r-i+1)\)
    2. 暴力扩展 \(z_i\)。即:若 \(s[z_i+1] = s[i+z_i]\) 则令 \(z_i\) 增加 \(1\),直到该条不再成立。
    3. 更新 Z-box。即:若区间 \([i,i+z_i-1]\) 的右端点 \((i+z_i - 1)\) 位于 Z-box \([l,r]\) 的右端点 \(r\) 右侧,则将 Z-box 更新为前者。
z[1]=n;
for(int i=2,l=0,r=0;i<=n;++i){
	if(i<=r) z[i]=std::min(z[i-l+1],r-i+1);
	while(i+z[i]<=n&&s[i+z[i]]==s[z[i]+1]) ++z[i];
	if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}

Luogu P5410 【模板】扩展 KMP/exKMP

事实上,我们可以使用与求 \(z\) 时几乎一致的方式求出 \(p\)

	z[1]=m;
	for(int i=2,l=0,r=0;i<=m;++i){
		if(i<=r) z[i]=std::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,l=0,r=0;i<=n;++i){
		if(i<=r) p[i]=std::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;
	}

Z 函数的应用场景就比较局限了。或许我们唯一能够期望的是它能在极端情况下少一个 log。

AC 自动机与多串匹配

现在有 \(n\) 个字符串 \(s_1,s_2,\cdots,s_n\)。它们的 AC 自动机 \(\mathcal A\) 可以看作是 \(s_1,s_2,\cdots,s_n\) 的 Trie 树 \(\mathcal T\) 上,新建出一些边形成的有向图结构。

具体来说,我们希望 \(\mathcal T\) 扩充为 \(\mathcal A\) 后,\(\mathcal A\) 满足如下性质:

  • 节点集合不变,原有的边仍然存在。
  • 包含所有形如 \(tr(x,c)\) 的边,其中 \(x\) 属于节点集合,\(c\) 属于字符集。
  • 设节点 \(x\) 表示的字符串为 \(\text{str}(x)\)\(\text{str}(x)+c\) 最长的在 \(\mathcal T\) 上出现过的后缀为 \(\text{msuf}(x)\),则 \(tr(x,c)\) 指向 \(\text{msuf}(x)\) 代表的节点。

以下给出扩充方法。考虑对 \(\mathcal T\) 进行 BFS,维护出 \(\text{fail}(x)\),指向代表 \(\text{str}(x)\)\(\mathcal T\) 上出现过的最长后缀的节点。

算法流程如下:

  1. 维护一个 BFS 队列,初始将根节点入队。

  2. 若队列为空,结束算法。否则,取出队列头元素 \(x\) 并弹出,转到 3。

  3. 遍历 \(c \in \Sigma\),检查 \(tr(x,c)\)。若 \(tr(x,c)\) 存在,转到 4,否则转到 5。

  4. \(y = tr(x,c)\)。将 \(\text{fail}(y)\) 更新为 \(tr(\text{fail}(x),c)\),并将 \(y\) 入队,保持 \(tr(x,c)\) 不变,转到 2。

  5. \(tr(x,c) \leftarrow tr(\text{fail}(x),c)\),转到 2。

inline void init(void){
	for(int i=0;i<26;++i) tr[0].nex[i]=1;
	std::queue <int> q; q.push(1);
	while(!q.empty()){
		int i=q.front();
		q.pop();
		for(int j=0;j<26;++j){
			int &nex=tr[i].nex[j];
			if(!nex) nex=tr[tr[i].fail].nex[j];
			else tr[nex].fail=tr[tr[i].fail].nex[j],q.push(nex);
		}
	}
	return;
}

不难发现 \((\text{fail}(x),x)\) 组成树形结构,称作 fail 树。

以下是几个简单应用。

在应用之前,有几点提示:

  • 做不动了,就考虑根号分治。
  • 如果你觉得不好做,想想有没有基于 \(\sum |S|\) 的做法。
  • 如果题目询问的是整串在整串中的出现信息,大概率 ACAM 和广义 SAM 近似等价,可以先考虑简单的 ACAM。

Luogu P5357 【模板】AC 自动机

给定文本串 \(S\) 和模式串 \(T_1,T_2,\cdots,T_n\),求每个模式串在 \(S\) 中出现的次数。

\(1 \leq n,\sum |T| \leq 2\times 10^5,1 \leq |S| \leq 2 \times 10^6\)

\(T\) 建出 AC 自动机,随后将 \(S\) 放上 AC 自动机进行匹配。流程为:

  • 维护指针 \(p\),初始为根。每个节点维护一个标记大小,初始为 \(0\)
  • 遍历 \(i = 1,2,\cdots,|S|\),令 \(p \leftarrow tr(p,S_i)\),将 \(p\) 节点的标记大小增加 \(1\)

随后遍历 \(i = 1,2,\cdots,n\),则根据 fail 指针的定义,\(T_i\) 对应节点在 fail 树上子树的标记大小之和即为答案。

Luogu P4052 [JSOI2007] 文本生成器

在 AC 自动机上 DP。

第一种方法是补集转化,设 \(f(i,j)\) 表示长度为 \(i\) 的字符串,把这个字符串在 ACAM 上匹配完成后停在节点 \(j\) 上,且路径上不经过任何终止节点(节点表示的字符串的某个后缀位于字典中)的方案数。转移是简单的。

第二种方法是,直接设 \(f(i,j,0/1)\) 表示长度为 \(i\) 的字符串,把这个字符串在 ACAM 上匹配完成后停在节点 \(j\) 上,且路径还未经过 / 已经经过终止节点的方案数。

这里给出第一种方法的实现。

# include <bits/stdc++.h>

const int N=2000010,INF=0x3f3f3f3f,mod=1e4+7;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,m;
char s[N];
namespace acam{
	int nex[N][26];
	int fail[N],cnt=1,fa[N];
	bool ed[N];
	inline void ins(void){
		int len=strlen(s+1),cur=1;
		for(int i=1;i<=len;++i){
			int &ne=nex[cur][s[i]-'A'];
			if(!ne) ne=++cnt,fa[cnt]=cur;
			cur=ne;
		}
		ed[cur]=true;
		return;
	}
	std::vector <int> G[N];
	inline void init(void){
		for(int i=0;i<26;++i) nex[0][i]=1;
		std::queue <int> q;q.push(1);
		while(!q.empty()){
			int i=q.front();
			q.pop(),ed[i]|=ed[fail[i]];
			for(int j=0;j<26;++j){
				if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
				else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		return;
	}
}
using namespace acam;

int f[105][6010];

int main(void){
	n=read(),m=read();
	for(int i=1;i<=n;++i) scanf("%s",s+1),ins();
	init();
	
	f[0][1]=1;
	int ans=1;
	for(int i=1;i<=m;++i) ans=ans*26%mod;
	
	for(int i=0;i<m;++i){
		for(int j=1;j<=cnt;++j){
			for(int c=0;c<26;++c){
				int jj=nex[j][c];
				if(ed[jj]) continue;
				f[i+1][jj]=(f[i][j]+f[i+1][jj])%mod;
			}
		}
	}
	for(int i=1;i<=cnt;++i) ans=(ans-f[m][i]+mod)%mod;
	printf("%d",ans);

	return 0;
}

Codeforces 86C Genetic engineering

限制可以表述为:对于每个位置,至少有一个模式串覆盖它。

据此可以 DP。设 \(f(i,j,k)\) 表示考虑了前 \(i\) 个字符,当前位于 AC 自动机上的 \(j\) 节点,下标最小的没有覆盖的位置在 \((i+1)-k\) 处(若全部被覆盖,则取 \(k=0\))的方案数。

初始有 \(f(0,\text{root},1)=1\)。考虑从 \(f(i,j,k)\) 转移到 \(f(i+1)\),枚举这一位填的字符 \(c\),记 \(tr(j,c) = j'\),那么,记以 \(j'\) 结尾的最长模式串长度为 \(l\),则当 \(l > k\) 时,该模式串覆盖掉之前所有的没有被覆盖的位置,\(f(i,j,k)\) 转移到 \(f(i+1,j',0)\);否则,\(f(i,j,k)\) 转移到 \(f(i+1,j',k+1)\)

# include <bits/stdc++.h>

const int N=100010,INF=0x3f3f3f3f,mod=1e9+9;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,m;
int f[1005][105][12];

char s[20];

std::unordered_map <char,int> mp;

namespace acam{
	int nex[N][4],fail[N];
	int cnt=1;
	int ml[N];
	
	inline void ins(void){
		int len=strlen(s+1),cur=1;
		for(int i=1;i<=len;++i){
			if(!nex[cur][mp[s[i]]]) nex[cur][mp[s[i]]]=++cnt;
			cur=nex[cur][mp[s[i]]];
		}
		ml[cur]=len;
		return;
	}
	inline void init(void){
		for(int i=0;i<4;++i) nex[0][i]=1;
		std::queue <int> q;
		q.push(1);
		while(!q.empty()){
			int i=q.front();
			q.pop();
			ml[i]=std::max(ml[i],ml[fail[i]]);
			for(int j=0;j<4;++j){
				if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
				else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		return;
	}
}
using namespace acam;

inline void add(int &a,int b){
	return a=(a+b)%mod,void();
}

int main(void){
	n=read(),m=read();
	mp['A']=0,mp['C']=1,mp['T']=2,mp['G']=3;
	
	for(int i=1;i<=m;++i){
		scanf("%s",s+1),ins();
	}
	init();
	f[0][1][0]=1;
	for(int i=0;i<n;++i){
		for(int j=1;j<=cnt;++j){
			for(int len=0;len<=10;++len){
				for(int d=0;d<4;++d){
					int k=nex[j][d];
					if(ml[k]>len) add(f[i+1][k][0],f[i][j][len]);
					else add(f[i+1][k][len+1],f[i][j][len]);
				}
			}
		}
	}
	int ans=0;
	for(int i=1;i<=cnt;++i) add(ans,f[n][i][0]);
	printf("%d",ans);
	return 0;
}

Luogu P2292 [HNOI2004] L 语言

给定大小为 \(n\) 的字典 \(D\)\(m\) 个文本串 \(T_1,T_2,\cdots,T_m\),对于每个文本串,求出最大的 \(i\),使得 \(T[1,i]\) 可以被划分为若干个字典中的单词。

\(1 \leq n \leq 20,1 \leq m \leq 50,1 \leq |t| \leq 2\times 10^6\),字典中的单词长度不超过 \(20\)

对于文本串 \(T\),考虑暴力 DP:设 \(f(i)\) 表示让 \(T[1,i]\) 能够被划分是否可行。转移需要枚举字典中的单词判断合法性。

考虑如何快速找出合法单词。称 fail 树上代表 \(T_i\) 的那些节点为终止节点,设 \(T[1,i]\) 在 AC 自动机上匹配后位于节点 \(p\),则 fail 树上节点 \(p\) 到根的路径中,所有的终止节点都是合法的转移串。

进一步地,我们只需要这些串的长度,因此可以进行状态压缩。

但是这道题 \(n \leq 20\) 真是有深意呀,总感觉暴力都过去了。

# include <bits/stdc++.h>

const int N=100010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

struct Node{
	int fail,nex[26],st,dep;
	bool ed;
}tr[N];

char s[2000010];
int n,m;
int cnt=1;

inline void ins(void){
	int len=strlen(s+1),cur=1;
	for(int i=1;i<=len;++i){
		int w=s[i]-'a';
		if(!tr[cur].nex[w]) tr[cur].nex[w]=++cnt,tr[cnt].dep=tr[cur].dep+1;
		cur=tr[cur].nex[w];
	}
	tr[cur].ed=true;
	return;
}
inline void init(void){
	for(int i=0;i<26;++i) tr[0].nex[i]=1;
	std::queue <int> q; q.push(1);
	while(!q.empty()){
		int i=q.front();
		q.pop();
		tr[i].st=tr[tr[i].fail].st|((int)tr[i].ed<<tr[i].dep);
		for(int j=0;j<26;++j){
			int &nex=tr[i].nex[j];
			if(!nex) nex=tr[tr[i].fail].nex[j];
			else tr[nex].fail=tr[tr[i].fail].nex[j],q.push(nex);
		}
	}
	return;
}

inline int query(void){
	int len=strlen(s+1),ans=0,cur=1;
	unsigned st=1;
	for(int i=1;i<=len;++i){
		cur=tr[cur].nex[s[i]-'a'];
		st<<=1;
		if(tr[cur].st&st) st|=1,ans=i;
	}
	return ans;
}

int main(void){
	n=read(),m=read();
	for(int i=1;i<=n;++i) scanf("%s",s+1),ins();
	init();
	for(int i=1;i<=m;++i) scanf("%s",s+1),printf("%d\n",query());
	return 0;
}

Luogu P2414 [NOI2011] 阿狸的打字机

给定一棵 Trie 树,\(m\) 次询问 Trie 树上某个节点 \(x\) 所代表的字符串在另一个节点 \(y\) 所代表的字符串中出现了多少次。

字符集大小为 \(26\),保证 Trie 树大小不超过 \(10^5\)\(1 \leq m \leq 10^5\)

首先建出 AC 自动机。

考虑询问只有一次怎么做。将 \(y\) 的每个前缀节点打上标记,则根据 fail 指针的定义,只需要查询 \(x\) 在 fail 树上的子树和。

存在多次询问时做法区别不大。考虑采用 DFS Trie 树的方式遍历所有的 \(y\),进入节点时给该节点打上标记,离开节点时撤销。将关于 \((x,y)\) 的询问挂在节点 \(y\) 上,遍历到 \(y\) 时查询。子树和使用树状数组维护,时间复杂度 \(O(n \Sigma + (n+m) \log n)\),其中 \(n\) 为 Trie 树大小。

# include <bits/stdc++.h>

const int N=100010,INF=0x3f3f3f3f;

struct Node{
	int fnex[26],nex[26];
	int fail;
	int fa;
}trie[N];
struct Edge{
	int to,next;
}edge[N<<1];
std::vector <std::pair <int,int> > qu[N];
int head[N],esum;
int n,m,cnt=1;
char op[N];
int endpos[N],dfn[N],t,size[N],sum[N],ans[N];
inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-')f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}
inline void add(int x,int y){
	edge[++esum]=(Edge){y,head[x]},head[x]=esum;
	return;
}
inline void GetFail(void){
	std::queue <int> q=std::queue <int> ();
	for(int i=0;i<26;++i){
		int to=trie[1].fnex[i];
		if(to)
			q.push(to),trie[to].fail=1;
		else
			trie[1].fnex[i]=1;
	}
	while(!q.empty()){
		int i=q.front();
		q.pop();
		add(trie[i].fail,i);
		for(int j=0;j<26;++j){
			int &to=trie[i].fnex[j];
			if(to)
				trie[to].fail=trie[trie[i].fail].fnex[j],q.push(to);
			else
				to=trie[trie[i].fail].fnex[j];
		}
	}
	return;
}
void dfs(int i){
	dfn[i]=++t,size[i]=1;
	for(int j=head[i],to;j;j=edge[j].next)
		to=edge[j].to,dfs(to),size[i]+=size[to];
	return;
}
inline int lowbit(int x){
	return x&(-x);
}
inline void addv(int x,int v){
	for(;x<=cnt;x+=lowbit(x))
		sum[x]+=v;
	return;
}
inline int query(int l,int r){
	int res=0;
	--l;
	for(;l;l-=lowbit(l))
		res-=sum[l];
	for(;r;r-=lowbit(r))
		res+=sum[r];
	return res;
}
void solve(int i){
	addv(dfn[i],1);
	for(int k=0,x;k<(int)qu[i].size();++k)
		x=qu[i][k].first,ans[qu[i][k].second]=query(dfn[x],dfn[x]+size[x]-1);
	for(int j=0;j<26;++j)
		if(trie[i].nex[j])
			solve(trie[i].nex[j]);
	addv(dfn[i],-1);
	return;
}
int main(void){
	scanf("%s",op+1);
	int p=1,oplen=strlen(op+1);
	trie[1].fa=1;
	for(int i=1;i<=oplen;++i){
		if(op[i]=='B')
			p=trie[p].fa;
		else if(op[i]=='P')
			endpos[++n]=p;
		else{
			int &to=trie[p].nex[op[i]-'a'];
			if(!to)
				to=++cnt,trie[to].fa=p;
			p=to;
		}
	}
	for(int i=1;i<=cnt;++i)
		for(int j=0;j<26;++j)
			trie[i].fnex[j]=trie[i].nex[j];
	GetFail();
	dfs(1);
	m=read();
	for(int i=1,x,y;i<=m;++i)
		x=read(),y=read(),qu[endpos[y]].push_back(std::make_pair(endpos[x],i));
	solve(1);
	for(int i=1;i<=m;++i)
		printf("%d\n",ans[i]);
	return 0;
}

Codeforces 547E Mike and Friends

出现次数可以差分,转化为 \(s_k\)\(s_{1\cdots r}\) 中的出现次数。考虑对 \(r\) 扫描线,对于 \(s_r\) 的每个前缀节点,将该节点的标记大小增加 \(1\),查询即查询 \(s_k\) 对应节点在 fail 树上的子树和。

# include <bits/stdc++.h>

const int N=500010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,q;
char s[N];
int edp[N];

namespace acam{
	int nex[N][26];
	int fail[N],cnt=1,fa[N];
	
	inline void ins(int x){
		int len=strlen(s+1),cur=1;
		for(int i=1;i<=len;++i){
			int &ne=nex[cur][s[i]-'a'];
			if(!ne) ne=++cnt,fa[cnt]=cur;
			cur=ne;
		}
		edp[x]=cur;
		return;
	}
	std::vector <int> G[N];
	int dfn[N],t,siz[N];
	void dfs(int x){
		dfn[x]=++t,siz[x]=1;
		for(auto y:G[x]) dfs(y),siz[x]+=siz[y];
		return;
	}	
	inline void init(void){
		for(int i=0;i<26;++i) nex[0][i]=1;
		std::queue <int> q;q.push(1);
		while(!q.empty()){
			int i=q.front();
			q.pop();
			for(int j=0;j<26;++j){
				if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
				else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
		dfs(1);
		return;
	}
}
using namespace acam;
int sum[N];
inline int lb(int x){
	return x&(-x);
}
inline void add(int x,int v){
	for(;x<=cnt;x+=lb(x)) sum[x]+=v;
	return;
}
inline int query(int x){
	int ans=0;
	for(;x;x-=lb(x)) ans+=sum[x];
	return ans;
}
inline int query(int l,int r){
	return query(r)-query(l-1);
}

std::vector <std::pair <int,int> > qr[N];
int ans[N];

int main(void){
	n=read(),q=read();
	for(int i=1;i<=n;++i) scanf("%s",s+1),ins(i);
	init();
	
	for(int i=1;i<=q;++i){
		int l=read(),r=read(),k=read();
		qr[r].push_back(std::make_pair(k,i));
		qr[l-1].push_back(std::make_pair(k,-i));
	}
	for(int i=1;i<=n;++i){
		for(int j=edp[i];j;j=fa[j]) add(dfn[j],1);
		for(auto qq:qr[i]){
			int x=edp[qq.first];
			ans[abs(qq.second)]+=(qq.second>0?1:-1)*query(dfn[x],dfn[x]+siz[x]-1);
		}
	}
	for(int i=1;i<=q;++i) printf("%d\n",ans[i]);

	return 0;
}

Luogu P5840 [COCI2015] Divljak

考虑对 \(S\) 建出 ACAM。则加入一个字符串 \(T\) 的贡献形如:找到 \(T\) 的每个前缀在 ACAM 上匹配后位于的节点 ,将这些节点到根路径并中的每一个点的标记大小增加 \(1\)

这是经典问题。将这些节点按照 DFS 序排序,采用如下方式表示贡献:

  • 将每个节点到根的路径上每一个点的标记大小增加 \(1\)
  • 将相邻节点的 LCA 到根的路径上每一个点的标记大小减小 \(1\)

需要支持链加单点查询。转换为单点加子树查询,树状数组维护即可。

# include <bits/stdc++.h>

const int N=2000010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,q;
char s[N];
int edp[N];

namespace acam{
	int nex[N][26];
	int fail[N],cnt=1,fa[N];
	
	inline void ins(int x){
		int len=strlen(s+1),cur=1;
		for(int i=1;i<=len;++i){
			int &ne=nex[cur][s[i]-'a'];
			if(!ne) ne=++cnt,fa[cnt]=cur;
			cur=ne;
		}
		edp[x]=cur;
		return;
	}
	std::vector <int> G[N];
	int dfn[N],t,siz[N];
	int f[N][20],dep[N];
	
	void dfs(int x,int fa){
		dep[x]=dep[fa]+1,dfn[x]=++t,siz[x]=1,f[x][0]=fa;
		for(int k=1;k<=19;++k) f[x][k]=f[f[x][k-1]][k-1];
		for(auto y:G[x]) dfs(y,x),siz[x]+=siz[y];
		return;
	}
	inline int lca(int u,int v){
		if(dep[u]<dep[v]) std::swap(u,v);
		for(int k=19;k>=0;--k) if(dep[f[u][k]]>=dep[v]) u=f[u][k];
		if(u==v) return u;
		for(int k=19;k>=0;--k) if(f[u][k]!=f[v][k]) u=f[u][k],v=f[v][k];
		return f[u][0];
	}
	inline void init(void){
		for(int i=0;i<26;++i) nex[0][i]=1;
		std::queue <int> q;q.push(1);
		while(!q.empty()){
			int i=q.front();
			q.pop();
			for(int j=0;j<26;++j){
				if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
				else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
		dfs(1,0);
		return;
	}
}
using namespace acam;
int sum[N];
inline int lb(int x){
	return x&(-x);
}
inline void add(int x,int v){
	for(;x<=cnt;x+=lb(x)) sum[x]+=v;
	return;
}
inline int query(int x){
	int ans=0;
	for(;x;x-=lb(x)) ans+=sum[x];
	return ans;
}
inline int query(int l,int r){
	return query(r)-query(l-1);
}

inline bool cmp(int u,int v){
	return dfn[u]<dfn[v];
}
int d[N];

int main(void){
	n=read();
	for(int i=1;i<=n;++i) scanf("%s",s+1),ins(i);
	init();
	
	q=read();
	
	for(int i=1,idx;i<=q;++i){
		int op=read();
		if(op==1){
			scanf("%s",s+1);
			int len=strlen(s+1),cur=1;
			for(int j=1;j<=len;++j){
				cur=nex[cur][s[j]-'a'],d[j]=cur;
			}
			std::sort(d+1,d+1+len,cmp);
			for(int j=1;j<=len;++j){
				add(dfn[d[j]],1);
				if(j>1) add(dfn[lca(d[j],d[j-1])],-1);
			}
		}else idx=edp[read()],printf("%d\n",query(dfn[idx],dfn[idx]+siz[idx]-1));	
	}
	return 0;
}

Codeforces 710F String Set Queries

首先,这里有一个幽默的 Hash,记串长和为 \(L\),不难发现串长种类数为 \(\sqrt L\),据此可以 \(O(L\sqrt L)\) Hash。

AC 自动机的部分有一种奇怪的二进制分组法,暂时没有想明白它的本质是什么。具体方法如下:考虑到出现次数可减,因此我们不在 AC 自动机中删除,而是分别维护被加入串的 AC 自动机和被删除串的 AC 自动机。

对于某一个集合而言,加入一个串会导致整个 AC 自动机的形态发生变化,需要在 Trie 树基础上重新跑 init 函数。考虑二进制分组,设当前集合中有 \(c\) 个串,则把这个集合分为若干组,组的大小为 \(c\) 的二进制拆分。例如,\(c = 23 = 16+4+2+1\),则把集合分为大小为 \(16,4,2,1\) 的三组,组内维护一个 AC 自动机。考虑加入第 \(c+1\) 个串,首先新开一组,在这一组内的 AC 自动机中插入该串。接着检查,如果之前的组大小和最后一组相等,则合并这两个组(模拟二进制加法的过程)。

合并两个组的代价是两个组内字符串的串长之和再乘上 \(|\Sigma|\)。那么对于每一个串,它被合并一次,所处的集合大小翻倍,因此只会被合并不超过 \(\log_2 m\) 次。从而总复杂度为 \(O(L|\Sigma|\log_2m)\)

# include <bits/stdc++.h>

const int N=300010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

char S[N];

struct acam{
	int nex[N][26],cnt,fail[N];
	int nnex[N][26];
	int ed[N],ss[N];
	int siz[30],bcnt;
	int root[30];
	inline void ins(char *s,int len,int &crt){
		if(!crt) crt=++cnt;
		int cur=crt;
		for(int i=1;i<=len;++i){
			int &ne=nex[cur][s[i]-'a'];
			if(!ne) ne=++cnt;
			cur=ne;
		}
		++ed[cur];
		return;
	}
	inline void merge(int &cur,int u,int v){
		if(!u||!v) return cur=u|v,void();
		cur=u,ed[cur]+=ed[v];
		for(int j=0;j<26;++j) merge(nex[cur][j],nex[u][j],nex[v][j]);
		return;
	}
	inline void init(int rt){
		std::queue <int> q;
		fail[rt]=0,q.push(rt);
		for(int i=0;i<26;++i) nnex[0][i]=rt;
		
		while(!q.empty()){
			int i=q.front();
			q.pop();
			ss[i]=ed[i]+ss[fail[i]];
			for(int j=0;j<26;++j){
				if(!nex[i][j]) nnex[i][j]=nnex[fail[i]][j];
				else nnex[i][j]=nex[i][j],fail[nex[i][j]]=nnex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		return;
	}
	inline long long query(char *s,int len,int cur){
		long long ans=0;
		for(int i=1;i<=len;++i) cur=nnex[cur][s[i]-'a'],ans+=ss[cur];
		return ans;
	}
	inline long long query_all(char *s,int len){
		long long ans=0;
		for(int i=1;i<=bcnt;++i) ans+=query(s,len,root[i]);
		return ans;
	}
	inline void real_ins(char *s,int len){
		siz[++bcnt]=1,root[bcnt]=0;
		ins(s,len,root[bcnt]),init(root[bcnt]);
		while(siz[bcnt]==siz[bcnt-1]){
			siz[bcnt-1]*=2,merge(root[bcnt-1],root[bcnt-1],root[bcnt]),--bcnt,init(root[bcnt]);
		}
		return;
	}
}A,B;
int n;

int main(void){
	n=read();
	for(int i=1;i<=n;++i){
		int op=read();
		scanf("%s",S+1);
		int len=strlen(S+1);
		if(op==1) A.real_ins(S,len);
		else if(op==2) B.real_ins(S,len);
		else printf("%lld\n",A.query_all(S,len)-B.query_all(S,len));
		fflush(stdout);
	}
	return 0;
}

Codeforces 587F Duff is Mad

547E 的对偶形式,但是很遗憾这道题没有 polylog 做法。

其原因是,本题的操作形如:将 \(s_l,s_{l+1}.\cdots,s_r\) 对应节点在 fail 树上的子树 \(+1\),然后查询 \(s_k\) 的所有前缀节点的权值之和。和 547E 不同,我们现在需要在文本串一侧处理前缀节点,这样就没法快速维护了。

这种情况的主流方法是阈值分治。具体来说,设定阈值 \(B\),分情况讨论:

  • 如果 \(|s_k| > B\)

    这样的串只会有不超过 \(\dfrac{L}{B}\) 个,其中 \(L\) 是串长和。考虑线性求解所有有关 \(s_k\) 的询问的答案,具体地,将 \(s_k\) 的所有前缀节点权值 \(+1\),那么一个 \(s_x\) 的贡献即 \(s_x\) 对应节点的子树和。子树和可以通过 DFS fail 树线性求出。差分后对 \(r\) 扫描线统计答案。

    这部分的复杂度是 \(O(\dfrac{L^2}{B} + q \log q)\) 的。

  • 如果 \(|s_k| \leq B\)

    这一部分,对于每个串,我们希望在 \(O(|s_k|)\) 左右求解。具体地,差分后对 \(r\) 扫描线统计答案,将询问挂在对应端点,每扫过一个字符串,就将该字符串对应的节点在 fail 树上的子树 \(+1\)(这部分使用树状数组),询问时暴力枚举前缀节点,使用树状数组。这部分的复杂度是 \(O(qB\log L+n \log L)\)

\(\dfrac{L^2}{B} = qB \log L\),解得最优阈值为 \(B = \dfrac{L}{\sqrt{q \log L}}\),此时时间复杂度为 \(O(n \log L + q \log q + \sqrt{q \log L}\cdot L)\)

# include <bits/stdc++.h>
# define fir first
# define sec second

const int N=100010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,q;
char s[N];
int edp[N];

namespace acam{
	int nex[N][26];
	int fail[N],cnt=1,fa[N];
	
	inline void ins(int x){
		int len=strlen(s+1),cur=1;
		for(int i=1;i<=len;++i){
			int &ne=nex[cur][s[i]-'a'];
			if(!ne) ne=++cnt,fa[cnt]=cur;
			cur=ne;
		}
		edp[x]=cur;
		return;
	}
	std::vector <int> G[N];
	int dfn[N],t,siz[N];
	
	void dfs(int x){
		dfn[x]=++t,siz[x]=1;
		for(auto y:G[x]) dfs(y),siz[x]+=siz[y];
		return;
	}
	inline void init(void){
		for(int i=0;i<26;++i) nex[0][i]=1;
		std::queue <int> q;q.push(1);
		while(!q.empty()){
			int i=q.front();
			q.pop();
			for(int j=0;j<26;++j){
				if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
				else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
		dfs(1);
		return;
	}
}
using namespace acam;

int m,T;
int len[N];

long long ans[N];
std::vector <std::pair <int,int> > Qs[N],Qh[N];

int sum[N],tag[N];

inline int lb(int x){
	return x&(-x);
}
inline void add(int x,int v){
	for(;x<=cnt;x+=lb(x)) sum[x]+=v;
	return;
}
inline int query(int x){
	int ans=0;
	for(;x;x-=lb(x)) ans+=sum[x];
	return ans;
}
inline int query(int l,int r){
	return query(r)-query(l-1);
}

void dfs_h(int x){
	for(auto y:G[x]) dfs_h(y),tag[x]+=tag[y];
	return;
}
int sig(int x){
	return (x>0)-(x<0);
}

int main(void){
	n=read(),q=read();
	for(int i=1;i<=n;++i){
		scanf("%s",s+1),ins(i),m+=(len[i]=strlen(s+1));
	}
	init();
	
	T=ceil(m/sqrt(q*log2(m)));
	
	for(int i=1;i<=q;++i){
		int l=read(),r=read(),k=read();
		if(len[k]>T) Qh[k].emplace_back(l-1,-i),Qh[k].emplace_back(r,i);
		else Qs[l-1].emplace_back(k,-i),Qs[r].emplace_back(k,i);
	}
	
	for(int i=1;i<=n;++i){
		if(len[i]>T){
			std::fill(tag+1,tag+1+cnt,0);
			for(int j=edp[i];j;j=fa[j]) tag[j]=1;
			dfs_h(1);
			std::sort(Qh[i].begin(),Qh[i].end());
			long long tsum=0;
			int pt=0;
			
			while(pt<(int)Qh[i].size()&&Qh[i][pt].fir==0) ++pt;
			
			for(int j=1;j<=n;++j){
				tsum+=tag[edp[j]];
				while(pt<(int)Qh[i].size()&&Qh[i][pt].fir==j){
					int id=abs(Qh[i][pt].sec),v=sig(Qh[i][pt].sec);
					ans[id]+=v*tsum;
					++pt;
				}
			}	
		}	
	}
	
	for(int i=1;i<=n;++i){
		add(dfn[edp[i]],1),add(dfn[edp[i]]+siz[edp[i]],-1);
		for(auto qa:Qs[i]){
			int x=qa.fir,id=abs(qa.sec),v=sig(qa.sec);
			for(int j=edp[x];j!=1;j=fa[j]) ans[id]+=v*query(dfn[j]);
		}
	}
	
	for(int i=1;i<=q;++i) printf("%lld\n",ans[i]);
	
	return 0;
}

Luogu P8203 [传智杯 #4 决赛] DDOSvoid 的馈赠

对于一个询问 \(t_x,t_y\),将 \(t_x\) 的每个前缀节点在 fail 树上到根的路径上打上标记 \(x\),将 \(t_y\) 的每个前缀节点在 fail 树上到根的路径打上标记 \(y\)。另外,将 \(s_i\) 对应的节点的权值 \(+1\)。所求即为同时有标记 \(x,y\) 的节点的权值之和。可以看作求两个虚树的交。

直接做仍然是不太好做的。因此我们同样考虑阈值分治。称长度大于阈值 \(B\) 的为大串,否则为小串。

考虑 \(t_x,t_y\) 至少有一个是大串的询问。不失一般性,设 \(t_x\) 是大串。将 \(t_x\) 的每个前缀节点在 fail 树上到根的路径上打上标记 \(x\),将具有标记 \(x\)\(s_i\) 对应的节点的权值 \(+1\)。对于每个 \(t_y\),询问时建立虚树,统计答案即可。这部分的复杂度为 \(O(\frac{L^2}{B} + L \log L)\)(就算 \(t_x\) 不同,我们也只会改变树上权值而非形态,因此对于一个串,虚树只需要建立一次,从而分析出 \(O(L \log L)\))。

接下来考虑 \(t_x,t_y\) 均为小串的询问。事先将 \(s_i\) 对应的节点权值 \(+1\),询问时建出 \(t_x,t_y\) 虚树的交回答询问即可。瓶颈在于建出虚树交的复杂度。若每次询问时抽取 \(t_x,t_y\) 所有前缀节点,暴力建出虚树,单次询问的复杂度为 \(O(B \log B)\)

若要去掉复杂度中的 \(O(\log B)\),可考虑以下方法:事先建出所有 \(t_x\) 所有前缀节点的虚树,并将节点按照 DFS 序排序。询问时拉出 \(t_x,t_y\) 的虚树节点序列并归并。具体地,对 \(t_y\) 虚树节点序列中的每个节点 \(r\),找到其在 \(t_x\) 序列中的前驱后继 \(p,s\)。则 \(\text{lca}(r,p),\text{lca}(r,s)\) 中深度较大者即为 \(t_y\) 的祖先中最深的位于 \(t_x\) 虚树上的节点。将该节点加入初始为空的集合 \(S\),则集合 \(S\) 的虚树即为两棵虚树的交。使用查询 \(O(1)\) 的 LCA 算法则单次复杂度为 \(O(B)\),从而这部分的总复杂度为 \(O(L \log L + qB)\)

此时不难发现 \(B = \sqrt L\) 时取得最优复杂度 \(O((L+q) \sqrt L )\)

Codeforces 1483F Exam

考虑枚举 \(s_i\),那么对于 \(s_i\) 的每个前缀 \(s_i[1,l]\),只有它的最长的作为某个 \(s_j\) 出现的后缀可能成为答案,我们称这个 \(s_j\) 为备选答案。这可以 AC 自动机预处理得出。我们枚举 \(l\),记录下每个 \(s_j\) 作为备选答案的次数,并记录下每个 \(s_j\) 的出现位置 \([st,ed]\)。该过程中,可能会出现某个区间被另一个区间包含的情况。如果这种情况发生,我们将不再认为被包含的区间作为备选答案出现了一次。

现在,枚举每个至少作为备选答案一次的 \(s_j\)。不难发现,当且仅当 \(s_j\) 作为备选答案出现的次数恰好等于其在 \(s_i\) 中的出现次数时,\((i,j)\) 是一对合法答案。欲求出现次数,只需要使用树状数组即可。

时间复杂度 \(O(L \log L)\)

# include <bits/stdc++.h>
# define fir first
# define sec second

const int N=2000010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

int n,q;
std::string S[N];

int edp[N],len[N];

namespace acam{
	int nex[N][26],fa[N];
	int fail[N],cnt=1;
	int mx[N];
	
	inline void ins(int x){
		int len=S[x].size(),cur=1;
		::len[x]=len;
		for(int i=1;i<=len;++i){
			int &ne=nex[cur][S[x][i-1]-'a'];
			if(!ne) ne=++cnt,fa[cnt]=cur;
			cur=ne;
		}
		if(::len[mx[cur]]<::len[x]) mx[cur]=x;
		edp[x]=cur;
		return;
	}
	std::vector <int> G[N];
	int dfn[N],t,siz[N];
	
	void dfs(int x){
		dfn[x]=++t,siz[x]=1;
		for(auto y:G[x]) dfs(y),siz[x]+=siz[y];
		return;
	}
	inline void init(void){
		for(int i=0;i<26;++i) nex[0][i]=1;
		std::queue <int> q;q.push(1);
		while(!q.empty()){
			int i=q.front();
			q.pop(),mx[i]=(!mx[i])?mx[fail[i]]:mx[i];
			for(int j=0;j<26;++j){
				if(!nex[i][j]) nex[i][j]=nex[fail[i]][j];
				else fail[nex[i][j]]=nex[fail[i]][j],q.push(nex[i][j]);
			}
		}
		for(int i=2;i<=cnt;++i) G[fail[i]].push_back(i);
		dfs(1);
		return;
	}
}
using namespace acam;

int sum[N];
inline int lb(int x){
	return x&(-x);
}
inline void add(int x,int v){
	for(;x<=cnt;x+=lb(x)) sum[x]+=v;
	return;
}
inline int query(int x){
	int ans=0;
	for(;x;x-=lb(x)) ans+=sum[x];
	return ans;
}
inline int query(int l,int r){
	return query(r)-query(l-1);
}

int L[N],na[N],qc;

int buc[N];
std::unordered_map <int,int> mp;

int main(void){
	n=read();
	for(int i=1;i<=n;++i){
		std::cin>>S[i];
		ins(i);
	}
	init();
	int ans=0;
	for(int i=1;i<=n;++i){
		for(int j=edp[i];j!=1;j=fa[j]) add(dfn[j],1);	
		qc=0,mp.clear();
		for(int j=1,cur=1;j<=len[i];++j){
			cur=nex[cur][S[i][j-1]-'a'];
			if(j==len[i]) cur=fail[cur];
			if(mx[cur]) L[++qc]=j-len[mx[cur]]+1,na[qc]=mx[cur];
		}
		for(int j=qc,lim=n+1;j;--j){
			if(L[j]>=lim) continue;
			else lim=L[j],++mp[na[j]];
		}		
		for(auto ele:mp){
			int id=ele.fir,occ=ele.sec;
			if(query(dfn[edp[id]],dfn[edp[id]]+siz[edp[id]]-1)==occ) ++ans;
		}
		for(int j=edp[i];j!=1;j=fa[j]) add(dfn[j],-1);
	}
	
	printf("%d",ans);
	
	return 0;
}

QOJ5034 >.<

本题要求在不经过给定路径作为子路径情况下的最短路。考虑使用 AC 自动机来维护这一限制。具体地,设 \(f(j)\) 表示从原图上点 \(1\) 的对应点开始走到 AC 自动机 \(j\) 点的最短路,并强制要求不经过终止节点。但是本题中,字符集大小为 \(n\),朴素的 AC 自动机难以通过。考虑性质:\(tr(i)\) 比起 \(tr(\text{fail}(i))\),只有在 \(i\) 节点出边的部分会有修改。

因此考虑主席树优化建图。具体地,将所有合法路径(即可以在原图上实际走出来的路径)和 \(1 \sim n\)\(n\) 个单点插入 AC 自动机,并将 \(1 \sim n\)\(n\) 个单点在 AC 自动机上对应的节点标号为 \(1 \sim n\)

那么要计算 \(tr(i)\) 时,只需要在 \(tr(\text{fail}(i))\) 的基础上修改 \(tr(i)\) 转移到的点即可,最后一起跑一遍 Dijkstra。

如果 \(n,m,k\) 同阶,那么复杂度为 \(O(n \log^2 n)\),因为点数、边数均为 \(O(n \log n)\)。但事实上,我们可以说明,精细实现的复杂度是 \(O(n \log n)\) 的:对于主席树上的虚点,我们连的都是边权为 \(0\) 的虚边。从而,一旦一个点的最短路固定下来了,其子树内所有虚点的最短路都会固定下来。因此这些虚点和虚边只会贡献 \(O(1)\) 次入队次数,且因为边权为 \(0\),入队时必然位于堆顶,不贡献堆的复杂度。而对于剩下的部分,显然只剩下 \(O(n)\) 个实点和 \(O(n)\) 条实边,因此这一部分复杂度为 \(O(n \log n)\)

本题不需要在 fail 树上搞花活,所以代码里面根节点被直接省略掉了。代码中仍采用 \(O(n \log ^2 n)\) 的实现。

# include <bits/stdc++.h>
# define fir first
# define sec second

const int N=200010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}
std::unordered_map <int,int> id[N];
std::vector <std::pair <int,int> > G[N];

int n,m,k;
int lim;
int ch[N];

struct Edge{
	int to,next,v;
}edge[N*40];
int head[N*20],esum;
inline void add(int x,int y,int v){
	edge[++esum]=(Edge){y,head[x],v},head[x]=esum;
	return;
}

namespace acam{
	std::unordered_map <int,int> tr[N];
	int fail[N];
	bool ed[N];
	int cnt;
	inline void ins(std::vector <int> &pa){
		int sz=pa.size(),cur=pa[0];
		for(int i=1;i<sz;++i){
			if(!tr[cur].count(pa[i])) tr[cur][pa[i]]=++cnt;
			cur=tr[cur][pa[i]];
		}
		ed[cur]=true;
		return;
	}
}

using namespace acam;

int col[N];
int rt[N];

namespace prit{
	struct Node{
		int lc,rc,idx;
	}tr[N*20];
	inline int& lc(int x){
		return tr[x].lc;
	}
	inline int& rc(int x){
		return tr[x].rc;
	}
	void build(int id,int &k,int l,int r){
		if(l>r) return;
		k=++cnt;
		if(l==r) return add(k,tr[k].idx=G[id][l].fir,G[id][l].sec),assert(G[id][l].fir),void();
		int mid=(l+r)>>1;
		build(id,lc(k),l,mid),build(id,rc(k),mid+1,r);
		add(k,lc(k),0),add(k,rc(k),0);
		return;
	}
	int modify(int &k,int lst,int l,int r,int x,int nto,int nw){
		k=++cnt,tr[k]=tr[lst];
		assert(lst);
		if(l==r) return add(k,tr[k].idx=nto,nw),assert(tr[lst].idx),tr[lst].idx;
		int mid=(l+r)>>1,res=0;
		if(x<=mid) res=modify(lc(k),lc(lst),l,mid,x,nto,nw);
		else res=modify(rc(k),rc(lst),mid+1,r,x,nto,nw);
		add(k,lc(k),0),add(k,rc(k),0);
		return res;
	}
}

typedef long long ll;
ll dis[N*20];
bool vis[N*20];

struct Heapval{
	int id;
	ll w;
	bool operator < (const Heapval &rhs) const{
		return w>rhs.w;
	}
};
std::priority_queue <Heapval> hp;

inline ll dijkstra(void){
	memset(dis,INF,sizeof(dis));
	dis[1]=0,hp.push((Heapval){1,0});
	while(!hp.empty()){	
		int i=hp.top().id;
		hp.pop();
		if(!vis[i]) vis[i]=true;
		else continue;		
		for(int j=head[i];j;j=edge[j].next){
			int to=edge[j].to;
			if(dis[to]>dis[i]+edge[j].v&&!(to<=lim&&ed[to]))
				dis[to]=dis[i]+edge[j].v,hp.push((Heapval){to,dis[to]});
		}
	}
	
	ll ans=dis[0];
	
	for(int i=1;i<=cnt;++i) if(col[i]==n&&!ed[i]) ans=std::min(ans,dis[i]);
	
	return (ans==dis[0])?-1:ans;
}

int main(void){
	n=read(),m=read(),k=read();
	for(int i=1;i<=m;++i){
		int u=read(),v=read(),w=read();
		id[u][v]=G[u].size(),G[u].emplace_back(v,w);
	}
	cnt=n;
	
	for(int i=1,sz;i<=k;++i){
		std::vector <int> pa;
		pa.resize(sz=read());
		for(auto &v:pa) v=read();
		for(int i=1;i<sz;++i) if(!id[pa[i-1]].count(pa[i])) goto FAILED;
		acam::ins(pa);
		FAILED:;
	}
	lim=cnt;
	std::queue <int> q;
	for(int i=1;i<=n;++i) q.push(col[i]=i),prit::build(i,rt[i],0,G[i].size()-1);
	
	while(!q.empty()){
		int i=q.front();
		q.pop(),ed[i]|=ed[fail[i]];
		if(fail[i]) rt[i]=rt[fail[i]];
		
		for(auto eg:tr[i]){
			int v=eg.fir,j=eg.sec,id=::id[col[i]][v];			
			fail[j]=prit::modify(rt[i],rt[i],0,G[col[i]].size()-1,id,j,G[col[i]][id].sec);
			q.push(j),col[j]=v;
		}		
		if(rt[i]) add(i,rt[i],0);
	}
	
	printf("%lld",dijkstra());

	return 0;
}

Luogu P8571 [JRKSJ R6] Dedicatus545

仍然不好做,考虑阈值分治。设定阈值 \(B\)

对于 \(s_k\) 是大串的情形,\(O(L+q \log n)\) 解决一个串是容易的。

对于 \(s_k\) 是小串的情形,考虑对 \(r\) 扫描线。从左到右扫描 \(r\),并给 \(s_r\) 的终止节点打上标记 \(r\)。扫到 \(r\) 时处理形如 \((l,r,k)\) 的询问。考虑某个 \(s_k\) 的前缀节点的贡献。若该节点到根的路径上,有大于等于 \(l\) 的标记时,该节点对答案有贡献,贡献大小为该节点在 fail 树上的子树和(此时 fail 树上仅 \(s_k\) 的前缀节点点权为 \(1\))。

建虚树查询即可。同样,我们可以把单点修改链查询变为区间 chkmax 单点查询。对于每个串,虚树只需建立一次,因此复杂度是均摊 \(O(L \log L)\) 的。取阈值为 \(\sqrt L\),总复杂度为 \(O(L \sqrt L + q \log n+ L \log L)\)

Luogu P5599 【XR-4】文本编辑器

单词的长度很小,这是无论如何都想利用上的性质。记字典中最长单词长度为 \(d\)。首先对字典建出 ACAM,并维护出每个状态 \(i\) 的对应串有多少个后缀出现在了字典中,记作 \(cnt(i)\)

先不考虑修改。如果我们能够维护出 \(s[1,i]\) 在 ACAM 中匹配得到的状态 \(sta(i)\),那么我们是可以对付查询的。这是因为 \(d\) 很小,所以对于询问 \([l,r]\) 而言,当 \(i\) 远大于 \(l\) 时,位置 \(i\) 对于答案的贡献就是 \(cnt(sta(i))\)。精细分析可知,当 \(i \in [l+d-1,r]\) 时,我们就可以认为 \(i\) 远大于 \(l\) 了。

\(i \in [l,l+d-2]\) 时,\(cnt(sta(i))\) 中的串不一定全部合法。因此,我们应当遍历 \(sta(i)\) 在 fail 树上的祖先,找到第一个节点长度小于等于 \(l-i+1\) 的祖先 \(f\),并取 \(cnt(f)\) 作为贡献。注意这一步的复杂度不应当变为 \(O(d^2)\),因此我们要对于每个节点维护 \(par(x,h)\),表示 fail 树上 \(x\) 的第一个节点长度不超过 \(h\) 的祖先(可以是它自己),这样在枚举 \(i\) 的时候可以一步定位到 \(f\)。因此,单次询问的复杂度为 \(O(d)\)

现在考虑修改。当 \(i\) 远大于 \(l\) 的时候,不难发现修改会使得 \(sta(i)=sta(i+|t|)\)。因为 \(s[l,r]\) 会变为 \(t\) 的若干次循环,因此这是容易证明的(无论 \(sta(i)\) 的长度更长,还是 \(sta(i+|t|)\) 的长度更长,都是不合理的)。

和查询类似地,我们可以发现,当 \(i \in [l+d-1,r]\) 时,我们认为 \(i\) 远大于 \(l\)。因此,这部分修改可以使用以下方法完成:

  • \(sta(l-1)\) 开始,当 \(l \leq i \leq l+d+t-1\) 时,暴力匹配出 \(sta(i)\)
  • 取出 \(sta[l+d-1,l+d+t-2]\),将这一段向后复制,直到 \(sta(r)\) 为止。
  • \(sta(r)\) 开始,当 \(r+1 \leq i \leq r+d-2\) 时,暴力匹配出 \(sta(i)\)

因此,我们只需要一个支持区间循环覆盖的线段树即可。时间复杂度 \(O(|\Sigma|\sum S + \sum T+q \log n)\)

SCOI2024 Day1 T1 (口胡)

考虑分类讨论。存在两种情况:\(s_i+t_j\) 完整地在某个 \(s_x\) 或者 \(t_x\) 中出现,以及 \(s_x+t_y\) 的分界线将 \(s_i+t_j\) 分成了两部分。

对于第一种情况,以完整出现在某个 \(s_x\) 中为例。若 \(s_i+t_j\) 完整地在某个 \(s_x\) 出现,考虑枚举 \(s_x\),对于 \(1 \leq l < |s_i|\),计算如下信息:作为 \(s_x[1,l]\) 后缀的 \(s\) 串数量,以及作为 \(s_x[l+1,|s_x|]\) 前缀的 \(t\) 串数量。对于前者,对所有 \(s_i\) 建 ACAM,将 \(s_x\) 从前往后匹配,若匹配完 \(s_x[1,l]\) 后位于的节点为 \(p\),则作为 \(s_x[1,l]\) 后缀的 \(s\) 串数量等于 \(p\) 到根的路径上终止节点数量之和(重复串算多次)。对于后者,对所有 \(t_i\) 的反串建 ACAM 后将 \(s_x\) 从后往前匹配,类似地可以算出答案。

对于同一个 \(s_x\),枚举 \(l\),答案即为二者乘积之和。枚举所有的 \(s_x,t_x\),再枚举 \(l\) 计算答案,我们就在 \(O(\sum \text{len})\) 的时间计算出了第一种情况的答案。

对于第二种情况,有三种子情况:\(s_i\)\(s_x+t_y\) 分成了两部分;\(t_j\)\(s_x+t_y\) 分成了两部分;\(s_i+t_j\) 的分界线恰好是 \(s_x+s_y\) 的分界线。

对于前两种子情况,取第一种子情况为例。枚举 \(t_y\)\(l\),钦定 \(s_i\)\(t_y\) 中的部分为 \(t_y[1,l]\)。那么合法的 \(t_j\) 数量就是作为 \(s_y[l+1,|t_y|]\) 前缀的 \(t\) 串数量。对于每个 \(s_i\),枚举 \(1 \leq k < |s_i|\),将 \(s_i\) 分为非空的两部分 \(A=s_i[1,k],B=s_i[k+1,|s_i|]\)。那么当且仅当 \(B=t_y[1,l]\) 时,这种划分有 \(cnt(A)\) 的贡献,其中 \(cnt(A)\) 表示存在后缀 \(A\)\(s_x\) 的数量。则对于这个 \(l\),总贡献为前面的贡献乘上合法的 \(t_j\) 数量。

考虑事先预处理,将所有 \(s_i\) 的所有后缀计算 Hash 后扔进 Hash Table,然后枚举 \(s_i\)\(k\),同样利用 Hash + Hash Table 可以支持 \(O(\sum \text{len})\) 处理,\(O(1)\) 询问贡献。

对于第三种子情况,使用 Hash 仍然容易计算。

回文相关

基础性质

重排

一个字符串能够重排成为回文串的充要条件是,只有至多一种字符出现了奇数次。

本质不同回文子串数量

定理 1

一个长度为 \(n\) 的字符串 \(s\) 的本质不同回文子串数量不超过 \(n\)

证明:反证法。令每个本质不同回文子串在其第一次结束处被统计。若存在两个本质不同回文子串 \(p,q(|p|<|q|)\)\(l\) 处被统计,那么 \(p\)\(q\) 的回文后缀。因为 \(q\) 是回文串,因此 \(p\) 必然也是 \(q\) 长度小于 \(|q|\) 的一个回文前缀,从而可以在某个小于 \(l\) 的位置被统计,和假设矛盾。

回文引理

回文串具有非常良好的性质。因此将它和 border 联系起来时,有几个简单的引理成立。

引理 1

\(t\) 是回文串 \(s\) 的后缀,则 \(t\)\(s\) 的 border 当且仅当 \(t\) 是回文串。

根据定义可证。

引理 2

\(t\)\(s\) 的 border(\(|t| \geq |s|/2\)),则 \(s\) 是回文串当且仅当 \(t\) 是回文串。

\(s\) 是回文串,利用引理 \(1\)

\(t\) 是回文串且是 \(s\) 的 border,根据定义,有 \(s[i] = s[|s|-|t|+i] = s[|s|-i+1]\)\(1 \leq i \leq |t|\))。因为 \(|t| \geq |s|/2\),所以上述信息足以判定 \(s\) 是一个回文串。

引理 3

\(t\) 是回文串 \(s\) 的 border,则 \(|s|-|t|\)\(s\) 的最小周期当且仅当 \(t\)\(s\) 的最长回文真后缀。

利用 border 和周期的对应关系,结合引理 1 可证。

引理 4

\(t\) 的所有回文 border 可以不重不漏地通过如下方式得到:不断令 \(t\) 变为 \(t\) 的最长回文真后缀。

我们注意到 \(t\) 在至多一步后就会变为回文串。根据引理 1,此后 \(t\) 一定会变为自己的最长 border,因此结合 《周期和 border》一节中的性质 2 即可证明。

定理 2

对于 \(|s| > 1\)\(s\) 的所有回文后缀按照长度排序后可以划分为 \(\lceil \log_2|s|\rceil\) 个等差数列。

利用引理 4,我们注意到 \(t\) 在至多一步后就会变为回文串 \(t'\),从而 \(s\) 的所有回文后缀就是 \(t'\)\(t'\) 的所有 border。结合《border 的结构》一节中的引理 2 立刻得证。

Manacher 算法

s[0]='~',s[++len]='#';
for(char x=getchar();x>='a'&&x<='z';x=getchar()){
	s[++len]=x,s[++len]='#';
}
for(int i=1,md=0,r=0;i<=len;++i){
	if(i<=r) p[i]=std::min(p[2*md-i],r-i+1);
	else p[i]=1;
	while(s[i-p[i]]==s[i+p[i]]) ++p[i];
	if(i+p[i]-1>r) md=i,r=i+p[i]-1;
	maxx=std::max(maxx,p[i]-1);
}

相信大家都会。

性质:

  • 原串中 \(s_i\) 在新串 \(t\) 的位置为 \(s_{2i}\)

  • 原串中子串 \(s[l,r]\) 的回文中心为 \(t_{l+r}\)

    分奇偶讨论容易证明。

几个基础应用

  • 求出以某个位置开始 / 结束的最长回文子串长度

    以后者为例。因为回文串的性质,前者与后者本质相同,可以将串取反后变为求以某个位置结束的最长回文子串长度。

    \(i+p_i - 1 > r\) 时(即回文区间的右端点向右移动时),此时对于新串中所有对应原串中字符的位置(即下标为偶数的位置)\(r < j < i+p_i\),以 \(j\) 为结尾的最长回文子串长度就是 \(j-i+1\)

    对于一个下标 \(x\) 来说,要想让以 \(x\) 结束的最长回文子串长度尽可能长,那么要找到最靠前的回文中心,使得它的回文半径能够覆盖到 \(x\)。对于 \(j \in (r,i+p_i)\),显然在 \(i\) 之前的回文中心都无法覆盖到 \(j\),并且此时 \(i\) 的回文半径能够覆盖 \(j\),因此 \(i\) 就是对于 \(j\) 而言最靠前的回文中心。

    由于 Manacher 的特性,分奇偶讨论容易证明新串中以 \(i\) 为回文中心,\(j\) 结尾的回文串在原串中对应一个以 \(j/2\) 结尾,长度为 \(j-i+1\) 的一个字符串。

    	for(int i=1,r=0,md=0;i<=len;++i){
    		if(i<=r) p[i]=std::min(r-i+1,p[2*md-i]);
    		else p[i]=1;
    		
    		while(s[i-p[i]]==s[i+p[i]]) ++p[i];
    		
    		if(i+p[i]-1>r){
    			for(int j=r+1;j<=i+p[i]-1;++j) if(j%2==0) L[j/2]=j-i+1;
    			r=i+p[i]-1,md=i;
    		}
    	}
    
  • 判断一个区间 \(s[l,r]\) 是否为回文串

    找到 \(s[l,r]\) 的回文中心在 \(t\) 中的位置 \(l+r\),若区间 \([l,r]\) 是回文串,则 \(t_{l+r}\) 的回文半径必须覆盖 \(t_{2r}\),即回文半径 \(p_{l+r} \geq 2r-(l+r)+1\)

    inline void manacher_init(void){
    	len=0,t[0]='~',t[++len]='#';
    	for(int i=1;i<=n;++i) t[++len]=s[i],t[++len]='#';
    	
    	for(int i=1,r=0,md=0;i<=len;++i){
    		if(i<=r) p[i]=std::min(p[2*md-i],r-i+1);
    		else p[i]=1;
    		while(t[i-p[i]]==t[i+p[i]]) ++p[i];
    		if(i+p[i]-1>r) r=i+p[i]-1,md=i;
    	}
    	return;
    }
    
    inline bool palin(int l,int r){
    	return p[l+r]>=2*r-(l+r)+1;
    }
    

回文树 / 回文自动机

一般来说称呼为后者,或其英文简写 PAM 时更加常见。

PAM 是接受 \(s\) 所有回文子串的类自动机结构。

该「类自动机」结构的转移边和我们熟知的 SAM, ACAM 略有出入。

image

左图展示了 \(\texttt{babbab}\) 的 PAM 结构,其中蓝色虚边为其 fail 树结构,右图中用黑色实边展示。

接下来给出 PAM 结构的性质:

  • PAM 中存在两个入度为 \(0\) 的起始节点 \(\text{even}\)\(\text{odd}\)。接下来分别用节点 \(E,O\) 来代指起始节点 \(\text{even}\)\(\text{odd}\)

  • 除了起始节点 \(E,O\) 外,每个节点都代表一个回文字符串。记 \(\text{len}(p)\) 表示节点 \(p\) 所代表字符串的长度,并规定 \(\text{len}(E)=0,\text{len}(O)=-1\)

  • 图中带字母的转移边 \(tr(p,c):(p \to q)\)(或记作 \(tr(p,c) = q\))含义为:在当前节点 \(p\) 代表的字符串左右两侧添加转移边对应字符 \(c\) 后,得到的字符串和转移边终点 \(q\) 代表的字符串相等。特殊地,对于 \(tr(O,c) : (O \to q)\),应当看作在一个长度为 \(-1\) 的字符串左右两侧添加字符 \(c\),最终得到的字符串是单个字符 \(c\)

  • 对于任意 \(tr(p,c) = q\),都有 \(\text{len}(p)+2 = \text{len}(q)\)

  • 除了起始节点外,每个节点恰为一条转移边的终点。因此,PAM 构成两棵有根树,分别以两个初始节点作为根。其中 \(\text{even}\) 子树中所有回文串长度均为偶数,\(\text{odd}\) 相反。

  • 节点 \(p\) 的 fail 指针 \(\text{fail}(p)\) 指向表示其最长回文真后缀的节点。该最长回文真后缀长度可以为 \(0\),此时 \(\text{fail}(p)\) 指向 \(E\)。特殊地,\(\text{fail}(E) = \text{fail}(O) = O\)

因为一个长度为 \(n\) 的字符串 \(s\) 的本质不同回文子串数量不超过 \(n\),因此一个字符串的 PAM 上最多只有 \(n+2\) 个节点。又由树形结构可知 PAM 上转移边不超过 \(n\) 条。

构造:末端插入法

对于字符串 \(s\),我们可以采用末端插入的增量构造法构造 PAM,即:从初始 \(s[1,0]\) 的 PAM(仅初始节点)开始,依次插入 \(s_i\),然后维护出 \(s[1,i]\) 的 SAM。

根据本节定理 1 及其证明,插入 \(s_i\) 至多增加 \(1\) 个本质不同回文子串,且这个子串只可能是 \(s[1,i]\) 的最长回文后缀。

我们考虑维护 \(last\) 指针,指向代表 \(s[1,i-1]\) 的最长回文后缀的节点。初始时令 \(last = E\),这是因为空串的最长回文后缀为空串。

注意到 \(s[1,i]\) 的最长回文后缀要么就是 \(s[i]\),要么是 \(s[1,i-1]\) 的某个回文后缀的左右两侧添加一个字符 \(s[i]\) 得到的。因此,我们初始令 \(p=last\),然后不断地将 \(p\) 变为 \(\text{fail}(p)\),直到 \(s[i-\text{len}(p)-1]=s[i]\)。此时在 \(p\) 代表的字符串左右两侧添加字符 \(s[i]\),就得到了 \(s[1,i]\) 的最长回文后缀。因为我们规定了 \(\text{len}(E)=0\)\(\text{len}(O)=-1\),所以上面的过程总是正确的,我们不用特意区分 \(s[i]\) 的最长回文后缀是否是单个字符 \(s[i]\)

\(tr(p,s[i]) = q\) 存在,则表明这个子串已经出现过,我们不做任何修改。接下来讨论这个子串没有出现过,且需要新建节点 \(q\) 的情况。

因为 \(q\) 对应的字符串是 \(s[1,i]\) 的最长回文后缀,所以当前不会存在 \(q\) 出发的转移边,我们只需要计算出 \(\text{fail}(q)\),即 \(q\) 对应字符串的最长回文后缀。和上面类似,我们令 \(p' = \text{fail}(p)\),然后不断地将 \(p'\) 变为 \(\text{fail}(p')\),直到 \(s[i-\text{len}(p')-1]=s[i]\)。此时将 \(\text{fail}(q)\) 置为 \(q'\) 即可,其中 \(tr(p',s[i]) = q'\)

\(q\) 对应字符串的最长回文后缀为空(此时一定有 \(\text{len}(q) = 1\)),我们总会到达 \(p' = O\)。按照定义,我们希望 \(\text{fail}(q) = E\)。在代码里面我们给 \(E\) 标号为 \(0\)\(O\) 标号为 \(1\),这样,此时 \(tr(O,s[i]) = tr(1,s[i]) = 0\),我们就可以自然地避免这个边界情况了。

最后,更新 \(tr(p,s[i]) = q\),然后令 \(last = q\)。注意到我们不能先更新 \(tr(p,s[i])\) 再计算 \(\text{fail}(q)\),这是因为若 \(p = O\),就有 \(\text{fail}(p) = \text{fail}(O) = O\),此时如果先更新 \(tr(O,s[i]) = q\),再计算 \(\text{fail}(q) = tr(\text{fail(p)},s[i]) = tr(O,s[i]) = q\),就会发现 \(q\)\(\text{fail}\) 指针是其本身,而这显然不是我们想要的。

int len[N],fail[N],ch[N][26],siz[N];
int cnt=-1,last;

inline int node(int x){ // 新建一个长度为 x 的空节点
	return len[++cnt]=x,cnt;
}
inline void init(void){ // 新建节点 E (标号为 0) 和节点 O (标号为 1)
	node(0),node(-1),fail[0]=1;
	return;
}
inline int getfail(int x,int pos){
	while(s[pos-1-len[x]]!=s[pos]) x=fail[x];
	return x;
}
inline void extend(int pos,int c){
	int x=getfail(last,pos),p; // 代码中 p 为文中 q; x 为文中 p
	if(!ch[x][c]){
		p=node(len[x]+2),fail[p]=ch[getfail(fail[x],pos)][c],ch[x][c]=p;
	}
	++siz[last=ch[x][c]];
	return;
}
...
int main(void){
    init();
    ...
}

这种方法被称作基础插入算法

考虑复杂度分析。记 \(n\) 为字符串长度。指针 \(p\) 每向上移动一次,节点长度至少减少 \(2\)。新建节点 \(q\) 使得节点长度增加 \(2\)。因为一共只会新建至多 \(n\) 个非初始节点,且节点长度不得小于 \(-1\),因此运用势能分析可知,代码实现中这部分的时间复杂度为 \(O(n)\),其中 \(n\) 为字符串长度,而空间复杂度为 \(O(n\Sigma)\)

对于指针 \(p'\)\(\text{fail}(q)\),我们有类似的势能分析。因此,基础插入算法的总势能为 \(O(n)\),从而:

  • 采用数组存储转移边 \(tr\),时间复杂度 \(O(n)\),空间复杂度 \(O(n \Sigma)\)
  • 采用 \(O(1)\) 额外空间,\(O(\log \Sigma)\) 定位的数据结构(如 std::map 或 std::set)存储转移边 \(tr\),时间复杂度 \(O(n \log |s|)\),空间复杂度 \(O(n)\)

一个有趣的事实是,我们根本不会用到 \(\text{fail}(O)\),因此 init() 中仅有 fail[0]=1 一句而非 fail[0]=fail[1]=1,这也符合网上大部分题解的书写习惯,虽然它看上去不太严谨......

构造:前端插入法

因为回文串的性质,对于字符串 \(s\) 而言,它的反串 \(s'\) 的本质不同回文子串集合和 \(s\) 的完全相同。因此,对于 \(s\) 采用前端插入法构造 PAM,即维护 \(last\) 表示 \(s[i,n]\) 的最长回文前缀,然后类似地计算出转移边和 fail 指针,本质上和对于 \(s\) 的反串 \(s'\) 采用后端插入法是相同的,因此前端插入法的复杂度和后端插入法的复杂度相同。

构造:不基于势能分析的末端插入法(可持久化)

如果 PAM 需要支持完全持久化,那么基于势能分析的插入法将不再有效。

基本插入算法的瓶颈在于遍历 \(last\) 的 fail 指针,找到第一个前驱字符和 \(s_i\) 相同的回文后缀 \(t\)。注意到,除了 \(\text{len}(t) = \text{len}(last)\) 的场合,\(t\) 必然是 \(last\) 对应串的真后缀。这种情况下,\(t\)\(s_i\) 具体是什么无关,而只和 \(last\) 有关。

因此,我们对于节点 \(x\),维护出 \(quick(x,c)\) 表示节点 \(x\) 所代表的字符串第一个前驱字符为 \(c\) 的回文真后缀所代表的节点。插入时,如果 \(p = last\) 不合法,只需要取出 \(quick(last,c)\) 就可以立刻求得最终的节点 \(p\)。同理,\(\text{fail}(q)\) 要么是 \(tr(\text{fail}(p),c)\),要么可以用 \(tr(quick(\text{fail}(x),c),c)\) 给出。

最后,我们需要维护出 \(quick(q,c)\)\(quick(q,c)\)\(quick(\text{fail}(q),c)\) 相比,恰有 \(1\) 处有差异。维护出 \(quick(q,c)\) 同样是简单的。

该算法的复杂度分析较为容易:直接从 \(\text{fail}(q)\) 进行 \(quick\) 的复制,时空复杂度为 \(O(n \Sigma)\)。使用可持久化数组,时空复杂度均变为 \(O(n \log \Sigma)\)

需要注意边界情况:\(quick(E,c),quick(O,c)\) 初始设为 \(O\)

char s[N];
int n;
int len[N],fail[N],ch[N][26],siz[N],quick[N][26];
int cnt=-1,last;

inline int node(int x){
	return len[++cnt]=x,cnt;
}
inline void init(void){
	node(0),node(-1),fail[0]=1;
	for(int i=0;i<26;++i) quick[0][i]=quick[1][i]=1; // 初始化 quick(E,c) / quick(O,c)
	return;
}

inline void extend(int pos,int c){
	int x=quick[last][c],p; // 代码中 p 为文中 q; x 为文中 p
	if(s[pos-len[last]-1]==s[pos]) x=last; // 特殊情况:p = last 合法的场合
	if(!ch[x][c]){
		p=node(len[x]+2),fail[p]=ch[quick[fail[x]][c]][c];
		if(s[pos-len[fail[x]]-1]==s[pos]) fail[p]=ch[fail[x]][c]; // 特殊情况:fail[q] = ch[fail[p]][c] 合法的场合
		memcpy(quick[p],quick[fail[p]],sizeof(quick[p]));
		quick[p][s[pos-len[fail[p]]]-'a']=fail[p]; // 更新 quick[q]
		ch[x][c]=p;
	}
	++siz[last=ch[x][c]];
	return;
}

给定一个字符串 \(s\)。保证每个字符为小写字母。对于 \(s\) 的每个位置,请求出以该位置结尾的回文子串个数。

这个字符串被进行了加密,除了第一个字符,其他字符都需要通过上一个位置的答案来解密。

具体地,若第 \(i(i\geq 1)\) 个位置的答案是 \(k\),第 \(i+1\) 个字符读入时的 \(\rm ASCII\) 码为 \(c\),则第 \(i+1\) 个字符实际的 \(\rm ASCII\) 码为 \((c-97+k)\bmod 26+97\)。所有字符在加密前后都为小写字母。

\(1 \leq |s| \leq 5 \times 10^5\)

容易发现,所求即为 \(last\) 在 fail 树上的深度。

给你一个由小写拉丁字母组成的字符串 \(s\)。我们定义 \(s\) 的一个子串的存在值为这个子串在 \(s\) 中出现的次数乘以这个子串的长度。

对于给你的这个字符串 \(s\),求所有回文子串中的最大存在值。

\(1 \leq |s| \leq 3 \times 10^5\)

如果能够求出每个节点代表的字符串的 endpos 集合大小,便可以解决原问题。

采用 ACAM 类似的套路,每次只在 \(last\) 处更新 endpos 集合大小,最后再遍历 fail 树求出子树和即为真正的子树大小。

以下给出不基于势能分析的末端插入法的实现。

# include <bits/stdc++.h>

const int N=300010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}
char s[N];
int n;
int len[N],fail[N],ch[N][26],siz[N],quick[N][26];
int cnt=-1,last;

inline int node(int x){
	return len[++cnt]=x,cnt;
}

inline void init(void){
	node(0),node(-1),fail[0]=1;
	for(int i=0;i<26;++i) quick[0][i]=quick[1][i]=1;
	return;
}


inline void extend(int pos,int c){
	int x=quick[last][c],p;
	if(s[pos-len[last]-1]==s[pos]) x=last;
	if(!ch[x][c]){
		p=node(len[x]+2),fail[p]=ch[quick[fail[x]][c]][c];
		if(s[pos-len[fail[x]]-1]==s[pos]) fail[p]=ch[fail[x]][c];
		memcpy(quick[p],quick[fail[p]],sizeof(quick[p]));
		quick[p][s[pos-len[fail[p]]]-'a']=fail[p];
		ch[x][c]=p;
	}
	++siz[last=ch[x][c]];
	return;
}

int main(void){
	scanf("%s",s+1),n=strlen(s+1),init();
	for(int i=1;i<=n;++i) extend(i,s[i]-'a');
	for(int i=cnt;i>1;--i) siz[fail[i]]+=siz[i];
	
	long long ans=0;
	for(int i=2;i<=cnt;++i) ans=std::max(ans,1ll*siz[i]*len[i]);
	
	printf("%lld",ans);
	return 0;
}

* 构造:双端插入法(HDU 5421)

我们可以同时维护出 \(last\)\(first\),表示当前串的最长回文后缀和最长回文前缀指向的节点,然后同时使用前端插入法和后端插入法。但此时前端插入也可能会使得 \(last\) 变化,后端插入同理。仔细分析可知,只有整个串都是回文串的时候,才会出现前述变化,而这是容易判断的。

构造:对给定 Trie 树构造 PAM

类似的分析可知,Trie 树对应的 PAM 上只有 \(O(siz)\) 个节点。

此时不应当采用基础插入算法。考虑 \(\{\texttt{a},\texttt{ba},\texttt{bba},\cdots,\texttt{bbbbb...a}\}\)(最后一个字符串中有 \(n\)\(\texttt b\))构成的 Trie,大小为 \(O(n)\)。每次遍历到 \(\texttt a\) 的分支时,都会消耗所有势能,而往下遍历 \(\texttt b\) 的分支时势能不会减小,因此复杂度为 \(O(n^2)\)

不基于势能分析的末端插入法复杂度不变。

* 带双端插入删除的 PAM 维护

可以参阅 2017 集训队论文《回文树及其应用》(中山市中山纪念中学 翁文涛)。

核心思想是,维护出那些本身回文且不是任何一个回文串回文前 / 后缀的串,称作重要子串。当字符被删除时,结合当前节点的重要性,以及在 fail 树上是否存在后代,可以判断出这些串是否会被从 PAM 上移除。

但这样会频繁更改 fail 树的结构,因此如果需要支持查询某个串的 endpos 集合大小,可能需要用到 LCT / ETT。当然,如果只需要查回文子串数量(位置不同就算不同),便只需要在插入 / 删除时知道当前所在节点的 fail 树深度,这是容易维护出来的。

带双端插入删除的 PAM 是支持可持久化的,但是这也太迷惑了......相信没有人会写。

例题 3 [CERC2014] Virus synthesis

初始有一个空串,利用下面的操作构造给定串 \(S\)

  1. 串开头或末尾加一个字符

  2. 串开头或末尾加一个该串的逆串

求最小操作数。

\(|S| \leq 10^5\),字符集为 \(\{A,T,C,G\}\)

考虑枚举最后一次 2 操作形成的串 \(T\)。它必然是 \(S\) 的一个偶回文子串。可以使用 PAM 求出所有这样的串。记 \(f(T)\) 表示从空串到 \(T\) 的最小操作次数,那么最小的 \(f(T) + n- |T|\) 就是答案。

现在重点是如何求出 \(f(T)\)。有结论:形成串 \(T\) 的最后一次操作必然是操作 2。以下为证明:

考虑归纳。\(|T| \leq 2\) 的情况是平凡的。

对于 \(|T| > 2\) 的情况,若形成串 \(T = BB^T\) 的最后一次操作不是操作 2,找到最后一次操作 2 结束后形成的串 \(T' = AA^T\),其中 \(S^T\) 表示串 \(S\) 的反转。

不难发现,上述总操作次数为 \(f(T') + 2(|B|-|A|)\)。注意到 \(|A| < |B|\),那么 \(A\)\(A^{T}\) 一定完整地出现在 \(T\) 回文中心的一侧。根据归纳假设,形成 \(AA^T\) 的最后一次操作是操作 2。那么,在这次操作之前,在完整地出现在 \(T\) 回文中心一侧的部分左右添加上字符,使其成为 \(B\)\(B^T\),最后再应用操作 \(2\),总操作次数为 \(f(T') + |B| - |A| < f(T') + 2(|B| - |A|)\),因此一定更优。

考虑在 PAM 上 DP。考虑一结论:回文串 \(T\) 的所有回文子串都可以通过若干次以下操作得到:

  • \(T\) 首尾去掉一个字符。
  • \(T\) 变为 \(T\) 的最长回文后缀。

那么有转移:\(f(T) = \min(|T|,f(\text{fa}(T))+1,f(\text{link}(T)) + \frac{1}{2}(|T| - |\text{link}(T)|)+1)\)。其中 \(\text{fa}(T)\)\(T\) 在 PAM 上的父亲(与 \(\text{fail}\) 树上的父亲区分),\(\text{link}(T)\)\(T\) 长度不超过 \(\frac{1}{2} T\) 的最长回文后缀。

\(\text{link}(T)\) 可以使用与求 \(\text{fail}(T)\) 时类似的方法求出,时间复杂度不变。

# include <bits/stdc++.h>
# define link walawala

const int N=300010,INF=0x3f3f3f3f;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

std::map <char,int> mp;

char s[N];

int n;

int len[N],fail[N],ch[N][4];

int cnt=-1,last;

int link[N],fa[N]; 

inline int node(int x){
	return len[++cnt]=x,memset(ch[cnt],0,sizeof(ch[cnt])),link[cnt]=fa[cnt]=0,cnt;
}

inline void init(void){
	cnt=-1,node(0),node(-1),fail[0]=1,last=0;
	return;
}

inline int getfail(int x,int pos){
	while(s[pos-1-len[x]]!=s[pos]) x=fail[x];
	return x;
}

inline void extend(int pos,int c){
	int x=getfail(last,pos),p;
	if(!ch[x][c]){
		p=node(len[x]+2),fail[p]=ch[getfail(fail[x],pos)][c],ch[x][c]=p;
		fa[p]=x;
		if(len[p]<=2) link[p]=fail[p];
		else{
			int cur=link[x];
			while(s[pos-1-len[cur]]!=s[pos]||(len[cur]+2)*2>len[p]) cur=fail[cur];
			link[p]=ch[cur][c];
		}
	}
	last=ch[x][c];
	return;
}

int dp[N];

inline void bfs(void){
	std::queue <int> q;
	for(int i=2;i<=cnt;++i) dp[i]=len[i];
	q.push(0),dp[0]=1;
	int ans=n;
	while(!q.empty()){
		int i=q.front();
		q.pop();
		for(int k=0;k<4;++k){
			int j=ch[i][k];
			if(!j) continue;
			dp[j]=dp[i]+1;
			int l=link[j];
			dp[j]=std::min(dp[j],dp[l]+len[j]/2+1-len[l]);
			ans=std::min(ans,n-len[j]+dp[j]);
			q.push(j);
		}
	}
	printf("%d\n",ans);
	return;
}

int main(void){
	mp['A']=0,mp['C']=1,mp['T']=2,mp['G']=3;
	int T=read();
	while(T--){
		scanf("%s",s+1),n=strlen(s+1),init();
		for(int i=1;i<=n;++i) extend(i,mp[s[i]]);
		bfs();
	}
	return 0;
}

例题 4 [GDKOI2013] 大山王国的城市规划

给定字符串 \(s\),选出尽可能多的回文子串 \(t_1,t_2,\cdots,t_k\) 使得不存在 \(i \neq j\) 满足 \(t_i\)\(t_j\) 的子串。

\(1 \leq |s| \leq 10^5\)

考虑结论:回文串 \(T\) 的所有回文子串都可以通过若干次以下操作得到:

  • \(T\) 首尾去掉一个字符。
  • \(T\) 变为 \(T\) 的最长回文后缀。

对应到 PAM 上,这两种操作分别为:

  • 令某个节点变为它在 PAM 上的父亲。
  • 令某个节点变为它在 fail 树上的父亲。

将每个节点的「两个父亲」向它连边,会得到一张 DAG。不难发现,所求即为 DAG 上的最长反链。这是简单的。

复杂度为网络流跑二分图匹配的 \(O(n \sqrt n)\)

最小回文划分

给定字符串 \(s\),求:

  • \(s\) 划分程若干个回文串,最少需要使用的回文串个数。(最小回文划分)

  • \(s\) 划分成若干个回文串的方案数。(回文划分计数)

\(1 \leq |s| \leq 10^5\)

不难发现我们可以使用 DP 解决。两种 DP 是类似的,下文中以回文划分计数为例。

显然,我们可以对 \(s\) 建立 PAM,同时进行 DP。具体来说,插入字符 \(s[i]\) 后,\(s[1,i]\) 的所有回文后缀都可以作为划分中在 \(i\) 结束的回文串。

考虑《回文引理》一节的定理 2,一个字符串 \(t\) 的回文 border 能够被划分为 \(O(\log_2 |t|)\) 个等差数列。因此,插入 \(s[i]\) 后,\(last\) 到根的链上的节点可以根据长度划分到 \(O(\log_2 i)\) 个等差数列中。

考虑维护如下信息:对于一个节点 \(x\),如果它是某个等差数列中长度最长的节点,那么就更新 \(g(x)\) 为 $ \sum \limits_{y \in S(x)} f(i-\text{len}(x))$,其中 \(S(x)\) 表示 \(x\) 所属等差数列中的节点。

现在,对于 \(x\),若 \(\text{fail}(x)\)\(x\) 属于同一个等差数列,考察 \(g(\text{fail}(x))\)

image

如图。\(\text{diff}(x)\) 表示等差数列的公差。因为 \(\text{fail}(x)\)\(x\) 的最长回文后缀,那么 \(\text{fail}(x)\) 上次出现的位置为 \(i - \text{diff}(x)\)。同时,在 \(i - \text{diff}(x)\) 处出现时,\(\text{fail}(x)\) 必然是作为这个等差数列中最长的节点出现的。

证明

因为 \(x\) 是回文串,显然 \(\text{fail}(x)\) 的确在 \(i - \text{diff}(x)\) 处出现过。下证其不可能在中间部分再次出现:我们的划分方式保证了 \(\text{fail}(x)\) 如果和 \(x\) 在同一个等差数列中,则 \(2|\text{fail}(x)| \geq |x|\)。若 \(\text{fail}(x)\)\((i - \text{diff}(x),i)\) 中出现,则两次出现必然有重叠。由引理 2,这两次出现的并是一个更长的回文串,且它同样是 \(x\) 的前缀。\(x\) 是回文串,从而它也是 \(x\) 的后缀,且比 \(\text{fail}(x)\) 长,这与 \(\text{fail}(x)\) 的定义矛盾。

如果 \(\text{fail}(x)\) 没有作为这个等差数列中最长的节点出现,那么会发生什么呢?关于这种情况,我已经有了一种绝妙的论证,来说明这不会发生。可惜这里地方太小,我写不下。

这样,\(g(\text{fail}(x)) = \sum \limits_{y \in S(x)} f((i - \text{diff}(x))-\text{len}(x))\),即上图中蓝色的部分。\(g(x)\)(图中橙色部分)和 \(g(\text{fail}(x))\) 相比,只多了一个 \(f(i - |\text{slink}(x)| + \text{diff}(x))\)。因此我们可以 \(O(1)\) 更新出 \(g(x)\)

要求解 \(f(i)\),我们只需要在 \(last\) 的 fail 链上找到每个等差数列的 \(x\) 节点,更新出 \(g(x)\) 并累加。时间复杂度 \(O(n \log n)\)

例题 5 CF932G Palindrome Partition

给定长度为 \(n\) 的字符串 \(s\),字符集为小写字母集。

求将 \(s\) 划分为偶数段 \(t_1,t_2,\cdots,t_{2k}\),且对于 \(i = 1,2,\cdots,k\),满足 \(t_i = t_{2k - i+1}\) 的方案数。

答案对 \(10^9+7\) 取模。

\(2 \leq n \leq 10^6\)

不难注意到我们要求的是划分构成一个回文序列的方案。这有点棘手,不妨令 \(s' = s_1s_ns_{2}s_{n-1}\cdots s_{n/2}s_{n/2+1}\)。这样 \(s'\) 的合法划分方案与 \(s'\) 的偶回文划分方案一一对应。

对于后者,可以利用上述方法求出。

# include <bits/stdc++.h>

const int N=1000010,INF=0x3f3f3f3f,mod=1e9+7;

char s[N],t[N];

int n;

inline int read(void){
	int res,f=1;
	char c;
	while((c=getchar())<'0'||c>'9')
		if(c=='-') f=-1;
	res=c-48;
	while((c=getchar())>='0'&&c<='9')
		res=res*10+c-48;
	return res*f;
}

namespace pam{
	int ch[N][26],len[N],slink[N],fail[N],diff[N];
	int cnt=-1,last;
	inline int node(int l){
		return len[++cnt]=l,cnt;
	}
	inline void init(void){
		node(0),node(-1),fail[0]=1;
		return;
	}
	inline int getfail(int x,int pos){
		while(t[pos-1-len[x]]!=t[pos]) x=fail[x];
		return x;
	}
	inline void extend(int pos,int c){
		int x=getfail(last,pos),p;
		if(!ch[x][c]){
			p=node(len[x]+2),fail[p]=ch[getfail(fail[x],pos)][c],ch[x][c]=p;
			diff[p]=len[p]-len[fail[p]];
			slink[p]=((diff[p]==diff[fail[p]])?slink[fail[p]]:fail[p]);
            // 留意这里的划分方式.
            // 我们不会把 3,7,11 划分到同一个等差数列中.而是划分为 {3},{7,11}.
            // 这是因为证明中用到了 2 * |fail(x)| >= |x| 的性质.
		}
		last=ch[x][c];
		return;
	}	
}

using namespace pam;

int g[N],f[N];

int main(void){
	scanf("%s",s+1),n=strlen(s+1);
	if(n%2) puts("0"),exit(0);
	for(int i=1,l=1,r=n;i<=n/2;++i,++l,--r) t[2*i-1]=s[l],t[2*i]=s[r];	
	init();
	f[0]=1;
	for(int i=1;i<=n;++i){
		extend(i,t[i]-'a');
		for(int j=last;j;j=slink[j]){
			g[j]=f[i-len[slink[j]]-diff[j]];
			if(slink[j]!=fail[j]) g[j]=(g[j]+g[fail[j]])%mod;
			if(i%2==0) f[i]=(f[i]+g[j])%mod;
		}
	}
	
	printf("%d",f[n]);

	return 0;
}

例题 6 Codeforces 906E Reverses

和例题 5 基本相同。

* 例题 7 LOJ6070「2017 山东一轮集训 Day4」基因

给定长度为 \(n\) 的小写字母串 \(s\)\(q\) 次查询,每次查询 \(s[l,r]\) 的本质不同回文子串数量。

强制在线。

离线版本:BZOJ 5384 有趣的字符串题

\(1 \leq n \leq 10^5,1 \leq q \leq 2 \times 10^5\)

  • 解法一

    采用分块。使用 PAM 维护出从每一块开头 \(L\) 到每个右端点 \(r\) 的本质不同回文子串数量 \(f(L,r)\),以及插入完 \([L,r]\) 后,\(last,first\) 指针在 PAM 上的位置。以及从 \(L\) 开始,回文子串 \(t\) 最早出现的位置(结束位置) \(p(L,t)\)

    考虑查询。若 \([l,r]\) 在一块内,则暴力建立 PAM 查询。否则,分为两部分 \([l,L),[L,r]\),其中 \(L\)\(l\) 所在下一块的开头。我们记录了 \(first\) 指针的位置,据此可以使用前端插入法将 \([l,L)\) 倒序插入。插入字符 \(s_x\) 后得到 \([x,r]\) 最长回文前缀 \(t\),则检验是否有 \(p(L,t) > r\),且 \(t\) 未被标记。若是,则表明 \(t\) 没有在 \((x,r]\) 中出现过,此时给 \(t\) 打上标记,让答案增加 \(1\)。另一部分的答案即 \(f(L,r)\),最终答案为两部分之和。

    取块长为 \(\sqrt n\),时间复杂度为 \(O((n+q)\sqrt n|\Sigma |)\)

  • 解法二

    先考虑允许离线的情形。此时我们对 \(r\) 扫描线,维护 \(l\) 的答案。

    考虑性质:\(s[1,i]\) 的所有回文后缀可以被划分为 \(O(\log_2 i)\) 个等差数列。

    考虑一个等差数列中的回文后缀。

    image

    如图。设 \(a\) 为最长串,\(c\) 为最短串,且公差为 \(d\)。根据回文划分时使用到的结论结论,\(b,c\) 的上次出现位置如下图。那么当起始位置 \(l\) 位于红线部分 \((i-|a|+1,i-|a|+1+d]\) 时,区间 \([l,r]\) 新出现了串 \(b\)。位于蓝线部分 \((i-|b|+1,i-|b|+1+d]\) 的时候,区间 \([l,r]\) 新出现了串 \(c\)。这两部分的并为 \((i-|a|+1,i-|c|+1]\)。对于更长的等差数列亦然。

    对于最长串 \(a\),如何获取其上一次出现的位置?不难发现,当 \(a\) 在 fail 树上的子树中节点作为最长回文后缀出现时,\(a\) 也会在此时出现。因此只需要查询 \(a\) 的子树最大值,就可以得到 \(a\) 上一次出现的 endpos,从而求出需要更新的区间。

    若需要支持强制在线,则使用主席树。时间复杂度 \(O(n \log^2 n + q \log n)\)

Luogu P4199 万径人踪灭

来一道餐后甜点吧。题意即求:有多少个不是原串子串的子序列是回文的,且关于某个位置对称。

考虑两个位置 \((l,r)\)。如果 \(s_l = s_r\),那么对于 \((l+r)/2\) 这个回文中心生成的回文子序列而言,\(s_l,s_r\) 要么同时选,要么同时不选。

这是一个和卷积的形式。具体地,枚举字符 \(a \in \Sigma\),令 \(f_i = [s_i = a]\),将 \([x^{l+r}]f^2(x)\) 累加到某个初始为零的数组 \(h\) 上。那么,合法的子序列数量即 \(\sum \limits_{p} (2^{h_p}-1)\)

最后需要减去回文子串数,用 Manacher 计算得出即可。

非平凡回文串划分判定

判定是否有合法方案将长度为 \(n\) 的字符串 \(s\) 划分为若干个长度不为 \(1\) 的回文串。

\(f(i)\) 表示 \(i\) 开始的最短回文串长度,使用 PAM 或 Manacher 容易求得 \(f(i)\)(后者不是那么显然。想一想,怎么求?)。考虑从左向右贪心,若实际的划分中,从 \(i\) 开始的段长度不为 \(f(i)\),则设实际的段长为 \(d > f(i)\)。此时考虑两种情况:

  • \(d/2 \leq f(i) < d\)

    注意到回文串的回文前缀必然是其 border,从而 \(s[i,i+d-1]\) 有周期 \(d-f(i) < d/2\)。周期的倍数仍为周期,于是它必然有长度大于 \(d/2\) 而小于 \(d\) 的周期,从而必然有长度小于 \(d/2\) 的 border。当 \(2f(i)-1 \neq d\) 时,必然存在长度不为 \(1\) 的 border,这个 border 必然是 \(s[i,i+d-1]\) 的回文串且长度小于 \(f(i)\),和 \(f(i)\) 的定义矛盾。

  • \(f(i) < d/2\)

    此时 \(s[i,i+d-1]\) 必然可以被分为三个回文串,其中第一个和最后一个回文串为 \(s[i,i+f(i)-1]\)。只要 \(2f(i)+1 \neq d\),中间的回文串长度必然不为 \(1\)

因此实际的段长只有 \(f(i),2f(i)-1,2f(i)+1\) 三种选择。据此可以 \(O(n)\) DP。

posted @ 2024-05-26 21:48  Meatherm  阅读(47)  评论(1编辑  收藏  举报