字符串学习笔记 (1)

www.luogu.com.cn/training/151054

字符串学习笔记 (1) 的主要内容:KMP、exKMP、KMPAM。

练习题的排列顺序是按照我的做题顺序,与难度无关。

一、字符串基础知识

注:若无特殊说明则默认字符串的下标从 1 开始,s,t 等字母代表的是字符串。

模式串 (P, pattern) 与文本串 (T, text):从文本串中进行模式匹配寻找模式串。

子串:从原串中选取连续的一段字符串,空串也是子串。

前缀:pre(s,k) 表示 sk (0k|s|) 个字符构成的子串。

后缀:suf(s,k) 表示 sk (0k|s|) 个字符构成的子串。

最长公共前缀:lcp(s,t) 表示 st 一样的最长的前缀。

最长公共后缀:lcs(s,t) 表示 st 一样的最长的后缀。

注意任何子串都既是某个前缀的后缀也是某个后缀的前缀,子串、前缀、后缀都可以为 s 可以为空串。

period:若 0<k<|s|,  i|s|k, s[i]=s[i+k],则称 ks 的一个 period(周期)。

border:若 0<k<|s|, pre(s,k)=suf(s,k),则称 pre(s,k)s 的一个 border(相同的前后缀)。

最长 border:lb(s) 表示 s 的最长的 border,若 s 没有 border 则 |lb(s)|=0

最长公共 border:lcb(s,t) 表示 st 一样的最长的 border,若不存在则 |lcb(s,t)|=0

注意 period 不能为 |s| 不能为 0,border 不能为 s 不能为空串。

一些基础的 border 的特殊性质:

  1. pre(s,k)s 的 border,则 |s|ks 的 period。
  2. ks 的 period,则 pre(s,|s|k)s 的 border。
  3. pre(s,k)s 的 border,|s|modk=0,则 s 是由 |s|kpre(s,k) 拼成的。
  4. s 是由 kspre(s,k) 拼成的,则 pre(s,k)s 的 border,|s|modk=0
  5. k<j<ipre(s,j)pre(s,i) 的 border,则 pre(s,jk)pre(s,ik) 的 border。
  6. k<j<ipre(s,jk)pre(s,ik) 的 border, 0x<k, s[jx]=s[ix],则 pre(s,j)pre(s,i) 的 border。
  7. st 的 border,tr 的 border,则 sr 的 border。
  8. sr 的 border,tr 的 border,|s|<|t|,则 st 的 border。
  9. lb(s),lb(lb(s)),lb(lb(lb(s))), 构成了 s所有 border,即 s所有 border 环环相扣,由长到短被一条链串在一起。
  10. s 若存在长度大于 |s|2 的 border,则必然存在长度小于 |s|2 的 border,但逆命题不一定成立。

其中第 1~2 条是 border 与 period 的关系,第 1~2, 3~4, 5~6 条为互逆命题,第 7~9 条是 border 的传递性。

这些特殊性质都非常简单,读者自证不难,举个栗子画个图就很容易证明。

二、字符串基础算法

1、Hash 函数

s 看成 p 进制数,一般令 a=1, b=2, , z=26, p=13113331

一般对 264 取模,即利用 unsigned long long 自然溢出代替常数巨大的取模运算。

显然有:h(s+t)=h(s)×p|t|+h(t), h(t)=h(s+t)h(s)×p|t|

2、Hash+二分

对于有单调性的信息纯 Hash+二分时间复杂度一般会比正解多 log,能拿到较高分。

例如:Hash+二分代替 Manacher 求最长回文子串 O(nlogn),求 SA O(nlog2n)

但是大多数题的纯 Hash 或者纯 Hash+二分的做法都能用其他字符串科技以思维难度更低,时间复杂度更优或常数更小来取代。

所以 Hash 是一个辅助工具、锦上添花的东西,不能只靠 Hash 做字符串题。

3、字符串的最小表示法

给定 s,若不断把它的首字符放到末尾,最终会得到最多 n 个不同的字符串,称这 n 个字符串是循环同构的。

这些字符串中字典序最小的一个称为 s 的最小表示。如何求最小表示?一种方法是最小表示法:

先将 s 复制一倍接到末尾上,那么 s[ii+|s|1] 即为以 i 开头的 s 的循环同构串,记为 t[i]

假设比较 t[i]t[j],第一个不相同的位置在 k 处,即 s[i+k]s[j+k]

s[i+k]>s[j+k],则 t[i+x] (0xk) 一定不是 s 的最小表示,因为 t[j+x]x 处更小。

同理若 s[i+k]<s[j+k],则 t[j+x] (0xk) 一定不是 s 的最小表示。

因此可以通过两个指针 i,j 不断向后移动比较循环同构串的大小,当其中一个移动到结尾时就找到了最小表示。

若每次比较向后扫描 k 的长度,则 i,j 其中一个指针必然向后移动 k

又因为 i,j 两个指针总共向后移动小于 2n,因此时间复杂度为 O(n)

