字符串全家桶
【前言】
字符串问题一直是我比较薄弱的环节,而各大类型考试均有涉及,所以特此记录。
我见过的常见的处理字符串的算法有:
- 字符串 Hash。
- KMP。
- ex-KMP。
- Manacher。
- AC 自动机。
- PAM。
- SA。
- SAM。
其中 3,4 仅简单介绍,7 不会。
【字符串 Hash】
【思想简述】
性价比最高的字符串算法,代码简短而且适用于很多匹配问题。
该算法不需过多介绍,我习惯的实现方式是利用 unsigned long long
自然溢出,乘数取 131 / 13331
。
在很多需要高级字符串算法处理的问题中,Hash 往往能作为骗分利器。(甚至因为常数小而吊打标算)
【适用范围】
几乎所有匹配相关的字符串问题。
优点:代码简单,常数小,时间 \(O(1)\)。
缺点:可能被卡,处理棘手的字符串问题时往往力不从心。
【简单例题】
求所有将 \(S\) 分割为形如 \(S=(AB)^iC\) ,且 \(F(A)\leq F(C)\) 的方案数,\(F\) 表示出现奇数次字符的个数。
这是一道很好的题目。
枚举第一个 \(AB\) 的长度,根据奇偶性分类讨论发现 \(F(C)\) 只有两种情况。
再枚举 \(i\),利用 Hash 判断是否循环,结合树状数组统计答案,时间复杂度为调和级数 \(O(n\log n)\)。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
typedef unsigned long long ULL;
const int N = 1050010;
const ULL P = 13331;
int T, n, num[N], t[N], sum[30];
char str[N];
ULL p[N], f[N];
void Change(int x){x ++; for(; x <= 27; x += x & -x) t[x] ++;}
int Ask(int x){x ++; int sum = 0; for(; x; x -= x & -x) sum += t[x]; return sum;}
void work(){
scanf("%s", str + 1);
n = strlen(str + 1);
for(int i = 1; i <= n; i ++) f[i] = f[i - 1] * P + str[i];
int tot = 0;
memset(sum, 0, sizeof(sum));
for(int i = n; i >= 1; i --){
if(sum[str[i] - 'a'] ^= 1) tot ++;
else tot --;
num[i] = tot;
}
long long ans = 0;
memset(t, 0, sizeof(t));
memset(sum, 0, sizeof(sum));
sum[str[1] - 'a'] ^= 1; tot = 1; Change(1);
for(int i = 2; i < n; i ++){
int even = 0, odd = 0;
odd = Ask(num[i + 1]);
if(i + i < n) even = Ask(num[i + i + 1]);
for(int j = 1; j <= n; j ++){
if(i * j >= n) break;
if(f[i] != f[i * j] - f[i * (j - 1)] * p[i]) break;
ans += (j & 1) ? odd : even;
}
if(sum[str[i] - 'a'] ^= 1) tot ++;
else tot --;
Change(tot);
}
printf("%lld\n", ans);
}
int main(){
scanf("%d", &T);
p[0] = 1;
for(int i = 1; i <= N - 10; i ++) p[i] = p[i - 1] * P;
while(T --) work();
return 0;
}
这是一道很经典的题目,其正解的思想可以很好的与 SA 结合。
然而观察数据范围,发现写个 \(O(n^2)\) 的算法就能拿到 \(\rm 95\ pts\),这是一个很可观的分数。
考虑处理出 \(f(i)\) 和 \(g(i)\) 分别表示以 \(i\) 开头 / 结尾的形如 \(AA\) 的串的个数, 这个直接结合 Hash 处理。
那么答案就是 \(\sum f(i)\times g(i+1)\),于是利用简单的 Hash 就可以轻松地获得 \(\rm 95\ pts\)。
代码不放了,估计不用 5 分钟。
【KMP】
【思想简述】
KMP 的重点在失配指针 nxt
的求解。
nxt[1] = 0;
for(int i = 2, j = 0; i <= n;i ++){
while(j > 0 && a[i] != a[j + 1]) j = nxt[j];
if(a[i] == a[j + 1]) j ++;
nxt[i] = j;
}
for(int i = 1, j = 0; i <= m; i ++){
while(j > 0 && b[i] != a[j + 1]) j = nxt[j];
if(b[i] == a[j + 1]) j ++;
f[i] = j;
// if(f[i] == n) puts("Success");
}
【适用范围】
KMP 的经典应用是解决单模式匹配问题,但是如果单纯的想解决这个问题,或许 Hash 更为方便。
所以 KMP 的如今更多用于利用 nxt
求出最长前缀后缀,从而求解某些问题。
优点:便于扩展(例如与 DP / 数据结构 结合),时间 \(O(n)\)。
缺点:较难理解。
【简单例题】
将失配状态改为 \(\rm j\times2 > i\) 即可。
KMP 结合 DP,设 \(f(i)\) 表示覆盖前 \(i\) 个字符的最小长度。
若最近一次出现 \(f(nxt(i))\) 的位置 \(pos \geq i - nxt(i)\),则 \(f(i)=f(nxt(i))\),因为可以覆盖到。
否则 \(f(i)=i\)。
利用栈维护匹配状况即可,同样可以用 Hash 乱搞。
【AC 自动机】
【思想简述】
trie 树与 KMP 的结合,关键同样是构建出 fail 指针。
个人习惯用桶排序,而不是 BFS。
int insert(char *str){
int len = strlen(str + 1), p = 0;
for(int i = 1; i <= len; i ++){
int ch = str[i] - 'a';
if(!tr[p][ch]) tr[p][ch] = ++ tot;
p = tr[p][ch];
dep[p] = i;
}
return p;
}
void build(){
for(int i = 1; i <= tot; i ++) bac[dep[i]] ++;
for(int i = 1; i <= tot; i ++) bac[i] += bac[i - 1];
for(int i = 1; i <= tot; i ++) arc[bac[dep[i]] --] = i;
for(int i = 1; i <= tot; i ++){
int u = arc[i];
for(int v = 0; v < 26; v ++)
if(tr[u][v]) fail[tr[u][v]] = tr[fail[u]][v];
else tr[u][v] = tr[fail[u]][v];
}
}
【适用范围】
AC 自动机的功能强大,适用的范围也很广。
值得一提的是,我们根据 fail 指针可以构造出 fail 树,在 fail 树上,一个节点的子树对应着这个节点
由于每个点都只连出一条 fail 边,且连到的点对应的字符串长度更小,所以 fail 边构成了一棵 fail 树。
fail 树是 Trie 上的每个前缀(也就是 AC 自动机上的所有节点)。
与某个模式串匹配(以某个模式串为后缀)的那些状态,就是那个串在 Trie 树上的终止节点在 fail 树上的子树。
匹配方式:建出 fail 树,记录自动机上的每个状态被匹配了几次,最后求出每个模式串在 Trie 上的终止节点在 fail 树上的子树总匹配次数就可以了。
引自:AC自动机学习笔记。
优点:功能强大,扩展丰富。(已经没有什么词能够赞美它了)
缺点:然而并没有。
【简单例题】
三道模板题。
因为只有 0 / 1 串,所以每次只有两种选择。
存在无限长的安全代码,对应于 AC 自动机上就是有可以不经过模式串终点的环,DFS 找环即可。
得出结论:如果在 fail 树上某个节点代表的状态是模式串的终点,那么它整棵子树都是不可进入的。
题目已经帮你把 trie 树建好了。
对于询问 \((x,y)\),考虑将 \(x\) 在 trie 树上的所有前缀状态在 fail 树上的节点标记。
那么答案就是 fail 树上 \(y\) 的子树中被标记的个数。
考虑离线算法,只需要一遍 DFS,用树状数组统计答案即可。
得出结论:
-
fail 树上的节点对应的是某个模式串的前缀,每个节点的子树对应以其为后缀的前缀。
-
求匹配就是求整棵子树被覆盖的个数,可以利用 DP 传递信息。
-
求多次匹配就是对于每个文本串的前缀都标记,然后利用树状数组进行数点。
根据 1 可以看出,其实 KMP 也是一个自动机,是 AC 自动机只插入一个模式串的特殊情况,它同样拥有 fail 树。
对于 \(S\) 建立 AC 自动机,每次加入 \(T\) 时统计贡献。
不难发现 \(T\) 上的每个前缀在 fail 树上的节点到根的链都有 \(1\) 的贡献,但最多也只能有 \(1\) 的贡献,不能重复覆盖。
考虑对于每个 \(T\),先处理出代表每个前缀的 trie 树节点,然后按照 fail 树上的 dfs 序排序。
然后利用树状数组插入,两两之间的 \(\rm lca\) 处 \(-1\),这样就能够避免重复,这是一个常用的树上问题技巧。
给一下代码吧:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 2000010;
int n, cnt, num;
int head[N], dfn[N], t[N], pos[N];
char str[N];
struct Edge{int nxt, to;} ed[N];
int read(){
int x = 0, f = 1; char c = getchar();
while(c < '0' || c > '9') f = (c == '-') ? -1 : 1, c = getchar();
while(c >= '0' && c <= '9') x = x * 10 + c - 48, c = getchar();
return x * f;
}
void add(int u, int v){
ed[++ cnt] = (Edge){head[u], v};
head[u] = cnt;
}
int top[N], sz[N], fa[N], dep[N], son[N];
void dfs1(int u, int Fa){
sz[u] = 1, fa[u] = Fa, dep[u] = dep[Fa] + 1;
for(int i = head[u], v; i; i = ed[i].nxt)
if((v = ed[i].to) != Fa){
dfs1(v, u), sz[u] += sz[v];
if(!son[u] || sz[v] > sz[son[u]]) son[u] = v;
}
}
void dfs2(int u, int Top){
top[u] = Top; dfn[u] = ++ num;
if(!son[u]) return; dfs2(son[u], Top);
for(int i = head[u], v; i; i = ed[i].nxt)
if((v = ed[i].to) != fa[u] && v != son[u]) dfs2(v, v);
}
int Lca(int x, int y){
while(top[x] != top[y]){
if(dep[top[x]] < dep[top[y]]) swap(x, y);
x = fa[top[x]];
}
return dep[x] < dep[y] ? x : y;
}
void Change(int x, int v){for(; x <= num; x += x & -x) t[x] += v;}
int Ask(int x){int sum = 0; for(; x; x -= x & -x) sum += t[x]; return sum;}
bool cmp(int x, int y){return dfn[x] < dfn[y];}
struct ACAM{
int tot, tr[N][26], fail[N], dep[N], bac[N], arc[N], now[N];
int insert(char *str){
int len = strlen(str + 1), p = 0;
for(int i = 1; i <= len; i ++){
int ch = str[i] - 'a';
if(!tr[p][ch]) tr[p][ch] = ++ tot;
p = tr[p][ch]; dep[p] = i;
}
return p;
}
void build(){
for(int i = 1; i <= tot; i ++) bac[dep[i]] ++;
for(int i = 1; i <= tot; i ++) bac[i] += bac[i - 1];
for(int i = 1; i <= tot; i ++) arc[bac[dep[i]] --] = i;
for(int i = 1; i <= tot; i ++){
int u = arc[i]; add(fail[u], u);
for(int v = 0; v < 26; v ++)
if(tr[u][v]) fail[tr[u][v]] = tr[fail[u]][v];
else tr[u][v] = tr[fail[u]][v];
}
}
void solve(char *str){
int len = strlen(str + 1), p = 0;
int cnt = 0;
for(int i = 1; i <= len; i ++)
p = tr[p][str[i] - 'a'], now[++ cnt] = p;
sort(now + 1, now + cnt + 1, cmp);
cnt = unique(now + 1, now + cnt + 1) - (now + 1);
for(int i = 1; i <= cnt; i ++){
Change(dfn[now[i]], 1);
if(i > 1) Change(dfn[Lca(now[i], now[i - 1])], -1);
}
}
} T;
int main(){
n = read();
for(int i = 1; i <= n; i ++){
scanf("%s", str + 1);
pos[i] = T.insert(str);
}
T.build();
dfs1(0, 0), dfs2(0, 0);
int q = read();
while(q --){
int opt = read();
if(opt == 1){
scanf("%s", str + 1);
T.solve(str);
}
else{
int x = read(); x = pos[x];
printf("%d\n", Ask(dfn[x] + sz[x] - 1) - Ask(dfn[x] - 1));
}
}
return 0;
}
【SA】
【思想简述】
后缀数组解决的最基本问题是后缀排序,通常使用倍增 + 基数排序的思想。
仅靠 sa 数组,后缀数组干不了很多事,然而有了 height 数组,就可以快速求出两个子串的 lcp 长度。
\(\rm height(i)\) 的含义是,排名为 \(i\) 的后缀与排名为 \(i - 1\) 的后缀的 lcp 长度。
其计算方法是利用 \(\rm height(i)\geq height(i-1)-1\) 这个很显然的结论。
不难证明 \(lcp(i,j)=\min_{i<k\leq j}\{height(k)\}\),于是利用 ST 表就可以 \(O(1)\) 求了。
void Get_Sa(){
for(int i = 1; i <= 256; i ++) bac[i] = 0;
for(int i = 1; i <= n; i ++) bac[str[i]] ++;
for(int i = 1; i <= 256; i ++) bac[i] += bac[i - 1];
for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);
for(int p = 1; p <= n; p <<= 1){
for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
#define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
if(rk[sa[n]] == n) break;
}
}
// 这里用 lcp 代替 height
void Get_H(){
for(int i = 1; i <= n; i ++){
int j = sa[rk[i] - 1], k = max(lcp[rk[i - 1]] - 1, 0);
while(str[i + k] == str[j + k] && str[i + k]) k ++;
lcp[rk[i]] = k;
}
}
【适用范围】
主要用于处理 lcp 相关问题。
优点:快速处理 lcp 相关问题。
缺点:预处理 \(O(n\log n)\)。
【简单例题】
经典应用了,一个后缀的贡献个数就是 总长度 - 与前一个后缀的 lcp 长度。
显然长度为 \(m\) 的子串就只有 \(n\) 个,枚举每个子串的起点,问题转变为能否更改至多 \(3\) 个字符使两串相等。
这个问题看上去挺匹配的样子,每次可以后移的位数为 lcp + 1。(这个 \(1\) 就是更改的位置)
看后移 \(4\) 次后能否 \(>m\)(最后一次多加了 \(1\) 所以是严格大于),lcp 可以将两串拼接后利用 SA + ST \(O(1)\) 求。
注意拼接时要加一个标识符(一定不会出现的符号)表示间隔,这是个常用技巧。
预处理 \(O(n\log n)\),查询 \(O(n)\)。
当然这题用 Hash + 二分 求 lcp 也是好的,预处理 \(O(n)\),查询 \(O(n\log n)\),可能常数还小一点。
上文提到过 Hash 的 \(\rm 95\ pts\),再看看最后 \(\rm 5\ pts\) 怎么拿。
一个很好的思想是切片:将长度为 \(n\) 的字符串划分为长度为 \(len\) 的很多片,查询是否有长度为 \(len\) 的 \(AA\) 串。
求出不同片之间的 \(\rm lcp\) 和 \(\rm lcs\),如果相邻三片之间的 \(\rm lcp+lcs\geq len\),那么就出现了形如 \(AA\) 的串。
利用差分思想统计答案,总时间复杂度为调和级数 \(O(n\log n)\)。
值得注意的是,\(\rm lcs\) 的处理方法为翻转字符串后再求一遍 \(\rm lcp\),但是在观察点处可能出现重复的统计。
为了避免重复,要从观察点 -1 的位置统计 \(\rm lcs\)。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 30010;
int n, f[N], g[N];
char str[N];
struct SA{
int bac[N], sa[N], rk[N], SA[N], RK[N], lcp[N], h[N];
int rmq[N][20], bin[1 << 18];
void Get_Sa(){
memset(bac, 0, sizeof(bac));
for(int i = 1; i <= n; i ++) bac[str[i]] ++;
for(int i = 1; i <= 256; i ++) bac[i] += bac[i - 1];
for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);
for(int p = 1; p <= n; p <<= 1){
for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
#define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
if(rk[sa[n]] == n) break;
}
}
void Get_H(){
for(int i = 1; i <= n; i ++){
int j = sa[rk[i] - 1], k = max(h[i - 1] - 1, 0);
while(str[i + k] == str[j + k] && str[i + k]) k ++;
h[i] = lcp[rk[i]] = k;
}
for(int i = 1; i <= 15; i ++)
for(int j = 1 << i; j < 1 << (i + 1); j ++) bin[j] = i;
for(int i = 1; i <= n; i ++) rmq[i][0] = lcp[i];
for(int j = 1; j <= 15; j ++)
for(int i = 1; i <= n - (1 << j) + 1; i ++)
rmq[i][j] = min(rmq[i][j - 1], rmq[i + (1 << (j - 1))][j - 1]);
}
int Query(int x, int y){
int u = rk[x], v = rk[y];
if(u > v) swap(u, v); u ++;
int k = bin[v - u + 1];
return min(rmq[u][k], rmq[v - (1 << k) + 1][k]);
}
void build(){
memset(sa, 0, sizeof(sa));
memset(rk, 0, sizeof(rk));
memset(SA, 0, sizeof(SA));
memset(RK, 0, sizeof(RK));
memset(lcp, 0, sizeof(lcp));
memset(h, 0, sizeof(h));
Get_Sa();
Get_H();
}
} A, B;
int main(){
int T; scanf("%d", &T);
while(T --){
scanf("%s", str + 1);
n = strlen(str + 1);
A.build();
reverse(str + 1, str + n + 1);
B.build();
memset(f, 0, sizeof(f));
memset(g, 0, sizeof(g));
for(int len = 1; len <= n / 2; len ++){
for(int l = len, r = l + len; r <= n; l += len, r += len){
int lcp = min(A.Query(l, r), len);
int lcs = min(B.Query(n - l + 2, n - r + 2), len - 1);
if(lcp + lcs >= len){
int t = lcp + lcs - len + 1;
g[l - lcs] ++, g[l - lcs + t] --;
f[r + lcp - t] ++, f[r + lcp] --;
}
}
}
for(int i = 1; i <= n; i ++)
f[i] += f[i - 1], g[i] += g[i - 1];
long long ans = 0;
for(int i = 1; i < n; i ++) ans += 1LL * f[i] * g[i + 1];
printf("%lld\n", ans);
}
return 0;
}
\(\sum\limits_{1\leq i<j\leq n}{\rm len}(T_i)+{\rm len}(T_j)-2\times {\rm lcp}(T_i, T_j)=\frac{1}{2}n(n-1)(n+1)-2\times \sum\limits_{1\leq i<j\leq n}{\rm lcp}(T_i, T_j)\)
所以问题就是求所有 \(\rm lcp\) 之和,利用 height 数组的转化,变为求所有区间的最小值之和。
利用单调栈算法,统计对前面的影响,不难写出 \(O(n)\) 的统计方法。
求 \(n\) 个串的最长公共子串。
其实这题 \(O(n^2)\) 可过,但是有更优的后缀数组做法 \(O(n\log n)\)。(DC3 甚至可以理论 \(O(n)\))
拼接每个串,枚举公共子序列在某个串的起点,之后用尽量短的长度涵盖 \(n\) 个串,求区间最小值。
区间右端点单调不减,这是一个经典的滑动窗口问题。
#include<cstdio>
#include<string>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 5000010;
int n, len;
int bin[N], bac[N], rk[N], sa[N];
int SA[N], RK[N], h[N], lcp[N], q[N];
char str[N];
void init(){
for(int i = 1; i <= len; i ++){
rk[i] = sa[i] = RK[i] = SA[i] = 0;
bin[i] = q[i] = h[i] = lcp[i] = 0;
}
}
void Get_Sa(char *str, int n, int mx){
for(int i = 1; i <= mx; i ++) bac[i] = 0;
for(int i = 1; i <= n; i ++) bac[str[i]] ++;
for(int i = 1; i <= mx; i ++) bac[i] += bac[i - 1];
for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);
for(int p = 1; p <= n; p <<= 1){
for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
#define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
if(rk[sa[n]] == n) break;
}
}
void Get_H(char *str, int n){
for(int i = 1; i <= n; i ++){
int j = sa[rk[i] - 1], k = max(0, h[i - 1] - 1);
while(str[i + k] == str[j + k] && str[i + k]) k ++;
h[i] = lcp[rk[i]] = k;
}
}
int main(){
while(~scanf("%d", &n) && n){
init();
len = 0;
for(int i = 1; i <= n; i ++){
scanf("%s", str + len + 1);
int tmp = strlen(str + len + 1);
bin[len + 1] = i;
len += tmp + 1; bin[len] = -1;
}
for(int i = 1; i <= len; i ++)
if(bin[i] == 0) bin[i] = bin[i - 1];
Get_Sa(str, len, 256); Get_H(str, len);
for(int i = 1; i <= n; i ++) bac[i] = 0;
int head = 1, tail = 0, cnt = 0;
int ans = 0, pos;
for(int l = 1, r = 1; l <= len; l ++){
for(; r <= len && cnt < n; r ++){
if(bin[sa[r]] != -1 && !bac[bin[sa[r]]] ++) cnt ++;
while(head <= tail && lcp[q[tail]] >= lcp[r]) tail --;
q[++ tail] = r;
}
while(head <= tail && q[head] <= l) head ++;
if(cnt == n && ans < lcp[q[head]])
ans = lcp[q[head]], pos = sa[l];
if(bin[sa[l]] != -1 && !--bac[bin[sa[l]]]) cnt --;
}
if(ans)
str[pos + ans] = 0, printf("%s\n", str + pos);
else
puts("IDENTITY LOST");
}
return 0;
}
给定字符串 \(A,B\) 和常数 \(k\)。询问有多少 \(i\),满足 \(A[i..i+k-1]\) 在 \(B\) 中出现过,但 \(A[i..i+k]\) 没有在 \(B\) 中出现过。
也就是求 \(A\) 串中,每个后缀与 \(B\) 中后缀的最大 \(\rm lcp\) 恰好 \(=k\) 的个数。
最大后缀就是前面和后面中最近的位置,正反各跑一遍就行,于是这道题就做完了。
给定 \(K\) 和字符串 \(A,B\)。求存在多少三元组 \((i,j,k)\) 满足 \(A[i..i+k-1]=B[j..j+k-1]\) 且 \(k\geq K\)。
根据 height 的大小可以将字符串分为很多段,每段内部统计答案。
分为 \(A\) 前面 \(B\) 的数量和 \(B\) 前面 \(A\) 的数量两种,其实可以一起处理,处理方法为上文提到的单调栈。
每个字符都是要入栈的,但是只有属于不同串的字符对答案有贡献。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 200010;
int k, n, len;
int sa[N], rk[N], SA[N], RK[N], lcp[N], bac[N];
int stk[N][3], top;
char str[N];
void init(){
memset(sa, 0, sizeof(sa));
memset(sa, 0, sizeof(SA));
memset(sa, 0, sizeof(rk));
memset(sa, 0, sizeof(RK));
memset(lcp, 0, sizeof(lcp));
scanf("%s", str + 1);
n = strlen(str + 1);
str[len = n + 1] = '#';
scanf("%s", str + n + 2);
n += strlen(str + n + 2) + 1;
}
void Get_Sa(){
for(int i = 0; i <= 256; i ++) bac[i] = 0;
for(int i = 1; i <= n; i ++) bac[str[i]] ++;
for(int i = 1; i <= 256; i ++) bac[i] += bac[i - 1];
for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);
for(int p = 1; p <= n; p <<= 1){
for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
#define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
if(rk[sa[n]] == n) break;
}
}
void Get_H(){
for(int i = 1; i <= n; i ++){
int j = sa[rk[i] - 1], k = max(0, lcp[rk[i - 1]] - 1);
while(str[i + k] == str[j + k] && str[i + k]) k ++;
lcp[rk[i]] = k;
}
}
int main(){
while(~scanf("%d", &k) && k){
init();
Get_Sa(), Get_H();
long long ans = 0, ans1, ans2; int cnt1, cnt2;
for(int l = 1, r; l <= n; l = r){
top = 0, ans1 = ans2 = 0;
for(r = l + 1; r <= n && lcp[r] >= k; r ++){
cnt1 = cnt2 = 0;
while(top && lcp[stk[top][0]] >= lcp[r]){
cnt1 += stk[top][1], ans1 -= 1LL * stk[top][1] * (lcp[stk[top][0]] - k + 1);
cnt2 += stk[top][2], ans2 -= 1LL * stk[top][2] * (lcp[stk[top][0]] - k + 1);
top --;
}
if(sa[r - 1] < len) cnt1 ++;
if(sa[r - 1] > len) cnt2 ++;
stk[++ top][0] = r;
stk[top][1] = cnt1; ans1 += 1LL * cnt1 * (lcp[r] - k + 1);
stk[top][2] = cnt2; ans2 += 1LL * cnt2 * (lcp[r] - k + 1);
if(sa[r] < len) ans += ans2;
if(sa[r] > len) ans += ans1;
}
}
printf("%lld\n", ans);
}
return 0;
}
【SAM】
目前不会
【其它算法】
【manacher】
解决回文串相关问题。
利用上一次匹配的最远距离,根据回文串的对称性加快匹配速度。
和 SA 中 height 的求解有异曲同工之妙。
更详细的戳这里。
【ex-KMP】
处理字符串每个后缀与自己的 \(\rm lcp\),可以说是 SA 的弱化版,但是时间是 \(O(n)\) 的。
貌似没有什么问题是只能用 ex-KMP 解决的,但多了解一些思想总没错。
更详细的戳这里。
【PAM】
回文自动机/回文树是处理回文串问题的有力工具。
它仅用 \(O(|S|)\) 的状态数就记录了字符串中所有本质不同的回文串信息。
更详细的戳这里。
【总结】
字符串问题是 OI 中特殊而有趣的一类,希望能在字符串问题中收获快乐。
引用资料&特别鸣谢:
完结撒花。