深入解析AC自动机(含Kmp和Trie树)

结构

AC自动机
Trie树
Kmp

AC自动机:
主要作用: 用来解决字符串的多模匹配问题.
主要思想:字典树组织多个模式串 + kmp避免回溯

Kmp

主要作用

首先,给定这样一个模式匹配问题:在一个长度为n的文本S中,找某个长度为m的关键词P, P可能多次出现,都需要找到。
在这里插入图片描述
我们首先想到的肯定是暴力做法,两层循环:

for(int i = 0;i + m < n;i ++){
	int j = 0;
	while(j < m && s1[i + j] == s2[j]){
		j ++;
	}
	if(j == m){
		std::cout << i << "\n";
	}
}

显然,我们设文本串长度为n,模式串长度为m, 在最坏情况下时间复杂度高达O(nm)。这在n和m都很大并且字符串匹配最坏(每次前m - 1个都匹配但最后一个却不匹配)的情况下这显然是不可接受的。

Kmp的出现就是出来解决这种单模匹配问题的,在kmp的算法下可以时间复杂度优化到O(n + m) 级别。

操作方法

Kmp到底是如何实现优化的呢?

Kmp 的要点在于避免回溯next数组

避免回溯

为什么要避免回溯呢?
首先,让我们回到字符串匹配最坏的请况即前面m - 1的字符全部匹配,第m个字符不匹配,你说我前面做了那么多努力,难道你就白白看着我全部白费,相信大家都不愿意,那么这时我们进行观察。
在这里插入图片描述
如图所示,正常的情况下,一段匹配结束之后j应该直接回溯到0,但是如果模式串s2存在前缀①和后缀②相等的话,那么我们就可以仅仅让j回溯到A位置,从而不白费我们的努力。

next数组

怎么实现避免回溯呢?
那就是预处理出模式串s2的最长公共前后缀,而next数组则是用来储存这的信息的。

next[i]表示字符串的前i个字符的最长公共前后缀(我这里的字符串下标是从0开始的)
在这里插入图片描述
在这里插入图片描述

next数组实现
s[i] == s[j]
在这里插入图片描述

s[i] != s[j]
在这里插入图片描述
当我们发现 s[i] != s[j] 时,我们只能往回缩小我们的最大公共前后缀,同时我们发现因为 A等于B,所以 2等于3,而往回缩的话即 i/ = next[i],所以1等于3, 我们就一直i/ = next[i], 直到s[i/] == s[j], next[j] = next[i/] + 1。

直击本质

其实如果我们将模式串s2和文本串s1合并,可以一眼看透Kmp的本质,s = s2 + ‘#’ + s1, 之后做next数组。
在这里插入图片描述
如图所示,我们看可以看到其实匹配的末位置就是合并之后next数组的最大位置。

代码实现

NOTE:直击本质部分只是为了便于大家理解和记忆但是我们接下来的代码实现并不依赖于这一部分,原因是虽然直击本质的代码实现便于理解和实现,但是却并不便于进行扩展,代码实现我们依旧依赖于最初的避免回溯部分。

在这里插入图片描述

#include<bits/stdc++.h>

using i64 = int64_t;

const int N = 1e6 + 10;

int next[N];

void Kmp(std::string& s1, std::string& s2){

	auto getNext = [&](std::string& s) -> void{
		next[0] = 0, next[1] = 0;//前0个和前1个的最长公共前后缀都是0
		for(int i = 1;i < (int)s.size();i ++){
			int j = next[i];
			//这里的话j在前面, i在后面, 与前面图片画的相反注意一下
			while(j && s[i] != s[j]){
				j = next[j];
			}
			//直到s[i] == s[j]
			if(s[i] == s[j]) next[i + 1] = j + 1;
			else next[i + 1] = 0;//实在没有最长公共前后缀就为0
		}
	};
    
	getNext(s2);

	int j = 0;//j在模式串s2上
	for(int i = 0;i < (int)s1.size();i ++){
		while(j && s1[i] != s2[j]){
			j = next[j];//回溯
		}
		if(s1[i] == s2[j]) j ++;
		if(j == (int)s2.size()){//匹配成功
			std::cout << i + 2 - (int)s2.size() << "\n";//初始匹配点
		}
	}
}

