Typesetting math: 71%

基础字符串

目录

Change Log#

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

符号与约定#

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

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

对于字符串 s,ts,t,用 ststs+ts+t 来表示 sstt 的拼接。

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

ΣΣ 表示字符集。字符集 ΣΣ 是一个有限全序集,字符串中仅含字符集中的字符。

匹配、周期和 Border#

定义#

对于字符串 ss,若 0r<|s|0r<|s|rr 使得 s[1,r]=s[|s|r+1,|s|]s[1,r]=s[|s|r+1,|s|],则称 s[1,r]s[1,r]ssborder

对于字符串 ss,若 0<p|s|0<p|s|pp 使得 i{1,2,,|s|p}i{1,2,,|s|p}si=si+psi=si+p,则称 ppss周期

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

Period-Border Lemma

s[1,r]s[1,r]ss 的 border 当且仅当 |s|r|s|rss 的周期。

对于长度为 nn 的字符串 ss,定义其前缀数组(一别称为 nextnext 数组)π=[π0,π1,π2,,πn]π=[π0,π1,π2,,πn],其中 πiπi 表示 s[1,i]s[1,i] 的最长 border 长度。特殊地,规定 π0=0π0=0

对于字符串 s,ts,t1lr|s|1lr|s|,若 s[l,r]=ts[l,r]=t,则称 s[l,r]s[l,r]tt 匹配。对于所有满足前述条件的 rr,称 rr 构成的集合为 ttss 中的匹配位置

基础性质#

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

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

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

  • 性质 3:若 ppss 的周期,且 kp|s|kp|s|,则 kpkp 也是 ss 的周期。

    只需要考虑周期定义。

周期引理#

弱周期引理 Weak Periodicity Lemma

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

周期引理 Periodicity Lemma

对于字符串 ss,若 p,qp,qss 的周期,且 p+qgcd(p,q)|s|p+qgcd(p,q)|s|,则 gcd(p,q)gcd(p,q) 也是 ss 的周期。

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

以下证明中默认字符串的下标从 00 开始,且长度为 nn

考虑给每种字符分配一个互不相同的正整数权值,即建立映射 f:ΣN+f:ΣN+,然后将字符串 s=s0s1sn1s=s0s1sn1 看作数列 f(s0),f(s1),,f(sn1)f(s0),f(s1),,f(sn1)

对于周期 p,qp,q,构造多项式 P(x)=p1i=0f(si)xi,Q(x)=q1i=0f(si)xiP(x)=p1i=0f(si)xi,Q(x)=q1i=0f(si)xi,这便是 s[0,p1],s[0,q1]s[0,p1],s[0,q1] 的生成函数。

定义字符串 s[0,p1],s[0,q1]s[0,p1],s[0,q1] 复制无穷多遍的生成函数为 Sp(x),Sq(X)Sp(x),Sq(X),则有 Sp(x)=i0f(simodp)xiSp(x)=i0f(simodp)xiSq(x)=i0f(simodq)xiSq(x)=i0f(simodq)xiSp(x),Sq(x)Sp(x),Sq(x) 的系数可以分别看作 P(x),Q(x)P(x),Q(x) 的系数复制无穷多遍产生的,可以表示为 Sp(x)=P(x)+xpP(x)+x2pP(x)++xkpP(x)+Sp(x)=P(x)+xpP(x)+x2pP(x)++xkpP(x)+,对于 Sq(x)Sq(x) 同理。根据生成函数的运算规则,Sp(x)=P(x)1xp,Sq(x)=Q(x)1xqSp(x)=P(x)1xp,Sq(x)=Q(x)1xq

不难发现字符串 ss 的生成函数 S(x)S(x)Sp(x),Sq(x)Sp(x),Sq(x) 对前 nn 项的截断,即 S(x)=Sp(x)modxn=Sq(x)modxnS(x)=Sp(x)modxn=Sq(x)modxn,从而 [xk]Sp(x)=[xk]Sq(x)[xk]Sp(x)=[xk]Sq(x),其中 k=0,1,,n1k=0,1,,n1

