字符串(一)KMP,Trie,AC自动机

· · 算法·理论

前言

::::info[AI使用声明] 本文使用了 deepseek 润色,保证人类贡献远大于 AI 贡献。(deepseek 基本删除了所有前言以外的“喵”伤心喵。) :::: ::::info[一些废话] 串串可爱喵。串串吃智商喵。(难过)\ 一觉醒来忘记了所有关于串串的知识,于是大复习了一遍。 :::: 这篇文章实质上是一篇超短的学习笔记喵。

KMP

Problem:\ 给定下面的两个字符串:

  • 文本串 T,长度为 n,下标为 1,\dots,n。
  • 模式串 P,长度为 m,下标为 1,\dots,m。

要求找到所有位置 i 使得 T_{i,\dots,i+m-1}=P_{1,\dots,m},即 P 在 T 中每一次完整出现的位置。

一个暴力做法是:对于每一个起点 i,逐字符比较 T_{i+j-1} 和 P_j(1\le j\le m),时间复杂度是 O(nm) 的。暴力的问题在于,一旦失配,之前已经比较过的信息全部丢弃,需要从头重新比较。

对于模式串 P,定义数组 \text{next}_{1,\dots,m},其中 \text{next}_i 表示 P_{1,\dots,i} 的所有真前缀中最长的、同时也是 P_{1,\dots,i} 真后缀的长度,即

\text{next}_i = \max\{k \mid 0 \le k < i,\ P_{1,\dots,k} = P_{i-k+1,\dots,i}\}

空串是任何字符串的前缀和后缀,因此 \text{next}_i\ge 0。注意 k<i,因为要求的是真前缀和真后缀。由定义可得 \text{next}_1=0。

::::info[为什么要这样定义?]{open} 匹配失配时,我们希望把模式串尽量少地往后滑动,同时保证已经匹配的部分仍然匹配。\text{next}_i 表示的是:P_{1,\dots,i} 的前缀可以和后缀对齐的最大长度。这样滑动之后,这一段已经和文本串匹配过,不需要重新比较。 ::::

假设 \text{next}_1,\dots,\text{next}_{i-1} 已经全部求出来了,设 j=\text{next}_{i-1},那么有 P_{1,\dots,j}=P_{i-j,\dots,i-1}。接下来要把 P_i 接上去:

::::info[解释]{open} 由 \text{next}_{i-1} 的定义可以得到

P_{1,\dots,j}=P_{i-j,\dots,i-1} \tag{①}

设要求的 \text{next}_i 对应的最长长度为 k,那么

P_{1,\dots,k}=P_{i-k+1,\dots,i} \tag{②}

寻找的 k<j+1(如果 P_{j+1}\ne P_i),那么 k\le j。②的 P_{1,\dots,k} 是 P_{1,\dots,j} 的前缀。\