代码是 P1368 【模板】最小表示法 的,另外一题代码几乎完全一样。

复制代码
 1 const int N = 3e5 + 10;
 2 int n, a[N << 1];
 3 
 4 int main()
 5 {
 6     read(n);
 7     for (int i = 1; i <= n; ++i) read(a[i]);
 8     for (int i = 1; i <= n; ++i) a[n + i] = a[i];
 9     int i = 1, j = 2, k;
10     while (i <= n && j <= n)
11     {
12         for (k = 0; k < n && a[i + k] == a[j + k]; ++k);
13         if (k == n) break;
14         a[i + k] > a[j + k] ? i += k + 1 : j += k + 1;
15         if (i == j) ++i;
16     }
17     int ans = Min(i, j);
18     for (int i = ans; i < ans + n; ++i) cout << a[i] << ' ';
19     return 0;
20 }
View Code
复制代码

三、KMP

1、KMP 算法

KMP 算法,英文全称 the Knuth-Morris-Pratt algorithm。

设两个指针 ij 表示当前文本串 t 匹配到 i,模式串 s 匹配到 j

KMP 的思想为:找一个最大的 j 满足 suf(pre(t,i),j)=pre(s,j),当 j=|s| 时意味着匹配到了模式串 s

考虑 ii1 向右移动一位,若 s[i]=t[i],则显然 ++j 即可。

但是若 s[i]t[i],则新的 j 与原先的 j 一定满足 j<jpre(s,j1)pre(s,j) 的 border。

这是因为 pre(s,j1) 显然是 s 的前缀和 pre(s,j) 的后缀,又因为 j<j 所以这个 s 的前缀也是 pre(s,j) 的前缀。

因此相当于找一个 pre(s,j) 的最长的 border pre(s,j1) 满足 t[i]=s[j]

由 border 的特殊性质 9,由长到短找到第一个满足 t[i]=s[j] 的 border 即为最长的 border。

因为找的都是模式串 s 的前缀的 border,与文本串 t 无关,所以可以预处理 nxt[j] 表示 pre(s,j) 的最长的 border 的长度。

因此 KMP 的 next 函数的实质是:nxt[j]=|lb(pre(s,j))|,即 s 的前缀的最长 border 的长度。

如何求 next 函数?设两个指针 ij (j<i) 表示当前已求出 nxt[1i1],且 nxt[i1]=j,现在需要求 nxt[i]

由 border 的特殊性质 6,若 nxt[i1]=j, s[i]=s[j+1],则 nxt[i]=j+1

由 border 的特殊性质 9,pre(s,i1) 的所有 border 是 nxt[i1],nxt[nxt[i1]],nxt[nxt[nxt[i1]]],

所以从 jnxt[i1] 开始看 s[i]=s[j+1] 是否成立,不成立就 jnxt[j] 用 next 函数往回退,找出最长 border。

虽然 KMP 的预处理 next 函数过程和模式匹配过程都有进(++j)有退(jnxt[j]),但是由于每次进都是 ++j,进的次数最多为 |s|+|t|j 非负,所以 Δj2(|s|+|t|),因此均摊后总时间复杂度是线性的,为 O(|s|+|t|)

根据整个 KMP 算法可以显然发现 next 函数满足一个非常非常非常重要的不等式:nxt[i]nxt[i1]+1 恒成立。

注意类似 next 函数或者能够抽象成类似 next 函数的函数 f(定义在字符串上或者能够抽象成在字符串上且从 i 开始不断 if[i]i 严格单调递减的函数)若也满足不等式 f[i]f[i1]+1 恒成立,则 f[i] 的计算也可以用 KMP 均摊成线性复杂度。

代码是 P3375 【模板】KMP字符串匹配 的,另外一题代码几乎完全一样。

复制代码
 1 const int N = 1e6 + 10;
 2 char s1[N], s2[N];
 3 int n, m, nxt[N];
 4 
 5 void build(const char *s)
 6 {
 7     nxt[1] = 0;
 8     for (int j = 0, i = 2; i <= n; ++i)
 9     {
10         while (j && s[i] != s[j + 1]) j = nxt[j];
11         if (s[i] == s[j + 1]) ++j;
12         nxt[i] = j;
13     }
14 }
15 
16 void match(const char *s, const char *t)
17 {
18     for (int j = 0, i = 1; i <= m; ++i)
19     {
20         while (j && t[i] != s[j + 1]) j = nxt[j];
21         if (t[i] == s[j + 1]) ++j;
22         if (j == n) cout << i - n + 1 << '\n';
23     }
24 }
25 
26 int main()
27 {
28     scanf("%s%s", s1 + 1, s2 + 1);
29     n = strlen(s2 + 1), m = strlen(s1 + 1);
30     build(s2); match(s2, s1);
31     for (int i = 1; i <= n; ++i) cout << nxt[i] << ' ';
32     return 0;
33 }
View Code
复制代码

2、KMP 与失配树

失配树,又称 next 树,fail 树,是只挂了一个串的 ACAM 的 fail 树。

