字符串进阶学习笔记

前言
特别感谢 绝顶我为峰 于20260727~20260728 的授课。 感谢学长生动形象的讲解,讲的很棒。(但是在讲回文自动机的时候我睡了:(
循环中用到的rg,即register的宏定义。如果看不懂请直接跳过,并无实际意义。
Tips:在学习自动机之前,建议先阅读 何为自动机。
这篇没有字典树和字符串哈希(就作为前置知识),想了解? 请前往 数据结构专题学习笔记 查看完整代码请前往 模板集合
笔记包括:
KMP | exKMP | KMP自动机 | AC自动机 | 回文自动机
ps:KMP自动机的板子我实在是找不到了,随手放了一个要用的题。请谅解。
内容有些许难度,如有任何疑问欢迎私信Emumumu luogu:1042335。
废话结束,正片开始。
KMP
算法目标:
求出 \(t\)(模式串) 在 \(S\) (文本串)中所有出现的位置。
实现流程:
考虑一个定义 border 为 \(S\) 的一个非 \(S\) 本身的子串 \(t\),满足 \(t\) 既是 \(S\) 的前缀,又是 \(S\) 的后缀。
对于这个问题,有一个朴素的想法就是从前面找了后面再判断后面的是否一样。这样平凡的想法,复杂度也是只有平凡的 \(O(n^2)\) (极限)。极容易被卡掉。那该如何优化到常数级别呢?下面就是由 Donald E. Knuth & James H. Morris & Vaughan R. Pratt 三位提出的著名算法——KMP。
KMP的核心思想就是利用已经匹配好的信息避免重复。观察暴力,发现重叠子问题是时间复杂度的瓶颈。所以KMP提出了利用一个数组表示前 \(i\) 个字符的最长border串长度,避免了大量的重复判断。记这个数组为 \(\pi\) 。
具体的转移流程:假设我们已经知道了1到i \(\pi\) 的值,考虑如何推出 \(\pi_i\)。先看它能否接在当前已匹配的那个前缀字符后面,如果相等,匹配长度就加 1,记录到 \(\pi\) 数组里。如果不相等,就不能直接接上,需要回退——跳到之前某个更短的前缀继续尝试,这个更短的前缀长度就是 \(\pi_{j-1}\) j为当前已经匹配成功的长度。重复回退,直到匹配成功或者回到起点(j=0)。
代码实现
for(rg int i=1,j=0;i<(int)t.size();i++) {
// i-> 遍历s的位置 | j -> 匹配成功的长度
while(j>0&&t[i]!=t[j]) j=p[j-1] ;
if(t[i]==t[j]) j++ ;
p[i]=j ;
}
那么问题来了,这个前缀函数border对于我们解决问题有什么用呢?考虑使用前缀函数来加速。仿照之前的操作定义一个 \(j\) 表示已匹配成功的长度,一个 \(i\) 表示遍历到的位置。\(\forall i \in [1, n]\) 我们执行以下操作:
- 检查文本串 \(S_i\) 是否能匹配 \(t_j\) 。
如果成功:往后遍历 \(j\) 。
如果失配了:将 \(j\) 回退到 \(\pi_{j-1}\)。(至于证明,我能力有限实在无法解答,如果日后有机会我会update这一块) - 当找完了 \(t\) 即 \(j=m\) 跳到上一个匹配成功的(\(\pi_{j-1}\))继续重复上述操作将文本串匹配完。
代码实现
for(rg int i=0,j=0;i<(int)s.size();) {
if(s[i]==t[j]) {i++; j++;}
else if(j>0) j=p[j-1] ;
else i++ ;
if(j==(int)t.size()) {
j=p[j-1] ;
// i-t.size()+1 -> t 在 s 中出现的位置
}
}
exKMP(Z函数)
算法目标
给定字符串 \(S\) 和 \(t\),求出 \(t\) 的 Z函数(即自身与每个后缀的LCP最长公共后缀长度),以及 \(t\) 与 \(S\) 的每个后缀的LCP长度。
实现流程
exKMP的核心流程就是维护一个匹配段 \([l,r]\) 表示 \(t\) 从 \(l\) 开始的最长后缀的最长匹配。通俗地讲就是匹配段 \([l,r]\) 就是当前已知的、能跟 \(t\) 的前缀完全匹配的最靠右的一段。(定义匹配为完全相等)利用这个性质,就可以比避免重复比较,时间复杂度 \(O(n)\) 。下面是具体流程。
- 求解Z函数 即 \(Z_i\) 表示 \(t\) 与 \(t\) 的后缀(即 \(t[i,..,n-1]\))的最长匹配长度。显然 \(Z_0\) 初始等于 \(n\)。遍历每个 \(Z_{i-l}\) 进行初始化。然后暴力向外扩展匹配。如果走到超过r的部分,更新 \([l,r]\)。复杂度\(O(n)\)
codeinline void Zfunction() { z[0]=n ; for(rg int i=1,l=0,r=0;i<n;i++) { if(i<=r) z[i]=min(r-i+1,z[i-l]) ; while(i+z[i]<n&&t[z[i]]==t[i+z[i]]) z[i]++ ; if(i+z[i]-1>r) {l=i; r=i+z[i]-1;} } } - 处理第二部分即exKMP 创建一个 \(p\) 数组,存的是 \(t\) 与 \(S\) 的每个LCP长度。做法与Z函数类似,转移参考Z函数。复杂度\(O(m)\)
codeinline void exKMP() { for(rg int i=0,l=0,r=-1;i<m;i++) { if(i<=r) p[i]=min(r-i+1,z[i-l]) ; while(i+p[i]<m&&p[i]<n&&s[i+p[i]]==t[p[i]]) p[i]++ ; if(i+p[i]-1>r) {l=i; r=i+p[i]-1;} } }
KMP自动机
算法目标
给定一个模式串 \(t\), 对于文本串 \(S\),找出 \(t\) 在 \(S\) 中所有出现的位置,同时要求支持增加字符。听着好像和KMP一样,但是时间却压缩到了严格的 \(O(|S|)\)。
实现流程
注意到朴素KMP时间复杂度瓶颈在于要沿着fail多次回退并且不支持修改。所以后人提出了用tire优化的exKMP的算法。下面是具体流程。
- 构建自动机 类似KMP的代码求出fail(不多赘述)。然后构建出tire。具体地:在当前状态i中,读入字符ch。如果匹配成功状态跳到i+1,如果失败了就跳到fail上接着找。
codeinline void build(){ m=t.size()-1; for(rg int i=2,j=0;i<=m;i++){ while(j&&t[i]!=t[j+1]) j=fail[j]; if(t[i]==t[j+1]) j++; fail[i]=j; } for(rg int c=0;c<M;c++) tire[0][c]=(t[1]==(char)('a'+c))?1:0; for(rg int i=1;i<=m;i++){ for(rg int c=0;c<M;c++){ char ch=(char)('a'+c); if(i<m&&t[i+1]==ch) tire[i][c]=i+1; else tire[i][c]=tire[fail[i]][c]; } } } - 匹配文本串 对于文本串的每一个字符c,如果匹配成功就跳到下一个tire上。然后就没了,
codeinline void match(string s){ for(rg int i=0,j=0;i<(int)s.size();i++) { int c=s[i]-'a'; j=tire[j][c]; if(j==m) j=tire[j][c]; } } - 下面就是KMP自动机的核心操作,增加字符 在已有的自动机上,增加一个字符c。首先我们需要计算新的fail 直接用KMP算,因为状态 m 代表“已经匹配了完整模式串”,它再读入任意字符后应该跳到哪里,和 fail[m] 状态读入同一字符后的跳转逻辑一致。所以直接把复制状态从\(tire_{fail_m,c}\)中复制过来。最后把旧状态转移到新状态就结束了。均摊复杂度为 \(O(|\Sigma|)\) 可视作一个较大常数。
codeinline void add(char c){ t+=c;m++; int j=fail[m-1]; while(j&&t[m]!=t[j+1]) j=fail[j]; if(t[m]==t[j+1]) j++; fail[m]=j; for(rg int c=0;c<M;c++) tire[m][c]=tire[fail[m]][c]; tire[m-1][t[m]-'a']=m; }
AC自动机
算法目标
给你一个文本串 \(S\) 和 \(n\) 个模式串 \(T_{1 \sim n}\),请你分别求出每个模式串 \(T_i\) 在 \(S\) 中出现的次数。
实现流程
看到这么多字符串,很自然的一个想法就是建一棵Tire,在Tire上跑KMP。其实AC自动机直接可以看作 KMP 思想在Tire上的推广。其核心思想就是在Tire上构建一个失配指针fail(形式化地从当前节点所代表的字符串中,找到最长的、能与某个模式串的前缀相匹配的真后缀,并指向该前缀所对应的Trie节点)在这棵树上跑文本串匹配过程。下面是具体流程。
-
建Tire 将所有模式串都插到tire上,类似Tire的加字符串板子,再增加一个映射关系表示节点 i 是哪些模式串的结尾。
codeinline void insert(string s,int pos) { int p=0 ; for(rg int i=0;i<(int)s.size();i++) { int c=s[i]-'a' ; if(tire[p][c]==0) tire[p][c]=++tot ; p=tire[p][c] ; } g[p].emplace_back(pos) ; } -
构建失配指针fail 其含义与KMP里的 \(\pi\) 数组含义完全一样——当在当前节点匹配失败时,应该跳到哪个节点继续匹配。构建过程考虑BFS。枚举u的每个子节点v(设其代表的字符为c)
- 从 u 的fail指针指向的节点开始,不断回退,直到找到某个点只有一个字符c,或者回到根。
- 如果找到了符合条件的节点w,将u的fail数组指向w。如果没有,则字符c是第一次出现,则将v的fail指向根节点。
- 将w加入队列。
但事实上为了加速匹配,通常会把每个节点的不存在的子节点,直接指向其 fail 指针的对应子节点。这样在匹配时就不需要 while 循环回退,可以直接跳转。
codeinline void build() { queue<int> q ; for(rg int i=0;i<26;i++) if(tire[0][i]!=0) q.emplace(tire[0][i]) ; while(!q.empty()) { int u=q.front(); q.pop() ; for(rg int i=0,v=tire[u][i];i<26;i++,v=tire[u][i]) { if(v!=0) { fail[v]=tire[fail[u]][i] ; q.emplace(v) ; } else tire[u][i]=tire[fail[u]][i] ; } } } -
匹配文本串 考虑使用一个cnt数组,初步地在匹配过程中,节点 u 被访问到的次数。即记录了文本串中所有模式串前缀的出现位置。
codeinline void search(string s) { int p=0 ; for(rg int i=0;i<(int)s.size();i++) { int c=s[i]-'a' ; cnt[p=tire[p][c]] ++ ; } } -
传递fail指针 沿着fail指针的方向,把计数从“长后缀”节点传递给“短后缀”节点。如果要将节点v的 \(\pi\) 指向u ,就可以直接 \(\pi_u=\pi_v\) 那么所有匹配到 v 的位置,也一定匹配到了u。所以我们需要把 \(cnt_v\) 累加到 \(cnt_u\) 上。考虑 \(TopoSort\) 。因为失配指针本身构成一棵树,所以直接用fail进行拓扑。对于每一个u的儿子v,做上述操作。最后收集答案,就可以使用到最开始的映射关系,将 \(cnt_u\) 累加到 \(ans_v\) 里。
codeinline void Toposort() { queue<int> q ; for(rg int i=1;i<=tot;i++) du[fail[i]]++ ; for(rg int i=1;i<=tot;i++) if(du[i]==0) q.emplace(i) ; while(!q.empty()) { int u=q.front(); q.pop() ; cnt[fail[u]]+=cnt[u] ; du[fail[u]]-- ; if(du[fail[u]]==0) q.emplace(fail[u]) ; } for(rg int i=1;i<=tot;i++) for(auto j:g[i]) ans[j]+=cnt[i] ; }
回文自动机
算法目标
给定一个只包含小写字母的字符串 \(S\) ,对于 \(S\) 的每个位置,请求出以该位置结尾的回文子串个数。
实现流程
考虑朴素的 \(O(n^3)\) 做法。不难想到,可以直接使用之前提到的 Hash 。再用Manacher就可以变成 \(O(n^2)\) 的。但主播,这都太吃系统配置(时间)了,有没有什么 \(O(n)\) 的做法呢?有的兄弟,有的。由此引出——回文自动机。
由自动机的性质得出,回文自动机也需要一张图或者一棵树把回文串连起来。具体的,点(节点)就是一个本质不同回文串,而边(转移)就是在回文串的两端同时加上同一个字符,得到一个新的回文串。这个定义是源于回文串的性质:一个大回文串用时删去左右两个长度相同的字符串,也一定是一个回文串。而又由于长度奇偶性不同的两个回文串性质也略有区别,所以再创建一个奇根和一个偶根,便于构造,省去麻烦的分讨。定义讲完了,下面是具体实现流程。
- 新建节点 更新len -> 节点 u 代表的回文串的长度。初始化fail -> 定义和AC自动机类似为节点 U 的最长真回文后缀节点编号(失配时跳)指向根节点,cnt -> 节点 u 代表的回文串的出现次数(或回文后缀数量)和 tire 为 0,并返回当前节点编号。
codeinline int addnode(int u) { len[++tot]=u ; fail[tot]=cnt[tot]=0 ; memset(tire[tot],0,sizeof(tire[tot])) ; return tot ; } - 查找fail 从节点 u 开始,沿着 fail 链回退,找到第一个能扩展当前字符 c 的节点。听着可能有点难懂,下面是图,便于理解(鼠标确实不好写字,字写得丑但应该也能看得懂)。匹配的定义是检查是否为回文串。

codeinline int getfail(int u) { while(s[n-len[u]-1]!=s[n]) u=fail[u] ; return u ; } - 插入字符 注意到回文自动机是强制在线,所以每次插入的时候也要将字符加到 \(S\) 里。再找可扩展点也就是刚才找fail。再检查转移是否存在,也就是 \(tire_{u,c}\) 是否存在。如果存在,说明已经有了直接跳过去,否则需要构建fail。构建的方法也简单,直接上
codeinline int insert(char ch,int c=0) { s[++n]=ch ; c=ch-'a' ; int u=getfail(last) ; if(!tire[u][c]) { // 不存在构建fail int v=addnode(len[u]+2) ; // 因为也前面加了一个 fail[v]=tire[getfail(fail[u])][c] ; tire[u][c]=v ; } last=tire[u][c] ; return cnt[last]=cnt[fail[last]]+1 ; }
然后就没了,记得初始化。
主播码字码了3天,实属不易。如果觉得对你有帮助,请点赞支持一下,谢谢。

浙公网安备 33010602011771号