• AC自动机感性瞎扯


    大家好,今天我们来扯自动AC机AC自动机了。

    I.前置知识

    trie树。(那些说需要kmp的,不会也没事,不过还是会方便理解一点)。

    II.用途

    AC自动机可以在(O(Sigma|S|))的时间内预处理,并在(O(|S|))内求出一组模式串集在一个文本串中的出现次数。

    换句话说,给你(n)个模式串(s_1)(s_n),和一个文本串(t),我们可以在(O(|T|))时间将每个(s_i)(t)做一次kmp。而用暴力的kmp是(O(|T|Sigma|S_i|))的。

    III.操作

    首先,我们建出一颗trie树。

    如果我们暴力匹配,失配就暴力回溯的话,复杂度甚至还不如暴力kmp。

    借鉴kmp的(next)思想,如果失配,我们是不是可以略过重复部分,只做有用的匹配呢?

    我们引出(fail)数组。这个神奇的数组的意思是:如果在节点(i)处失配了,我们可以直接跳到点(fail_i),避免回溯。

    IV.求(fail)

    显然,如果失配,我们希望这个(fail)的位置尽量更深一点,以加快匹配。

    并且,(i)(fail_i)必定要有相同的前缀,不然这次跳跃就是不合法的。

    我们现在采取暴力bfs的方法求(fail)

    IV.i.根的子节点

    显然,对于根的子节点,它们不可能有一个合法的(fail)。而由于深度足够浅,暴力回溯也没有问题。因此,我们将它们的(fail)全都指向根。之后,将它们入队。

    另外,对于没有出现在trie树中的子节点,将它们全部设成指向根,即默认失配。

    IV.ii.普通节点

    我们取出队首节点,设为(x)

    枚举它的所有子节点:

    IV.ii.1.已有节点(设为(y)

    因为(x)(fail_x)有共同的前缀,所以当前缀由(x)延伸到(y)时,(fail_x)也可以向相同方向延伸。

    关键代码((t):trie数组,(ch):子节点编号,(fail)(fail)数组):

    t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
    

    非常易记,因为刚好是内外互换。

    IV.ii.2.空节点

    默认直接失配,直接指向原本的(fail)

    关键代码:

     t[x].ch[i]=t[t[x].fail].ch[i];
    

    IV.iii.总代码

    void build(){
    	for(int i=0;i<26;i++){
    		if(t[1].ch[i])q.push(t[1].ch[i]),t[t[1].ch[i]].fail=1;
    		else t[1].ch[i]=1;
    	}
    	while(!q.empty()){
    		int x=q.front();q.pop();
    		for(int i=0;i<26;i++){
    			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
    			else t[x].ch[i]=t[t[x].fail].ch[i];
    		}
    	}
    }
    

    IV.iv.感性理解

    第一步,根的子节点(未出现节点未标出)入队。

    现在,我们到了第(1)层(根为(0)层)的第一个节点(A)

    考虑它的儿子,第(2)层的第一个节点(B)

    它指向(A)(fail)(根)的儿子(B)

    即有:

    (2)层的第一个节点(B)并无儿子,忽略它的操作。

    考虑第(2)层的第二个节点(C)

    它指向根的儿子(C)。但是,这个(C)并不存在,它被暴力连到了根。

    即有:

    (2)层的第一个节点(C)并无儿子,忽略它的操作。

    考虑第(1)层的第二个节点(B)

    它唯一的儿子,第(2)层的第三个节点(B)。这个节点应指向根的(B)儿子——它的父亲,第(1)层的第二个节点(B)

    考虑第(3)层的第一个节点(A)

    它父亲的(fail)(第(1)层的第二个节点(B))并无(A)儿子。但是这个(A)儿子已经被赋成第(1)层的第一个节点(A)。可是是,这个第(1)层的第一个节点(A)也没有(A)儿子,则它被赋成了根(这个节点的(fail))的(A)儿子——第(1)层的第一个节点(A)。(也就是说,第(1)层的第一个节点(A)(A)儿子是它自己!)

    则第(3)层的第一个节点(A)(fail)连向了第(1)层的第一个节点(A)。(好好理解一下!)

    最后有:

    V.匹配

    不知道大家还记不记得我们一开始的结论:

    我们引出(fail)数组。这个神奇的数组的意思是:如果在节点(i)处失配了,我们可以直接跳到点(fail_i),避免回溯。

    然后我们就可以暴力跳了。

    见代码:

    void merge(){
    	int x=1;
    	for(int i=0;i<S;i++){
    		x=t[x].ch[s[i]-'a'];
    		for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
    	}
    }
    

    (fin):该节点是哪条字符串的结尾;(occ):统计答案的计数器)

    为什么跳到一个节点,要对它所有的祖先(fail)也增加答案呢?

    我也不知道。

    其实是因为它所有的祖先(fail),当到达该节点时,也又出现了一次(因为(fail)是最长相同前缀)。

    那为什么暴力跳就可以,不用担心失配呢?

    因为所有未出现的节点,都在求(fail)时被我们重连了!

    所以暴力跳就行。

    VI.大礼包

    (代码:【模板】AC自动机(加强版)

    #include<bits/stdc++.h>
    using namespace std;
    int n,cnt,occ[210],S,mx;
    char dict[210][100],s[1001000];
    struct node{
    	int ch[26],fin,fail;
    }t[20100];
    void ins(int id){
    	int x=1;
    	for(int i=0;i<S;i++){
    		if(!t[x].ch[dict[id][i]-'a'])t[x].ch[dict[id][i]-'a']=++cnt;
    		x=t[x].ch[dict[id][i]-'a'];
    	}
    	t[x].fin=id;
    }
    queue<int>q;
    void build(){
    	for(int i=0;i<26;i++){
    		if(t[1].ch[i])q.push(t[1].ch[i]),t[t[1].ch[i]].fail=1;
    		else t[1].ch[i]=1;
    	}
    	while(!q.empty()){
    		int x=q.front();q.pop();
    		for(int i=0;i<26;i++){
    			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
    			else t[x].ch[i]=t[t[x].fail].ch[i];
    		}
    	}
    }
    void merge(){
    	int x=1;
    	for(int i=0;i<S;i++){
    		x=t[x].ch[s[i]-'a'];
    		for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
    	}
    }
    int main(){
    	while(scanf("%d",&n)){
    		if(!n)return 0;
    		memset(t,0,sizeof(t)),memset(occ,0,sizeof(occ)),mx=0,cnt=1;
    		for(int i=1;i<=n;i++)scanf("%s",dict[i]),S=strlen(dict[i]),ins(i);
    		build();
    		scanf("%s",s),S=strlen(s);
    		merge();
    		for(int i=1;i<=n;i++)mx=max(mx,occ[i]);
    		printf("%d
    ",mx);
    		for(int i=1;i<=n;i++)if(mx==occ[i])printf("%s
    ",dict[i]);
    	}
    	return 0;
    }
    

    VII.复杂度

    (ins)函数单次复杂度为(O(|S|))

    (build)函数复杂度为(O(size)),其中(size)为trie树的大小(可看作(O(Sigma |S|)))。

    (merge)函数最大单次复杂度为(O(size|S|))

    什么?不是说复杂度为(O(|S|))吗?那个(size)是从哪里冒出来的?

    看这段代码。

    for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
    

    它更新了节点(x)的所有祖先(fail)

    明显,单次跳(fail),最少令层次上升一层。则暴力跳的话,复杂度为(O(dep)),其中(dep)为深度。

    有没有什么办法优化它呢?

    VIII.拓扑优化

    因为(fail_i)的层次必定比(i)的层次要低,则依靠(fail)关系建成的(fail)图必定是一张(DAG)

    提到(DAG),我们一下就能想到拓扑排序

    因此我们可以在更新时,不暴力跳(fail),转而在(i)处打上标记。

    最终用拓扑排序统计子树和。

    IX.具体实现

    (ins)完全相同。

    (build)函数:

    void build(){
    	for(int i=0;i<26;i++){
    		if(t[1].ch[i])t[t[1].ch[i]].fail=1,q.push(t[1].ch[i]),t[1].in++;
    		else t[1].ch[i]=1;
    	}
    	while(!q.empty()){
    		int x=q.front();q.pop();
    		for(int i=0;i<26;i++){
    			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]),t[t[t[x].fail].ch[i]].in++;
    			else t[x].ch[i]=t[t[x].fail].ch[i];
    		}
    	}
    }
    

    唯一的区别就是在建(fail)时统计了每个节点的入度,便于拓扑排序。

    (merge)函数:

    
    void merge(){
    	int x=1;
    	for(int i=0;i<S;i++){
    		x=t[x].ch[s[i]-'a'];
    		t[x].tms++;
    	}
    }
    

    可以看到,暴力跳(fail)的操作已经被修改(tag)(代码中的(tms))取代。

    最后是拓扑排序的(topo)函数:

    void topo(){
    	for(int i=1;i<=cnt;i++)if(!t[i].in)q.push(i);
    	while(!q.empty()){
    		int x=q.front();q.pop();
    		t[t[x].fail].tms+=t[x].tms;
    		t[t[x].fail].in--;
    		if(!t[t[x].fail].in)q.push(t[x].fail);
    		for(int i=0;i<t[x].fin.size();i++)occ[t[x].fin[i]]=t[x].tms;
    	}
    }
    

    总体就是个大拓扑。

    关键就是可能有相同的字符串,因此用(vector)来存编号。另外在拓扑中顺便维护子树和

    即关键代码

    t[t[x].fail].tms+=t[x].tms;
    

    X.大礼包II

    (代码:【模板】AC自动机(二次加强版)

    #include<bits/stdc++.h>
    using namespace std;
    int n,cnt=1,S,occ[200100];
    char s[2001000];
    struct AC_Automaton{
    	int ch[26],fail,tms,in;
    	vector<int>fin;
    }t[200100];
    void ins(int id){
    	int x=1;
    	for(int i=0;i<S;i++){
    		if(!t[x].ch[s[i]-'a'])t[x].ch[s[i]-'a']=++cnt;
    		x=t[x].ch[s[i]-'a'];
    	}
    	t[x].fin.push_back(id);
    }
    queue<int>q;
    void build(){
    	for(int i=0;i<26;i++){
    		if(t[1].ch[i])t[t[1].ch[i]].fail=1,q.push(t[1].ch[i]),t[1].in++;
    		else t[1].ch[i]=1;
    	}
    	while(!q.empty()){
    		int x=q.front();q.pop();
    		for(int i=0;i<26;i++){
    			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]),t[t[t[x].fail].ch[i]].in++;
    			else t[x].ch[i]=t[t[x].fail].ch[i];
    		}
    	}
    }
    void merge(){
    	int x=1;
    	for(int i=0;i<S;i++){
    		x=t[x].ch[s[i]-'a'];
    		t[x].tms++;
    	}
    }
    void topo(){
    	for(int i=1;i<=cnt;i++)if(!t[i].in)q.push(i);
    	while(!q.empty()){
    		int x=q.front();q.pop();
    		t[t[x].fail].tms+=t[x].tms;
    		t[t[x].fail].in--;
    		if(!t[t[x].fail].in)q.push(t[x].fail);
    		for(int i=0;i<t[x].fin.size();i++)occ[t[x].fin[i]]=t[x].tms;
    	}
    }
    int main(){
    	scanf("%d",&n);
    	for(int i=1;i<=n;i++)scanf("%s",s),S=strlen(s),ins(i);
    	build();
    	scanf("%s",s),S=strlen(s);
    	merge();
    	topo();
    	for(int i=1;i<=n;i++)printf("%d
    ",occ[i]);
    	return 0;
    } 
    
  • 相关阅读:
    ES6中的新特性
    怎样优化CPU
    es6 一些小知识
    ECMAScript有6种继承方式(实现继承)
    angula的factory service provider
    angular的继承作用域通信
    MVP和MVC的区别
    判断浏览器版本语句大全
    IScroll5+在ios、android点击(click)事件不兼容解决方法
    css中单位px和em,rem的区别
  • 原文地址:https://www.cnblogs.com/Troverld/p/12781036.html
Copyright © 2020-2023  润新知