s 的失配树是将 nxt[i] 视为 i 的父节点,由 0nn+1 个点构成的一棵树,0 为根节点,深度为 0

由于 i 只有一个 nxt[i]nxt[i]<i,所以显然是一棵树。

失配树的特殊性质:

  1. i 的所有祖先都是 pre(s,i) 的 border,点 i 的父亲是 lb(pre(s,i))
  2. 没有祖先关系的两个点 i,j 没有 border 关系。
  3. 祖先的节点编号一定小于后代的节点编号。

性质非常显然易证。

KMP 预处理 nxt[i] 可看成:从 jfa[i1] 往上走,找到第一个满足 s[i]=s[j+1] 的点 j+1,并 fa[i]j+1

KMP 模式匹配过程可看成:从 jfa[i1] 往上走,找到第一个满足 t[i]=s[j+1] 的点 j+1,并 fa[i]j+1

失配树是 KMP 的核心,KMP 看似难以理解,考虑成失配树上的操作会很容易明白,很多 KMP 难题考虑失配树直接变得很简单。

题意:给定 sm 次询问,每次给 p,q|lcb(pre(s,p),pre(s,q))|1|s|106, 1m105

由失配树的特殊性质 1,答案显然是失配树上 pq 的 lca,当然若 p,q 是祖先后代关系应该是 lca 的父亲。

复制代码
 1 const int N = 1e6 + 10;
 2 const int logN = 20;
 3 char s[N];
 4 int nxt[N], dep[N], fa[logN][N];
 5 
 6 inline int lca(int u, int v)
 7 {
 8     if (dep[u] < dep[v]) swap(u, v);
 9     for (int i = logN - 1; ~i; --i)
10         if (dep[fa[i][u]] >= dep[v]) u = fa[i][u];
11     if (u == v) return fa[0][u];
12     for (int i = logN - 1; ~i; --i)
13         if (fa[i][u] != fa[i][v]) u = fa[i][u], v = fa[i][v];
14     return fa[0][u];
15 }
16 
17 int main()
18 {
19     scanf("%s", s + 1); int n = strlen(s + 1);
20     nxt[1] = 0; dep[1] = 1; fa[0][1] = 0;
21     for (int j = 0, i = 2; i <= n; ++i)
22     {
23         while (j && s[i] != s[j + 1]) j = nxt[j];
24         if (s[i] == s[j + 1]) ++j;
25         nxt[i] = j; dep[i] = dep[j] + 1; fa[0][i] = j;
26     }
27     for (int j = 1; j < logN; ++j)
28         for (int i = 1; i <= n; ++i)
29             fa[j][i] = fa[j - 1][fa[j - 1][i]];
30     int q, u, v; read(q);
31     while (q--) read(u, v), cout << lca(u, v) << '\n';
32     return 0;
33 }
View Code
复制代码

3、KMP 练习

  • KMP 练习 1

题意 1:给定 s,求最短的 t,使得 s 是若干个 t 拼成的字符串的前缀。1|s|,|t|107

题意 2:给定 s,求字典序最小的 t,使得 s 是若干个 t 拼成的字符串的前缀。1|s|,|t|107

题意 3:给定 s,求最短的 t,使得 s 恰好是若干个 t 拼成的字符串。1|s|,|t|107

题意 4:给定 s,求字典序最小的 t,使得 s 恰好是若干个 t 拼成的字符串。1|s|,|t|107

题意 5:给定 s,求最短的 t,使得 s 是若干个 t 拼成的字符串的子串,若有多解输出任意一个。1|s|,|t|107

题意 6:给定 s,求最短的 t,使得 s 是若干个 t 拼成的字符串的子串,若有多解输出字典序最小的解。1|s|,|t|107

不难发现这 6 个题意实际都是与 s 的最短周期有关,答案都与 pre(s,|s||lb(s)|) 有关。

注意题意 3 和 4 还要判断 |s|mod(|s||lb(s)|))=0,题意 6 还要在题意 5 的基础上求答案的最小表示。

对于题意 3 和 4 可以发现一个结论:合法答案的长度必然是最短答案的长度的倍数,利用 border 的性质易证。

复制代码
 1 const int N = 2e3 + 10;
 2 char s[N];
 3 int nxt[N];
 4 
 5 int main()
 6 {
 7     int T; read(T);
 8     while (T--)
 9     {
10         scanf("%s", s + 1); int n = strlen(s + 1);
11         nxt[1] = 0;
12         for (int j = 0, i = 2; i <= n; ++i)
13         {
14             while (j && s[i] != s[j + 1]) j = nxt[j];
15             if (s[i] == s[j + 1]) ++j;
16             nxt[i] = j;
17         }
18         int l = n - (n - nxt[n] << 1) + 1, Case = 8, i = l;
19         while (Case--)
20         {
21             putchar(s[i]); ++i;
22             if (i > n) i = l;
23         }
24         puts("...");
25     }
26     return 0;
27 }
View Code
复制代码

