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

kmp

对于一个字符串\(s_{0\dots n}\),称\(s_{0\dots i}(0 \leq i < n)\) 为它的前缀, 称\(s_{i\dots n}(0 < i \leq n)\)为它的后缀

例如字符串\(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] = 'c' \neq s[6] = 'b'\)按照正常暴力思路就是回到\(i = 2, j = 1\)重新开始暴力

但是我们其实是可以发现已经成功匹配的模式子串\(t_{1\dots5} = "ababa"\)有相同的前缀后缀\(a, aba\),即\(t_1 = t_5, t_{1\dots3} = t_{3\dots5}\),且\(t_{1\dots4} \neq t_{2\dots5}\)(这样的话前缀后缀应该有四位才对)

因为成功匹配,所以会有\(s_{1\dots5} = t_{1\dots5}\), 所以我们就可以知道\(s_{1\dots3} = t_{1\dots3} = t_{3\dots5} = s_{3\dots5}\), \(s_{3\dots5} = t_{1\dots3} \neq s_{2\dots4}\)

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

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

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

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

也就是要求出所有的\(t_{1\dots i}(1\leq i\leq n)\)的最长前缀后缀的长度。

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

接下来就可以递推了

对于已经求得的\(f[i-1] = j\),他表示\(t_{1\dots j} = t_{i - j\dots i - 1}\), 那么如果\(t_{j+1} = t_i\),就会有\(t_{1\dots j+1} = t_{i-j\dots i}\),这就是一个相同的前缀后缀且一定是最长的(因为\(f[i-1]\)表示的也是最长的),\(f[i] = j + 1\)

但是如果\(t_{j+1} \neq t_i\),是不是直接判\(f[i] = 0\)呢。

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

\(t_{1\dots f[j]} = t_{i-f[j]\dots i-1}\),也就是说长度为\(f[j]\)的子串\(t_{1\dots f[j]}\)也是模式子串\(t_{1\dots i - 1}\)的相同前缀后缀(只不过不是最长而已)

同理可得,\(j, f[j], f[f[j]], f[f[f[j]]]\dots\)均为\(t_{1\dots i-1}\)的相同前缀后缀的长度,我们就可以一一看看这样的长度的相同前缀后缀能不能多接一个。边界就是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\), 如果\(j\)\(nxt[i]\)存在,那么我们就可以去寻找这个\(nxt[i]\)的失配指针。这里先说如果不存在,那么可以加一个小优化,把\(nxt[i]\)指向\(j\)的失配指针的\(nxt[i]\), 这样就可以直接连到可能匹配到的模式串。但是这样会破坏字典树的结构,在某些题目用了会WA……

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

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

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

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

代码如下:(题目

#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,那么就可以保证\(dfn_u\)\(dfn_{u + sz - 1}\)全部都是这棵子树的上节点的下标,并且这棵子树上节点全部都在这个序列里。

所以我们通过\(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 @ 2021-07-22 21:12  IdanSuce  阅读(75)  评论(0编辑  收藏  举报