int main(){
	std::ios::sync_with_stdio(false);
	std::cin.tie(nullptr);

	std::string s1, s2;
	std::cin >> s1 >> s2;

	Kmp(s1, s2);

	for(int i = 0;i < (int)s2.size();i ++){
		std::cout << next[i + 1] << " "; 
	}
	std::cout << "\n";

	return 0;
}

扩展问题

最短循环节问题

在这里插入图片描述
这道题如何用Kmp做呢?
设可能的s2的最短长度为了L, 本题所组成的字符串一共有两种情况:
case1: s1由完整的k个s2组成,则next[n]则为(k - 1) * L,所以 n - next[n]为L。
case2: s1由k个s2和一个z(不完整的s2)组成,则nexr[n] = (k - 1) * L + z, 所以n - next[n] = k * L + z - (k - 1) * L - z = L。
所以答案为 n - next[n]。

在S中删除所有P

在这里插入图片描述
本题的最大问题是在于P之间可能是嵌套的,一直循环用kmp, 时间复杂度为O(n2),会超时,所以我们还是要解决回溯问题。
在这里插入图片描述

#include<bits/stdc++.h>

using i64 = int64_t;

const int N = 1e6 + 10;

int next[N];
int p[N];
int stk[N];
int top = 0;

void getNext(std::string b){
	next[0] = 0, next[1] = 0;
	for(int i = 1;i < (int)b.size();i ++){
		int j = next[i];
		while(j && b[i] != b[j]){
			j = next[j];
		}
		if(b[i] == b[j]) next[i + 1] = j + 1;
		else{
			next[i + 1] = 0;
		}
	} 
}

int main(){
	std::ios::sync_with_stdio(false);
	std::cin.tie(nullptr);

    std::string a, b;
    std::cin >> a >> b;
    
    getNext(b);

    int j = 0;
    for(int i = 0;i < (int)a.size();i ++){
    	while(j && a[i] != b[j]){
    		j = next[j];
    	}
    	p[i] = j;
    	stk[++ top] = i;
    	if(a[i] == b[j]){
    		j ++;
    	}
    	if(j == (int)b.size()){
    		top -= (int)b.size();
    		j = p[stk[top]] + 1;
    	}
    }
    
    for(int i = 1;i <= top;i ++){
    	std::cout << a[stk[i]];
    }
    std::cout << "\n";

    return 0;
}

Trie树

Trie树简介

Trie, 又叫字典树前缀树,用来储存查询字符串。

那Trie树是怎么储存字符串的呢?
给定五个字符串:acdachsfaeadsbf,Trie树将以下的形式来储存字符串:
在这里插入图片描述

比如我们走1 -> 2 -> 4,存储的就是字符串acd

数组模拟

储存

那我们怎么用数组来实现这个数据结构呢?
在这里插入图片描述
如图可以表示为 son[u][c] = v
字符串的话,我们可以把字母映射成0~25。
之前内个图我们可以表示为:

son[0][0] = 1;//son[0][a] = 1
son[1][2] = 2;//son[1][c] = 2
son[2][3] = 3;//son[1][d] = 3
son[2][7] = 4;//son[2][h] = 4
son[0][10] = 5;//son[0][s] = 5
son[5][1] = 11//son[5][b] = 11
......

我们用一个变量idx来进行每次编号的递增,我们以编号0根节点,当son[u][c] = 0说明与u接下来的分支没有字母c,否则son[u][c] = idx.
存储字符串的模板:

void insert(std::string s){
	int p = 0;
	for(int i = 0;i < (int)s.size();i ++){
		int u = match(s[i]);
		if(!con[p][u]) con[p][u] = ++idx;
		p = con[p][u];
		cnt[p] ++;
	}
}
查询

查询的话我们给每个字符串的结尾打上标记
比如我们考虑两个字符串:ababcd
在这里插入图片描述
我们可以开一个cnt数组,存储一个字符串,我们可以在结尾cnt[p] ++
查询字符串的模板:

void insert(std::string s){
	int p = 0;
	for(int i = 0;i < (int)s.size();i ++){
		int u = match(s[i]);
		if(!con[p][u]) con[p][u] = ++idx;
		p = con[p][u];
		cnt[p] ++;
	}
}

完整模板

int con[N][80], cnt[N], idx = 0;
bool st[N];

int match(char s){
	if(s >= 'A' && s <= 'Z'){
		return s - 'A';
	}
	if(s >= 'a' && s <= 'z'){
		return s - 'a' + 26;
	}
	if(s >= '0' && s <= '9'){
		return s - '0' + 52;
	}
}