代码是 AcWing 141. 周期 的,另外两题代码几乎完全一样。

复制代码
 1 const int N = 1e6 + 10;
 2 char s[N];
 3 int nxt[N];
 4 
 5 int main()
 6 {
 7     int T = 0;
 8     while (true)
 9     {
10         int n; read(n); if (!n) break;
11         scanf("%s", s + 1); nxt[1] = 0;
12         for (int j = 0, i = 2; i <= n; ++i)
13         {
14             while (j && s[i] != s[j + 1]) j = nxt[j];
15             if (s[i] == s[j + 1]) ++j;
16             nxt[i] = j;
17         }
18         cout << "Test case #" << ++T << '\n';
19         for (int i = 1; i <= n; ++i)
20             if (!(i % (i - nxt[i])) && i / (i - nxt[i]) > 1)
21                 cout << i << ' ' << i / (i - nxt[i]) << '\n';
22         cout << '\n';
23     }
24     return 0;
25 }
View Code
复制代码
复制代码
 1 const int N = 1e6 + 10;
 2 char s[N];
 3 int nxt[N];
 4 
 5 int main()
 6 {
 7     int n; read(n); scanf("%s", s + 1);
 8     for (int j = 0, i = 2; i <= n; ++i)
 9     {
10         while (j && s[i] != s[j + 1]) j = nxt[j];
11         if (s[i] == s[j + 1]) ++j;
12         nxt[i] = j;
13     }
14     cout << n - nxt[n];
15     return 0;
16 }
View Code
复制代码
  • KMP 练习 2

题意:给定 s,求字典序最小的 t,使得 s 是若干个 t 拼成的字符串的子串。1|t||s|107

虽然写的是“KMP 练习 2”但是不知道是否能用 KMP 或者其他什么神仙做法做到线性复杂度……

给一个可能 hack 掉很多做法的数据:saabaa,答案是 aaaab,并不是 aab

暴力是先求出每个串的前缀的最小表示(参考 P5334 [JSOI2019]节日庆典)再找合法的取字典序最小的,用 SA 可以做到一个 log

  • KMP 练习 3

给定文本串 s 和模式串 t,求最多能模式匹配互不重叠的模式串 t 的个数。1|s|,|t|107

显然从左到右贪心能选就选。

  • KMP 练习 4

题意:给定 s,对每个 pre(s,i) 都求最长的 t,使得 |t|<ipre(s,i) 是若干个 t 拼成的字符串的前缀。1|s|107

实际就是求 s 的每个前缀的最长周期,答案显然为 pre(s,i) 的最短 border,在失配树上就是 i 的所有祖先中离 0 最近的那个点。

可以先构造出失配树再 DFS 一遍,也可以将 fa[i] 进行路径压缩优化,时间复杂度为 O(|s|)

复制代码
 1 const int N = 1e6 + 10;
 2 char s[N];
 3 int nxt[N], fa[N];
 4 
 5 inline int find(int x) { return nxt[x] ? fa[x] = find(fa[x]) : x; }
 6 
 7 int main()
 8 {
 9     int n; read(n); scanf("%s", s + 1); ll ans = 0;
10     nxt[1] = 0; fa[1] = 1;
11     for (int j = 0, i = 2; i <= n; ++i)
12     {
13         while (j && s[i] != s[j + 1]) j = nxt[j];
14         if (s[i] == s[j + 1]) ++j;
15         nxt[i] = j; !j ? fa[i] = i : fa[i] = find(j);
16     }
17     for (int i = 1; i <= n; ++i)
18         if (nxt[i]) ans += i - fa[i];
19     cout << ans;
20     return 0;
21 }
View Code
复制代码
  • KMP 练习 5

题意:给定 n×m 的字符矩阵,求最小连续子矩阵,使得该子矩阵无限复制扩张后的矩阵包含原矩阵。1n,m3×103

实际上就是 KMP 练习 1 的题意 1 的字符串变成了二维的情况,将每一行和每一列都当成一个整体(最好 Hash 成一个数)即可。

n 行整体求出最短周期 p,对 m 列整体求出最短周期 q,则答案显然为 p×q 的字符矩阵。

KMP 时间复杂度为 O(nm),用 Hash 优化比较运算能做到 O(n+m),但输入量为 O(nm) 所以总时间复杂度还是 O(nm)。 

