基础字符串算法 I

1. Hash 字符串哈希

1.1. 算法简介

字符串哈希(Hash)一般通过进制哈希实现.
即:将字符串看作一个数,通过进制转化为整型,进制数一般为特殊质数/模数常ull自然溢出(防止Hash冲突)。
哈希冲突:指不同输入的映射键值相同。(防止哈希一般采用不同进制数或模数)

1.2. 应用

1.2.1. 判断字符串相等

预处理整个数列的 \(Hash[1-n]\) ,比较 \(calc(l_1, r_1)\)\(calc(l_2, r_2)\),哈希值相等即字符串相等。

1.2.2 判断周期

\(calc(1, i)==calc(j+1, n)\) ,则 \(s[1,n]\) 的循环节为 \(s[i,j]\) 。画图理解一下。

1.3. 例题

I. P3538 [POI2012]OKR-A Horrible Poem

循环节经典问题。先哈希预处理。
对于每个询问 \(l,r\) ,循环节长度一定为 \(len=r-l+1\) 的因数,对因数枚举即可(可以一开始先尝试 \(1\) 来卡常)。

while(Q--){
    ll l, r, len, ans;
    scanf("%lld%lld", &l, &r);
    ans=len=r-l+1;//len用于分解所有(r-l+1)的质因子
    if(calc(l, r-1)==calc(l+1, r)) {puts("1"); continue;}//卡常

    while(len>1){
	if (calc(l+ans/g[len], r) == calc(l, r-ans/g[len]))  ans /= g[len];// 判断 是否可分
	len /= g[len];//分解 试遍每一个质因子 g[i]为i的最小质因子
    }
    printf("%lld\n", ans);
}

2. Manacher

2.1. 算法简介

Manacher用于回文串匹配
1.所有字符中间(包括首尾)添加相同的分隔符 \(op1\) ,并在串的最前方插入另一种分隔符 \(op2\) (防止越界)。
2.定义:
\(p[i]\) 为以 \(i\) 为中心的回文半径(不包括 \(i\) 本身),即: \(s[i-p[i]]~s[i+p[i]]\) 为以 \(i\) 为中心的最长回文串;
\(mr\) 为当前所有回文串的最右端点, \(d\)\(mr\) 对应的回文中心,即: \(d+p[d]==mr\) ;
3.处理:对于新位置i:
\(i<=mr\) , 设 \(i\) 关于 \(d\) 的对称点为 \(j\)\(p[i]=min(p[j], mr-i)\) ;否则 \(p[i]\) 转移后继续用朴素算法暴力拓展 \(p[i]\) ,并更新 \(d/mr\)

时间复杂度分析: \(mr\) 只会右移,线性 \(\mathcal{O}(n)\)
模板 P3805 【模板】manacher 算法

ll d=0, mr=0;
for(ll i = 1; i <= n ; i++){
    p[i]=0;
    if(i<=mr) p[i]=min(p[d*2-i], mr-i);
    while(t[i-p[i]-1]==t[i+p[i]+1]) p[i]++;
    if(i+p[i]>mr) mr=i+p[i], d=i;    ans=max(ans, p[i]);
}

2. 例题

I. P3501 [POI2010]ANT-Antisymmetry

较裸的Manacher。更改回文条件 \(check()\) 即可:同为间隔符(\('\#'\))或为不同数字(\(0\oplus 1=1\)),跑一遍Manacher好啦。

II. P1659 [国家集训队]拉拉队排练

求出每个回文中心 \(d=i\) 的最长回文半径 \(p[i]\) (常规Manacher)。
求所有回文串个数?—— \(c[len]\) 记录最长回文半径 \(p[i]==len\) 的个数,对 \(c[len]\) 由大往小做逆向前缀和 \(s[len]\) :得到回文半径为len的回文串个数——处理前 \(k\) 小串即可。

ll d=0, mr=0;
for(ll i = 1; i <= n ; i++){
    p[i]=0;
    if(i<=mr)  p[i]=min(p[d*2-i], mr-i);
    while(s[i-p[i]-1]==s[i+p[i]+1]) p[i]++;
    if(i+p[i]>mr) mr=i+p[i], d=i;
    c[p[i]*2+1]++;
}
for(ll i = n ; i > 0 ; i--) c[i]+=c[i+1];//后序前缀和 维护个数(相当于上文的s[])