void insert(std::string s){
	int p = 0;
	for(int i = 0;i < (int)s.size();i ++){
		int u = match(s[i]);
		if(!con[p][u]) con[p][u] = ++idx;
		p = con[p][u];
		cnt[p] ++;
	}
}

int query(std::string s){
	int res = 0;
	int p = 0;
	for(int i = 0;i < (int)s.size();i ++){
		int u = match(s[i]);
		if(!con[p][u]){
			return 0;
		}
		p = con[p][u];
	}
	return cnt[p];
}

void recovery(){
	for(int i = 0;i <= idx;i ++){
		for(int j = 0;j < 80;j ++){
		    con[i][j] = 0;
		}
	}
	for(int i = 0;i <= idx;i ++){
		cnt[i] = 0;
	}
	idx = 0;
}

AC自动机

简介

我们学会Kmp的单模匹配问题后,现在我给出这样一个问题:给定一个长度为n的文本S,以及k个平均长度为m的模式串P1, P2, P3...Pk,要求搜索这些模式串出现的位置。
没错,这就是著名的多模匹配问题,如果我们直接用kmp来进行每次查询的话,则时间复杂度高达O(k * (n + m)), 当k足够大时绝对会超时,那么怎么办呢?
这时我们就引入了著名数据结构——AC自动机
思想
AC自动机的主要思想 = 用Trie树同时组织多个模式串 + Kmp 避免回溯。
(1)我们可以先用Trie树把模式串全部存进去,然后在放入文本串进行匹配。
(2)避免回溯,我们知道kmp中有next数组可以时i指针避免回溯,而AC自动机也有一个叫做fail指针的来帮忙避免回溯。

构造

在这里插入图片描述
通过上述这个图,我们可以发现其实匹配P1, P2, P3这三个字符串,只需要遍历P1这一个字符串即可,所以我们可以通过一个叫做Fail指针的东西来进行标记(其实也是一种在树上的类似最长公共前后缀的东西)
现在我们给三个字符串:abcd, b, cd,构建出的字典树:
在这里插入图片描述
根据上述图片可以看出来,让每个字符的fail指针指向上一层满足后缀包含关系且
同字符的idx(即编号),没有的则指向根节点0, 注意不满足后缀包含关系的同字符是不能指向的,如下图:
在这里插入图片描述
Fail指针的代码实现
那么Fail指针在代码中怎么实现,其实我们只需要让一个节点x的fail指针指向的是x的父节点的fail指针指向的节点的与x同字符的子节点。而且其实每一层的每个节点都会创造26个虚拟节点来使得下一层合适的可以进行跨越.
在这里插入图片描述
如图所示,在第二层更新信息的时候5就会创造一个虚拟节点8来指向6,然后下来再更新节点指向8,即指向了6。
模板

 void work(){
        int hh = 0, tt = -1;
        for(int i = 0;i < Need;i ++){
            if(t[0].son[i]){
                q[++ tt] = t[0].son[i];
            }
        }

        while(hh <= tt){
        //这里是用数组模拟栈,为了以拓扑序存储住所有的节点         
            int now = q[hh ++];

            for(int i = 0;i < Need;i ++){   
                if(t[now].son[i]){//如果儿子存在
                    t[t[now].son[i]].fail = t[t[now].fail].son[i];
                    //指向父节点的fail指针指向的节点的子节点
                    q[++ tt] = t[now].son[i]; 
                }else{
                    t[now].son[i] = t[t[now].fail].son[i];
                    //虚拟节点指向
                }
            }

        }
    }

时间复杂度分析

我们设有k个模式串,平均长度为m,文本串的长度为n,建Trie树的时间复杂度为O(km),模式匹配的复杂度一般来说为O(n),但是特殊数据会将O(n)卡到O(nm),
比如现在我们给四个字符串:abcdbcd,cd, d:
在这里插入图片描述
当我们走到‘d’时fail要操作4次,这样浪费时间。
我们传统AC自动机的查询模板是这样的:

void query(const std::string& s, int n){//查询有多少个匹配
        std::unordered_map<int, int> res;

        int now = 0;
        for(auto& c : s){
            int u = c - 'a';
            now = t[now].son[u];
            int tmp = now;
            while(tmp){//把所有的fail都匹配一遍
                if(t[tmp].end){
                	f[tmp] ++;
                }
                tmp = t[tmp].fail;
            }
        }

    }