复制代码
 1 const int N = 1e4 + 10, M = 80;
 2 const ull p = 131;
 3 char s[N][M];
 4 ull h[N];
 5 int nxt[N];
 6 
 7 int main()
 8 {
 9     int n, m; read(n, m);
10     for (int i = 1; i <= n; ++i)
11         scanf("%s", s[i] + 1);
12     
13     for (int i = 1; i <= n; ++i)
14         for (int j = 1; j <= m; ++j)
15             h[i] = h[i] * p + s[i][j] - 96;
16     nxt[1] = 0;
17     for (int j = 0, i = 2; i <= n; ++i)
18     {
19         while (j && h[i] != h[j + 1]) j = nxt[j];
20         if (h[i] == h[j + 1]) ++j;
21         nxt[i] = j;
22     }
23     int r = n - nxt[n];
24     
25     memset(h, 0, sizeof(h));
26     for (int j = 1; j <= m; ++j)
27         for (int i = 1; i <= n; ++i)
28             h[j] = h[j] * p + s[i][j] - 96;
29     nxt[1] = 0;
30     for (int j = 0, i = 2; i <= m; ++i)
31     {
32         while (j && h[i] != h[j + 1]) j = nxt[j];
33         if (h[i] == h[j + 1]) ++j;
34         nxt[i] = j;
35     }
36     int c = m - nxt[m];
37     
38     cout << r * c;
39     return 0;
40 }
View Code
复制代码
  • KMP 练习 6

题意:给定 s,对每个 pre(s,i) 求长度不超过 i2 的 border 数量。1|s|107

实际上就是求失配树上 i 的祖先中第一个编号不超过 i2 的点的深度,直接暴力倍增就可以做到 O(|s|log|s|)

Nxt[i] 表示 pre(s,i) 的长度不超过 i2 的最长 border 的长度,则不等式 Nxt[i]Nxt[i1]+1 恒成立。

证明:

因为 Nxt[i]i2, Nxt[i1]i12,所以 Nxt[i]1i21i12

又因为 Nxt[i]1pre(s,i1) 某个 border 的长度,Nxt[i1]pre(s,i1) 长度不超过 i12 的最长 border 的长度,所以 Nxt[i]1Nxt[i1]Nxt[i]Nxt[i1]+1

证毕。

因此用 KMP 均摊线性求出 Nxt[i] 即可,注意每次进后若超过 i2 要回退一次,时间复杂度为 O(|s|)

复制代码
 1 const int N = 1e6 + 10;
 2 const ll p = 1e9 + 7;
 3 char s[N];
 4 int nxt[N], dep[N];
 5 
 6 int main()
 7 {
 8     int T; read(T);
 9     while (T--)
10     {
11         scanf("%s", s + 1);
12         int n = strlen(s + 1); ll ans = 1;
13         memset(nxt, 0, sizeof(nxt));
14         memset(dep, 0, sizeof(dep));
15         nxt[1] = 0; dep[1] = 1;
16         for (int j = 0, i = 2; i <= n; ++i)
17         {
18             while (j && s[i] != s[j + 1]) j = nxt[j];
19             if (s[i] == s[j + 1]) ++j;
20             nxt[i] = j; dep[i] = dep[j] + 1;
21         }
22         for (int j = 0, i = 1; i <= n; ++i)
23         {
24             while (j && s[i] != s[j + 1]) j = nxt[j];
25             if (s[i] == s[j + 1]) ++j;
26             if (j << 1 > i) j = nxt[j];
27             ans = (ans * (dep[j] + 1)) % p;
28         }
29         cout << ans << '\n';
30     }
31     return 0;
32 }
View Code
复制代码
  • KMP 练习 7

题意:给定文本串 s,求最短模式串 t,使得在 s 中出现过的所有的 t 能将 s 的每个位置都至少覆盖一次。1|s|,|t|107

s 的首尾字符都要被 t 覆盖到所以显然 ts 的 border,所以答案只能是失配树上 0|s| 这条链上第一个合法的节点。

由失配树的特殊性质 1,pre(s,i)i 的所有后代的 border,所以 i 的子树内所有节点排序后相邻两个节点的差的最大值(即最大间隔 maxgap)就是以 pre(s,i) 作为模式串 ts 中相邻两个模式串 t 的距离的最大值,只要 maxgapi 显然是合法的。

随着点的不断删除 maxgap 显然单调不减,因此只需要一种数据结构支持删除 i,查询 i 的前驱和后继,可以考虑双向链表。

因此维护当前 i 的子树内的点即可,有意思的是由失配树的特殊性质 3,i 的祖先即使不删除显然也不会影响答案(懒)。

由于每个点最多只会被删除一次,所以时间复杂度为 O(|s|)

当然这道题还有一种不用失配树的更简单的做法:直接考虑 DP,设 dp[i] 表示 pre(s,i) 的答案,实质是合法最短 border 长度。

显然 dp[i] 只可能是 dp[nxt[i]]i,它能由 dp[nxt[i]] 转移当且仅当存在一个 j 满足 dp[j]=dp[nxt[i]], inxt[i]j

因此开一个桶 b[i] 来存储 j 的值,表示以 pre(s,i) 作为模式串 t,文本串为 pre(s,j) 合法的最大的 j

时间复杂度也是 O(|s|)

第一份代码是失配树的,第二份代码是 DP 的。