*III. P4555 [国家集训队]最长双回文串

开始只想到 \(\mathcal{O}(n^2)\) 做法:枚举两个回文串。——看了Alex_Wei's Blog惊叹并理解了线性\(\mathcal{O}(n)\)
——处理以原串中 \(i\) 结尾和以 \(i\) 开头的最长回文串(正倒序 \(2\) 次 Manacher),具体是:
设 Manacher 操作串位置为 \(j\)\(x[i],y[i]\) 表示原串中 \(i\) 结尾和以 \(i\) 开头的最长回文串。
正序更新 \(mr\) 时, \(mr_{old}+1 ~ mr_{new}+1\) 结尾的最长回文串皆以 \(d_{new}\) 为中心。
倒序更新 \(ml\) 同理, \(mr_{new} ~ ml_{old}-1\) 开头的最长回文串皆以 \(d_{new}\) 为中心。
注意Manacher操作串中 \(j%2==0\) 才可更新 \(x[j>>1]/y[j>>1]\)(对应原串位置 \(i\)

for(ll i = 1, d=0, mr=0; i <= 2*n ; i++){//x[i]:以i结尾最长
    p[i]=0;
    if(i<mr) p[i]=min(p[d*2-i], mr-i);
    while(t[i-p[i]-1]==t[i+p[i]+1]) p[i]++;
    if(i+p[i]>mr){
        for(ll j = mr+1 ; j <= i+p[i] ; j++)
        if(j%2==0) x[j>>1]=j-i+1;//Manacher操作串中j%2==0更新x[j>>1]/y[j>>1]
	mr=i+p[i], d=i;//update d/mr
    }
}
for(ll i = 2*n-1, d=2*n, ml=2*n; i > 0 ; i--){//y[i]:以i开头最长
    p[i]=0;
    if(i>ml) p[i]=min(p[d*2-i], i-ml);
    while(t[i-p[i]-1]==t[i+p[i]+1]) p[i]++;
    if(i-p[i]<ml){
	for(ll j = ml-1; j >= i-p[i] ; j--)
	if(j%2==0) y[j>>1]=i-j+1;//Manacher操作串中j%2==0更新x[j>>1]/y[j>>1]
	ml=i-p[i], d=i;//update d/ml
    }
}
ll ans=0;
for(ll i = 1; i < n ; i++) ans=max(ans, x[i]+y[i+1]);

IV. UVA11475 Extend to Palindrome

简单,求出右端点 \(mr==n\) 即以 \(s[n]\) 结尾的最长回文串即可。可以在更新 \(mr\) 时标记 \(c[mr]=d\) 获取最长回文串的中心,答案即为非回文部分添在末尾。

*V. P5446 [THUPC2018]绿绿和串串

和 IV 类似,较复杂些(THUPC!!!)
1.对于对称中心 \(d=i\) ,若右端点 \(mr_i==n\) 则中心 \(d=i\) 可以直接反转整个串而满足结果; 记 \(b[i]=1\) 表示以 \(d=i\) 中心可行。
2.否则,对于不满足的中心 \(d=i\) ,若左端点为 \(l_i==1\) 且右端点 \(b[r_i]=1\) , 则中心覆盖的回文串可以多次翻转而满足结果。
注意到对称中心 \(d\) 越靠右, \(b[i]=1\) 可能性越大(条件\(1\)得到)。则Manacher后,由右至左枚举,check条件 \(1\) /通过 \(b[]\) 数组转移可行性条件\(2\)

void sol(){
    memset(b, 0, sizeof(b));
    scanf("%s", s+1);
    n=strlen(s+1);
    s[0]='~', s[n+1]='!';//翻转串为奇数串,无需加间隔符
    ll mid=0, mr=0;
    for(ll i = 1; i <= n ; i++){//一次Manacher
	p[i]=0;
	if(i<mr) p[i]=min(p[mid*2-i], mr-i);
	while(s[i-p[i]-1]==s[i+p[i]+1]) p[i]++;
	if(i+p[i]>mr) mr=i+p[i], mid=i;
    }

    // i+p[i]==n(条件1)/b[]转移(条件2)
    for(ll i = n ; i > 0 ; i--)
	if(i+p[i]==n) b[i]=1;// 条件1:i+p[i]==n/b[]同步回文可行性(由大串向小串)
	else if(b[i+p[i]]&&i-p[i]==1) b[i]=1;// 条件2:b[]转移

    for(ll i = 1; i <= n ; i++)  if(b[i]) printf("%lld ", i);puts("");/*...output..*/
}

3. KMP

3.1. 算法分析

与其他提高级字符串算法相同,KMP利用已有信息————\(nxt[]\)数组优化匹配。
定义 \(nxt[i]\) 为模式串 \(t\)\(i\) 位的最长公共真前后缀

3.1.1. \(s\)\(t\) 匹配:

假设已经求出 \(nxt[]\)。。。
文本串匹配到 \(s[i]\) (\(i-1\)完成匹配),模式串匹配完 \(t[p]\) 匹配时:

  • \(s[i]!=t[p+1]\)
    • \(p!=0\): \(p\leftarrow nxt[p]\),再循环进入操作 \(1\)
    • \(p==0\): 进入操作 \(2\);
  • \(s[i]==t[p+1]\): \(\quad p++\) , \(end\);

给出原因
\(s[i]==t[p+1]\) : \(p++\)\(s\)\(t\) 可以继续匹配。
\(s[i]!=t[p+1]\) : \(s\)\(t[nxt[p]+1]\) 可以继续尝试匹配。
对于 \(p\)\(nxt[p]\) 表示 \(t[1 , nxt[p]]=t[p-nxt[p]+1, p]\) ,则 \(t[1, nxt[p]]=s[i-nxt[p], i-1]\) ,即 \(s[i-1]\)\(nxt[p]\)同样匹配,于是 \(s[i]\) 继续和 \(p=nxt[p]\) 匹配即可。特殊的:若 \(p=0\) 时仍不匹配,\(p=0\) 保持即可;

3.1.2. 求 \(nxt[]\) 数组

类似 \(s\)\(t\) 匹配, \(t\) 可以和自己(相同字符串 \(t'\) )匹配以求 \(nxt[]\) 数组。
为保证 \(nxt[]\) 为最长前后缀, \(t'\)\(t[2]\) 开始匹配 \(t\)
设已经求出 \(nxt_1~nxt_i-1\) ,当前 \(t'\) 匹配到 \(t[p]\)\(s[i]\)等待匹配,求 \(nxt_i\) .

  • \(s[i]==t[p+1]\): \(nxt[i]=p+1\),end;
  • \(s[i]!=t[p+1]\): \(p\leftarrow nxt[p]\) ,循环操作直到 \(p=0\)\(s[i]!=t[p+1]\)
    模板P3375 【模板】KMP字符串匹配
    scanf("%s%s", s+1, t+1);
    n=strlen(s+1), m=strlen(t+1);
    for(ll i = 2, p = 0 ; i <= m ; i++){//t与t'匹配
        while(p && t[i]!=t[p+1]) p=nxt[p];//失配
        nxt[i] = (p+=t[i]==t[p+1]);//配
    }
    for(ll i = 1, p = 0 ; i <= n ; i++){//s与t匹配
        while(p && s[i]!=t[p+1]) p=nxt[p];
        if((p+=s[i]==t[p+1])==m) printf("%lld\n", i-m+1), p=nxt[p];
    }
    for(ll i = 1; i <= m ; i++) printf("%lld ", nxt[i]);

3.2. 拓展:KMP自动机

推荐前置芝士: 自动机/确定有限状态自动机

对于一个模式串 \(s\) 建立KMP自动机, 状态集合 \(Q\)\(0~n\) ,状态 \(i\) 表示输入的所有字符 \(t[1,p]\)\(s\) 的最长匹配长度为 \(i\) ,即 \(t[p-i+1, p]=s[1, i]\) 。转移函数 \(\delta(i, c)\) 为:
\(\delta(i, c) = \begin{cases} i+1 \quad\quad\quad s_{i + 1} = c \\ 0 \quad\quad\quad\quad s_{i + 1} \neq c \land i = 0 \\ \delta(nxt_i, c) \quad s_{i + 1} \neq c \land i \neq 0 \\ \end{cases}\)
极好理解:若当前字符匹配,转移到 \(i+1\) ;否则若 \(i==0\) ,匹配长度仍为 \(0\) ;否则转移到 \(\delta(nxt[i], c)\) —— \(s[1, i]\) 最长真前缀后缀长度( \(i\) 接受 \(c\) 转移的状态等价于 \(nxt_i\) 接受 \(c\) 转移的状态)。

3.3. 特殊性质及定义

详见 5. Border与支配树

3.4. 例题

I. P4391 [BOI2009]Radio Transmission 无线传输 && UVA10298 Power Strings

基础KMP循环题:\(s1\) 为循环串, \(n-nxt[n]\) 即为 \(s2\) 最短长度。UVA10298变一下即可。

II. P3435 [POI2006] OKR-Periods of Words

对于前缀 \(s[1 , p]\)\(n-nxt[p]\) 一定为该前缀的一个合法周期, 则 \(n-nxt[nxt[p]](nxt[p]!=0)\)同样合法周期;
注意到 \(nxt[p]\) 构成失配树,问题转化为对于每个前缀 \(s[1,p]\) ,求出其在失配树上的最浅非根结点x, \(i-x\) 即为前缀的最长周期;

III. P4824 [USACO15FEB] Censoring S

使用 \(stk\) 维护文本串 \(s[i]\) 匹配后指针 \(p\) 状态。设当前已匹配 \(t[p]\) 。先正常匹配,更新 \(p\) 指针,并将当前指针状态\(p\) 压入栈。若 \(p==m\) 即完全匹配 \(t\) ,则\(top-=m, p\rightarrow stk[top]\)(模拟清除字串/退栈)。

for(ll i = 2, p=0; i <= m ; i++){//求nxt[]
    while(p && t[i]!=t[p+1]) p=nxt[p];
    nxt[i] = (p+= t[i]==t[p+1]);
}
for(ll i = 1, p=0; i <= n ; i++){
    while(p && s[i]!=t[p+1]) p=nxt[p];
    f[i]= (p+=s[i]==t[p+1]);
    stk[++top]=i;

    if(p==m) top-=m, p=f[stk[top]];//p 与 f[stk[top]]栈顶同步
}

IV. CF1200E Compress Words

KMP裸暴力。 \(n\) 个单词首尾相同部分压缩去重,做 \(n-1\) 次KMP即可。
设当前文本串(已压缩部分)为 \(s\) ,模式串 \(t\) 长度为 \(len_t\) ,则直接从 \(s[len_s-len_t+1]\) 开始匹配(优化避免无意义匹配)即可,最后 \(t[nxt[len_t]+1, len_t]\) 加入文本串。

*V. UVA11022 String Factoring

显然是一个 \(\mathcal{O}(n^3)\) 区间DP。
不过可以用KMP优化:对每个区间做一次KMP以判断当前区间是否循环, \(len-nxt[r-(l-1)]\) 判断,存在则直接用循环节信息更新即可。


    for(ll len = 2; len <= n ; len++)
    for(ll l = 1; l+len-1 <= n ; l++){
	ll r=l+len-1;
	for(ll k = l; k < r ; k++) f[l][r]=min(f[l][r], f[l][k]+f[k+1][r]);
	for(ll i = l+1, p=0; i <= r ; i++){//区间内 KMP
            while(p && s[i]!=s[p+1+(l-1)]) p=nxt[p];
		nxt[i-(l-1)] = (p+= s[i]==s[p+1+(l-1)]);
            }
		
        ll res=len-nxt[r-(l-1)];
        if(len%res==0) f[l][r]=min(f[l][r], f[l][l+res-1]);//整除 判断 完全周期
}

*VI. CF526D Om Nom and Necklace

简明题意:要求检查 \(s\) 的每一个前缀是否满足样式: \(ABAB...BABA,cnt(A)==k+1,cnt(B)==k\)
将ABABABABA视为CCCCA(C为满足条件的最短长度的字符串),注意到还可化为EEA,方案合法当且仅当E长度大于A长度
C长度即为 \(len\leftarrow i-nxt[i]\) ,C个数为 \(ct \leftarrow i/len\) ,若给定 \(k\) ,则E应有 \(k\) 段,每段含C的个数为 \(ct/k\) ,A含C的个数为 \(ct\%k\)

  • \(i\%len==0\) : 即前缀i可视作C的循环, \(ct/k>=ct\%k\) 即可。
  • \(i\%len!=0\) : 须满足 \(ct/k>ct\%k\)
scanf("%lld%lld%s", &n, &k, s+1);
for(ll i = 2, p=0; i <= n ; i++){//KMP
    while(p && s[i]!=s[p+1]) p=nxt[p];
    nxt[i] = (p+= s[i]==s[p+1]);
}
//理解为CCCCA(=EEA)
for(ll i = 1; i <= n ; i++){
    ll len=i-nxt[i];//最短C长度
    ll ct=i/len;//C个数
    if(i%len) cout<<(ct/k-ct%k>0);// ct/k表示E长度  ct%k表示A包含的C的个数
    else cout<<(ct/k-ct%k>=0);
}

VII. P3193 [HNOI2008]GT考试

KMP自动机运用。
设状态i表示文本串匹配到模式串第 \(i\) 位, \(tr[i][j]\)表示当前状态为 \(i\) 输入下一位 \(j\) 时将转移到状态 \(tr[i][j]\)
\(g[i][j]\) 表示当前状态为 \(i\) 时,有 \(g[i][j]\) 种加字符/数字的方案使得状态转移到 \(j\);可以通过 \(tr[][]\) 得到 \(g[][]\)
一共有 \(N\in[1, 10^9]\) 位,而状态转移方案 \(g[i][j]\) 只与当前位下一位有关———果断选择矩阵快速幂优化,初始矩阵即为 \(g[][]\) ;

scanf("%lld%lld%lld%s", &n, &m, &mod, s+1);
	for(ll i = 2, p=0; i <= m ; i++){//KMP
		while(p && s[i]!=s[p+1]) p=nxt[p];
		nxt[i] = (p+= s[i]==s[p+1]);
	}
	for(ll i = 0 ; i <= m ; i++)//求tr[][]
	for(ll j = 0 ; j <= 9 ; j++){
		if(i<m && j==s[i+1]-'0') tr[i][j]=i+1;
		else if(!i) tr[i][j]=0;
		else tr[i][j]=tr[nxt[i]][j];
		g.o[i][tr[i][j]]++;//处理 g[][]
	}
	f.o[0][0]=1;

	Mat S=f;
	while(n){//快速幂
		if(n&1) S=S*g;
		n>>=1, g=g*g;
	}
	ll ans=0;
	for(ll i = 0; i < m ; i++) ans=(ans+S.o[0][i])%mod;
	printf("%lld", ans);

4. Z Algorithm(扩展KMP)

4.1. 算法简介

定义一个字符串的** Z函数 \(z_i\) **为s的后缀 \(i\)\(s\) 本身的最长公共前缀,即 \(z_i=|lcp(s[i, n], s)|\) ,有: \(s[i, i+z_i+1]=s[1, z_i]\)
一般令z_1=0, 通过增量法线性求出z函数。

\([i, i+z_i-1]\)\(i\)匹配段————Z-box,根据定义有 \(s[i, i+z_i-1]=s[1, z_i]\) 。操作求Z函数过程中,我们维护最靠右的Z-box \([1, r]\) ,初始化 \(l=r=0\) ;
匹配到 \(i\)

  • \(i>r\) ,直接暴力匹配。
  • \(i<=r\) ,因为 \(s[l, r]=s[i, r-l+1]\) ,所以有 \(s[i, r]=s[i-l+1, r-l+1]\) 。故首先令 \(z[i]\leftarrow \min(z[i-l+1], r-i+1)\) ,然后暴力匹配。

时间复杂度分析:每次成功的匹配会使 \(r\) 右移一位,线性 \(\mathcal{O}(n)\)

4.2. 应用:前缀字符串匹配

求t的每个前缀与 \(s\)\(p_i\)

  • 解法1:令 \(S=s+' '+t\) ,对 \(S\) 求Z函数即可
  • 解法2:类似于对 \(s\) 对自身求Z函数,我们维护最右匹配段 \([l, r]\) 表示 \(t[l, r]=s[1, r-l+1]\) 。对于 \(i\) ,若 \(i>r\) , 直接暴力匹配;否则 \(p_i\leftarrow \min(z_{i-l+1}, r-i+1)\) 然后再暴力匹配。

4.3. 例题

4.3.1. P5410【模板】扩展KMP(Z函数)

scanf("%s%s", s+1, t+1);
ll ls=strlen(s+1), lt=strlen(t+1);

z[1]=lt;
for(ll i = 2, l = 0, r = 0; i <= lt ; i++){//对t本身求Z函数
    if(i<=r) z[i] = min(z[i - l + 1], r - i + 1);
    while(t[1 + z[i]] == t[i + z[i]]) z[i]++;
    if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}	
for(ll i = 1, l = 0, r = 0; i <= ls ; i++){//s与t 匹配
    if(i <= r) p[i] = min(z[i - l + 1], r - i + 1);
    while(p[i]<lt && s[i + p[i]] == t[1 + p[i]]) p[i]++;//p[i]<lt 判断匹配长度与模式串大小
    if(i + p[i] - 1 > r) l = i, r = i + p[i] - 1;
}
	
ll ans=0;
for(ll i = 1; i <= lt ; i++) ans ^= 1ll * i * (z[i]+1);
printf("%lld\n", ans);	ans=0;
for(ll i = 1; i <= ls ; i++) ans ^= 1ll * i * (p[i]+1);
printf("%lld\n", ans);

4.3.2. [P7114 P7114 [NOIP2020] 字符串匹配

看到循环,我这次选择了Z函数:显然对于 \(i\)\(s[1~i-1]\) 可能为循环节且可以根据Z函数拓展循环节。
先预处理Z函数, \(op[i]\)二进制与异或表示各个字母出现奇偶(区间奇偶由异或前缀和可得), \(popcnt(op)\) 快速求出奇数次字母个数。

从左至右枚举 \(i\) ( \(i=3\) 开始)设以 \(s[1~i-1]\) 为循环节, \(sum_j\) 维护出现奇数次字母小于j的字符串总数
注意到:以 \(s[1~i-1]\) 为循环节,循环次数最多为 \((i+z_i-1)/(i-1)\)

scanf("%s", s+1);
n=strlen(s+1);
for(ll i = 1; i <= n ; i++) op[i]=op[i-1]^(1<<(s[i]-'a'));//op[]
for(ll i = 2, l = 0, r = 0; i <= n ; i++){//Z函数
    if(i <= r) z[i] = min(z[i-l+1], r-i+1);
    while(s[1+z[i]] == s[i+z[i]]) z[i]++;
    if(i+z[i]-1 > r) l = i, r = i+z[i]-1;
}

for(ll i = 1; i <= 26 ; i++) sum[i]++;
for(ll i = 3; i <= n ; i++){
    ll d=i-1;	
    
    ll ct = popcnt(op[n]^op[i-1]);
    ans+=sum[ct];
    if(z[i]+1>=i){
        for(ll j = d*2; j <= min(i+z[i]-1, n-1); j+=d){//循环次数最多为(i+z_i-1)/(i-1)
	    ll ct=popcnt(op[n]^op[j]);
            ans+=sum[ct];
        }
    }

    ct=popcnt(op[i-1]);
    for(ll i = ct ; i <= 26 ; i++) sum[i]++;//更新sum[]
}
cout<<ans<<"\n";
posted @ 2022-10-13 14:51  Yzfu  阅读(28)  评论(0)    收藏  举报