基础字符串算法 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";

浙公网安备 33010602011771号