考虑对 Sp(x)Sp(x)Sq(x)Sq(x) 作差,得到

P(x)1xpQ(x)1xq=1xgcd(p,q)(1xp)(1xq)(1xq1xgcd(p,q)P(x)+1xp1xgcd(p,q)Q(x))P(x)1xpQ(x)1xq=1xgcd(p,q)(1xp)(1xq)(1xq1xgcd(p,q)P(x)+1xp1xgcd(p,q)Q(x))

长除法容易证明 (1xa),(1xb)(1xa),(1xb) 都是 (1xab)(1xab) 的因式,因此 1xq1xgcd(p,q),1xp1xgcd(p,q)1xq1xgcd(p,q),1xp1xgcd(p,q) 是整式,从而 H(x)=1xq1xgcd(p,q)P(x)+1xp1xgcd(p,q)Q(x)H(x)=1xq1xgcd(p,q)P(x)+1xp1xgcd(p,q)Q(x) 是次数不超过 p+q1gcd(p,q)p+q1gcd(p,q) 的多项式。同时,1xgcd(p,q)(1xp)(1xq)1xgcd(p,q)(1xp)(1xq) 是一个常数项不为 00 的形式幂级数。若 H(x)0H(x)0,则取左侧幂级数的常数项和 H(x)H(x) 相乘,最终的结果中必然会得到不为 00 的一个 xixi,其中 ip+q1gcd(p,q)ip+q1gcd(p,q)。根据上面的讨论,在 k=0,1,,n1k=0,1,,n1 处,我们都有 [xk](Sp(x)Sq(x))=0[xk](Sp(x)Sq(x))=0,又因为 p+qgcd(p,q)np+qgcd(p,q)n,从而 in1in1,这样我们同时有 xixi 项的系数不为 00xixi 项的系数必须为 00,出现了矛盾,因此 H(x)=0H(x)=0

这样我们就有 Sp(x)Sq(x)=0Sp(x)Sq(x)=0,从而 Sp(x)Sp(x)Sq(x)Sq(x) 每一项的系数都相等。根据裴蜀定理,存在整数 a,ba,b 使得 ap+bq=gcd(p,q)ap+bq=gcd(p,q)。这样就有 [xi]Sp(x)=[xi+ap]Sp(x)=[xi+ap]Sq(x)=[xi+ap+bq]Sq(x)=[xi+gcd(p,q)]Sp(x)[xi]Sp(x)=[xi+ap]Sp(x)=[xi+ap]Sq(x)=[xi+ap+bq]Sq(x)=[xi+gcd(p,q)]Sp(x),从而 si=si+gcd(p,q)si=si+gcd(p,q)。如果 apap 是负数并使得 iapiap 是负数,那么我们可以先让 ii 变为 i+bqi+bq,因为此时 bqbq 一定是正整数,从而我们可以避免取负数项的系数。

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

匹配引理#

引理

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

对平凡情况进行考察后,我们只需要考虑 uuvv 中匹配了至少 33 次的情况。

image

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

uu 的最小周期为 prpr。仅根据周期定义,u1u1vv 中匹配的最后 pp 个位置不一定满足 v(x)=v(x+p)v(x)=v(x+p)。因为 u1u1u2u2 是相同的,且 pr=gcd(d,q)dpr=gcd(d,q)d,因此 |u1u2||u1u2| 中的 |u|d|u|d 个位置必然有 v(x)=v(x+p)v(x)=v(x+p)。如果 |u1u2|p|u1u2|p,那么 u1u1 中最后 pp 个位置也可以借助 u2u2 中对应位置提供的信息来满足 v(x)=v(x+p)v(x)=v(x+p)(因为 dpdp,所以这确实成立),从而 pp 也是 u1u2u1u2 的周期。因为 ppuu 的最小周期,且根据上图有 |u1u2|q|u1u2|q,因此 pq|u1u2|pq|u1u2|,从而 |u1u2|p|u1u2|p 确实成立。