如果大家这样写,在洛谷的模板题上就会喜提一堆TLE。
在这里插入图片描述
那么我们应该怎么办呢?

拓扑排序优化

如果我们把之前内个树上的所有fail指针全部拿出来:
在这里插入图片描述
就会发现这组成了一个DAG(有向无环图),利用拓扑序我们就可以大大优化(这也明白了我们为什么要用数组模拟的栈)。

完整模板

在这里插入图片描述

#include<bits/stdc++.h>

using i64 = int64_t;

constexpr int N = 1e6 * 26 + 110;

int f[N], q[N];

struct AhoCorasick{
    static constexpr int Need = 26;
    struct node{
        int depth;
        int fail;
        int end;
        std::array<int, Need> son;
        node(): depth{0}, fail{0}, end{0}, son{}{}
    };

    std::vector<node> t;

    AhoCorasick(){
        init();
    }

    void init(){
        t.assign(1, node());
        t[0].son.fill(0);
        t[0].depth = 0;
        t[0].end = 0;
    }

    int newNode(){
        t.emplace_back();
        return t.size() - 1;
    }

    int insert(const std::string& s){

        int p = 0;
        for(auto& c : s){
            int u = c - 'a';
            if(!t[p].son[u]){
                t[p].son[u] = newNode();
                t[t[p].son[u]].depth = t[p].depth + 1;
            }
            p = t[p].son[u];
        }

        t[p].end ++;

        return p;
    }

    void work(){
        int hh = 0, tt = -1;
        for(int i = 0;i < Need;i ++){
            if(t[0].son[i]){
                q[++ tt] = t[0].son[i];
            }
        }

        while(hh <= tt){            
            int now = q[hh ++];

            for(int i = 0;i < Need;i ++){   
                if(t[now].son[i]){
                    t[t[now].son[i]].fail = t[t[now].fail].son[i];
                    q[++ tt] = t[now].son[i]; 
                }else{
                    t[now].son[i] = t[t[now].fail].son[i];
                }
            }

        }
    }

    int son(int p, int x){
        return t[p].son[x];
    }

    int fail(int p){
        return t[p].fail;
    }

    int depth(int p){
        return t[p].depth;
    }

    int size(){
        return t.size();
    }

};

int main(){
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);

    int n;
    std::cin >> n;

    AhoCorasick a;
    std::vector<std::string> b(n);
    std::vector<int> end(n);//尾节点
    for(int i = 0;i < n;i ++){
        std::cin >> b[i];
        end[i] = a.insert(b[i]);
    }

    a.work();

    std::string t;
    std::cin >> t;
    
    int p = 0;
    for(auto& c : t){
        p = a.son(p, c - 'a');
        f[p] ++;
    }

    for(int i = a.size() - 1;i >= 0;i --){
        f[a.fail(q[i])] += f[q[i]];//q数组满足拓扑序,从后往前加
    }
    
    for(int i = 0;i < n;i ++){
        std::cout << f[end[i]] << "\n";
    }
    
    return 0;
}

补充模板

如果一个题特别卡TLE,那么你选择下面这个代码模板,常数复杂度较小点:

#include<bits/stdc++.h>

using i64 = int64_t;

constexpr int N = 1e6 + 110, M = 1000000;

int q[N];

struct AhoCorasick{
    static constexpr int Need = 26;
    struct node{
        int depth;
        int fail;
        int end;
        std::array<int, Need> son;
        node(): depth{0}, fail{0}, end{0}, son{}{}
    };

    std::vector<node> t;

    AhoCorasick(){
        init();
    }

    void init(){
        t.assign(1, node());
        t[0].son.fill(0);
        t[0].depth = 0;
        t[0].end = 0;
    }

    int newNode(){
        t.emplace_back();
        return t.size() - 1;
    }

    int insert(const std::string& s){

        int p = 0;
        for(auto& c : s){
            int u = c - 'a';
            if(!t[p].son[u]){
                t[p].son[u] = newNode();
                t[t[p].son[u]].depth = t[p].depth + 1;
            }
            p = t[p].son[u];
        }

        t[p].end ++;

        return p;
    }

