曇天も払う|

AzusidNya

园龄:1年6个月粉丝:4关注:4

进行一个字符串算法的总结

进行一个字符串算法的总结。

本文参考 字符串基础 by Alex_Wei。

这边代码习惯字符串下标从 0 开始,例题的分析都是基于这一点的。


Manacher 算法

这玩意是用来求回文子串的。

虽然一个字符串的子串数量是 O(n2) 级别的,但是回文串有更好的描述方式。

注意到若一个子串 [l,r] 是以 mid 为回文中心的回文串,那么将左端点和右端点朝着 mid 方向挪动若干单位也是回文串。因此我们只需要记录回文中心和最大的回文半径就可以得到所有回文子串的信息。

回文中心的个数是 O(n) 级别的,所以能高度压缩地记录回文串的信息。

Manacher 算法就是一个 O(n) 的时间复杂度求出每个点的回文半径的算法。

随便敲个字符串 aazuusuuzazza 出来。

观察到偶数长度的回文子串的回文中心在两个字符之间,而奇数长度的回文子串回文中心是一个字符。

为了把这两种子串统一起来,我们在两个字符之间加入一个不会在串中出现的字符,例如 @

然后我们得到了串串 @a@a@z@u@u@s@u@u@z@a@z@z@a@

Ri 为新串的第 i 个字符的最长回文半径,那么我们要求的是 R 数组。

手玩一下,以第 i 个字符为回文中心的最长串串的长度就是 Ri1

考虑一种暴力。枚举回文中心后尝试向两边扩展,能扩展则扩展。

因为回文串级别是 O(n2) 的,所以这个算法的时间复杂度是 O(n2) 的。

但是我们发现回文串有些比较好的性质。

举个例子,我们在上面抓个子串出来。就决定是你了,@a@a@z@u@u@s@u@u@z@a@z@

假设我们已经知道了最中间的那个 sRi10,能扩展到最远的地方是倒数第二个 @

再考虑这个 s 后面三个字符的那个 @。我们的 Ri 还用从 0 开始枚举吗?

把这个 @ 对称过去,找到 s 前面三个的 @。我们求出了这个 @ 的最长回文半径是 3

那对称地,这个 @ 的最长回文半径至少是 3,这样我们将 Ri 赋初始值为 3 就行了。

再考虑倒数第四个字符的那个 a。对称过去是第二个字符的 a,其 R2,所以赋这个 a 初值为 2。这个时候就能继续扩展到 4。然后能扩展到最远的地方更远了,所以更新当前对称中心和扩展到的最远地方。

实现上,设当前扩展到最远的地方为 r,对称中心为 c

如果当前字符 i>r,那么令 Ri=0。否则令 Ri=min{R2ci,ri+1}

然后暴力扩展,更新 rc

对于每个 r,在 i<r 的时候更新是 O(1) 的,而 r 最多变化 n 次。

所以时间复杂度是 O(n) 的。


P3805 【模板】manacher

R 数组求出来后对 Ri1max 即可。

namespace azus{
int n;
string s, a;
int R[23000005];
int main(){
cin >> s;
n = s.length();
for(int i = 0; i < n; i ++)
a += "@", a += s[i];
a = a + "@"; n = a.length(); a = " " + a;
R[1] = 1;
int r = 1, c = 1, ans = 0;
for(int i = 1; i <= n; i ++){
if(i <= r)
R[i] = min(r - i + 1, R[2 * c - i]);
while(i - R[i] >= 0 && i + R[i] <= n && a[i - R[i]] == a[i + R[i]]) R[i] ++;
if(i + R[i] - 1 > r) r = i + R[i] - 1, c = i;
ans = max(ans, R[i] - 1);
}
cout << ans;
return 0;
}
}

意外的发现 a += "@"a = a + "@" 有很大区别,前者复杂度 O(1),后者还要加上复制串串的复杂度所以是 O(n)

P3501 [POI2010] ANT-Antisymmetry

和回文串性质一样,在 while 循环中改改条件,变成扩展 ANT-Antisymmetry 串就行了。

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

对于每个 @,统计以它为右端点的最长回文串和以它为左端点的最长回文串。

但是我们统计的 Ri 是一个点能扩展到的最长长度。

定义一个回文串是饱和的,如果这个字符串不能再扩展了。我们发现有些点结尾或开头的不饱和字符串比包和字符串还长。

所以简单递推更新一下即可。

//Manacher 中
ls[i + R[i] - 1] = max(ls[i + R[i] - 1], R[i] - 1);
rs[i - R[i] + 1] = max(rs[i - R[i] + 1], R[i] - 1);
//进行一个简单递推和统计方案
for(int i = n; i >= 3; i -= 2)
ls[i] = max(ls[i + 2] - 2, ls[i]);
for(int i = 3; i <= n; i += 2)
rs[i] = max(rs[i - 2] - 2, rs[i]);
for(int i = 1; i <= n; i ++)
if(a[i] == '@' && ls[i] && rs[i]) ans = max(ans, ls[i] + rs[i]);

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

把每个长度的奇回文串的长度用桶统计一下。

然后用快速幂直接做就行了。

for(int i = 2; i <= n; i += 2)
t[R[i] - 1] ++;
for(int i = n - (!(n & 1)); i >= 1; i --){
if(!t[i]) continue;
if(k <= t[i]) {ans = ans * ksm(i, k) % P, k = 0; break;}
if(i == 1) break;
k -= t[i], ans = ans * ksm(i, t[i]) % P;
t[i - 2] += t[i], t[i] = 0;
}

P5446 [THUPC2018] 绿绿和串串

这东西乍看下去有点复杂。

观察下,首先发现如果有一个以最后一个字符结尾的回文子串,那么以这个子串回文中心翻转一下一定满足条件。

然后,如果以 i 为轴翻转的串满足条件,那么如果有个字符串能翻转造出字符串 [1,i] 那也能满足条件。

这个字符串必须从 1 开始翻转,所以这个字符串的回文中心固定为 12(1+i) 这个位置。

从后往前递推判断每个字符是否能满足条件即可。

//Manacher 中
if(a[i] != '@' && i + R[i] - 1 == n) flg[i] = 1;
//进行一个递推
for(int i = n - 1; i >= 2; i -= 2){
if(flg[i]){
int u = 1 + (i + 1) / 2;
if(a[u] == '@') continue;
if(u + R[u] - 1 == i + 1) flg[u] = 1;
}
}
for(int i = 1; i <= n; i ++)
if(flg[i]) cout << i / 2 << " ";

KMP

进行一个大家都会 KMP 的假设。但是这里还是用三行总结下 KMP 的精髓。

定义字符串的 border 是字符串的后缀和前缀的最长匹配字符串,定义前缀函数 π(i) 表示字符串 s[1,i] 的 border 的长度。

容易发现 s[1,i] 的 border 一定是 s[1,i1] 的某个若干阶 border 后面连一个字符构成的。

根据这个性质不断跳 border 就能求出 π 数组,然后字符串匹配就好做了。


Z 算法 / 扩展 KMP

这个东西其实和 Manacher 有点像。

定义一个字符串 s 的 Z 函数 zi 表示 si 后缀(第 i 个字符开始的后缀)和 s 的最长公共前缀的长度。

也就是 zi=|lcp(sufi,s)|。其中 z1 无意义,可以设为 0 也可以设为 n

举个例子,这里有个可爱的串串 aazaazaau

那么 z 函数值就是 {z1,1,0,5,1,0,2,1,0}。(如 z4=|lcp{aazaazaau,aazaau}|=|aazaa|=5

有暴力做法,对每个位置暴力向后匹配。时间复杂度是 O(n2)

和 Manacher 一样,这样的话有些性质没有被利用起来。

我们称位置 i 的匹配段为 [i,i+zi1],这个东西也被称为 Z-Box

维护当前 r 值最大的 Z-box,然后分两类情况讨论。用 aazaazaa 举例。

第一种情况,例如匹配第一个 a 和第三个 z 时,当前最大的 r<i,所以要暴力向后匹配。

第二种情况,例如第六个字符 z。当前求出了前五个字符的 zi,并且 l=4,r=8

此时 s[1,5]s[4,8] 是一样的,所以 s[6] 等于 s[3]zi 就能直接初始化为 z3

s[7] 理应等于 s[4],但是这样它的 Z_box 超过了 r,所以要初始化为 ri+1=2

综上所述,这种情况可以直接把 zi 初始化为 min(ri+1,zil+1)

时间复杂度是 O(n) 的,证明方法和 Manacher 的证明方法差不多。

Z Algorithm (JavaScript Demo) (utdallas.edu) 可以可视化的观察 Z 算法的过程,输入几个串串试一下就懂了。

如果代码习惯是字符串下标从 0 开始,写起来会和上面有点不一样,但是知道原理后很好实现。

应用是可以求匹配串的所有后缀和模式串的 LCP,像 KMP 一样把两个串中间用个 @ 拼起来就行了。


P5410 【模板】扩展 KMP/exKMP(Z 函数)

把两个串串用 @ 拼起来后直接求 Z 函数即可。

namespace azus{
int n;
string a, b;
int Z[40000005];
int Z_alorgithm(){
int l = 0, r = 0;
for(int i = 1; i < n; i ++){
if(i <= r) Z[i] = min(Z[i - l], r - i + 1);
while(a[i + Z[i]] == a[Z[i]]) Z[i] ++;
if(i + Z[i] - 1 > r) l = i, r = i + Z[i] - 1;
}
return 0;
}
int main(){
cin >> b >> a;
int n1 = a.size(), n2 = b.size();
a += "@";
a += b;
n = a.size();
Z_alorgithm();
Z[0] = n1;
int ans1 = 0;
for(int i = 0; i < n1; i ++)
ans1 ^= (i + 1) * (Z[i] + 1);
cout << ans1 << "\n"; ans1 = 0;
for(int i = n1 + 1; i < n; i ++)
ans1 ^= (i - n1) * (Z[i] + 1);
cout << ans1 << "\n";
return 0;
}
}

CF432D Prefixes and Suffixes

把 Z 函数求出来,那么完美子串 sufi/prezi 一定满足 zi=ni

观察下 Z 函数的性质。如果一个后缀与前缀的 LCP 即 z 函数值为 k,那么一切长度小于 k 的完美子串都作为这个后缀的前缀出现,也就是所有长度小于 k 的完美子串都在这个位置出现了一次。

用桶记录完美子串的长度,然后做后缀和就行了。

//Z算法中
cnt[Z[i]] ++;
if(n - i == Z[i]) flg[Z[i]] = 1, ans ++;
//后缀和
Z[0] = n, flg[n] = 1, cnt[n] ++, ans ++;
for(int i = n; i >= 1; i --)
cnt[i] += cnt[i + 1];

CF526D Om Nom and Necklace

转换下题面。把 AB 看成一个整体,问题变成了每个前缀是否由一个串串循环 k 次再加上它的一个前缀组成。

然后动动脑子可以想到一个 KMP 算法,不动脑子也可以想到一个 KMP + Z 函数的算法。

先将不动脑子的 KMP + Z 函数的做法。

怎么用 KMP 判循环节?border 有个性质,如果 S 由长为 |S|p 的 border,那么 S 有周期 p

如果 iborder[i]|ik,那么就是完整的循环节拼起来的。循环节长度为 ik

在这后面接个前缀即可,只需要跑一次 Z 算法,然后再前缀和一下就行了。

// in KMP
if((i + 1) % k == 0){
if(((i + 1) / k) % (i + 1 - border[i]) == 0)
flg[i] = 1;
}
// in Z-algorithm
if(flg[i - 1]){
cnt[i - 1] ++;
cnt[i + min(Z[i], i / k)] --;
}
// in main
for(int i = 1; i < n - 1; i ++)
cnt[i] += cnt[i - 1], cout << (bool)cnt[i];

如果不想用 Z 函数也是可以的。

iborder[i]|ik 扔掉,直接算下循环节循环了多少次,除以 k 后向上取整,然后把余下的循环节和剩下不在循环节里的字符串拼起来,判断下长度有没有大于循环节就行了。

但是会了 Z 算法为什么不用呢?


后缀数组(SA)

SA 是好的。虽然有 SAM,但是不是所有 SA 题 SAM 都能做,而且 SA 的理解难度和实现难度低于 SAM。

这玩意扩展出的 ht 数组很强大,算是串串题中比较泛用的东西了。

前置知识:倍增、计数排序、基数排序。

一个长为 n 的串串有 n 个后缀,我们希望把所有后缀按字典序进行排序。

具体地,我们要得到两个数组:

  • sa[i] 表示字典序第 i 名的后缀的起始位置,即第 i 名的后缀是 sufsai

  • rk[i]sa[i] 互为反函数,它表示从第 i 个字符开始的后缀在所有后缀中的排名,即第 rki 名的后缀是 sufi


O(n2logn) 算法:

把所有后缀取出来 sort 一遍。


O(nlog2n) 算法:

考虑倍增。

假设我们知道了原串中长度为 ω 的子串的排名,现在要得到原串中所有长度为 2ω 的子串的排名。

考虑怎么比较 s[i,i+2ω1]s[j,j+2ω1] 的大小。

其实只用先比较 s[i,i+ω1]s[j,j+ω1] 的大小,如果相等再比较 s[i+ω,i+2ω1]s[j+ω,j+2ω1] 的大小就行了。

也就是,设以 i 开头的长度为 ω 的字符串的排名为 rki(ω)

只用以 rki(ω) 为第一关键字,rki+ω(ω) 为第二关键字对 rk 重新排序就能得到 rki(2ω) 了。

ωn 时,排序就完成了。 什么,sai 怎么求?这不是很简单的事吗!

时间复杂度 O(nlog2n)


O(nlogn) 算法:

发现每次要排序的东西是 rk,这玩意是值域小于 n 的,又是双关键字排序。直接基数排序秒了()

先用计数排序把第二关键字排一遍,然后再计数排序对第一关键字排序一遍。

计数排序是稳定的,所以这样就完成了双关键字的排序。

到这里应该能写出代码了,如果不能的话可以回去复习下计数排序、基数排序。

但是这样虽然时间复杂度是对的,常数却过大了。所以要优化常数。这里有三个优化。

首先第二关键字其实是不用计数排序的。

考虑第二关键字排序的本质,实际上就是把位置大于 iω+1 的子串放前面去,剩下的逆推 ω 个单位后按原顺序放到后面就行了。

例如 abcabaω=2

对于后面两个的 ba,它往后推 2 个字符后到字符串末了,是空串,空串最小。

rk(2)={2,4,5,2,3,1},本作为第一关键字的长度为 2 的串在 ω=4 的时候作为它前两个字符的第二关键字,例如后面两个 ba 其实就是作为第三个字符的 c 的第二关键字。也就是第二关键字其实是有序了的。所以将后面四个字符的第二关键字都推到前面去,再加上第一步操作空串占的位置就行了。每个字符的第二关键字顺序为: {6,4,5,3,1,2}。注意这个数组不是 rk 数组也不是 sa 数组,这只是对第一关键字基数排序的辅助数组,在下面的代码中会用 idi 表示。

然后第二个优化是优化值域。因为我们的计数排序是对 rk 排序,而 rk 的值域不一定每次都是 n。例如当 ω=1 并且限定小写字母的时候,rk 的值域是 26。所以可以每次更新完 rk 数组后更新计数排序的值域。这里优化很大。

最后是第三个优化。设想如果倍增到某时候,rk 的值域是 n,那就意味着所有的后缀都已经排好序了,没有必要继续倍增了,这个时候可以直接退出循环。

经过这三个常数优化,SA 的效率已经很高了。如果想追求更高的效率,可以去学学一些 O(n) 后缀排序的算法例如 SA-ISDC3 算法。大多数时候小常数 O(nlogn) 是够用的所以这里不讲。


P3809 【模板】后缀排序

namespace azus{
int n;
string s;
int sa[1000005], rk[1000005], cnt[1000005], id[1000005], oldrk[1000005];
int sufsort(){
int m = 128;
for(int i = 1; i <= n; i ++)
rk[i] = s[i], cnt[rk[i]] ++;
for(int i = 1; i <= m; i ++)
cnt[i] += cnt[i - 1];
for(int i = n; i >= 1; i --)
sa[cnt[rk[i]] --] = i;
int p = 0;
for(int w = 1; w <= n; w <<= 1, m = p){
int nw = 0;
for(int i = n - w + 1; i <= n; i ++)
id[++ nw] = i;
for(int i = 1; i <= n; i ++)
if(sa[i] > w) id[++ nw] = sa[i] - w; //第二关键字排序
memset(cnt, 0, sizeof(cnt));
for(int i = 1; i <= n; i ++)
cnt[rk[i]] ++;
for(int i = 1; i <= m; i ++)
cnt[i] += cnt[i - 1];
for(int i = n; i >= 1; i --) //倒序枚举保证计数排序是稳定的,这是基数排序正确的基础
sa[cnt[rk[id[i]]] --] = id[i]; // 第一关键字计数排序
p = 0;
memcpy(oldrk, rk, sizeof(oldrk));
for(int i = 1; i <= n; i ++){
if(oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w])
rk[sa[i]] = p;
else rk[sa[i]] = ++ p;
}
if(p == n) break;
}
return 0;
}
int main(){
cin >> s;
n = s.length(); s = " " + s;
sufsort();
for(int i = 1; i <= n; i ++)
cout << sa[i] << " ";
return 0;
}
}

板题的参考实现。但是我的实现其实是参考 OI-wiki 的。

P4051 [JSOI2007] 字符加密

破环成链赋值两倍后就是后缀排序了,也很板。

s = s + s;
n = s.length(); s = " " + s;
sufsort();
string ans = "";
for(int i = 1; i <= n; i ++)
if(sa[i] <= n / 2)
ans += s[sa[i] + n / 2 - 1];
cout << ans;

P2870 [USACO07DEC] Best Cow Line G

贪心地选取。每次选头尾中较小者。

但是我们发现这样遇到头尾相同的情况就不能这么判断。只能两边都贪心一次判断选头还是尾。

然后发现这玩意本质是维护一个正串和一个反串,两个串都取头,实际上就是判断两个串当前后缀的字典序。

可以哈希,但是我们会了 SA。

s=s+@+s,然后对 s 求出 rk 数组就可以 O(1) 比较了。

sufsort();
int l = 1, r = 1, cnt = 0;
while(l <= m - r + 1){
if(rk[l] < rk[r + m + 1]){
cnt ++; cout << s[l];
l ++;
}
else{
cnt ++; cout << s[r + m + 1];
r ++;
}
if(cnt == 80) cout << "\n", cnt = 0;
}

Height 数组 in SA

定义 hti 表示 |lcp(suf(sai1),suf(sai))|

你说得对但是这就是 SA 最妙的地方。实际上求 sa 数组和 rk 数组就是为了求这东西。

有种 O(nlogn)ht 数组的方式,就是哈希 + 二分,但是这很不可爱,没用到 sark 的美妙性质。

实际上 ht 数组本身有个非常好的性质。有了这个性质就很好求它。先说结论:

ht(rku)ht(rku1)1

看这个性质的证明看了半小时,我还是太弱了。现在来证明它。


直接上例子。

其中第一列表示的是 rki,第二列是 sai,第三列表示的是 suf(sai)

i=6,那么 i1=5,即将 ii1 定位到加粗的两行上。它们的 saisai1 分别是 6,3

考虑 rksai+1,rksai1+1,即 rk4,rk7(11,8),又即下划线的两行。

因为 hi>0,而这两个串 (11,8) 是由 (6,5) 串前面删掉一个字符得到的,所以 |lcp(suf(sa11),suf(sa8))|=h61=1,转化成普遍结论就是 |lcp(suf(sai+1),suf(sai1+1))|=hi1

观察下划线的两行,我们会发现这两行之间的两行的 h 值都是不小于 h61=1 的。

这是巧合吗?不如提出猜想然后尝试证明这不是巧合。提出猜想:

rki<rkk<rkj,

lcp|(suf(sa(rki)),suf(sa(rkj)))|lcp|(suf(sa(rki)),suf(sa(rkk)))|

lcp|(suf(sa(rki)),suf(sa(rkj)))|lcp|(suf(sa(rkk)),suf(sa(rkj)))|

文字表述就是对于后缀排序后的一段区间,区间两端的字符串的 lcp 的长度对于端点与区间内的字符串的 lcp 而言一定是最小的。