此时,若 p<dp<d,则 u1u1 向右移动 pp 的距离就会产生一次匹配,和 u2u2uuvv 中第二次匹配矛盾。于是 dpr=gcd(d,q)ddpr=gcd(d,q)d 成立,从而 p=d=r=gcd(d,q)p=d=r=gcd(d,q)

推论

若字符串 u,vu,v 满足 2|u|v2|u|v,则 uuvv 中的所有匹配位置构成一个等差数列。若该等差数列项数不小于 33,则其公差 dduu 的最小周期 per(u)per(u),且此时易知 per(u)|u|/2per(u)|u|/2

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

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

Border 的结构#

引理 1

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

笔者注:此处不取整。

image

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

接下来对 ss 的所有 border 考虑如下的引理 2:

引理 2

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

我们将 border 按照长度 xx 分类:x[1,2),[2,4),,[2k1,2k),[2k,n)x[1,2),[2,4),,[2k1,2k),[2k,n)

x[2k,n)x[2k,n),其中 2kn/22kn/2,那么使用引理 11 可证。接下来讨论 x[2i1,2i)x[2i1,2i) 的情形。

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

LargePS(u,v)={kkPS(u,v),k|u|/2}LargePS(u,v)={kkPS(u,v),k|u|/2}

引理 3

LargePS(u,v)LargePS(u,v) 构成一个等差数列。

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

uvuv,考察 LargePS(u,v)LargePS(u,v) 中的最大元素 xx

image

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

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

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

小结#

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

在具体应用到题目中时,通常会考虑所有非空 border 构成的长度序列 b1b2bkb1b2bk,同时令 b0=0,bk+1=|s|b0=0,bk+1=|s|。一种较为常用的划分方法是,从后往前考虑每个 bi(1ik)bi(1ik),并判定:

  • bi+1bi=bibi1bi+1bi=bibi1,则将 bibi 划分进当前等差数列;
  • 否则,将 bibi 划分进下一个等差数列。

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

这样做的好处是,因为 bi10bi10,因此对于同一个等差数列中的相邻元素 bi,bi+1bi,bi+1,都有 bi+12bibi+12bi。这个性质和匹配引理的条件颇为相似,在某些题目中会派上用场。

最后给出一条性质作为结尾:若满足 k>1k>1 的等差数列 b=[b1,,bk]b=[b1,,bk] 存在,那么根据周期的定义,ss 存在大小为公差 b2b1b2b1 的周期,因此,除了 bkbk,其余的 bibi 都满足 s[bi+1]s[bi+1] 相同。

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

前缀数组的求法#

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

  • π1=0π1=0
  • 枚举 i=2,,ni=2,,n,依次执行如下算法:
    1. 初始化指针 jπi1jπi1
    2. s[j+1]=s[i]s[j+1]=s[i],令 πij+1πij+1,结束算法;
    3. j=0j=0,令 πi0πi0,结束算法。
    4. jπjjπj,回到 2。

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

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

前缀数组与字符串匹配#

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

  • 枚举 i=1,2,,ni=1,2,,n。同时维护指针 jj,表示 s[1,i]s[1,i] 的某个后缀和 t[1,j]t[1,j] 相等。初始令 j0j0

    依次执行如下算法:

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

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

      结束算法。

    3. s[i]=s[j+1]s[i]=s[j+1] 不成立,则判断:若 j=0j=0,则结束算法。否则令 jπjjπj,转到 1。

该算法的正确性证明和时间复杂度证明与求前缀数组是类似的,此处不再赘述。可以分析出时间复杂度为 O(n+m)O(n+m),其中 O(m)O(m) 的部分为求 ππ 的复杂度,O(n)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;
}

或者偷懒:将 sstt 用分隔符连接,每次只需要查询某一位的 ππ 值是否为 |s||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

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

