字符串进阶学习笔记

image

前言

特别感谢 绝顶我为峰 于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]\) 我们执行以下操作:

  1. 检查文本串 \(S_i\) 是否能匹配 \(t_j\)
    如果成功:往后遍历 \(j\)
    如果失配了:将 \(j\) 回退到 \(\pi_{j-1}\)。(至于证明,我能力有限实在无法解答,如果日后有机会我会update这一块)
  2. 当找完了 \(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)\) 。下面是具体流程。

  1. 求解Z函数\(Z_i\) 表示 \(t\)\(t\) 的后缀(即 \(t[i,..,n-1]\))的最长匹配长度。显然 \(Z_0\) 初始等于 \(n\)。遍历每个 \(Z_{i-l}\) 进行初始化。然后暴力向外扩展匹配。如果走到超过r的部分,更新 \([l,r]\)。复杂度\(O(n)\)
    code
    inline 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;}
    	}
    }
    
  2. 处理第二部分即exKMP 创建一个 \(p\) 数组,存的是 \(t\)\(S\) 的每个LCP长度。做法与Z函数类似,转移参考Z函数。复杂度\(O(m)\)
    code
    inline 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的算法。下面是具体流程。

  1. 构建自动机 类似KMP的代码求出fail(不多赘述)。然后构建出tire。具体地:在当前状态i中,读入字符ch。如果匹配成功状态跳到i+1,如果失败了就跳到fail上接着找。
    code
    inline 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];
    		}
    	}
    }
    
  2. 匹配文本串 对于文本串的每一个字符c,如果匹配成功就跳到下一个tire上。然后就没了,
    code
    inline 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];
    	}
    }
    
  3. 下面就是KMP自动机的核心操作,增加字符 在已有的自动机上,增加一个字符c。首先我们需要计算新的fail 直接用KMP算,因为状态 m 代表“已经匹配了完整模式串”,它再读入任意字符后应该跳到哪里,和 fail[m] 状态读入同一字符后的跳转逻辑一致。所以直接把复制状态从\(tire_{fail_m,c}\)中复制过来。最后把旧状态转移到新状态就结束了。均摊复杂度为 \(O(|\Sigma|)\) 可视作一个较大常数。
    code
    inline 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节点)在这棵树上跑文本串匹配过程。下面是具体流程。

  1. 建Tire 将所有模式串都插到tire上,类似Tire的加字符串板子,再增加一个映射关系表示节点 i 是哪些模式串的结尾。
    code

    inline 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) ;
    }
    
  2. 构建失配指针fail 其含义与KMP里的 \(\pi\) 数组含义完全一样——当在当前节点匹配失败时,应该跳到哪个节点继续匹配。构建过程考虑BFS。枚举u的每个子节点v(设其代表的字符为c)

    • 从 u 的fail指针指向的节点开始,不断回退,直到找到某个点只有一个字符c,或者回到根。
    • 如果找到了符合条件的节点w,将u的fail数组指向w。如果没有,则字符c是第一次出现,则将v的fail指向根节点。
    • 将w加入队列。

    但事实上为了加速匹配,通常会把每个节点的不存在的子节点,直接指向其 fail 指针的对应子节点。这样在匹配时就不需要 while 循环回退,可以直接跳转。
    code

    inline 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] ;
    		}
    	}
    }
    
  3. 匹配文本串 考虑使用一个cnt数组,初步地在匹配过程中,节点 u 被访问到的次数。即记录了文本串中所有模式串前缀的出现位置。
    code

    inline 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]] ++ ;
    	}
    }
    
  4. 传递fail指针 沿着fail指针的方向,把计数从“长后缀”节点传递给“短后缀”节点。如果要将节点v的 \(\pi\) 指向u ,就可以直接 \(\pi_u=\pi_v\) 那么所有匹配到 v 的位置,也一定匹配到了u。所以我们需要把 \(cnt_v\) 累加到 \(cnt_u\) 上。考虑 \(TopoSort\) 。因为失配指针本身构成一棵树,所以直接用fail进行拓扑。对于每一个u的儿子v,做上述操作。最后收集答案,就可以使用到最开始的映射关系,将 \(cnt_u\) 累加到 \(ans_v\) 里。
    code

    inline 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)\) 的做法呢?有的兄弟,有的。由此引出——回文自动机。

由自动机的性质得出,回文自动机也需要一张图或者一棵树把回文串连起来。具体的,点(节点)就是一个本质不同回文串,而边(转移)就是在回文串的两端同时加上同一个字符,得到一个新的回文串。这个定义是源于回文串的性质:一个大回文串用时删去左右两个长度相同的字符串,也一定是一个回文串。而又由于长度奇偶性不同的两个回文串性质也略有区别,所以再创建一个奇根和一个偶根,便于构造,省去麻烦的分讨。定义讲完了,下面是具体实现流程。

  1. 新建节点 更新len -> 节点 u 代表的回文串的长度。初始化fail -> 定义和AC自动机类似为节点 U 的最长真回文后缀节点编号(失配时跳)指向根节点,cnt -> 节点 u 代表的回文串的出现次数(或回文后缀数量)和 tire 为 0,并返回当前节点编号。
    code
    inline int addnode(int u) {
    	len[++tot]=u ;
    	fail[tot]=cnt[tot]=0 ;
    	memset(tire[tot],0,sizeof(tire[tot])) ;
    	return tot ;
    }
    
  2. 查找fail 从节点 u 开始,沿着 fail 链回退,找到第一个能扩展当前字符 c 的节点。听着可能有点难懂,下面是图,便于理解(鼠标确实不好写字,字写得丑但应该也能看得懂)。匹配的定义是检查是否为回文串。
    image
    code
    inline int getfail(int u) {
    	while(s[n-len[u]-1]!=s[n]) u=fail[u] ;
    	return u ;
    }
    
  3. 插入字符 注意到回文自动机是强制在线,所以每次插入的时候也要将字符加到 \(S\) 里。再找可扩展点也就是刚才找fail。再检查转移是否存在,也就是 \(tire_{u,c}\) 是否存在。如果存在,说明已经有了直接跳过去,否则需要构建fail。构建的方法也简单,直接上
    code
    inline 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天,实属不易。如果觉得对你有帮助,请点赞支持一下,谢谢。

posted @ 2026-07-29 22:41  Emumumu  阅读(14)  评论(0)    收藏  举报