$$P_{i-k+1,\dots,i-1}=P_{j-k+2,\dots,j} \tag{③}$$ ②是 $P_{1,\dots,k}=P_{i-k+1,\dots,i}$,拆成前 $k-1$ 位容易得到 $P_{1,\dots,k-1}=P_{i-k+1,\dots,i-1}$,$P_k=P_i$。那么由 ③ 得: $$P_{1,\dots,k-1}=P_{j-k+2,\dots,j}$$ 想找最大的 $k$,等价于找最大的 $k-1$。\ $k$ 要满足 $P_{1,\dots,k-1}=P_{j-k+2,\dots,j}$,也就是 $k-1$ 是 $P_{1,\dots,j}$ 的真前缀真后缀相等的长度。最大的 $k-1$ 就是 $\text{next}_j$。\ 由 $\max (k-1)=\text{next}_j$ 得到 $\max k=\text{next}_j+1$。 :::: 那么令 $j=\text{next}_j$ 重复比较即可。如果 $j$ 一直退到 $0$,说明没有任何非空前缀能匹配,此时 $\text{next}_i=0$。 KMP 匹配过程如下:\ 设 $j$ 为当前 $P$ 已经匹配到了的位置,初始的时候 $j=0$。\ 遍历 $T$ 的每一个位置 $i$($1\le i\le n$): 1. 如果 $j>0$ 并且 $T_i\ne P_{j+1}$,说明失配了,重复 $j=\text{next}_j$ 直到 $j=0$ 或者 $T_i=P_{j+1}$。 2. 如果 $T_i=P_{j+1}$,那么 $j \gets j+1$。 3. 如果 $j=m$,就找到了完整的匹配。此时记录答案,并令 $j=\text{next}_j$ 继续寻找重叠匹配。 注意到 $T$ 的指针 $i$ 永远不会后退,只是调整 $P$ 的指针 $j$。 ::::info[为什么失配的时候 $j=\text{next}_j$]{open} 当前的状态是 $P_{1,\dots,j}=T_{i-j,\dots,i-1}$ 并且 $T_i\ne P_{j+1}$。需要在 $P_{1,\dots,j}$ 中寻找一个最长的真前缀,它也是 $P_{1,\dots,j}$ 的真后缀。这个长度是 $\text{next}_j$。\ 设 $k=\text{next}_j$,那么 $P_{1,\dots,k}=P_{j-k+1,\dots,j}$。由于 $P_{1,\dots,j}=T_{i-j,\dots,i-1}$,所以 $P_{j-k+1,\dots,j}=T_{i-k,\dots,i-1}$,于是有 $P_{1,\dots,k}=T_{i-k,\dots,i-1}$。\ 把 $P$ 往后滑动,使得 $P$ 的第 $1$ 位对齐到 $T$ 的第 $i-k$ 位,那么 $P$ 的前 $k$ 位已经和 $T$ 匹配了,可以直接从 $P$ 的第 $k+1$ 位和 $T$ 的第 $i$ 位继续比较即可。 :::: 时间复杂度是 $O(n+m)$ 的。\ 板子题:[模板KMP](https://www.luogu.com.cn/problem/P3375) ::::success[P3375] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 1e6 + 5; int n,m; char p[N],t[N]; int nxt[N]; void gnext() { nxt[1] = 0; int j = 0; for(register int i = 2;i <= m;++ i) { while(j > 0 && p[j + 1] != p[i]) j = nxt[j]; if(p[j + 1] == p[i]) j++; nxt[i] = j; } } void kmp() { int j = 0; for(register int i = 1;i <= n;++ i) { while(j > 0 && t[i] != p[j + 1]) j = nxt[j]; if(t[i] == p[j + 1]) j ++; if(j == m) { printf("%lld\n",i - m + 1); j = nxt[j]; } } } signed main() { cin >> (t + 1); cin >> (p + 1); n = strlen(t + 1); m = strlen(p + 1); gnext(); kmp(); for(register int i = 1;i <= m;++ i) printf("%lld ",nxt[i]); printf("\n"); return 0; } ``` :::: ### 例一:[无线传输](https://www.luogu.com.cn/problem/P4391) > 题意:\ > 给定一个字符串 $s$,它是由某个字符串 $p$ 不断拼接得到的(最后一段可能不完整),求最小的 $|P|$。 > 设 $s$ 的长度为 $n$,$\text{next}_n$ 是 $s$ 的最长的真前缀等于真后缀的长度,其前 $\text{next}_n$ 个字符和最后 $\text{next}_n$ 个字符是相同的。把 $s$ 往前平移 $n-\text{next}_n$ 位,重叠的部分(即 $s$ 的后 $\text{next}_n$ 个字符与前 $\text{next}_n$ 个字符)完全相同,说明 $s$ 是以 $n-\text{next}_n$ 为周期循环的,所以最小循环节长度是 $n-\text{next}_n$。 之所以取 $\text{next}_n$ 而不是更短的 border,是因为 $\text{next}_n$ 是最长 border,平移后重叠最多、剩余最少,得到的周期最小。 ::::success[P4391] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 1e6 + 5; int n; char s[N]; int nxt[N]; void gnext() { nxt[1] = 0; int j = 0; for(register int i = 2;i <= n;++i) { while(j > 0 && s[j + 1] != s[i]) j = nxt[j]; if(s[j + 1] == s[i]) j ++; nxt[i] = j; } } signed main() { scanf("%lld",&n); cin >> (s + 1); gnext(); printf("%lld\n",n - nxt[n]); return 0; } ``` :::: ### 例二:[OKR-Periods of Words](https://www.luogu.com.cn/problem/P3435) > Problem:\ > 给一个字符串 $s$,对于每一个前缀 $s_{1,\dots,i}$ 求它的最长周期长度。计算该字符串所有前缀的最大周期长度之和。 如果 $s_{1,\dots,i}$ 有一个长度为 $p$ 的周期,当且仅当 $s_{1,\dots,i-p}=s_{p+1,\dots,i}$,$i-p$ 是 $s_{1,\dots,i}$ 的一个真前缀和真后缀相等长度。因为如果 $s_{1,\dots,i-p}=s_{p+1,\dots,i}$,那么对于任意 $1\le j\le i-p$ 都有 $s_j=s_{j+p}$,那么 $s$ 以 $p$ 为周期。\ 而 $\text{next}_i$ 是最长的真前缀和真后缀相等的长度,要找到最短的非零的就不断从 $\text{next}_i$ 出发跳 $\text{next}$。跳完之后,$j$ 满足 $\text{next}_j=0$,那么 $j$ 是链上最后一个非零值,这个 $j$ 就是最短的非零真前缀和真后缀相等的长度。\ 但是直接跳会退化为 $O(n^2)$,不妨在跳的过程中,直接找到最终停下的位置 $j$,然后把当前节点的 $\text{next}_i$ 直接改为 $j$。这样以后再次跳到这个节点时就能直接得到答案。这是路径压缩的思想。 路径压缩之所以正确,是因为每个点只需要知道它沿 $\text{next}$ 链最终跳到的最后一个非零位置,压缩后不改变这个信息,却能让后续查询均摊变快。 ::::success[P3435] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 1e6 + 5; int n; char s[N]; int nxt[N]; int ans; void gnext() { nxt[1] = 0; int j = 0; for(register int i = 2;i <= n;++ i) { while(j > 0 && s[j + 1] != s[i]) j = nxt[j]; if(s[j + 1] == s[i]) j ++; nxt[i] = j; } } signed main() { scanf("%lld",&n); cin >> (s + 1); gnext(); for(register int i = 2;i <= n;++i) { if(nxt[i] > 0) { int j = nxt[i]; while(nxt[j] > 0) j = nxt[j]; nxt[i] = j; ans += i - j; } } printf("%lld\n",ans); return 0; } ``` :::: ### 例三:[动物园](https://www.luogu.com.cn/problem/P2375) > 给定一个长度为 $n$ 的字符串 $s$,对于它的每一个前缀 $s_{1,\dots,i}$,设 $\text{num}_i$ 为满足这个子串既是 $s_{1,\dots,i}$ 的真前缀又是其真后缀且其长度不超过 $\frac{i}{2}$ 的子串数量,求所有 $\text{num}$ 的乘积模 $10^9+7$。 ::::warning[提示]{open} 注意 $\text{num}_i$ 统计的是不同长度的数量,不是出现次数。每个满足条件的长度算一个。空串也视为一种前后缀相等的子串。 :::: 设 $\text{cnt}_i$ 表示 $s_{1,\dots,i}$ 的所有真前后缀相等的长度数量(包括空串)。由于 $\text{next}_i$ 是最长的真前后缀相同的长度,所以 $s_{1,\dots,i}$ 的所有真前后缀相等的长度包括 $\text{next}_i$ 和 $s_{1,\dots,\text{next}_i}$ 的所有真前后缀相等的长度。因此有递推式: $$\text{cnt}_i=\text{cnt}_{\text{next}_i}+1$$ 其中 $\text{cnt}_0=0$,$\text{cnt}_1=1$($s_{1,\dots,1}$ 的真前后缀只有空串)。\ 设 $j$ 为满足 $j\le \frac{i}{2}$ 的最长的真前缀和真后缀相等的长度,那么所有长度不超过 $j$ 的真前后缀相等长度都满足 $\le \frac{i}{2}$,这些长度包括空串以及长度 $1,2,\dots,j$,数量恰好是 $\text{num}_i=\text{cnt}_j+1$。 这样设计是因为 $\text{cnt}$ 已经把 $\text{next}$ 链上所有 border 长度都统计好了,只要把 $j$ 限制到不超过 $i/2$,就能直接读出满足条件的数量。 ::::success[P2375] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 1e6 + 5; const int Mod = 1e9 + 7; int n; char s[N]; int nxt[N]; int cnt[N]; int ans; void gnext() { nxt[1] = 0; cnt[0] = 0; cnt[1] = 1; int j = 0; for(register int i = 2;i <= n;++i) { while(j > 0 && s[j + 1] != s[i]) j = nxt[j]; if(s[j + 1] == s[i]) j++; nxt[i] = j; cnt[i] = cnt[j] + 1; } } signed main() { int T; scanf("%lld",&T); while(T--) { cin >> (s + 1); n = strlen(s + 1); gnext(); ans = 1; int j = 0; for(register int i = 2;i <= n;++i) { while(j > 0 && s[j + 1] != s[i]) j = nxt[j]; if(s[j + 1] == s[i]) j++; while(j > i / 2) j = nxt[j]; ans = ans * (cnt[j] + 1) % Mod; } printf("%lld\n",ans); } return 0; } ``` :::: ### 例四:[Password](https://codeforces.com/problemset/problem/126/B) > 题意:\ > 给定一个字符串 $S$,长度 $n\le 10^6$,找到一个子串 $t$ 满足 $t$ 同时是 $S$ 的前后缀并且在中间也出现过。 > 输出最长的这样的 $t$,如果不存在输出 `Just a legend`。 对于前后缀的条件直接使用 $\text{next}_n$,但是它对应的子串可能没有在中间出现。\ 如果某一个 $\text{next}_i=x(2\le i\le n-1)$ 说明 $S$ 的中间位置 $i$ 处存在一个长度为 $x$ 的子串,它是 $S_{1,\dots,i}$ 的后缀也是 $S$ 的前缀,这个就是在中间出现。\ 设 $\text{maxx}=\max\{\text{next}_1,\text{next}_2,\dots,\text{next}_{n-1}\}$,即所有非末尾位置中 $\text{next}$ 的最大值。 注意:$\text{maxx}$ 只能说明“不存在长度大于 $\text{maxx}$ 的中间前后缀”,但不能说明长度为某个 $x\le \text{maxx}$ 的中间前后缀一定存在。因此从 $x=\text{next}_n$ 开始尝试时,如果 $x>\text{maxx}$,可以直接跳到 $\text{next}_x$;一旦 $x\le \text{maxx}$,还需要在 $i=2,\dots,n-1$ 中检查是否真的存在 $\text{next}_i=x$。若不存在,就继续令 $x=\text{next}_x$,直到 $x=0$。 ::::success[CF126B] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e6 + 5; int n; char s[N]; int nxt[N],maxx; signed main() { cin >> (s + 1); n = strlen(s + 1); nxt[1] = 0; for(register int i = 2,j = 0;i <= n;++ i) { while(j > 0 && s[i] != s[j + 1]) j = nxt[j]; if(s[i] == s[j + 1]) ++ j; nxt[i] = j; if(i != n) maxx = max(maxx,nxt[i]); } for(register int x = nxt[n];x > 0;x = nxt[x]) { if(x > maxx) continue; for(register int i = 2;i < n;++ i) { if(nxt[i] == x) { for(register int j = i - x + 1;j <= i;++ j) printf("%c",s[j]); printf("\n"); return 0; } } } printf("Just a legend\n"); return 0; } ``` :::: ### 例五:[Censoring S](https://www.luogu.com.cn/problem/P4824) > 题意:\ > 给定两个字符串 $S,T$,在 $S$ 中不断删除所有出现的 $T$(每一次删除后两端的字符串会拼接)直到 $S$ 中不再包含 $T$,输出最终的 $S$。 考虑使用 KMP 进行匹配,但是删除之后需要重新匹配。这样的话时间复杂度又是巨大的了。\ 设指针 $j$ 表示已经匹配了模式串 $T$ 的前 $j$ 个字符。如果 $j=|T|$ 我们刚刚扫描过的连续 $|T|$ 个字符恰好等于 $T$。这样考虑用一个栈维护当前保留的字符在文本串中的下标,读到字符 $c$ 就将其下标丢入栈中,那么删除就是删除栈顶的 $|T|$ 个下标。\ 栈顶在删除之后变成了原来在这 $|T|$ 个字符之前的那个字符。在把每个字符压栈时,记录当时 KMP 的匹配指针 $j$ 存到 $f_i$ 里($i$ 是该字符在文本串中的下标),这样就不需要重复走了,到时候只需要令 $j \to f_{\text{stk}_{\text{top}}}$ 就能够恢复指针。 之所以能这样恢复,是因为删除后当前已匹配状态只取决于新的栈顶字符在未删除时的匹配长度,而这个长度已经存下来了。 ::::success[P4824] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 1e6 + 5; int lena, lenb; int nxt[N],f[N],stk[N],top; char a[N],b[N]; signed main() { cin >> (a + 1) >> (b + 1); lena = strlen(a + 1); lenb = strlen(b + 1); for(register int i = 2,j = 0;i <= lenb;++ i) { while(j > 0 && b[i] != b[j + 1]) j = nxt[j]; if(b[i] == b[j + 1]) ++ j; nxt[i] = j; } for(register int i = 1,j = 0;i <= lena;++ i) { while(j > 0 && a[i] != b[j + 1]) j = nxt[j]; if(a[i] == b[j + 1]) ++ j; f[i] = j; // 记录处理完第 i 个字符后的匹配长度 stk[++top] = i; // 栈中存文本串下标 if(j == lenb) { top -= lenb; // 弹出被删除的 |T| 个字符 j = f[stk[top]]; // 恢复匹配指针 } } for(register int i = 1;i <= top;++ i) cout << a[stk[i]]; puts(""); return 0; } ``` :::: ### 例六:[符文破译](https://www.luogu.com.cn/problem/P8112) > 题意:\ > 给定两个字符串 $T$ 和 $S$,将 $S$ 划分为若干个子串,使得每一个都是 $T$ 的前缀,求最小划分段数,无解输出 `Fake`。 设置指针 $j$ 表示当前匹配到的 $T$ 的前缀长度。如果在某一时刻遭遇了 $j=0$,说明当前字符无法作为任何一段的起点,直接输出 `Fake`。\ 关键在于贪心划分。希望每段尽可能长,这样段数最少。考虑维护一个变量 $x$ 表示上一段的终点位置。\ 在 KMP 匹配过程中,每走到位置 $i$,当前匹配长度为 $j$ 如果 $i-j+1>x$,说明当前匹配的起点超过了上一段的终点,需要开新的一段,段数增加,并更新 $x\to i$。 ::::info[贪心的正确性]{open} 每次匹配的终点 $i$ 只会往前推进,而当前匹配的起点 $i-j+1$ 如果超过了上一段终点 $x$,就说明 $[x+1,i]$ 这一段必须以某种方式划分,而当前匹配提供了最长的可能段,取它不会使后续变差。 :::: ::::success[P8112] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e7 + 5; int lt,ls; char t[N],s[N]; int nxt[N]; int ans; signed main() { scanf("%d%d",&lt,&ls); cin >> (t + 1); cin >> (s + 1); for(register int i = 2,j = 0;i <= lt;++ i) { while(j > 0 && t[i] != t[j + 1]) j = nxt[j]; if(t[i] == t[j + 1]) j ++; nxt[i] = j; } for(register int i = 1,j = 0,x = 0;i <= ls;++ i) { while(j > 0 && (j == lt || s[i] != t[j + 1])) j = nxt[j]; if(s[i] == t[j + 1]) j ++; if(j == 0) { puts("Fake"); return 0; } if(i - j + 1 > x) { ans ++; x = i; } } printf("%d\n",ans); return 0; } ``` :::: ## Trie 字典树 > Problem:\ > 给定 $n$ 个字符串 $S_1,\dots,S_n$,判断 $t$ 个字符串 $T$ 有没有与任意一个字符串相等(即每一个字符都相等),其中 $|T|,|S|\le 10^3$,$t,n\le 10^3$。 > 考虑暴力比较每一个 $S_i$,时间复杂度高达 $O((\sum_i|S_i|) \cdot (\sum_j|T_j|))$。注意到如果 $T$ 与任意一个 $S$ 没有公共前缀,那么单次查询可以做到 $O(|T|)$。因此从前缀入手。\ 建立一个树,根节点代表空串。除了根以外,每一条边对应一个字符,那么从根节点出发,到达任意一个节点,会对应一个字符串的前缀(或者一个字符串)。\ 具体建立方法如下。建立分为两个部分,一个是与别人共用的前缀,一个是自己的部分。可以把字符串看成链,走完共用的链之后再不断跳到此时没有东西的对应字符的空点,标记该节点为一个完整字符串的结尾即可。 ::::info[关于空间]{open} 那么字典树的空间复杂度是 $O(26^L)$ 吗(其中 $L$ 为最大深度)?这是不对的,原因请看下文:\ 一般建树时,每个节点可以存所有 $26$ 个字符的儿子指针;如果某个儿子不存在,就置为空。\ 这样虽然会预留一些空位置,但总节点数只与所有插入字符串的总长度有关。\ 设所有 $S_i$ 的总长度 $L=\sum_{i}|S_i|$,包含根节点在内节点数至多为 $L+1$。\ 规定数组 $ch[x][c]$ 存每一个节点的 $26$ 个儿子,空间复杂度是 $O(26L)$ 的。 :::: 遍历从根节点开始。对于任意一个跳到的节点 $x$(默认到 $x$ 查找的字符串和 $T$ 都能对应)跳到 $T$ 的下一个字符 $c$。每一个字母都能从字典树上找到对应的编号,一般取 `C = c - 'a'`。那么跳到下一个指针 `ch[x][C]`,会出现两种情况: - 节点不存在。直接返回查询不到即可,因为如果有对应的 $S$ 的话这个前缀路径就应该被建立出来。 - 存在,继续往下查找即可。 从根到某个节点的路径并不一定对应完整的 $S$,有可能只是 $S$ 的前缀。因此在建立 $S$ 的时候,可以在建立完之后在末尾节点添加标记,表示从根节点到这个节点是一个完整的字符串。在遍历完 $T$ 到达一个节点的时候,如果这个节点没有标记,证明它只是一个前缀而已。\ 如果只是为了查询是否在字典树里面有路径能走到 $T$(也就是前缀或者完整字符串都可以),那么就不用检查标记了。\ 建树的时间复杂度是 $O(\sum_i|S_i|)$,单独一次查询的时间复杂度是 $O(|T|)$。\ 注意到 Trie 的性质:节点 $v$ 是节点 $u$ 的祖先,当且仅当 $v$ 代表的字符串是 $u$ 代表的字符串的前缀。\ 把字符串翻转后重新建造字典树,可以查后缀。\ 板子题:[于是他错误的点名开始了](https://www.luogu.com.cn/problem/P2580)。 > 题意:\ > 给定 $n$ 个字符串表示每一个同学的名字 $S$,$m$ 个字符串表示教练报的名字 $T$,判断每一个 $T$($|T|,|S|\le 50$,$n\le 10^4,m\le 10^5$) > - 如果这个名字有对应的 $S$ 且前面没有报过完全相同的 $T$,输出 `OK`。 > - 如果这个名字有对应的 $S$,但前面有报过完全相同的 $T$,输出 `REPEAT`。 > - 如果这个名字没有对应的 $S$,输出 `WRONG`。 考虑建立字典树。如果没有查询到就是 `WRONG`,查询到了可以引入一个标记表示 **$T$ 最后一个节点 $x$ 是不是第一次走到**,如果是就是 `OK`,否则是 `REPEAT`。 ::::success[P2580] ``` #include<bits/stdc++.h> using namespace std; const int N = 1e6 + 5; const int M = 50; const int INF = 0x3f3f3f3f; int n,m; string s; int nxt[N][M]; int num = 1,ret[N]; void insret(string s) { int u = 1; for(char ch:s) { int c = ch - 'a'; if(!nxt[u][c]) nxt[u][c] = ++num; u = nxt[u][c]; } ret[u] = 1; return; } int find(string s) { int u = 1; for(char ch:s) { int c = ch - 'a'; if(!nxt[u][c]) return -1; u = nxt[u][c]; } return u; } int main() { scanf("%d",&n); for(int i = 1;i <= n;i ++) { cin >> s; insret(s); } scanf("%d",&m); while(m --){ string s; cin >> s; int u = find(s); if(u == -1 || !ret[u]){ printf("WRONG\n"); }else if(ret[u] == 1) { ret[u] = INF; printf("OK\n"); }else{ printf("REPEAT\n"); } } return 0; } ``` :::: ### 例一:[前缀单词](https://www.luogu.com.cn/problem/P1666) > 题意: > 一组单词是安全的,当且仅当不存在一个单词是另一个单词的前缀。\ > 给定单词集合 $S$(共有 $n$ 个字符串,$n\le 50$),判定有多少子集是安全的。(空集也是安全的,答案对 $10^9+7$ 取模) > 考虑暴力所有的 $2^n$ 个子集,每个子集检查是否存在前缀关系。那么大暴力,时间复杂度 $O(2^n n^3)$,可以获得 $30pts$ 的好成绩。\ 换一个角度,前缀关系在 Trie 上体现为祖先关系。如果把所有单词插进 Trie,那么一个单词是另一个单词的前缀就等价于一个单词的终止节点是另一个单词终止节点的祖先。\ 那么问题就转化为: > 在 Trie 的终止集合中,选取一个子集使得没有一个终止节点是另一个终止节点的祖先。问有多少种选法。 > 设 $f_u$ 表示以 $u$ 为根的子树里面安全集合的方案数。那么 - 如果 $u$ 不是任何单词的终止点,儿子的子树之间互不影响,独立选择安全集合,所以方案数 $$f_u=\prod_{v=son_u}f_v$$ - 如果 $u$ 是某个单词的终止节点,有两种选择: - 不选择 $u$,那么仍然可以在子树里面随便选择,方案数是 $\prod_{v=son_u}f_v$。 - 选择 $u$,那么后面的所有终止节点都无法选择,所以方案数是 $1$。 那么有 $$f_u=\prod_{v=son_u}f_v+1$$ 整理一下,可以得到动态转移方程: $$f_u=\prod_{v=son_u}f_v+ [u~\text{is the end node of a string}]$$ ::::success[P1666] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 5005; const int M = 26; const int Mod = 1e9 + 7; int n; string s; int nxt[N][M]; int num,ret[N]; long long f[N]; void insret(string s) { int u = 0; for(char ch : s) { int c = ch - 'a'; if(!nxt[u][c]) nxt[u][c] = ++num; u = nxt[u][c]; } ret[u] ++; } void dfs(int u) { long long sum = 1; for(int i = 0;i < 26;++ i) { if(nxt[u][i]) { dfs(nxt[u][i]); sum = sum * f[nxt[u][i]] % Mod; } } f[u] = (sum + ret[u]) % Mod; } signed main() { scanf("%lld",&n); for(int i = 1;i <= n;++ i) { cin >> s; insret(s); } dfs(0); printf("%lld\n",f[0]); return 0; } ``` :::: ### 例二:[新单词接龙问题](https://www.luogu.com.cn/problem/P1688) > 题意:\ > 给定 $n$ 个已经按照字典序排好序的单词。你要从这些单词中选择一些单词(按照题目所给的顺序)组成一个新的串,使得相邻单词 $w_i$ 和 $w_{i+1}$ 之间,可以通过恰好一次删除或插入或修改互相转换,求最长的这样的串的长度。 > 由于一次操作只会在字典树上移动一步,所以如果 $w_i$ 能够通过一次操作变成 $w_j$,那么 $w_i$ 一定在 $w_j$ 的前面。设 $f_i$ 表示以第 $i$ 个单词开头的最长的接龙长度,那么 $$f_i=1+\max_{j>i,\ w_i \text{ 可以一次转化为 } w_j}f_j$$ 那么问题就变成了对于当前的 $w_i$,怎么快速找到所有能和它一次转换的且在它后面的单词。\ 考虑所有的三种转换: - 删除一个字母,当前的单词 $w$,删掉某个位置的字母后得到 $w'$,如果 $w'$ 在字典树中,且其终止节点有 $dp$ 值就可以转移。枚举 $w$ 每个位置,删掉它判断即可。 - 插入一个字母,当前的单词 $w$,某个位置插入某个字母后得到 $w'$,如果 $w'$ 在字典树中可以转移。枚举插入位置和插入字母即可。 - 修改一个字母,当前的单词 $w$,某个位置修改某个字母后得到 $w'$,如果 $w'$ 在字典树中可以转移。枚举插入位置和插入字母(不能是原来的字母)即可。 因为 DP 是从后往前转移,所以倒序处理单词,查询时字典树中已经存好了所有下标更大的单词的 $dp$ 值,直接取最大值即可。 时间复杂度是 $O(n\times 26\times |S|^2)$ 的。 ::::success[P1688] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 5e5 + 5; const int M = 30; const int INF = 0x3f3f3f3f; int n; string s[N]; int nxt[N][M]; int num = 1; int mx[N]; int dp[N]; void insert(string s, int val) { int u = 1; for(char ch : s) { int c = ch - 'a'; if(!nxt[u][c]) nxt[u][c] = ++num; u = nxt[u][c]; } mx[u] = max(mx[u], val); } int find(string s) { int u = 1; for(char ch : s) { int c = ch - 'a'; if(!nxt[u][c]) return -1; u = nxt[u][c]; } return u; } int query(string s) { int res = 0; int len = s.size(); for(register int i = 0;i < len;++i) { string t = s.substr(0, i) + s.substr(i + 1); int u = find(t); if(u != -1) res = max(res,mx[u]); } for(register int i = 0;i <= len;++i) { for(register int c = 0;c < 26;++c) { string t = s.substr(0, i) + char('a' + c) + s.substr(i); int u = find(t); if(u != -1) res = max(res,mx[u]); } } for(register int i = 0;i < len;++ i) { for(register int c = 0;c < 26;++ c) { if(s[i] - 'a' == c) continue; string t = s; t[i] = char('a' + c); int u = find(t); if(u != -1) res = max(res,mx[u]); } } return res; } signed main() { scanf("%lld",&n); for(register int i = 1;i <= n;++ i) { cin >> s[i]; } int ans = 0; for(register int i = n;i >= 1;-- i) { dp[i] = query(s[i]) + 1; insert(s[i],dp[i]); ans = max(ans,dp[i]); } printf("%lld\n",ans); return 0; } ``` :::: ### 例三:[魔族密码](https://www.luogu.com.cn/problem/P1481) > 题意:\ > 给定 $n$ 个字符串按照字典序输入,从中选择尽量多的单词使得前一个单词始终是后一个单词的前缀。求最多能选多少个单词。 这道题其实是求在 Trie 上找一条从根往下的路径,路径上经过的单词结尾节点数量最多。那么在插入的时候维护一个 $\text{mx}_u$ 表示从根节点到 $u$ 的路径上最多能经过多少个单词的结尾。\ 插入一个单词时,沿着路径走: - 每一次到达一个节点,更新 $\text{mx}_u\to\max\{\text{mx}_u,\text{mx}_{fa_u}\}$,因为路径是从父节点延伸过来的。 - 走到单词的末尾的时候更新 $\text{mx}_u\to\text{mx}_u + 1$ 表示这个单词的结尾也算进词链里。 最后的答案就是找到最大的 $\text{mx}$。 这样做的原因是,每个单词结束时,它一定可以作为前面某个前缀单词链的延续,沿路径取最大值就能得到最长词链。 ::::success[P1481] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 2005; const int M = 26; const int INF = 0x3f3f3f3f; int n,m; string s; int nxt[N][M]; int num = 1,ret[N]; int mx[N]; int ans; void insret(string s) { int u = 1; for(char ch : s) { int c = ch - 'a'; if(!nxt[u][c]) nxt[u][c] = ++num; mx[nxt[u][c]] = max(mx[nxt[u][c]],mx[u]); u = nxt[u][c]; } mx[u] ++; ret[u] = 1; ans = max(ans,mx[u]); return; } int main() { scanf("%d",&n); for(int i = 1;i <= n;i ++) { cin >> s; insret(s); } printf("%d\n",ans); return 0; } ``` :::: ### 例四:[Secret Message G](https://www.luogu.com.cn/problem/P2922) > 题意:\ > 给定 $m$ 个信息串和一个暗号串,求有多少个信息串满足:信息串是暗号串的前缀,或者暗号串是信息串的前缀。 由于匹配方向是双向的,所以需要在 Trie 的每一个节点上面维护两个值,分别记录以节点 $u$ 结尾的信息串数量(记为 $\text{ed}_u$)和经过节点 $u$ 的信息串数量(记为 $\text{pass}_u$),当查询暗号串的时候: 1. 信息串是暗号的前缀,每一次走到一个节点 $u$ 累计 $\text{ed}_u$。 2. 暗号是信息串的前缀:走完暗号后停在节点 $v$。此时所有经过 $v$,但还没结束的信息串,都以暗号为前缀。数量是 $\text{pass}_v-\text{ed}_v$。 两者不会重复,因为第二步只统计了长度严格大于暗号的信息串。最终的答案就是第一步的累加值和第二部的累加值的和。 分开统计的原因就是两个方向的匹配条件不同,直接混在一起会重复计算“完全相等”的信息串。 ::::success[P2922] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 500005; const int M = 2; const int INF = 0x3f3f3f3f; int m,n; int s[10005]; int nxt[N][M]; int num = 1,ed[N],pass[N]; void insret(int len) { int u = 1; pass[u] ++; for(int i = 1;i <= len;i ++) { int c = s[i]; if(!nxt[u][c]) nxt[u][c] = ++num; u = nxt[u][c]; pass[u] ++; } ed[u] ++; return; } int find(int len) { int u = 1; int res = 0; for(int i = 1;i <= len;i ++) { int c = s[i]; if(!nxt[u][c]) return res; u = nxt[u][c]; res += ed[u]; } res += pass[u] - ed[u]; return res; } int main() { scanf("%d%d",&m,&n); for(int i = 1;i <= m;i ++) { int len; scanf("%d",&len); for(int j = 1;j <= len;j ++) scanf("%d",&s[j]); insret(len); } for(int i = 1;i <= n;i ++) { int len; scanf("%d",&len); for(int j = 1;j <= len;j ++) scanf("%d",&s[j]); printf("%d\n",find(len)); } return 0; } ``` :::: ## AC 自动机 > Problem:\ > 给定 $n$ 个模式串 $P_1,P_2,\dots,P_n$ 和一个长度为 $m$ 的文本串 $T$。统计每一个模式串 $P$ 在 $T$ 中出现的次数。 考虑对于每一个模式串单独用 KMP 在 $T$ 中匹配一次。这样的时间复杂度是 $O(nm)$ 的。\ 暴力的问题在于,每次只匹配一个模式串,没有利用模式串之间的公共信息。如果能够把所有的模式串放在一个字典树上,一次扫描文本串就同时匹配所有模式串,就能大幅降低复杂度。这就引出了 AC 自动机,其本质是在 Trie 上做 KMP。\ 需要给 Trie 上的每一个节点分配一个 $\text{fail}$ 指针。$\text{fail}_u$ 指向节点 $v$,$v$ 满足 $S(v)$ 是 $S(u)$ 的最长的真后缀。如果不存在这样的 $v$,那么 $\text{fail}_u$ 指向根节点。换句话说,$\text{fail}_u$ 就是 $u$ 的最长后缀在 Trie 里对应的节点。\ $\text{fail}$ 指针按照 BFS 顺序计算。因为 $\text{fail}_u$ 指向的节点其代表串为 $S(u)$ 的真后缀,深度严格小于 $u$,所以要先把浅层的节点算完,才能算深层的。 ::::info[计算细节]{open} 从队列中取出节点 $u$,对于 $u$ 的每个字符 $c$ 儿子 $v$ 计算 $\text{fail}_v$。由于 $S(v)=S(u)+c$,$S(v)$ 的真后缀就可以写成 $S(u)$ 的某个真后缀加上字符 $c$。已知 $\text{fail}_u$ 指向 $S(u)$ 的最长真后缀,令 $p=\text{fail}_u$, - 如果 $p$ 存在字符 $c$ 的儿子 $w$,那么 $S(w)=S(p)+c$,它是 $S(v)$ 的真后缀。由于 $p$ 属 $S(u)$ 的最长真后缀,$w$ 就是 $S(v)$ 的最长真后缀,所以 $\text{fail}_v=w$。 - 如果 $p$ 不存在字符 $c$ 的儿子,$S(p)+c$ 就不存在于 Trie,需要找到更短的后缀。令 $p=\text{fail}_p$,重复即可。 如果 $p$ 一直推到根也没有字符 $c$ 的儿子,那么指向根。\ 考虑优化上面的策略,就是补全 Trie: - 如果 $u$ 本来就有 $c$ 的儿子,那么不做任何事情。 - 否则,令 $\text{next}_{u,c}=\text{next}_{\text{fail}_u,c}$。这样匹配的时候不管有没有儿子,补全之后,Trie 上的每个节点对每个字符都有一条转移边。 :::: 匹配的过程:设当前在自动机上的节点为 $\text{now}$ 初始的时候为根。遍历文本串 $T$ 的每一个字符 $c$,直接走(已经补全完毕),走到 $\text{now}$ 后,所有以 $\text{now}$ 结尾的字符串,以及 $\text{now}$ 的所有 $\text{fail}$ 祖先代表的字符串,都在 $T$ 的当前位置作为后缀出现了。对每个位置,沿 $\text{now}$ 的 $\text{fail}$ 链往上走,每遇到一个终止节点,就给该模式串的计数器加一。 ::::info[这样统计为什么是对的?]{open} 走到 $\text{now}$ 意味着 $S(\text{now})$ 是当前文本串的一段后缀,沿着 $\text{fail}$ 的链一直走到根,经过的每个节点代表的字符串都是文本串的后缀。所以,如果某个终止节点在这条链上,它对应的模式串就出现在文本串中。 :::: AC 自动机匹配文本串时,每走到一个节点 $u$,需要沿 $\text{fail}$ 链向上跳,遇到终止节点就统计。但是这样可能会由于 $\text{fail}$ 链(长度记为 $L$)太长而退化到 $O(|T|L)$。\ 这里提供两种优化方式。\ 考虑把 $\text{fail}$ 链的贡献预先合并到每个节点上,查询时不再跳链。我们设原本 $cnt_u$ 表示以 $u$ 结尾的模式串数量,在建立过程中我们处理到点 $u$ 时 $cnt_u→cnt_u+cnt_{\text{fail}_u}$,那么此时定义就转化为从 $u$ 到根的整条 $\text{fail}$ 链上的终止节点总数。但是它只能回答总共匹配多少次,不能回答每个模式串分别匹配多少次。所以我们考虑用树上差分解决这个问题。\ 我们不在每次操作时真的沿着路径加,而是只在路径的底部端点 $u$ 处记录一次 $c_u→c_u+1$($c$ 是差分数组),所有路径加操作完成后,对树做一次自底向上的求和。处理完后,每个节点的 $c$ 值就表示它被多少条 $u$ 到根的路径经过。 板子:[AC自动机模板](https://www.luogu.com.cn/problem/P3808) ::::success[$\text{fail}$ 优化] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e6 + 5; const int M = 26; int n,ct; int tr[N][M]; int fail[N]; int tag[N]; int vis[N]; char s[N]; void ins(char *s) { int u = 0; for(int i = 0;s[i];i++) { int c = s[i] - 'a'; if(!tr[u][c]) tr[u][c] = ++ct; u = tr[u][c]; } tag[u]++; } void build() { queue<int> Q; for(int i = 0;i < M;i++) if(tr[0][i]) Q.push(tr[0][i]); while(!Q.empty()) { int u = Q.front(); Q.pop(); for(int i = 0;i < M;i++) { if(tr[u][i]) { fail[tr[u][i]] = tr[fail[u]][i]; Q.push(tr[u][i]); } else { tr[u][i] = tr[fail[u]][i]; } } } } int qry(char *s) { int u = 0,res = 0; for(int i = 0;s[i];i++) { u = tr[u][s[i] - 'a']; for(int j = u;j && !vis[j];j = fail[j]) { vis[j] = 1; res += tag[j]; } } return res; } int main() { scanf("%d",&n); for(int i = 1;i <= n;i++) { scanf("%s",s); ins(s); } build(); scanf("%s",s); printf("%d\n",qry(s)); return 0; } ``` :::: ::::success[树上差分] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e6 + 5; int n; string s; string T; struct node{ int fail; int son[26 + 5]; int cnt; }ac[N]; int ct; int p[N]; int a[N]; void insert(int x,string s) { int curr = 0; int len = s.length(); for(int i = 0;i < len;i ++) { int k = s[i] - 'a'; if(ac[curr].son[k]) { curr = ac[curr].son[k]; }else{ ac[curr].son[k] = ++ct; curr = ct; } } p[x] = curr; } void build() { queue<int> Q; for(int i = 0;i < 26;i ++) { int v = ac[0].son[i]; if(v) { a[++a[0]] = v; Q.push(v); } } while(!Q.empty()) { int u = Q.front(); Q.pop(); for(int i = 0;i < 26;i ++) { int &v = ac[u].son[i]; int &kmp = ac[ac[u].fail].son[i]; if(v) { a[++a[0]] = v; int &failv = ac[v].fail; failv = kmp; Q.push(v); }else{ v = kmp; } } } } void solve(string s) { int len = s.length(); int curr = 0; for(int i = 0;i < len;i ++) { int x = s[i] - 'a'; curr = ac[curr].son[x]; ac[curr].cnt ++; } for(int i = a[0];i > 0;i --) { int u = a[i]; int fa = ac[u].fail; ac[fa].cnt += ac[u].cnt; } } int main() { int n; scanf("%d",&n); for(int i = 1;i <= n;i ++) { cin >> s; insert(i,s); } build(); cin >> T; solve(T); int ans = 0; for(int i = 1;i <= n;i ++) { if(ac[p[i]].cnt > 0) ans ++; } printf("%d",ans); return 0; } :::: ### 例一:[[TJOI2013] 单词](https://www.luogu.com.cn/problem/P3966) > 题意:\ > 给定 $n$ 个单词,求每一个单词在所有单词中出现的总次数。 把所有单词插入 AC 自动机。在插入每个单词的路径上,对每个经过的节点 $\text{cnt}\to\text{cnt}+1$。这样,$\text{cnt}_u$ 就表示节点 $u$ 代表的字符串作为前缀在所有单词中出现了多少次。然后按 BFS 序逆序(从深到浅)处理,把每个节点的 $\text{cnt}$ 加到它的 $\text{fail}$ 父节点上。处理完后,$\text{cnt}_u$ 就是以 $u$ 结尾的字符串在所有单词中作为后缀出现的次数。 ::::info[正确性] 如果说单词 $x$ 出现在单词 $y$ 中,$x$ 是 $y$ 的前缀或者在前缀链条上,传递时累加 $\text{cnt}$ 可以解决此问题。 :::: 这里在插入路径上给每个前缀节点加一,是因为一个单词的每个前缀都可能成为别的单词的后缀;fail 树累加后,这些贡献会沿着 fail 链汇集到真正以该节点为后缀的终止节点上。 ::::success[P3966] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e6 + 5; int n; string s; struct node { int fail; int son[26 + 5]; int cnt; } ac[N]; int ct; int p[205]; int a[N]; void insert(int x, string s) { int curr = 0; int len = s.length(); for(int i = 0; i < len; i++) { int k = s[i] - 'a'; if(ac[curr].son[k]) { curr = ac[curr].son[k]; } else { ac[curr].son[k] = ++ct; curr = ct; } ac[curr].cnt++; } p[x] = curr; } void build() { queue<int> Q; for(int i = 0; i < 26; i++) { int v = ac[0].son[i]; if(v) { a[++a[0]] = v; Q.push(v); } } while(!Q.empty()) { int u = Q.front(); Q.pop(); for(int i = 0; i < 26; i++) { int &v = ac[u].son[i]; int &kmp = ac[ac[u].fail].son[i]; if(v) { a[++a[0]] = v; ac[v].fail = kmp; Q.push(v); } else { v = kmp; } } } } int main() { scanf("%d", &n); for(int i = 1; i <= n; i++) { cin >> s; insert(i, s); } build(); for(int i = a[0]; i > 0; i--) { int u = a[i]; ac[ac[u].fail].cnt += ac[u].cnt; } for(int i = 1; i <= n; i++) { printf("%d\n", ac[p[i]].cnt); } return 0; } ``` :::: ### 例二:[病毒](https://www.luogu.com.cn/problem/P2444) > 题意:\ > 给定 $n$ 个由 $0$ 和 $1$ 组成的模式串(病毒代码段)。问是否存在一个无限长的 $01$ 字符串,使得其中不包含任何一个给定的病毒代码段作为子串。如果存在,输出 `TAK`,否则输出 `NIE`。 先把所有病毒代码段放到 Trie 里,随后在构建 $\text{fail}$ 指针时,如果一个节点 $u$ 是某个模式串的终止节点,或者它的 $\text{fail}_u$ 是危险节点(即 $\text{fail}$ 链上存在终止节点),那么 $u$ 也是危险节点。这意味着一旦匹配过程中走到 $u$,就说明当前文本串包含了某个病毒代码段。\ 希望在 AC 自动机上找到一个无限长的安全路径。因为字符串是无限的,而在有限状态的自动机上无限地走下去,必然会在某个时刻进入一个环。所以问题转化为:在 AC 自动机的 Trie 中(包含 $\text{fail}$ 转移边),寻找一个从根节点可达的环,且环上的所有节点都不是危险节点。 - 如果存在这样的环,就可以沿着环无限地走下去,构造出无限长的安全代码。 - 否则,任何一条不经过危险节点的路径都是有限的,不可能产生无限长的安全代码。 之所以要判断环,是因为无限长路径在有限状态自动机中必然重复经过某个状态,重复段就是环;只要这个环安全,就能一直绕圈生成无限安全串。 ::::success[P2444] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 33333; int ch[N][2]; int fail[N]; bool danger[N]; bool vis[N]; bool inst[N]; int num = 0; void insert(string s) { int u = 0; for (char c : s) { int x = c - '0'; if (!ch[u][x]) ch[u][x] = ++num; u = ch[u][x]; } danger[u] = true; } void build() { queue<int> Q; for (int i = 0; i < 2; ++i) { if (ch[0][i]) Q.push(ch[0][i]); } while (!Q.empty()) { int u = Q.front(); Q.pop(); danger[u] |= danger[fail[u]]; for (int i = 0; i < 2; ++i) { if (ch[u][i]) { fail[ch[u][i]] = ch[fail[u]][i]; Q.push(ch[u][i]); } else { ch[u][i] = ch[fail[u]][i]; } } } } void dfs(int u) { if (inst[u]) { puts("TAK"); exit(0); } if (vis[u] || danger[u]) return; vis[u] = inst[u] = true; dfs(ch[u][0]); dfs(ch[u][1]); inst[u] = false; } int main() { int n; scanf("%d",&n); for (int i = 1; i <= n; ++i) { string s; cin >> s; insert(s); } build(); dfs(0); puts("NIE"); return 0; } ``` :::: ### 例三:[数数](https://www.luogu.com.cn/problem/P3311) > 题意:\ > 给定一个整数 $n$(以十进制字符串形式给出,$|n|\le 1200$)和一个数字串集合 $S$($|S|\le 1500$,每个串长度不超过 $1500$)。求 $[1,n]$ 中有多少个正整数,其十进制表示中**不包含** $S$ 中任意一个元素作为子串。答案对 $10^9+7$ 取模。 > 同上一道题目标记所有危险的节点。\ 设 $f_{p,u,l,s}$ 表示前 $p$ 位,在 AC 自动机中处于节点 $u$,当前数字是否紧贴上界,以及是否已经开始填非零数字。转移时枚举当前位填的数字 $d$,如果 $l=1$(紧贴)那么 $d$ 不能超过 $n$ 在当前位的数字。否则可以随便取 $0\sim 9$。沿着 AC 自动机的边走,如果走到点 $\text{ch}_{u,d}$ 是危险节点就跳过。 ::::warning[提示] 如果当前仍在填前导零,则不应真正开始匹配。可以增加一维状态表示是否已经开始填非零数字。 :::: 从 $f_{p,u,l,s}$ 出发,枚举当前位要填的数字 $d$: - 如果 $d=0$ 且 $s=0$,说明这一位仍然是前导零,那么自动机节点 $u$ 保持不变(仍为根),$s$ 仍为 0,直接递归到下一层。 - 否则已经在填数字了,或者这一位是非零数字,沿 AC 自动机的转移走一步:$v=\text{nxt}_{u,d}$。如果 $v$ 不是危险节点就递归到 $dp_{p+1,v,l,1}$。 多开一维前导零状态的原因是:数字前面的零不属于数字本身,如果直接拿它们去 AC 自动机上匹配,会把 `0`、`00` 这类模式串错误地匹配进去。 ::::success[P3311] ```c++ #include<bits/stdc++.h> #define int long long using namespace std; const int N = 1505; const int Mod = 1e9 + 7; int m; char n[N],s[N]; int t[N][10]; int f[N]; int d[N]; int g[N][N][2][2]; int c; int L; void ins(char *s) { int u = 0; int len = strlen(s + 1); for(register int i = 1;i <= len;++i) { int x = s[i] - '0'; if(!t[u][x]) t[u][x] = ++c; u = t[u][x]; } d[u] = 1; } void build() { queue<int> Q; for(register int i = 0;i < 10;++i) { if(t[0][i]) Q.push(t[0][i]); } while(!Q.empty()) { int u = Q.front(); Q.pop(); d[u] |= d[f[u]]; for(register int i = 0;i < 10;++i) { int &v = t[u][i]; int &w = t[f[u]][i]; if(v) { f[v] = w; Q.push(v); } else { v = w; } } } } int dfs(int i,int u,int l,int s) { if(i == L) return s; if(g[i][u][l][s] != -1) return g[i][u][l][s]; int r = 0; int up = l ? n[i] - '0' : 9; for(register int x = 0;x <= up;++x) { int nl = l && (x == up); if(!s && x == 0) { r = (r + dfs(i + 1,u,nl,0)) % Mod; } else { int v = t[u][x]; if(d[v]) continue; r = (r + dfs(i + 1,v,nl,1)) % Mod; } } return g[i][u][l][s] = r; } signed main() { scanf("%s",n); L = strlen(n); scanf("%d",&m); for(register int i = 1;i <= m;++i) { scanf("%s",s + 1); ins(s); } build(); memset(g,-1,sizeof(g)); printf("%d\n",dfs(0,0,1,0)); return 0; } ``` :::: ### 例四:[Censoring G](https://www.luogu.com.cn/problem/P3121) > 题意:\ > 给定文本串 $S$ 和 $n$ 个模式串(屏蔽词)。反复删除 $S$ 中出现的所有模式串(删后两端拼接,可能形成新模式串),输出最终结果。 这道题比 KMP 部分的 Censoring S 更复杂。\ 匹配到的模式串一定在已处理部分的末尾。所以可以用栈维护当前保留的字符。使用两个栈,字符栈和状态栈(每次压入字符后 AC 自动机所在节点编号)每个模式串的终止节点记录它的长度 $\text{len}_u$。如果当前字符 $\text{len}_{\text{now}}>0$,那么匹配到一个完整模式串,两个栈一起弹出元素,恢复指针即可。 恢复指针时直接取弹出后栈顶的状态,是因为删除后新的末尾就是原来这个字符,而它在被压入时已经记录了当时自动机的位置。 ::::success[P3121] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e5 + 5; int n; string s; string T; struct AC{ int fail; int son[26]; }ac[N]; int ct; int p[N]; void insert(int x,string s) { int curr = 0; int len = s.length(); for(int i = 0;i < len;i ++) { int k = s[i] - 'a'; if(ac[curr].son[k]) { curr = ac[curr].son[k]; }else{ ac[curr].son[k] = ++ct; curr = ct; } } p[curr] = len; } void build() { queue<int> Q; for(int i = 0;i < 26;i ++) { int v = ac[0].son[i]; if(v) { Q.push(v); } } while(!Q.empty()) { int u = Q.front(); Q.pop(); for(int i = 0;i < 26;i ++) { int &v = ac[u].son[i]; int kmp = ac[ac[u].fail].son[i]; if(v) { ac[v].fail = kmp; Q.push(v); }else{ v = kmp; } } } } int s1[N],s2[N],top; void solve(string s){ int curr = 0,len = s.length(); for(int i = 0;i < len;i ++){ int x = s[i] - 'a'; curr = ac[curr].son[x]; s1[++top] = curr; s2[top] = i; if(p[curr]){ top -= p[curr]; if(!top) curr = 0; else curr = s1[top]; } } } int main() { cin >> T; scanf("%d",&n); for(int i = 1;i <= n;i ++) { cin >> s; insert(i,s); } build(); solve(T); for(int i = 1;i <= top;i ++) cout << T[s2[i]]; return 0; } ``` :::: ### 例五:[Video Game G](https://www.luogu.com.cn/problem/P3041) > 题意:\ > 给定 $n$ 个模式串(只含 $A、B、C$)和长度 $k$。构造一个长度为 $k$ 的字符串(只用 $A、B、C$),使包含的模式串总次数最多(重复出现算多次)。求最大值。 > 设 $f_{i,u}$ 表示长度为 $i$ 当前在自动机上节点 $u$ 时,最多能够匹配到的模式串数量。一个节点 $u$ 被经过时,它的所有 $\text{fail}$ 祖先代表的模式串也都匹配了。所以节点 $u$ 的贡献($\text{val}$)等于从根到 $u$ 的 $\text{fail}$ 链上所有终止节点的数量。设 $\text{tag}_u$ 是以 $u$ 结尾的模式串数量,有: $$\text{val}_u=\text{val}_{\text{fail}_u}+\text{tag}_u$$ 那么可以得到转移方程: $$f_{i+1,\text{next}_{u,c}}=\max\{f_{i+1,\text{next}_{u,c}},f_{i,u}+\text{val}_{\text{next}_{u,c}}\}$$ 这样定义 $\text{val}$ 的原因是,走到 $u$ 时所有 fail 祖先也都是当前串的后缀,因此它们的终止标记都应计入这一步的贡献。 ::::success[P3041] ```c++ #include<bits/stdc++.h> using namespace std; const int N = 1e3 + 5; const int INF = 0x80808080; int n,k; string s; string t; struct node{ int fail,son[6],cnt; }ac[N]; int cnt,p[N],dp[N][N]; int point[N]; void insert(int x,string s) { int curr = 0; int len = s.length(); for(int i = 0;i < len;i ++) { int k = s[i] - 'A'; if(ac[curr].son[k]) curr = ac[curr].son[k]; else{ ac[curr].son[k] = ++cnt; curr = cnt; } } p[x] = curr; point[p[x]] ++; } void build() { queue<int> q; for(int i = 0;i < 3;i ++) { if(ac[0].son[i]) { q.push(ac[0].son[i]); } } while(!q.empty()) { int u = q.front(); q.pop(); for(int i = 0;i < 3;i ++) { int &v = ac[u].son[i]; if(v) { ac[v].fail = ac[ac[u].fail].son[i]; q.push(v); }else{ v = ac[ac[u].fail].son[i]; } } point[u] += point[ac[u].fail]; } } int main(){ scanf("%d%d",&n,&k); for(int i = 1;i <= n;i++){ cin >> s; insert(i,s); } build(); memset(dp,INF,sizeof dp); dp[0][0] = 0; for(int i = 1;i <= k;i ++) { for(int j = 0;j <= cnt;j ++) { for(int c = 0;c < 3;c ++) { int nxt = ac[j].son[c]; dp[i][nxt] = max(dp[i][nxt],dp[i - 1][j] + point[nxt]); } } } int ans = 0; for(int i = 0;i <= cnt;i ++) ans = max(ans,dp[k][i]); printf("%d",ans); return 0; } ``` :::: ### 例六:[谐音替换](https://www.luogu.com.cn/problem/P14363) > 题意:\ > 给定 $n$ 个规则 $(s1, s2)$,$q$ 个询问 $(t1, t2)$。规则 $i$ 能贡献询问 $j$,当且仅当存在一种替换方式,把 $t1$ 里某个等于 $s1$ 的子串替换成 $s2$ 后,恰好得到 $t2$。 我们对任意一对字符串 $(s1, s2)$,找到第一个不同位置 $l$ 和最后一个不同位置 $r$。我们定义一个函数 $$\text{exc(s1,s2)}=s1_{0,1,\dots,l-1}+c+s1_{l,l+1,\dots,r}+s2_{l,l+1,\dots,r}+c+s1_{r+1,r+2,\dots}$$ 这里 $c$ 是一个分格符。那么字串匹配的时候两个 $c$ 要严格对其,即 - 第一个 $c$ 对齐,前缀部分必须完全匹配;第二个 $c$ 对其,后缀部分必须完全匹配。所以 $s1$ 的公共前缀是 $t1$ 公共前缀的后缀,$s1$ 的公共后缀是 $t1$ 公共后缀的前缀。 - 两个 $c$ 之间,$s1_{l,l+1,\dots,r}+s2_{l,l+1,\dots,r}$ 必须整体匹配。由于两段长度各自相等,所以整体相等就等价于分开相等,所以 $t1_{l,l+1,\dots,r}=t1_{l',l'+1,\dots,r'}$,$t2_{l,l+1,\dots,r}=t2_{l',l'+1,\dots,r'}

把所有规则的 \text{exc}(s1, s2) 插入 AC 自动机。对每个询问,跑一遍 \text{exc}(t1, t2),统计有多少个规则串作为子串出现。 ::::success[P14363]

#include<bits/stdc++.h>
using namespace std;

const int N = 5e6 + 10;

int n,tot,pid,Q;
int cnt[N];
struct node {
    int son[27];
    int fail;
    int id;
} tr[N];

void build(string t,int &idx) {
    int len = t.size(),u = 0;
    for(int i = 0;i < len;i++) {
        if(!tr[u].son[t[i] - 'a']) tr[u].son[t[i] - 'a'] = ++tot;
        u = tr[u].son[t[i] - 'a'];
    }
    if(!tr[u].id) tr[u].id = ++pid;
    idx = tr[u].id;
    cnt[u]++;
}

queue<int> q;
void Fail() {
    for(int i = 0;i < 27;i++) if(tr[0].son[i]) q.push(tr[0].son[i]);
    while(!q.empty()) {
        int u = q.front();
        q.pop();
        cnt[u] += cnt[tr[u].fail];
        for(int i = 0;i < 27;i++) {
            if(tr[u].son[i]) {
                tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
                q.push(tr[u].son[i]);
            } else tr[u].son[i] = tr[tr[u].fail].son[i];
        }
    }
}

int query(string t) {
    int u = 0,res = 0,len = t.size();
    for(int i = 0;i < len;i++) {
        u = tr[u].son[t[i] - 'a'];
        res += cnt[u];
    }
    return res;
}

string exc(string s1,string s2) {
    int len = s1.size(),l = 0,r = len - 1;
    for(int j = 0;j < len;j++) if(s1[j] != s2[j]) { l = j; break; }
    for(int j = len - 1;j >= 0;j--) if(s1[j] != s2[j]) { r = j; break; }
    string s = "";
    for(int j = 0;j < l;j++) s += s1[j];
    s += '{';
    for(int j = l;j <= r;j++) s += s1[j];
    for(int j = l;j <= r;j++) s += s2[j];
    s += '{';
    for(int j = r + 1;j < len;j++) s += s1[j];
    return s;
}

signed main() {
    scanf("%d%d",&n,&Q);
    for(int i = 1;i <= n;i++) {
        string s1,s2;
        cin >> s1 >> s2;
        int id;
        build(exc(s1,s2),id);
    }
    Fail();
    while(Q --) {
        string t1,t2;
        cin >> t1 >> t2;
        if(t1.size() != t2.size()) { puts("0"); continue; }
        printf("%d\n",query(exc(t1,t2)));
    }
    return 0;
}

::::