AC自動機學習筆記-1(怎么造一台AC自動機?)


月更博主又來送溫暖啦QwQ

今天我們學習的算法是AC自動機。AC自動機是解決字符串多模匹配問題的利器,而且代碼也十分好打=w=

在這一篇博客里,我將講解AC自動機是什么,以及怎么構建一個最朴素的AC自動機。(不知道為什么我寫出來的AC自動機常數就是大得要命=。=)

前置知識

首先你一定要對Trie樹以及KMP了如指掌,尤其是要明白KMP中失配數組(next或fail數組)的本質:利用已經匹配過的部分,跳過重復的匹配,達到快速匹配的目的。

AC自動機是什么

大家都知道KMP可以用於在一個大字符串(文本串)中尋找另一個小的字符串(模式串),那么如果有n個模式串,要你把它們全部在文本串中找出來呢?當然,我們可以做n次KMP(別小瞧30分哦),但是其效率並不能差強人意。這個時候,我們可以嘗試把模式串做成Trie樹,似乎可以提高效率。

比如說,我們有5個模式串:she,shr,say,he,her,那么它們所建出來的Trie樹應該是長成這樣的:(紅色節點表示單詞的結尾)

那么,怎么用它來匹配呢?如果我們把文本串的每一個點都作為起點放到Tire樹上匹配,它的復雜度將會是...我要你Tire樹有何用(╯‵□′)╯︵┻━┻

既然這樣,那么如果只把文本串的第一個字符為起點,會發生什么呢?

你以為會是這樣的:

完美!

然而實際上卻是這樣的:

問題很明顯,當我們匹配完she時,he其實也被匹配到了。所以我們希望這棵Trie樹上能夠加點東西,讓它可以達到下面的效果:

上圖中,紅色的箭頭就是失配指針——fail指針。它表示文本串在當前節點失配后,我們應該到哪個節點去繼續匹配。很顯然,對於每個節點,我們要找到這個節點-代表的字符串-在樹上所有的節點-表示的字符串中-能找到的最長的后綴,意思就是“我當前匹配到了這個點,我也相當於匹配到了的節點(中的深度最大的節點)。”比如說,在我舉的例子中,當我們匹配到了she時,我們在樹上走的路徑也包含了he,he是she的一個后綴。我們在she上失配,至少說明我們已經匹配到了he,於是就可以跳到代表he的節點上繼續匹配。

到這里,你是不是發現fail指針和KMP中的next指針簡直一毛一樣?它們都被稱為“失配指針”。將Trie樹上的每一個點都加上fail指針,它就變成了AC自動機。AC自動機其實就是Trie+KMP,它可以用來解決在文本串中尋找很多模式串,即多模匹配問題。

對於一開始的5個單詞,它們所構建出的AC自動機就長這樣(沒有畫出紅色箭頭的點,其fail指針都指向根節點):

如何構建AC自動機

顯然,我們要做的就是快速地求出所有點的fail指針。我們以bfs的順序依次求出每個節點的fail,這樣,當我們要求一個節點的fail時,它的父親的fail肯定已經求出來了。若當前節點為A,其父節點為B,B的fail為C,那么C所代表的字符串一定是B的最長的后綴。如果C有一個兒子D的字符與A的字符等同,那么顯然D所代表的串(C加一個字符)就是A所代表的串(B加一個字符)的最長后綴。如果C沒有一個兒子,使其字符與A的字符等同呢?很簡單,只需要再訪問C的fail就行了。如此反復,直到A的最長后綴找到,或者A的fail指向根節點為止。(A在Trie樹中沒有后綴,乖乖回到根重新匹配吧!)

為了解釋得更清楚,我舉一個例子。下面這幅圖是我根據別的地方的圖重新畫的(n次轉載?),出處我沒找到_(:з」∠)_。節點是根據bfs序標號的。

步驟:

  1. 為了少一些特判,設置一個輔助根節點0號節點,0號節點的所有兒子都指向真正的根節點1號節點,然后將1號節點的fail指向0號節點。
  2. 找到2號節點的父親節點的fail節點0號節點,看0號節點有沒有為a的子節點。有,於是2號節點的fail指向1號節點。
  3. 找到3號節點的父親節點的fail節點0號節點,看0號節點有沒有為b的子節點。有,於是3號節點的fail指向1號節點。
  4. 找到4號節點的父親節點的fail節點1號節點,看1號節點有沒有為b的子節點。有,於是4號節點的fail指向3號節點。
  5. 同上。
  6. 同上。
  7. 同上。
  8. 找到8號節點的父親節點的fail節點5號節點,看5號節點有沒有為b的子節點。沒有,於是再找到5號節點的fail節點2號節點,看2號節點有沒有為b的子節點。有,於是8號節點的fail指向4號節點。

這樣,一個AC自動機就做好了。

注意到由於輔助節點的存在,我們不需要做任何特判,在樹上沒有后綴的節點的fail指針會自動連向根節點。

構建fail指針的代碼:

void build()
{
	for(int i=0;i<26;++i)ch[0][i]=1;
    fail[1]=0;
    queue<int>q;
    q.push(1);
    while(!q.empty())
    {
    	int x=q.front();q.pop();
        for(int i=0;i<26;++i)
        {
        	int c=ch[x][i];
            if(!c)continue;
            int fa=fail[x];
            while(fa&&!ch[fa][i])fa=fail[fa];
            fail[c]=ch[fa][i];
            q.push(c);
        }
    }
}

如何利用AC自動機來查找

這個問題似乎顯而易見,只要根據文本串的內容沿着Trie樹的邊往下走就行了,一失配就沿着fail邊向上跳。

。。。

我在被大佬虐飛之前也是這么想的QwQ

fail邊不只是失配指針這么簡單,如果你像我剛才說的那么做的話,你就可能會面臨下面這樣的問題:

為了不讓這種事情發生,我們每遇到一個fail指針就必須向上跳到頂,以保證不會漏過任何一個子串,就像這樣:

當然,這樣未免也太蠢了,於是這里又有一個小優化:如果一個節點的fail指向一個結尾節點,那么這個點也成為一個(偽)結尾節點。在匹配時,如果遇到結尾節點,就進行相應的計數處理。

進行匹配的代碼:

void print(int x)
{
	while(x)
    {
    	if(end[x])
        {
			//計數、打印等等,視題目要求而定
        }
        x=fail[x];
    }
}

void match(char *s)
{
	int len=strlen(s),now=1;
    for(int i=0;i<len;++i)
    {
    	int id=s[i]-'a';
        while(now&&!ch[now][id])now=fail[now];
        now=ch[now][id];
        if(end[now]||en[now])print(now);
        //en[now]即為偽結尾標記
    }
}

//記得在build中加上這句話
void build()
{
	...
    if(end[fail[c]]||en[fail[c]])en[c]=1;
    ...
}

一個被我們忽略的問題

時間復雜度???

設模式串平均長度為 $ l $ ,建樹復雜度為 $ O(nl) $ ,構建fail指針為 $ O(nl) $ ,匹配時因為每次都要跳fail邊,復雜度上界可以達到 $ O(ml) $ ,所以總復雜度為 $ O((n+m)l) $ !

這和暴力有什么區別(╯°Д°)╯︵┻━┻???

雖然說,這個上界應該是十分松的,但是我們想要的是能跑 $ 1e6 $ 的速度!

這個時候我們就需要優化了。。。然而我已經沒時間寫辣QwQ!這些就留到下一篇博客吧!

謝謝你的資瓷啦QwQ!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM