进行一个字符串算法的总结
进行一个字符串算法的总结。
本文参考 字符串基础 by Alex_Wei。
这边代码习惯字符串下标从
Manacher 算法
这玩意是用来求回文子串的。
虽然一个字符串的子串数量是
注意到若一个子串
回文中心的个数是
Manacher 算法就是一个
随便敲个字符串
观察到偶数长度的回文子串的回文中心在两个字符之间,而奇数长度的回文子串回文中心是一个字符。
为了把这两种子串统一起来,我们在两个字符之间加入一个不会在串中出现的字符,例如
然后我们得到了串串
设
手玩一下,以第
考虑一种暴力。枚举回文中心后尝试向两边扩展,能扩展则扩展。
因为回文串级别是
但是我们发现回文串有些比较好的性质。
举个例子,我们在上面抓个子串出来。就决定是你了,
假设我们已经知道了最中间的那个
再考虑这个
把这个
那对称地,这个
再考虑倒数第四个字符的那个
实现上,设当前扩展到最远的地方为
如果当前字符
然后暴力扩展,更新
对于每个
所以时间复杂度是
P3805 【模板】manacher
把
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 + "@"
有很大区别,前者复杂度
P3501 [POI2010] ANT-Antisymmetry
和回文串性质一样,在 while 循环中改改条件,变成扩展 ANT-Antisymmetry 串就行了。
P4555 [国家集训队] 最长双回文串
对于每个
但是我们统计的
定义一个回文串是饱和的,如果这个字符串不能再扩展了。我们发现有些点结尾或开头的不饱和字符串比包和字符串还长。
所以简单递推更新一下即可。
//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] 绿绿和串串
这东西乍看下去有点复杂。
观察下,首先发现如果有一个以最后一个字符结尾的回文子串,那么以这个子串回文中心翻转一下一定满足条件。
然后,如果以
这个字符串必须从
从后往前递推判断每个字符是否能满足条件即可。
//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 是字符串的后缀和前缀的最长匹配字符串,定义前缀函数
表示字符串 的 border 的长度。 容易发现
的 border 一定是 的某个若干阶 border 后面连一个字符构成的。 根据这个性质不断跳 border 就能求出
数组,然后字符串匹配就好做了。
Z 算法 / 扩展 KMP
这个东西其实和 Manacher 有点像。
定义一个字符串
也就是
举个例子,这里有个可爱的串串
那么
有暴力做法,对每个位置暴力向后匹配。时间复杂度是
和 Manacher 一样,这样的话有些性质没有被利用起来。
我们称位置
维护当前
第一种情况,例如匹配第一个
第二种情况,例如第六个字符
此时
综上所述,这种情况可以直接把
时间复杂度是
Z Algorithm (JavaScript Demo) (utdallas.edu) 可以可视化的观察
如果代码习惯是字符串下标从
应用是可以求匹配串的所有后缀和模式串的 LCP,像 KMP 一样把两个串中间用个
P5410 【模板】扩展 KMP/exKMP(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 函数求出来,那么完美子串
观察下 Z 函数的性质。如果一个后缀与前缀的 LCP 即
用桶记录完美子串的长度,然后做后缀和就行了。
//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
转换下题面。把
然后动动脑子可以想到一个 KMP 算法,不动脑子也可以想到一个 KMP + Z 函数的算法。
先将不动脑子的 KMP + Z 函数的做法。
怎么用 KMP 判循环节?border 有个性质,如果
如果
在这后面接个前缀即可,只需要跑一次 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 函数也是可以的。
把
但是会了 Z 算法为什么不用呢?
后缀数组(SA)
SA 是好的。虽然有 SAM,但是不是所有 SA 题 SAM 都能做,而且 SA 的理解难度和实现难度低于 SAM。
这玩意扩展出的
前置知识:倍增、计数排序、基数排序。
一个长为
具体地,我们要得到两个数组:
-
sa[i]
表示字典序第 名的后缀的起始位置,即第 名的后缀是 -
rk[i]
和sa[i]
互为反函数,它表示从第 个字符开始的后缀在所有后缀中的排名,即第 名的后缀是 。
把所有后缀取出来 sort 一遍。
考虑倍增。
假设我们知道了原串中长度为
考虑怎么比较
其实只用先比较
也就是,设以
只用以
当
时间复杂度
发现每次要排序的东西是
先用计数排序把第二关键字排一遍,然后再计数排序对第一关键字排序一遍。
计数排序是稳定的,所以这样就完成了双关键字的排序。
到这里应该能写出代码了,如果不能的话可以回去复习下计数排序、基数排序。
但是这样虽然时间复杂度是对的,常数却过大了。所以要优化常数。这里有三个优化。
首先第二关键字其实是不用计数排序的。
考虑第二关键字排序的本质,实际上就是把位置大于
例如
对于后面两个的
而
然后第二个优化是优化值域。因为我们的计数排序是对
最后是第三个优化。设想如果倍增到某时候,
经过这三个常数优化,SA 的效率已经很高了。如果想追求更高的效率,可以去学学一些
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。
令
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
定义
你说得对但是这就是 SA 最妙的地方。实际上求
有种
实际上
看这个性质的证明看了半小时,我还是太弱了。现在来证明它。
直接上例子。
其中第一列表示的是
令
考虑
因为
观察下划线的两行,我们会发现这两行之间的两行的
这是巧合吗?不如提出猜想然后尝试证明这不是巧合。提出猜想:
文字表述就是对于后缀排序后的一段区间,区间两端的字符串的
这猜想显然是对的。因为这段区间一定能在每一个串中把端点的
回到上面。设
令
而
根据这个结论,很容易写出代码。
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; }
这可比二分 + 哈希的做法优秀多了。
因为
接下来就是一车的 SA 的题了。
P4248 [AHOI2013] 差异
如果有两个后缀
挺好证的。如果能够证明上面
原题转化成一个常数减去两倍的所有后缀两两
常数很好推但是我懒,我选择直接对
看所有后缀两两
对每个
找出左边第一个小于
单调栈即可。
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
双倍经验。
对每次求的区间的
P3763 [TJOI2017] DNA
考虑一种暴力。对于每个
然后发现这个暴力还挺优秀。因为出现第四个失配就退出了。
那优化这个暴力。令
每次跳
P2852 [USACO06DEC] Milk Patterns G
板。把
P6640 [BJOI2020] 封印
令
考虑求出一个
就是找一个属于
而后缀排序后,两个后缀的
所以对每个属于
求出
二分答案
本文作者:AzusidNya の 部屋
本文链接:https://www.cnblogs.com/AzusidNya/p/18253335
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步