字符串匹配(kmp+trie+aho-corasic automaton+fail tree)

kmp

对于一个字符串s0n,称s0i(0i<n 为它的前缀, 称sin(0<in)为它的后缀

例如字符串abcdef的前缀有a,ab,abc,abcde,abcdef, 后缀有f,ef,def,cdef,bcdef

如果前缀后缀之中有相同的……在匹配中可以起到出其不意的效果。

例如对于模式串t=ababacb, 文本串s=abababaababacb

设匹配进行到文本串的第i位,模式串的第j位,即模式串第j位之前已经匹配成功(开头是第1位开始算的话)

然后我们就发现程序很顺利进行到i=5,j=5并且匹配成功,这个时候i++,j++, i=6,j=6

然后就发现t[6]=cs[6]=b按照正常暴力思路就是回到i=2,j=1重新开始暴力

但是我们其实是可以发现已经成功匹配的模式子串t15="ababa"有相同的前缀后缀a,aba,即t1=t5,t13=t35,且t14t25(这样的话前缀后缀应该有四位才对)

因为成功匹配,所以会有s15=t15, 所以我们就可以知道s13=t13=t35=s35, s35=t13s24

这就是说在模式串的前三位我们又快速匹配成功了,根据上一次失败的匹配的结果。这里我们就可得到现在i=6,j=3+1=4开始匹配

这里j=3+1的3是根据已经匹配的模式子串的最长前缀后缀长度为3得到的

那么怎么快速求最长前缀后缀呢

对于字符串t1n,注意这次从1开始(当然从0开始也没问题),我们要匹配,就要求出所有前缀 的最长前缀后缀长度。

也就是要求出所有的t1i(1in)的最长前缀后缀的长度。

我们就设数组f[i]表示t1i的最长前缀后缀的长度,很容易得到f[0]=0(明显犯规了嘛), f[1]=0(就一个字母哪来前缀后缀)

接下来就可以递推了

对于已经求得的f[i1]=j,他表示t1j=tiji1, 那么如果tj+1=ti,就会有t1j+1=tiji,这就是一个相同的前缀后缀且一定是最长的(因为f[i1]表示的也是最长的),f[i]=j+1

但是如果tj+1ti,是不是直接判f[i]=0呢。

我们也可以考虑看一下t1j的最长相同前缀后缀。因为我们是一路推过来的,而且根据定义必定有前缀后缀的长度小于字符串的长度,即i>f[i], 那么我们就可以知道t1j的最长前缀后缀一定是已经求出来的,为f[j],他表示t1f[j]=tjf[j]+1j,同时又因为t1j=tiji1,所以我们可以得到tjf[j]+1j=tif[j]i1=t1f[j],

t1f[j]=tif[j]i1,也就是说长度为f[j]的子串t1f[j]也是模式子串t1i1的相同前缀后缀(只不过不是最长而已)

同理可得,j,f[j],f[f[j]],f[f[f[j]]]均为t1i1的相同前缀后缀的长度,我们就可以一一看看这样的长度的相同前缀后缀能不能多接一个。边界就是0的时候,空串是不会有前缀后缀的了……

写成代码就是这样

for (int i=2;i<=lb;i++)
{
    int j = kmp[i-1];
	while(j&&b[i]!=b[j+1]) j=kmp[j];    
	if(b[j+1]==b[i])kmp[i]=j+1;    
	else kmp[i]=0;
}

trie

类似于字典的构造,建立一棵树,每个节点表示一个字母,层深度表示串的长度,把有相同前缀的串放在同一个子树内。可以快速完成多个文本串中匹配一个模式串的任务。

trie

比如这棵trie,打了红色标记就表示有一个串到尾了。所以这棵trie存储了文本串abc,abd,abcd,b,bcd,efg,hi?

对于有相同前缀ab的串abc,abd,abcd,都放在了字数ab内,那么如果我们找的模式串前缀也是ab,那么只需要在ab子树内寻找就可以了。

aho-corasic automaton

trie上kmp,一个文本串中匹配多个模式串

众所周知kmp是因为有回溯指针才能快速匹配,那么我们把多个模式串建立一个trie,然后用文本串一一匹配,如果找到标记了结束的红点就说明找到了一个模式串。

那么其实我们也可以建立一个回溯指针的。如果匹配失败就看看回溯指针那边能否匹配成功。

在AC自动机里面也叫做失配指针。

fail

这里就是搞定之后的失配指针

对于建立了的字典树,第一层(也就是第一个字母)的失配指针全部指向根节点(就一个字母你指什么指了也是自己还是失配)并且扔入队列(对就用BFS)

然后每次就从队头取出元素,我们可以叫它j, 如果jnxt[i]存在,那么我们就可以去寻找这个nxt[i]的失配指针。这里先说如果不存在,那么可以加一个小优化,把nxt[i]指向j的失配指针的nxt[i], 这样就可以直接连到可能匹配到的模式串。但是这样会破坏字典树的结构,在某些题目用了会WA……

这里失配指针也模仿kmp的f的求法,如果j的失配指针有nxt[i],那么jnxt[i]的失配指针就指向jfailnxt[i] 如果还是没有就看看jfailfail有没有……因为失配指针指向的不是根节点就是同一个字母,保证指向的节点所表示的串会等于当前节点的某个后缀。

匹配的时候,就用文本串从根节点开始匹配,匹配成功文本串指针后移,并且看看下一个字母能不能匹配。如果不能,那就跳去失败指针…………如果最后到了根节点还是没有这个字母,那么……没办法,文本串指针还是要后移一位。

每匹配到一位,我们就检查所有的失配指针指向的节点,jfail,jfailfail,如果有标志是某个模式串的末尾,那么就说明, 啊,我匹配到了!

但是与此同时也要把这个标志取消,以免以后重复访问到,重复计算。这样也可以标记访问过,下次不再访问,节省时间。

代码如下:(题目

#include <cstdio>
#include <cstring>
#include <queue>

namespace Aho_Corasic_automaton
{	
	class A_C_maton
	{
		private:
			
		public:
			A_C_maton();
			~A_C_maton();
			int query(const char*);
			void set_fail();
			const A_C_maton* operator [](const int k) const
			{ if(k < 0 or k >= 26) return nxt[26]; return nxt[k]; }
			void add(const char*);
		protected:
			A_C_maton *fail, *nxt[27];
			int ed;
	};
	
	A_C_maton::A_C_maton()
	{
		fail = NULL; ed = 0;
		for(register int i = 0; i < 27; ++i) nxt[i] = NULL;
	}
	
	A_C_maton::~A_C_maton()
	{
		for(register int i = 0; i < 27; ++i) if(nxt[i]) delete nxt[i];
		delete fail;
	}
	
	void A_C_maton::add(const char* str)
	{
		const register int len = std::strlen(str);
		A_C_maton* p = this;
		for(register int i = 0; i < len; ++i)
		{
			if(p -> nxt[str[i] - 'a'] == NULL) p -> nxt[str[i] - 'a'] = new A_C_maton;
			p = p -> nxt[str[i] - 'a'];
		}
		++(p -> ed);
	}
	
	void A_C_maton::set_fail()
	{
		this -> fail = this;
		std::queue <A_C_maton*> q;
		for(register int i = 0; i < 27; ++i)
		{
			if(this -> nxt[i])
			{
				this -> nxt[i] -> fail = this;
				q.push(this -> nxt[i]);
			}else this -> nxt[i] = this;
		}
		while(not q.empty())
		{
			A_C_maton* p = q.front(); q.pop();
			for(register int i = 0; i < 26; ++i)
			{
				if(p -> nxt[i])
				{
					q.push(p -> nxt[i]);
					if(p -> fail -> nxt[i]) p -> nxt[i] -> fail = p -> fail -> nxt[i];
				}else p -> nxt[i] = p -> fail -> nxt[i];
			}
		}
	}
	
	int A_C_maton::query(const char* str)
	{
		int ans = 0;
		set_fail();
		A_C_maton* p = this;
		const register int len = std::strlen(str);
		for(register int i = 0; i < len; ++i)
		{
			p = p -> nxt[str[i] - 'a'];
			for(register A_C_maton* r = p; r and r -> ed != -1; r = r -> fail)
			{
				ans += r -> ed;
				r -> ed = -1;
			}
		}
		return ans;
	}
	
}

using namespace Aho_Corasic_automaton;
A_C_maton* root = new A_C_maton;
int n;
char str[1000001];

int main()
{
	scanf("%d", &n);
	while(n--)
	{
		scanf("%s", str);
		root -> add(str);
	}
	scanf("%s", str);
	printf("%d\n", root -> query(str));
	return 0;
}

fail tree

Aho-Corasic automaton求的是出现的模式串的个数

如果要求模式串出现的次数呢?

那么其实时间复杂度就有点高了。而且对于aaaaaaaaaaaaaaaaaaa这样的模式串,不断跳失配指针,是很慢的。

那么怎么办呢。

我们回忆一下Aho-Corasic automaton的工作过程。

首先在Trie树里跳文本串,每跳到一个节点就沿着失配指针遍历下去。如果遍历到某个模式串的末尾,那么就说明这个模式串在文本串中出现了一次,可以打上一个标记。那么正常的Aho-Corasic automaton 就是跳完文本串之后,统计每个模式串的结尾的标记个数,就是这个模式串在文本串中出现的次数。

但是这样即使我们提前存储下每个模式串结尾在Trie中的位置,还是会很慢,因为我们不仅要跳很多个失配指针,而且还可能多次跳过一个节点(因为模式串的出现次数肯定很多orz)

我们就可以考虑一下怎么优化。

我们可以发现,一个节点被打上标记,当且仅当它被文本串直接访问或者通过跳失配指针访问。

所以对于每个被标记的节点,我们都可以沿着网线失配指针爬回去,一定可以访问到某个被文本串直接访问过的节点。

那么如果我们根据失配指针反向建有向图,一个模式串的结尾能走到多少个标记,说明有多少次文本串可以直接访问、或通过失配指针访问到这个模式串。

也就是说,我们只要让文本串直接访问到的节点打上标记,跳完之后就统计每个模式串的结尾节点,通过失配指针反向建的有向边来询问节点,统计标记的个数,就是这个模式串出现的次数。这样每个失配指针只跳了一次,文本串也只跳了一次Trie,复杂度大大降低

但是这里要注意建fail的时候不能用优化。这个优化其实就是改变了Trie的结构,同样也会改变失配指针,使其不满足一棵树的结构,就不能正常计算。

这里就告诉你,为什么根据失配指针反向建图是树的结构。

每个节点只有一个失配指针,所以出度是1。反向建图,每个节点的入度是1,也就是只有一个父亲。

那不就是树吗!

而且由于我们通过失配指针最后访问到的都是根,所以反向建图出来的树的根节点就是原来Trie的根。

所以对于每个模式串的末尾,其实就是统计以这个末尾节点为根的子树里面有多少个标记。

然后我们再想想标记是怎么来的。每次文本串遍历到节点才加上一个标记。

那么我们可以假设所有节点的初值都是0,然后每次文本串访问到一个节点就加上1(单点修改),最后就是查找这个子树里面所有节点的和(区间查询)。

单点修改+区间查询 = 树状数组

但是这是一棵树,怎么换成一段数组呢?

我们可以用前缀表达式,对于u为根的子树, 大小为sz, 用前缀表达式dfs出dfs序dfn,那么就可以保证dfnudfnu+sz1全部都是这棵子树的上节点的下标,并且这棵子树上节点全部都在这个序列里。

所以我们通过dfn来统计子树上标记的个数

看有注释的代码:(题目

这道题,所谓的文章就是所有单词组合在一起,但是每个单词之间要插入一个分隔符。文本串跳到了分隔符就要跳回根节点重新跳。

#include<bits/stdc++.h>
using namespace std;
template <typename T>
struct Edge
{
	Edge *nxt;
	T* t;
}; /// 反向建图用链式前向星

struct Trie
{
	Trie* nxt[27], *fail;
	Edge <Trie> *edge;
	int dfn, sz;
	Trie* operator [] (int k) const 
	{
		if(k < 0 or k > 26) return nxt[0];
		return nxt[k];
	}
	Trie()
	{
		for(register int i = 0; i < 27; ++ i) nxt[i] = NULL;
		fail = NULL; edge = NULL;
		dfn = 0; sz = 1;
	}
}*root;

Trie* add(Trie* p, char * str)
{
    /// 把str加入到Trie中
	for(register int i = 0; str[i]; ++i)
	{
		if(p -> nxt[str[i] - 'a' + 1] == NULL) p -> nxt[str[i] - 'a' + 1] = new Trie;
		p = (*p)[str[i] - 'a' + 1];
	}
	return p;
}
int n;
char t[1000201];
Trie* ed[210];
void set_fail(Trie* p)
{
    /// 建立失配指针
	p -> fail = p;
	std::queue <Trie*> q;
	for(register int i = 0; i < 27; ++i)
	{
		if(p -> nxt[i])
		{
			p -> nxt[i] -> fail = p;
			q.push(p -> nxt[i]);
		}else p -> nxt[i] = p;
        // 第一层字母如果就失配就要跳回根节点
	}
	while(not q.empty())
	{
		register Trie* j = q.front(); q.pop();
		for(register int i = 0; i < 27; ++i)
		{
			if(j -> nxt[i])
			{
				q.push(j -> nxt[i]);
				Trie* k = j -> fail;
				while(k and k != p and k -> fail and k -> nxt[i] == NULL) k = k -> fail;
				j -> nxt[i] -> fail = k -> nxt[i];				
			}/// else p -> nxt[i] = p -> fail -> nxt[i];
            /// 这个优化不能要。如果没有这个节点就没有,优化了就破坏了结构,会影响下面建图
		}
	}
}

void addE(Trie* a, Trie* b)
{
    /// 反向建图,加边
	Edge<Trie> *e = new Edge<Trie>();
	e -> nxt = a -> edge;
	e -> t = b;
	a -> edge = e;
	return ;
}

void set_up(Trie *p)
{
    /// 反向建图
	queue <Trie*> q;
	q.push(p);
    /// 通过广搜来遍历每一个节点,通过失配指针反向建图
	while(not q.empty())
	{
		Trie *j = q.front(); q.pop();
		for(register int i = 1; i < 27; ++i)
		{
			if(j -> nxt[i] and j -> nxt[i] != p)
			{
                // 有这个子节点而且这个节点不是根节点
                // 如果根节点进入到队列,那么就会永远在队列中……就MLE了 
				q.push(j -> nxt[i]);
				addE(j -> nxt[i] -> fail, j -> nxt[i]);
			}
		}
	}
}
int dfn;
void dfs(Trie *p)
{
    /// 先序遍历求dfs序
	p -> dfn = ++dfn;
	for(register Edge<Trie>* i = p -> edge; i; i = i -> nxt)
	{
		dfs(i -> t);
        // 对于每一条边都深搜下去
		p -> sz += i -> t -> sz;
        // 并且维护父节点的大小
	}
}

int lowbit(int);
void change(int, int);
int query(int); 
int sum[1000201];
// 树状数组求和
void solve(Trie* p, char *str, int len)
{
	for(register int i = 0; i < len; ++i)
	{
        // 标记文本串直接跳到的节点
		register const int x = str[i] - 'a' + 1;
		while(not p -> nxt[x] and p -> fail != p) p = p -> fail;
		p = p -> nxt[x];
		change(p -> dfn, 1);
	}
}
int len;


int main()
{
	root = new Trie;
	cin >> n;
	for(register int i = 1; i <= n; ++ i)
	{
		cin >> (t + len);
		ed[i] = add(root, t + len);
		len += strlen(t + len);
		t[len++] = 'a' - 1;
        // 储存文章
	}
	set_fail(root);
	set_up(root);
	dfs(root);
	solve(root, t, len);
	for(register int i = 1; i <= n; ++i)
	{
		int ans = query(ed[i] -> dfn + ed[i] -> sz - 1) - query(ed[i] -> dfn - 1);
		cout << ans << endl;
	}
	return 0;
}

int lowbit(int x)
{
	return x & (-x);
}

void change(int n, int x)
{
	for(int i = n; i <= dfn; i += lowbit(i)) sum[i] += x;
}

int query(int n)
{
	int res = 0;
	while(n)
	{
		res += sum[n];
		n -= lowbit(n);
	}
	return res;
}
posted @   IdanSuce  阅读(78)  评论(0编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示