复制代码
 1 const int N = 5e5 + 10;
 2 char s[N];
 3 int maxgap, nxt[N], pre[N], suf[N];
 4 bool border[N];
 5 vector<int> son[N];
 6 
 7 void del(int u)
 8 {
 9     if (~pre[u] && ~suf[u]) maxgap = Max(maxgap, suf[u] - pre[u]);
10     suf[pre[u]] = suf[u]; pre[suf[u]] = pre[u]; pre[u] = suf[u] = -1;
11     for (int i = 0; i < son[u].size(); ++i) del(son[u][i]);
12 }
13 
14 int main()
15 {
16     scanf("%s", s + 1); int n = strlen(s + 1);
17     nxt[1] = 0; son[0].emplace_back(1);
18     for (int j = 0, i = 2; i <= n; ++i)
19     {
20         while (j && s[i] != s[j + 1]) j = nxt[j];
21         if (s[i] == s[j + 1]) ++j;
22         nxt[i] = j; son[j].emplace_back(i);
23     }
24     for (int j = n; j; j = nxt[j]) border[j] = true;
25     for (int i = 1; i <= n; ++i) pre[i] = i - 1, suf[i] = i + 1;
26     pre[0] = suf[0] = pre[n + 1] = suf[n + 1] = -1; maxgap = 1;
27     int u = 0, v;
28     while (u != n)
29     {
30         for (int i = 0; i < son[u].size(); ++i)
31             border[son[u][i]] ? (void)(v = son[u][i]) : del(son[u][i]);
32         if (maxgap <= v) { cout << v; return 0; }
33         u = v;
34     }
35     cout << n;
36     return 0;
37 }
View Code
复制代码
复制代码
 1 const int N = 5e5 + 10;
 2 char s[N];
 3 int nxt[N], dp[N], b[N];
 4 
 5 int main()
 6 {
 7     scanf("%s", s + 1); int n = strlen(s + 1);
 8     for (int j = 0, i = 2; i <= n; ++i)
 9     {
10         while (j && s[i] != s[j + 1]) j = nxt[j];
11         if (s[i] == s[j + 1]) ++j;
12         nxt[i] = j;
13     }
14     for (int i = 1; i <= n; ++i)
15         dp[i] = i - nxt[i] <= b[dp[nxt[i]]] ? dp[nxt[i]] : i, b[dp[i]] = i;
16     cout << dp[n];
17     return 0;
18 }
View Code
复制代码
  • KMP 练习 8

题意:给定字符集 Σ 和长为 n 仅由 Σ 中的字符构成的 s,求长为 m 仅由 Σ 中的字符构成的 t 的个数,使得 st 的子串,答案对 109+7 取模。1|Σ|104, 1n100, nm<2128

dp[i][j] 表示当前 t 构造到 is 匹配到 j 的答案,枚举 i+1 处的字母,此时 s 匹配到 j,则 dp[i+1][j]+=dp[i][j]

注意 dp[i][n] 不转移,相当于 s 第一次出现时统计答案,显然不会算重,最终答案为 dp[i][n],这样能做到 O(|Σ|n2m)

dp[i][] 只能转移到 dp[i+1][]j 只与 s 有关与 t 无关,n 很小但 m 巨大,这都启发我们矩阵快速幂。

dp[i][] 看成 1×n 的行向量,将转移看成 n×n 的列向量,根据 s 预处理出转移矩阵即可。

最终答案的计算可以再加一列记录 dp[i][n] 的前缀和,也可以容斥计算,dp[m][0n1] 显然是 s 不是 t 的子串的答案。

预处理 O(|Σ|n2),矩阵快速幂 O(n3logm),所以总时间复杂度为 O(|Σ|n2+n3logm)

复制代码
 1 const int N = 25;
 2 int n, m, p, ans;
 3 char s[N], t[N];
 4 int nxt[N];
 5 
 6 struct matrix
 7 {
 8     int a[N][N];
 9     matrix() { clear(); }
10     inline void clear()
11     {
12         for (int i = 0; i <= m; ++i)
13             for (int j = 0; j <= m; ++j)
14                 a[i][j] = 0;
15     }
16     inline void init()
17     {
18         for (int i = 0; i <= m; ++i)
19             for (int j = 0; j <= m; ++j)
20                 a[i][j] = i == j;
21     }
22 } f, g;
23 inline matrix operator * (const matrix &x, const matrix &y)
24 {
25     matrix ans;
26     for (int i = 0; i <= m; ++i)
27         for (int j = 0; j <= m; ++j)
28             for (int k = 0; k <= m; ++k)
29                 addmod(ans.a[i][j] += x.a[i][k] * y.a[k][j] % p, p);
30     return ans;
31 }
32 inline matrix quickpow(matrix x, int y)
33 {
34     matrix ans; ans.init();
35     for (; y; x = x * x, y >>= 1)
36         if (y & 1) ans = ans * x;
37     return ans;
38 }
39 
40 int main()
41 {
42     read(n, m, p); scanf("%s", s + 1);
43     for (int j = 0, i = 2; i <= m; ++i)
44     {
45         while (j && s[i] != s[j + 1]) j = nxt[j];
46         if (s[i] == s[j + 1]) ++j;
47         nxt[i] = j;
48     }
49     f.a[0][0] = 1;
50     for (int i = 0; i < m; ++i)
51         for (int c = 0; c < 10; ++c)
52         {
53             int j = i;
54             while (j && c + '0' != s[j + 1]) j = nxt[j];
55             if (c + '0' == s[j + 1]) ++j;
56             ++g.a[i][j];
57         }
58     f = f * quickpow(g, n);
59     for (int i = 0; i < m; ++i) addmod(ans += f.a[0][i], p);
60     cout << ans;
61     return 0;
62 }
View Code
复制代码
  • KMP 练习 9

题意:给定 s,t,对每个 j 都求出 j 是满足 pre(suf(s,i),j)=pre(t,j) 的最大值的 i 的个数。1|s|,|t|107

问题等价于将 s 的所有后缀与 t 匹配,后缀的首字符和 t 的首字符对齐,对每个 j 求匹配长度恰好为 j 的后缀个数。

看起来似乎很难用 KMP 解决,但可以用差分思想去转化,若问题是匹配长度至少为 j 呢?

pre(s,i) 匹配长度为 j,则以 ij+1,inxt[j]+1, 为首字符的后缀的匹配长度分别至少为 j,nxt[j],

因此相当于失配树上将 j0 这条链上的点的答案都 +1,先打标记最后遍历失配树求答案,时间复杂度为 O(|s|+|t|)

View Code
  • KMP 练习 10

题意:给定文本串 s 和模式串 t,将 s 中的 t 不断删除,求最后的 s,注意删除一个 t 后两边会拼在一起。1|s|,|t|107

用一个栈来存储当前的 s,再记录当前的 s 的前缀的匹配长度 Nxt[i] 即可。

View Code
  • KMP 练习 11

题意:给定 s,可以将 s 中连续几个相同的部分压缩成一个,操作可以进行若干次,求最终 s 的长度最小值。1|s|500

考虑区间 DP,实际上压缩就是 KMP 练习 1 的题意 3 的问题,每个区间(子串)都找最短循环节即可,时间复杂度为 O(|s|3)

注意 P4302 [SCOI2003]字符串折叠UVA1630 串折叠 Folding 压缩后不一定更优。

代码是 UVA1630 串折叠 Folding 的,另外两题代码几乎完全一样。

复制代码
 1 const int N = 105;
 2 char str[N];
 3 int dp[N][N];
 4 
 5 inline int digit(int x)
 6 {
 7     int t = 0;
 8     if (!x) ++t;
 9     while (x) x /= 10, ++t;
10     return t;
11 }
12 
13 inline bool check(int l, int r, int &p)
14 {
15     int nxt[N]; char s[N]; int n = r - l + 1;
16     memset(nxt, 0, sizeof(nxt)); nxt[1] = 0;
17     for (int i = l; i <= r; ++i) s[i - l + 1] = str[i];
18     for (int j = 0, i = 2; i <= n; ++i)
19     {
20         while (j && s[i] != s[j + 1]) j = nxt[j];
21         if (s[i] == s[j + 1]) ++j;
22         nxt[i] = j;
23     }
24     p = n - nxt[n];
25     if (n % p || n / p <= 1) return false;
26     if (p == 1 && n / p <= 3) return false;
27     if (p == 2 && n / p <= 2) return false;
28     return true;
29 }
30 
31 inline void print(int l, int r)
32 {
33     if (l == r) { putchar(str[l]); return; }
34     int p, n = r - l + 1;
35     if (check(l, r, p))
36         { cout << n / p; putchar('('); print(l, l + p - 1); putchar(')'); return; }
37     for (int k = l; k < r; ++k)
38         if (dp[l][r] == dp[l][k] + dp[k + 1][r])
39             { print(l, k), print(k + 1, r); return; }
40 }
41 
42 int main()
43 {
44     while (~scanf("%s", str + 1))
45     {
46         int n = strlen(str + 1); memset(dp, 0x3f, sizeof(dp));
47         for (int i = 1; i <= n; ++i) dp[i][i] = 1;
48         for (int len = 2; len <= n; ++len)
49             for (int p, l = 1, r = l + len - 1; r <= n; ++l, ++r)
50             {
51                 dp[l][r] = check(l, r, p) ? digit(len / p) + 2 + dp[l][l + p - 1] : len;
52                 for (int k = l; k < r; ++k)
53                     dp[l][r] = Min(dp[l][r], dp[l][k] + dp[k + 1][r]);
54             }
55         print(1, n); cout << '\n';
56     }
57     return 0;
58 }
View Code
复制代码
  • KMP 练习 12

题意:给定文本串 s 和模式串 t,判断 t 是否在 s 中出现恰好一次。1|s|,|t|107

直接匹配求次数即可。

题意:给定 s,求它的最长前缀 t(不能为空但可以为 s)使得 t 翻转后恰好也是 s 的后缀。1|s|107

s 翻转并和原来的 s 求 LCP 即可。