注意到如果一个长度为 nn 的字符串 ss 有最短非平凡(大小不为 nn 本身)循环节 cc,那么一定有 cn/2cn/2。若 ss 的最小周期 pp 不为 cc,则 p+cnp+cn,根据周期引理,gcd(p,c)p<cgcd(p,c)p<css 的周期。因为 ccnn 的非平凡循环节,因此 gcd(p,c)cngcd(p,c)cn,同时 gcd(p,c)<cgcd(p,c)<c,推出矛盾。

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

KMP 算法的可持久化#

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

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

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

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

例题 Codeforces 1721E

给定长度为 nn 的字符串 ssmm 次询问,每次给出一个字符串 tt,询问字符串 s+ts+t 的前缀数组 ππ 中,最后 |t||t| 位的值。

字符集为小写字母集。

1n106,1m105,1|t|101n106,1m105,1|t|10

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

以下给出代码。

# 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] 论战捆竹竿#

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

多测,T5,1n5×105,1w1018T5,1n5×105,1w1018

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

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

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

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

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

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

Codeforces 1286E Fedya the Potter Strikes Back#

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

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

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

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

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

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

# 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#

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

两个序列 a,ba,b 相同,当且仅当对于每一位 iia[1,i1]a[1,i1] 中小于 aiai 的数的数量和 b[1,i1]b[1,i1] 中小于 bibi 的数的数量相等。同时,对于这一位,a[1,i1]a[1,i1] 中等于 aiai 的数的数量也要和 b[1,i1]b[1,i1] 中等于 bibi 的数的数量相等。这些要求本质上确定了插入第 ii 位时这个数要插在的位置。

等价仍然是具有传递性的,因此这不影响 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) 构成的序列,其中 xx 描述这段字符的数量,cc 描述这段字符的种类。例如,aaaabbbbaaaaaabbbbaa 可以被描述为 [(4,a),(4,b),(2,a)][(4,a),(4,b),(2,a)]

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

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

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

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

在序列中加入第 ii 个二元组 (xi,ci)(xi,ci) 的时候,我们需要关心新加入的这 xixi 个字符对应前缀的最长 border 长度。我们可以从长到短遍历二元组序列中前缀 i1i1 的所有 border​ bb,并检查该 border 的下一个位置 b+1b+1 是否有 cb+1=cicb+1=ci。如果是,那么新加入的第 k(1kmin(xi,xb+1))k(1kmin(xi,xb+1)) 个字符就存在一个长度为 (bj=1xj)+k(bj=1xj)+k 的 border 了。

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

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

具体地,我们维护两个指针 cl,crcl,cr,其中 clcl 表示当前位于的 border,crcr 表示上一个 border。如果 crcl=clπclcrcl=clπcl,则说明 clcl 是等差数列中的次长 border,我们直接跳到等差数列中的第一个 border,并将 crcr 置为 11(这样下一次判断一定不会成立)。否则,我们只向前跳一步,即将 crcr 置为 clclclcl 置为 πclπ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 函数#

对于长度为 nn 的字符串 ss,定义其 Z 函数数组 z=[z1,z2,,zn]z=[z1,z2,,zn],其中 zizis[1,n]s[1,n]s[i,n]s[i,n] 的 LCP 长度。

与前缀函数 ππ 类似,zz 也可以在线性时间内求出。算法如下:

  • z1=nz1=n

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

    1. irir,说明 ii 位于 Z-box 中。此时根据定义,有 s[l,r]=s[1,rl+1]s[l,r]=s[1,rl+1],从而 s[i,r]=s[il+1,rl+1]s[i,r]=s[il+1,rl+1]。因此 zimin(zil+1,ri+1)zimin(zil+1,ri+1)
    2. 暴力扩展 zizi。即:若 s[zi+1]=s[i+zi]s[zi+1]=s[i+zi] 则令 zizi 增加 11,直到该条不再成立。
    3. 更新 Z-box。即:若区间 [i,i+zi1][i,i+zi1] 的右端点 (i+zi1)(i+zi1) 位于 Z-box [l,r][l,r] 的右端点 rr 右侧,则将 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#

事实上,我们可以使用与求 zz 时几乎一致的方式求出 pp

	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 自动机与多串匹配#