    void work(){
        int hh = 0, tt = -1;
        for(int i = 0;i < Need;i ++){
            if(t[0].son[i]){
                q[++ tt] = t[0].son[i];
            }
        }

        while(hh <= tt){            
            int now = q[hh ++];

            for(int i = 0;i < Need;i ++){   
                if(t[now].son[i]){
                    t[t[now].son[i]].fail = t[t[now].fail].son[i];
                    q[++ tt] = t[now].son[i]; 
                }else{
                    t[now].son[i] = t[t[now].fail].son[i];
                }
            }

        }
    }

    int son(int p, int x){
        return t[p].son[x];
    }

    int fail(int p){
        return t[p].fail;
    }

    int depth(int p){
        return t[p].depth;
    }

    int size(){
        return t.size();
    }

};

int main(){
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);

    std::string t;
    while(std::cin >> t){
    	int n;
	    std::cin >> n;

	    AhoCorasick a;
	    std::vector<std::string> b(n);
	    std::vector<int> end(n);
	    for(int i = 0;i < n;i ++){
	        std::cin >> b[i];
	        end[i] = a.insert(b[i]);
	    }

	    a.work();

	    int p = 0;
	    std::vector<int> f(a.size() + M);//M尽量加上,虽然我也觉得没有必要但是没加就是会re,不知道为什么
	    for(auto& c : t){
	        p = a.son(p, c - 'a');
	        f[p] ++;
	    }

	    for(int i = a.size() - 1;i >= 0;i --){
	        f[a.fail(q[i])] += f[q[i]];
	    }
	    
	    int ans = 0;
	    for(int i = 0;i < n;i ++){
	        ans += (f[end[i]] == 0 ? 0 : 1);
	    }

	    std::cout << ans << "\n";
    }
    
    
    return 0;
}

如果一个代码特别卡RE, 第一你可选择上面内个代码模板加M,第二你可以选择下面这个代码模板(来自jiangly):

#include<bits/stdc++.h>

using i64 = int64_t;

struct AhoCorasick{
    static constexpr int Need = 26;
    struct node{
        int depth;
        int fail;
        int end;
        std::array<int, Need> son;
        node(): depth{0}, fail{0}, end{0}, son{}{}
    };

    std::vector<node> t;

    AhoCorasick(){
        init();
    }

    void init(){
        t.assign(1, node());
        t[0].son.fill(0);
        t[0].depth = 0;
        t[0].end = 0;
    }

    int newNode(){
        t.emplace_back();
        return t.size() - 1;
    }

    int insert(const std::string& s){

        int p = 0;
        for(auto& c : s){
            int u = c - 'a';
            if(!t[p].son[u]){
                t[p].son[u] = newNode();
                t[t[p].son[u]].depth = t[p].depth + 1;
            }
            p = t[p].son[u];
        }

        t[p].end ++;

        return p;
    }

    void work(){
        std::queue<int> q;
        for(int i = 0;i < Need;i ++){
            if(t[0].son[i]){
                q.push(t[0].son[i]);
            }
        }

        while(!q.empty()){
            int now = q.front();
            q.pop();

            for(int i = 0;i < Need;i ++){   
                if(t[now].son[i]){
                    t[t[now].son[i]].fail = t[t[now].fail].son[i];
                    q.push(t[now].son[i]); 
                }else{
                    t[now].son[i] = t[t[now].fail].son[i];
                }
            }

        }
    }

    int son(int p, int x){
        return t[p].son[x];
    }

    int fail(int p){
        return t[p].fail;
    }

    int depth(int p){
        return t[p].depth;
    }

    int size(){
        return t.size();
    }

};

int main(){
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);

    int n;
    std::cin >> n;

    AhoCorasick a;
    std::vector<std::string> b(n);
    std::vector<int> end(n);
    for(int i = 0;i < n;i ++){
        std::cin >> b[i];
        end[i] = a.insert(b[i]);
    }

    a.work();
    
    std::string s;
    std::cin >> s;

    int p = 0;
    std::vector<int> f(a.size());
    for(auto& c : s){
        p = a.son(p, c - 'a');
        f[p] ++;
    }

    std::vector<std::vector<int>> adj(a.size());
    for(int i = 1;i < a.size();i ++){//建立有向无环图
        adj[a.fail(i)].push_back(i);
    }

    auto dfs = [&](auto dfs, int u) -> void{//拓扑往上加
        for(auto v : adj[u]){
            dfs(dfs, v);
            f[u] += f[v]; 
        }
    };
    dfs(dfs, 0);

    for(int i = 0;i < n;i ++){
        std::cout << f[end[i]] << "\n";
    }
    
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值