2.多字符串匹配問題和Trie(字典樹):
對於多字符串匹配問題,我們一般會用hash(散列表)或者Trie(字典樹)儲存。
a.hash:
將字符串利用hash函數映射到對應的hash值,然後將字符串插入對應函數值點的儲存空間。(這裏不關於hash函數的選擇和具體實現方式。)
b.Trie:
這是一個樹,輸的節點有|{字符集}|個指針,如果一個單詞對應的字母的x後面有字母y,那麼他的y指針就指向一個新的節點。
上圖是一個集合{he,hers,his,she}構成的字典樹。
c.字典樹的定義:
字典樹的節點如下面,數據分爲兩部分;一部分是指針數組,用來指向單詞的下一個字母;另一部分是數據域,存儲單詞結尾的標記、單詞計數、或者是字符串之間映射的對應串。
- typedef struct node {
- type save;
- node* next[LETTE_SIZE];
- }tnode;
d.新單詞的插入:
從根節點開始查找,如果單詞當前字母指針不空,則沿着這個指針查找;如果爲空,則插入新的節點,沿着該節點方向查找。
- void insert( char* word, int l, type data )
- {
- tnode* now = root;
- for ( int i = 0 ; i < l ; ++ i ) {
- if ( !now->next[word[i]-'a'] )
- now->next[word[i]-'a'] = newnode();
- now = now->next[word[i]-'a'];
- }Deal(now->save, data);//處理相應操作
- }
新節點的插入:每次插入的新節點,初始化所有指針和數據域初始爲空。
- tnode* newnode()
- {
- for ( int i = 0 ; i < 26 ; ++ i )
- dict[size].next[i] = NULL;
- dict[size].word = NULL;
- return &dict[size ++];
- }
e.單詞查詢:
返回對應單詞結束位置節點的save即可。
- type query( char* word, int l )
- {
- tnode* now = root;
- for ( int i = 0 ; i < l ; ++ i ) {
- if ( !now->next[word[i]-'a'] )
- return false;
- now = now->next[word[i]-'a'];
- }return now->save;
- }
3.AC自動機的介紹:
在此認爲大家已經有了 KMP算法以及Trie(字典樹)的基礎(如果,上面的講述不夠詳細,還請查看相關資料)。AC自動機可以理解爲 KMP算法的多模式串形式擴展。
那麼什麼是 AC自動機呢,通俗的說就是Trie的每個節點加上了一個fail指針,fail指針指向當前匹配失敗的跳轉位置,這就類似於KMP的next數組。
4.AC自動機的構造:
既然我們知道了 AC自動機是用來做什麼的,那麼我們就來說一說怎麼在 Trie上構造 AC自動機。
首先,我們看一下條轉時的條件,如同 KMP算法一樣, AC自動機在匹配時如果當前字符匹配失敗,那麼利用fail指針進行跳轉。由此可知如果跳轉,跳轉到的串的前綴,必爲跳轉前的模式串的後綴。由此可知,跳轉的新位置的深度一定小於跳之前的節點。所以我們可以利用 bfs在 Trie上面進行 fail指針的求解。
下面,是具體的構造過程(和KMP是一樣的)。首先 root節點的fail定義爲空,然後每個節點的fail都取決自己的父節點的fail指針,從父節點的fail出發,直到找到存在這個字符爲邊的節點(向回遞歸),將他的孩子賦值給尋找節點。如果找不到就指向根節點,具體參照代碼:
- setfail()
- {
- Q[0] = root;
- root->fail = NULL;
- for ( int move = 0,save = 1 ; move < save ; ++ move ) {//利用bfs求解
- tnode* now = Q[move];
- for ( int i = 0 ; i < dictsize ; ++ i )
- if ( now->next[i] ) {
- tnode* p = now->fail;//從父節點的fail節點開始
- while ( p && !p->next[i] ) p = p->fail;//尋找本節點的fail節點
- now->next[i]->fail = p?p->next[i]:root;//不存在fail賦值爲root
- Q[save ++] = now->next[i];
- }
- }
- }
在前面的Trie建立了fail指針(虛線){其實,這兩個圖片上的字符應該是在邊上的,偷懶了,網上找的圖片,有時間回來自己做一下}
4.多串匹配:
既然已經構造好 AC自動機,下面就是寫出他的最常見的操作,多串匹配。其實匹配過程很簡單,利用Trie匹配字符串,如果失敗利用fail指針找到下次匹配的位置即可。具體參照代碼:
- query( char* line )
- {
- tnode* now = root;
- for ( int i = 0 ; line[i] ; ++ i ) {
- int index = ID( line[i] );//取得字符對應的邊的編號
- while ( now && !now->next[index] ) now = now->fail;//如果不能匹配,尋找fail指向的節點
- now = now?now->next[index]:root;//失敗時返回root,否則返回節點
- for ( tnode* p = now ; p ; p = p->fail )
- 判斷匹配
- }
- }
5.對於 AC自動機的改進:
通過匹配的過程我們可以看出,fail是用來尋找下次跳轉的位置的,跳轉時的 next一定是爲空的。那麼我們爲什麼不用這些 next指針直接指向下一個跳轉節點呢,那樣的話,匹配時每次去 next指針的對象即可。這個被稱作Trie圖,具體參照代碼:
- setfail()
- {
- Q[0] = root;
- root->fail = NULL;
- for ( int move = 0,save = 1 ; move < save ; ++ move ) {
- tnode* now = Q[move];
- for ( int i = 0 ; i < dictsize ; ++ i )
- if ( now->next[i] ) {
- tnode* p = now->fail;
- while ( p && !p->next[i] ) p = p->fail;
- now->next[i]->fail = p?p->next[i]:root;
- Q[save ++] = now->next[i];
- }else now->next[i] = now!=root?now->fail->next[i]:root;//其實只多了這一句
- }
- }
6.從自動機的角度理解:
自動機可以理解成一個有向圖,圖中的每個節點都代表一個狀態,邊上對應的是識別的字符,那麼每次識別一個字符就會發生一個狀態轉向另一個狀態。有一個初始狀態(root),很多個結束狀態(Trie中被標記的點)。
那麼我們的匹配過程就是從 root狀態出發,利用串的字符尋找下一個狀態,每走一步就吃掉一個字符,如果發現到達標記狀態則匹配成功。
這是一個自動機的示例,其中箭頭指向的是起始狀態(S),雙圈的代表結束狀態(C,D,E,F)
7.時間複雜度分析:
對於Trie的匹配來說時間複雜性爲:O(max(L(Pi))L(T))其中L串的長度函數,P是模式串,T是目標串。
對於 AC自動機來說時間複雜性爲:O(L(T)+max(L(Pi))+m)氣質m是模式串的數量。
對於 Trie 圖 來說時間複雜性爲:O(L(T))在此的時間複雜性都是指匹配的複雜度。
對於構造的代價是 O(sum(L(Pi)))其中sum是求和函數。
8.題目分析:
下面對於近期所做的 AC自動機的題目加以分類總結
a.模式匹配:這類問題一般都是統計目標串中模式串的個數。下面是oj中的題目編號,和說明:
hdu1686 Oulipo: 尋找模式串的出現次數,可以重複及覆蓋,直接求解
hdu2087 剪花布條: 同上
hdu2222 Keywords Search: 同上
hdu2896 病毒侵襲: 同上
hdu3065 病毒侵襲持續中: 同上,不過要注意非法字符直接返回root,否則會RE
hdu3336 Count thestring: 同上上
zoj3228 Searching the String: 同上,不過不允許覆蓋,記錄每個狀態的最晚結束位置即可
zoj3430 Detect the Virus: 同上上,統計很簡單,主要是編碼有點糾結
b.字符串統計:這類題目一般都是求解某種串個數,可先求解狀態轉移矩陣然後利用矩陣乘法或 DP求解
poj2778 DNA Sequence: 求解不包含某些子串的串的個數,利用AC自動機構造轉移矩陣,然後利用矩陣乘法求解路徑個數
hdu2243 考研路茫茫——單詞情結:上題的升級版,做法一樣,由於長度不定最後要利用快速冪和,有點糾結
zoj1540 Censored!: 題目和上面的類似,不過狀態過多不宜使用矩陣乘法,所以利用 DP求解
hdu2825 Wireless Password: 統計關鍵字不少於k的串的個數,並且每個只用一次,先利用狀態壓縮 DP統計
c.字符串構造(AC自動機+DP):其實本組和上一組基本相同,不過都是求最優解,所以單獨拿出來了,而且沒什麼共同點
zoj3013 Word Segmenting: 其實這個題目本來是用字典樹寫的,學了AC自動機之後就優化了一下,求解單詞覆蓋的最小失敗次數
poj3691 DNA repair: 求解將目標串取除某些串的最少操作,改變合法狀態時如果對應不同則+1,否則不變;非法則不轉移
zoj3545 Rescue the Rabbit: 這就是萬惡之源了,AC自動機就是爲了他學的,構造最優串,用狀態壓縮DP記錄轉移狀態,最後求解
hdu2296 Ring: 構造一個串使其權值最大長度最小,而且要字典序最小。有點糾結,DP長度短的優先,然後字典序
hdu3341 Lost's revenge: 傳說中的RE神題,由於狀態計算錯誤,導致RE2次,其實就是DP,不過要先將狀態分解在拼裝
zoj3190 Resource Archiver: 本次學習的收尾題目,傳說中的神題,要先構造AC自動機,然後利用最短路優化將問題轉化爲TSP問題
9.結束語:
AC自動機的總結到此結束,不久後還會更新,如果那裏有錯還請指出。
最後給出本人的 AC自動機的模板,和上面題目的更加詳細的題解和代碼,請在本空間搜索即可。
- /*
- AC自動機模板:針對具體問題需要對 query和 makeID操作行修改。
- 進行了狀態合併(如果一個串是另一個串的字串則值域可以合併),並且轉化爲 Tire圖,
- 一般情況下不再使用 fail指針,而是直接使用 next指針。
- 如果構造某種要求的串,可構造出不包含關鍵字的狀態轉移矩陣,然後利用矩陣乘法或 DP求解。
- */
- /* AC_DFA define */
- #define nodesize 100001 //節點個數
- #define dictsize 26 //字符集大小
- typedef struct node1
- {
- int flag; //值域
- int code; //狀態域
- node1* fail;
- node1* next[dictsize];
- }tnode;
- tnode dict[nodesize+1];
- tnode* Q[nodesize+1];
- int ID[256];
- class AC_DFA
- {
- private:
- int size;
- tnode* root;
- public:
- AC_DFA() {
- makeID();
- memset( dict, 0, sizeof( dict ) );
- root=NULL; size=0; root=newnode();
- }
- void makeID() {
- for ( int i = 0 ; i < 26 ; ++ i )
- ID['a'+i] = i;
- }
- void init() {
- memset( dict, 0, sizeof( dict ) );
- root=NULL; size=0; root=newnode();
- }
- tnode* newnode() {
- dict[size].code = size;
- dict[size].fail = root;
- return &dict[ size ++ ];
- }
- void insert( char* word, int l ) {
- tnode* now = root;
- for ( int i = 0 ; i < l ; ++ i ) {
- if ( !now->next[ID[word[i]]] )
- now->next[ID[word[i]]] = newnode();
- now = now->next[ID[word[i]]];
- }now->flag = 1;//now->flag ++ 用於統計
- }
- void setfail() {
- Q[0] = root; root->fail = NULL;
- for ( int move = 0,save = 1 ; move < save ; ++ move ) {
- tnode* now = Q[move];
- for ( int i = 0 ; i < dictsize ; ++ i )
- if ( now->next[i] ) {
- tnode* p = now->fail;
- while ( p && !p->next[i] ) p = p->fail;
- now->next[i]->fail = p?p->next[i]:root;
- //now->next[i]->flag += now->next[i]->fail->flag;//狀態合併,不能計數
- Q[save ++] = now->next[i];
- }else now->next[i] = now==root?root:now->fail->next[i];//構建 Trie圖
- }
- }
- int query( char* line, int L ) {//統計字串出現個數,可重複及交叉
- int sum = 0;
- tnode *temp,*now = root;
- for ( int i = 0 ; i < L ; ++ i ) {
- now = now->next[ID[line[i]]];
- temp = now;
- while (temp && temp->flag) {
- sum += temp->flag;
- temp = temp->fail;
- //temp->flag = 0 用於統計時相同的單詞只計數一次
- }
- }
- return sum;
- }
- };
- /* AC_DFA end */