现在有 nn 个字符串 s1,s2,,sns1,s2,,sn。它们的 AC 自动机 AA 可以看作是 s1,s2,,sns1,s2,,sn 的 Trie 树 TT 上,新建出一些边形成的有向图结构。

具体来说,我们希望 TT 扩充为 AA 后,AA 满足如下性质:

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

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

算法流程如下:

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

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

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

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

  5. tr(x,c)tr(fail(x),c)tr(x,c)tr(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;
}

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

以下是几个简单应用。

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

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

Luogu P5357 【模板】AC 自动机#

给定文本串 SS 和模式串 T1,T2,,TnT1,T2,,Tn,求每个模式串在 SS 中出现的次数。

1n,|T|2×105,1|S|2×1061n,|T|2×105,1|S|2×106

TT 建出 AC 自动机,随后将 SS 放上 AC 自动机进行匹配。流程为:

  • 维护指针 pp,初始为根。每个节点维护一个标记大小,初始为 00
  • 遍历 i=1,2,,|S|i=1,2,,|S|,令 ptr(p,Si)ptr(p,Si),将 pp 节点的标记大小增加 11

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

Luogu P4052 [JSOI2007] 文本生成器#

在 AC 自动机上 DP。

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

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

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

# 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)f(i,j,k) 表示考虑了前 ii 个字符,当前位于 AC 自动机上的 jj 节点,下标最小的没有覆盖的位置在 (i+1)k(i+1)k 处(若全部被覆盖,则取 k=0k=0)的方案数。

初始有 f(0,root,1)=1f(0,root,1)=1。考虑从 f(i,j,k)f(i,j,k) 转移到 f(i+1)f(i+1),枚举这一位填的字符 cc,记 tr(j,c)=jtr(j,c)=j,那么,记以 jj 结尾的最长模式串长度为 ll,则当 l>kl>k 时,该模式串覆盖掉之前所有的没有被覆盖的位置,f(i,j,k)f(i,j,k) 转移到 f(i+1,j,0)f(i+1,j,0);否则,f(i,j,k)f(i,j,k) 转移到 f(i+1,j,k+1)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 语言#

给定大小为 nn 的字典 DDmm 个文本串 T1,T2,,TmT1,T2,,Tm,对于每个文本串,求出最大的 ii,使得 T[1,i]T[1,i] 可以被划分为若干个字典中的单词。

1n20,1m50,1|t|2×1061n20,1m50,1|t|2×106,字典中的单词长度不超过 2020

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

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

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

但是这道题 n20n20 真是有深意呀,总感觉暴力都过去了。

# 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 树,mm 次询问 Trie 树上某个节点 xx 所代表的字符串在另一个节点 yy 所代表的字符串中出现了多少次。

字符集大小为 2626,保证 Trie 树大小不超过 1051051m1051m105

首先建出 AC 自动机。

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

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

出现次数可以差分,转化为 sksks1rs1r 中的出现次数。考虑对 rr 扫描线,对于 srsr 的每个前缀节点,将该节点的标记大小增加 11,查询即查询 sksk 对应节点在 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#

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

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

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

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

# 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,记串长和为 LL,不难发现串长种类数为 LL,据此可以 O(LL)O(LL) Hash。

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

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

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

# 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 做法。

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

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

  • 如果 |sk|>B|sk|>B

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

    这部分的复杂度是 O(L2B+qlogq)O(L2B+qlogq) 的。

  • 如果 |sk|B|sk|B

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

L2B=qBlogLL2B=qBlogL,解得最优阈值为 B=LqlogLB=LqlogL,此时时间复杂度为 O(nlogL+qlogq+qlogLL)O(nlogL+qlogq+qlogLL)

# 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 的馈赠#

对于一个询问 tx,tytx,ty,将 txtx 的每个前缀节点在 fail 树上到根的路径上打上标记 xx,将 tyty 的每个前缀节点在 fail 树上到根的路径打上标记 yy。另外,将 sisi 对应的节点的权值 +1+1。所求即为同时有标记 x,yx,y 的节点的权值之和。可以看作求两个虚树的交。

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