题意:给定 n×m 的文本字符矩阵和 p×q 的模式字符矩阵,求出现次数。1n,m,p,q3×103

与 KMP 练习 5 的做法类似,二维 Hash 后直接匹配即可,时间复杂度为 O(nm+pq)

  • KMP 练习 15

题意:给定仅由小写字母和问号构成的文本串 s 和仅由小写字母构成的模式串 t,问号可以替换为任意一个小写字母,求 ts 中的出现次数的最大值。1|s|,|t|3×103

与 KMP 练习 8 的做法类似,由于 |s||t| 同阶,即使预处理转移矩阵时间复杂度仍然不会变。

因此直接暴力 DP 求解即可,时间复杂度为 O(Σ|s||t|)

  • KMP 练习 16

题意:给定两段墙 st,求 s 墙中与 t 墙顶部形状相同的区间数。1|s|107

形状相同即差分后子串相同,然后求子串出现次数即可,注意 t=1 的情况需要特判。

  • KMP 练习 17

题意:给定 s,求最长的 t,满足 t 既是 s 的前缀也是 s 的后缀还是 s 的子串(不含前后缀)。1|s|107

首先答案显然一定是 s 的某个 border,其次子串必然是某个后缀的前缀。

  • KMP 练习 18

题意:

  • KMP 练习 19

题意:

  • KMP 练习 20

题意:

    • 例题:

四、exKMP

1、exKMP 算法

exKMP,又称扩展 KMP,Z 函数,Z Algorithm。

实际上 KMP 与 exKMP 的思想类似,过程相像,学懂了 KMP 后 exKMP 很容易理解。

n=|s|,定义函数 z[i]=|lcp(s,s[in])|,即 s 与以 s[i] 开头的后缀的最长公共前缀的长度。

特别地,z[1] 的取值为 0 还是 n 需要看题意,这里的前缀是否必须是真前缀(不包含 s 本身)。

z 被称为 s 的 Z 函数,s[ii+z[i]1] 被称为 i 的匹配段,又称 i 的 Z-box。

计算 z[i] 的过程与 nxt[i] 类似,也需要利用 z[1i1],从 2n 顺次计算。

我们需要维护右端点最靠右的 Z-box s[lr],显然它也是 pre(s,rl+1),计算 z[i] 时保证 li,令 l,r 的初值为 0

ir,显然 s[ir]=s[il+1rl+1],因此若 z[il+1]ri+1,则 z[i]z[il+1],否则 z[i]ri+1 后暴力向右扩展。若 i>r,直接暴力向右扩展。最后注意用求出的 z[i] 更新 l,r 即可。

这个网站 是对 exKMP 预处理 z 函数的演示,模式匹配的过程也跟预处理的过程非常像,画个图不难自推。

考虑 r 只会向前进不会向后退,rn,所以时间复杂度均摊线性为 O(n)

不难发现实质上 KMP 是 ss 的前缀的最长后缀匹配,exKMP 是 ss 的后缀的最长前缀匹配。

它们都能处理子串模式匹配问题,KMP 能做的题 exKMP 都能做,exKMP 能做的题 KMP 都能做。

只不过有的时候某个信息更方便处理或处理的时间复杂度更优,我们需要根据实际情况选择用 KMP 还是 exKMP。

复制代码
 1 const int N = 2e7 + 10;
 2 char s[N], t[N];
 3 int z[N], Z[N];
 4 
 5 int main()
 6 {
 7     scanf("%s%s", t + 1, s + 1);
 8     int n = strlen(s + 1), m = strlen(t + 1); z[1] = n;
 9     for (int l = 0, r = 0, i = 2; i <= n; ++i)
10     {
11         if (i <= r) z[i] = Min(z[i - l + 1], r - i + 1);
12         while (i + z[i] <= n && s[i + z[i]] == s[z[i] + 1]) ++z[i];
13         if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
14     }
15     for (int l = 0, r = 0, i = 1; i <= m; ++i)
16     {
17         if (i <= r) Z[i] = Min(z[i - l + 1], r - i + 1);
18         while (i + Z[i] <= m && t[i + Z[i]] == s[Z[i] + 1]) ++Z[i];
19         if (i + Z[i] - 1 > r) l = i, r = i + Z[i] - 1;
20     }
21     ll sum = 0; for (int i = 1; i <= n; ++i) sum ^= i * (z[i] + 1ll); cout << sum << '\n';
22     sum = 0; for (int i = 1; i <= m; ++i) sum ^= i * (Z[i] + 1ll); cout << sum;
23     return 0;
24 }
View Code
复制代码

2、KMP 与 exKMP 的相互转化

初学时可能会有疑问,为什么“KMP 能做的题 exKMP 都能做,exKMP 能做的题 KMP 都能做”?

因为在不知道原串的前提下,只知道 nxt[i] 可以求出 z[i],只知道 z[i] 也可以求出 nxt[i],即它们之间可以相互转化。

Dan Gusfield 对其进行了详细证明:

3、exKMP 练习

posted @   jhqqwq  阅读(98)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示