这猜想显然是对的。因为这段区间一定能在每一个串中把端点的 lcp 取出来。在两个 aa 开头的后缀之间不会出现 ac 开头的后缀。

回到上面。设 p=rk(sai+1)q=rk(sai1+1),显然有 q<p。因为 |lcp(suf(sai+1),suf(sai1+1))|=hti1,所以在 rk(sai+1)rk(sai1+1) 之间的所有 rk(i)=j 都满足 |lcp(suf(j),suf(p))|hti1

u=p1,就有 htphti1

p=rk(sai+1),根据 rksa 互为反函数,i=rk(sai)。换元,令 u=sai+1,就有 ht(rku)ht(rku1)1。证完了。


根据这个结论,很容易写出代码。

for(int i = 1, k = 0; i <= n; i ++){
if(k) k --;
while(s[i + k] == s[sa[rk[i] - 1] + k]) k ++;
ht[rk[i]] = k;
}

这可比二分 + 哈希的做法优秀多了。

因为 k 最大是 n,又最多减 n 次,所以时间复杂度是 O(n) 的。


接下来就是一车的 SA 的题了。

P4248 [AHOI2013] 差异

如果有两个后缀 sufisufj,它们的 lcp 的长度等于:

mink=rki+1rkjhtk

挺好证的。如果能够证明上面 ht 的关键性质,证这个会比较轻松,所以这里略过证明。

原题转化成一个常数减去两倍的所有后缀两两 lcp 的长度的和。

常数很好推但是我懒,我选择直接对 i(n1) 求和。

看所有后缀两两 lcp 的长度的和怎么做。

对每个 hti 单独求贡献,即求有多少个区间满足最小值是 hti 并且 i 出现在最前面。这是为了不重不漏。

找出左边第一个小于 hti 的位置和右边第一个不大于 hti 的位置,左右端点可在的区间就确定了。

单调栈即可。

for(int i = 2; i <= n; i ++){
while(top && ht[st[top]] > ht[i]) top --;
l[i] = st[top];
st[++ top] = i;
}
top = 1; st[1] = n + 1;
for(int i = n; i >= 2; i --){
while(top && ht[st[top]] >= ht[i]) top --;
r[i] = st[top];
st[++ top] = i;
}
for(int i = 2; i <= n; i ++){
res -= 2ll * (r[i] - i) * (i - l[i]) * ht[i];
}

P7409 SvT

双倍经验。

对每次求的区间的 rk 排序去重后,用 st 表求出相邻两个 rk 这一区间的 ht 最小值,然后就和上题没区别了。

P3763 [TJOI2017] DNA

考虑一种暴力。对于每个 i[1,|S0||S|+1],暴力向后匹配三次,不行就退出循环。时间复杂度 O(n2)

然后发现这个暴力还挺优秀。因为出现第四个失配就退出了。

那优化这个暴力。令 s=S0+@+S,然后两个子串匹配就可以直接用后缀的 lcp 做。

每次跳 lcp 即可。直接 st 表,时间复杂度 O(Tnlogn)

P2852 [USACO06DEC] Milk Patterns G

板。把 ht 数组求出来后,如果一段区间的最小值为 hti,那么 hti 的贡献就是区间长度,如果区间长度大于 k 就更新答案就行了。单调栈解决。

P6640 [BJOI2020] 封印

a=t+@+s

考虑求出一个 f 数组,其中 fi 表示满足 s[i,i+x]t 中出现过的最大 x

就是找一个属于 t 的后缀 j 使得 |lcp(suf(j),suf(i+|t|+1))| 最大,而 fi 就是这个最大长度。

而后缀排序后,两个后缀的 rk 越接近,它们的 lcp 就越长。

所以对每个属于 s 的后缀找到它前后两个属于 t 的后缀,用 ht 求出 lcp 后取最大值即可求出 fi

求出 fi 后,易知答案为:

maxi=lrmin(fi,ri+1)

二分答案 x,检查是否有 maxi=lrx+1fix 即可。

本文作者:AzusidNya の 部屋

本文链接:https://www.cnblogs.com/AzusidNya/p/18253335

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   AzusidNya  阅读(25)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起