考虑 tx,tytx,ty 至少有一个是大串的询问。不失一般性,设 txtx 是大串。将 txtx 的每个前缀节点在 fail 树上到根的路径上打上标记 xx,将具有标记 xxsisi 对应的节点的权值 +1+1。对于每个 tyty,询问时建立虚树,统计答案即可。这部分的复杂度为 O(L2B+LlogL)O(L2B+LlogL)(就算 txtx 不同,我们也只会改变树上权值而非形态,因此对于一个串,虚树只需要建立一次,从而分析出 O(LlogL)O(LlogL))。

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

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

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

Codeforces 1483F Exam#

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

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

时间复杂度 O(LlogL)O(LlogL)

# 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)f(j) 表示从原图上点 11 的对应点开始走到 AC 自动机 jj 点的最短路,并强制要求不经过终止节点。但是本题中,字符集大小为 nn,朴素的 AC 自动机难以通过。考虑性质:tr(i)tr(i) 比起 tr(fail(i))tr(fail(i)),只有在 ii 节点出边的部分会有修改。

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

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

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

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

# 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#

仍然不好做,考虑阈值分治。设定阈值 BB

对于 sksk 是大串的情形,O(L+qlogn)O(L+qlogn) 解决一个串是容易的。

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

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

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

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

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

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

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

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

  • sta(l1)sta(l1) 开始,当 lil+d+t1lil+d+t1 时,暴力匹配出 sta(i)sta(i)
  • 取出 sta[l+d1,l+d+t2]sta[l+d1,l+d+t2],将这一段向后复制,直到 sta(r)sta(r) 为止。
  • sta(r)sta(r) 开始,当 r+1ir+d2r+1ir+d2 时,暴力匹配出 sta(i)sta(i)

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

SCOI2024 Day1 T1 (口胡)#

考虑分类讨论。存在两种情况:si+tjsi+tj 完整地在某个 sxsx 或者 txtx 中出现,以及 sx+tysx+ty 的分界线将 si+tjsi+tj 分成了两部分。

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

对于同一个 sxsx,枚举 ll,答案即为二者乘积之和。枚举所有的 sx,txsx,tx,再枚举 ll 计算答案,我们就在 O(len)O(len) 的时间计算出了第一种情况的答案。

对于第二种情况,有三种子情况:sisisx+tysx+ty 分成了两部分;tjtjsx+tysx+ty 分成了两部分;si+tjsi+tj 的分界线恰好是 sx+sysx+sy 的分界线。

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

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

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

回文相关#

基础性质#

重排#

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

本质不同回文子串数量#

定理 1

一个长度为 nn 的字符串 ss 的本质不同回文子串数量不超过 nn

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

回文引理#

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

引理 1

tt 是回文串 ss 的后缀,则 ttss 的 border 当且仅当 tt 是回文串。

根据定义可证。

引理 2

ttss 的 border(|t||s|/2|t||s|/2),则 ss 是回文串当且仅当 tt 是回文串。

ss 是回文串,利用引理 11

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

引理 3

tt 是回文串 ss 的 border,则 |s||t||s||t|ss 的最小周期当且仅当 ttss 的最长回文真后缀。

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

引理 4

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

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

定理 2

对于 |s|>1|s|>1ss 的所有回文后缀按照长度排序后可以划分为 log2|s|log2|s| 个等差数列。

利用引理 4,我们注意到 tt 在至多一步后就会变为回文串 tt,从而 ss 的所有回文后缀就是 tttt 的所有 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);
}

相信大家都会。

性质:

  • 原串中 sisi 在新串 tt 的位置为 s2is2i

  • 原串中子串 s[l,r]s[l,r] 的回文中心为 tl+rtl+r

    分奇偶讨论容易证明。

几个基础应用#

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

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

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

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

    由于 Manacher 的特性,分奇偶讨论容易证明新串中以 ii 为回文中心,jj 结尾的回文串在原串中对应一个以 j/2j/2 结尾,长度为 ji+1ji+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] 是否为回文串

    找到 s[l,r]s[l,r] 的回文中心在 tt 中的位置 l+rl+r,若区间 [l,r][l,r] 是回文串,则 tl+rtl+r 的回文半径必须覆盖 t2rt2r,即回文半径 pl+r2r(l+r)+1pl+r2r(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 是接受 ss 所有回文子串的类自动机结构。

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

image

左图展示了 babbabbabbab 的 PAM 结构,其中蓝色虚边为其 fail 树结构,右图中用黑色实边展示。

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

  • PAM 中存在两个入度为 00 的起始节点 evenevenoddodd。接下来分别用节点 E,OE,O 来代指起始节点 evenodd

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

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

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

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

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

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

构造:末端插入法#

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

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

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

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

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

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

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

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

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Σ)

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

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

一个有趣的事实是,我们根本不会用到 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 指针,找到第一个前驱字符和 si 相同的回文后缀 t。注意到,除了 len(t)=len(last) 的场合,t 必然是 last 对应串的真后缀。这种情况下,tsi 具体是什么无关,而只和 last 有关。

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

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

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

需要注意边界情况: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(i1) 个位置的答案是 k,第 i+1 个字符读入时的 ASCII 码为 c,则第 i+1 个字符实际的 ASCII 码为 (c97+k)mod26+97。所有字符在加密前后都为小写字母。

1|s|5×105

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

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

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

1|s|3×105

如果能够求出每个节点代表的字符串的 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)#

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

构造:对给定 Trie 树构造 PAM#

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

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

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

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

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

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

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

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

例题 3 [CERC2014] Virus synthesis#

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

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

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

求最小操作数。

|S|105,字符集为 {A,T,C,G}

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

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

考虑归纳。|T|2 的情况是平凡的。

对于 |T|>2 的情况,若形成串 T=BBT 的最后一次操作不是操作 2,找到最后一次操作 2 结束后形成的串 T=AAT,其中 ST 表示串 S 的反转。

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

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

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

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

link(T) 可以使用与求 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,选出尽可能多的回文子串 t1,t2,,tk 使得不存在 ij 满足 titj 的子串。

1|s|105

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

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

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

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

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

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

最小回文划分#

给定字符串 s,求:

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

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

1|s|105

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

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

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

考虑维护如下信息:对于一个节点 x,如果它是某个等差数列中长度最长的节点,那么就更新 g(x)yS(x)f(ilen(x)),其中 S(x) 表示 x 所属等差数列中的节点。

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

image

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

证明

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

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

这样,g(fail(x))=yS(x)f((idiff(x))len(x)),即上图中蓝色的部分。g(x)(图中橙色部分)和 g(fail(x)) 相比,只多了一个 f(i|slink(x)|+diff(x))。因此我们可以 O(1) 更新出 g(x)

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

例题 5 CF932G Palindrome Partition#

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

求将 s 划分为偶数段 t1,t2,,t2k,且对于 i=1,2,,k,满足 ti=t2ki+1 的方案数。

答案对 109+7 取模。

2n106

不难注意到我们要求的是划分构成一个回文序列的方案。这有点棘手,不妨令 s=s1sns2sn1sn/2sn/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 的小写字母串 sq 次查询,每次查询 s[l,r] 的本质不同回文子串数量。

强制在线。

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

1n105,1q2×105

  • 解法一

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

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

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

  • 解法二

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

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

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

    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(nlog2n+qlogn)

Luogu P4199 万径人踪灭#

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

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

这是一个和卷积的形式。具体地,枚举字符 aΣ,令 fi=[si=a],将 [xl+r]f2(x) 累加到某个初始为零的数组 h 上。那么,合法的子序列数量即 p(2hp1)

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

非平凡回文串划分判定#

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

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

  • d/2f(i)<d

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

  • f(i)<d/2

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

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

作者:Meatherm

出处:https://www.cnblogs.com/Meatherm/p/18214357

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   Meatherm  阅读(105)  评论(2编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
more_horiz
keyboard_arrow_up light_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示