簡介:html
本文是博主自身對AC自動機的原理的一些理解和見解,主要以舉例的方式講解,同時又配以相應的圖片。代碼實現部分也予以明確的註釋,但願給你們不同的感覺。AC自動機主要用於多模式字符串的匹配,本質上是KMP算法的樹形擴展。這篇文章主要介紹AC自動機的工做原理,並在此基礎上用Java代碼實現一個簡易的AC自動機。 java
歡迎探討,若有錯誤敬請指正 算法
如需轉載,請註明出處 http://www.cnblogs.com/nullzx/數組
咱們如今考慮這樣一個問題,在一個文本串text中,咱們想找出多個目標字符串target1,target2,……出現的次數和位置。例如:求出目標字符串集合{"nihao","hao","hs","hsr"}在給定文本"sdmfhsgnshejfgnihaofhsrnihao"中全部可能出現的位置。解決這個問題,咱們通常的辦法就是在文本串中對每一個目標字符串單獨查找,並記錄下每次出現的位置。顯然這樣的方式可以解決問題,可是在文本串較大、目標字符串衆多的時候效率比較低。爲了提升效率,貝爾實驗室於1975年發明著名的多模字符串匹配算法——AC自動機。AC自動機在實現上要依託於Trie樹(也稱字典樹)並借鑑了KMP模式匹配算法的核心思想。實際上你能夠把KMP算法當作每一個節點都僅有一個孩子節點的AC自動機。ui
2.1 初識AC自動機this
AC自動機的基礎是Trie樹。和Trie樹不一樣的是,樹中的每一個結點除了有指向孩子的指針(或者說引用),還有一個fail指針,它表示輸入的字符與當前結點的全部孩子結點都不匹配時(注意,不是和該結點自己不匹配),自動機的狀態應轉移到的狀態(或者說應該轉移到的結點)。fail指針的功能能夠類比於KMP算法中next數組的功能。spa
咱們如今來看一個用目標字符串集合{abd,abdk, abchijn, chnit, ijabdf, ijaij}構造出來的AC自動機.net
上圖是一個構建好的AC自動機,其中根結點不存儲任何字符,根結點的fail指針爲null。虛線表示該結點的fail指針的指向,全部表示字符串的最後一個字符的結點外部都用紅圈表示,咱們稱該結點爲這個字符串的終結結點。每一個結點實際上都有fail指針,但爲了表示方便,本文約定一個原則,即全部指向根結點的 fail虛線都未畫出。3d
從上圖中的AC自動機,咱們能夠看出一個重要的性質:每一個結點的fail指針表示由根結點到該結點所組成的字符序列的全部後綴 和 整個目標字符串集合(也就是整個Trie樹)中的全部前綴 二者中最長公共的部分。指針
好比圖中,由根結點到目標字符串「ijabdf」中的 ‘d’組成的字符序列「ijabd」的全部後綴在整個目標字符串集{abd,abdk, abchijn, chnit, ijabdf, ijaij}的全部前綴中最長公共的部分就是abd,而圖中d結點(字符串「ijabdf」中的這個d)的fail正是指向了字符序列abd的最後一個字符。
2.2 AC自動機的運行過程:
1)表示當前結點的指針指向AC自動機的根結點,即curr = root
2)從文本串中讀取(下)一個字符
3)從當前結點的全部孩子結點中尋找與該字符匹配的結點,
若成功:判斷當前結點以及當前結點fail指向的結點是否表示一個字符串的結束,如果,則將文本串中索引發點記錄在對應字符串保存結果集合中(索引發點= 當前索引-字符串長度+1)。curr指向該孩子結點,繼續執行第2步
若失敗:執行第4步。
4)若fail == null(說明目標字符串中沒有任何字符串是輸入字符串的前綴,至關於重啓狀態機)curr = root, 執行步驟2,
不然,將當前結點的指針指向fail結點,執行步驟3)
如今,咱們來一個具體的例子加深理解,初始時當前結點爲root結點,咱們如今假設文本串text = 「abchnijabdfk」。
圖中的實曲線表示了整個搜索過程當中的當前結點指針的轉移過程,結點旁的文字表示了當前結點下讀取的文本串字符。好比初始時,當前指針指向根結點時,輸入字符‘a’,則當前指針指向結點a,此時再輸入字符‘b’,自動機狀態轉移到結點b,……,以此類推。圖中AC自動機的最後狀態只是剛好回到根結點。
須要說明的是,當指針位於結點b(圖中曲線通過了兩次b,這裏指第二次的b,即目標字符串「ijabdf」中的b),這時讀取文本串字符下標爲9的字符(即‘d’)時,因爲b的全部孩子結點(這裏剛好只有一個孩子結點)中存在可以匹配輸入字符d的結點,那麼當前結點指針就指向告終點d,而此時該結點d的fail指針指向的結點又剛好表示了字符串「abc」的終結結點(用紅圈表示),因此咱們找到了目標字符串「abc」一次。這個過程咱們在圖中用虛線表示,但狀態沒有轉移到「abd」中的d結點。
在輸入完全部文本串字符後,咱們在文本串中找到了目標字符串集合中的abd一次,位於文本串中下標爲7的位置;目標字符串ijabdf一次,位於文本串中下標爲5的位置。
3.1 構造的基本方法
首先咱們將全部的目標字符串插入到Trie樹中,而後經過廣度優先遍歷爲每一個結點的全部孩子節點的fail指針找到正確的指向。
肯定fail指針指向的問題和KMP算法中構造next數組的方式一模一樣。具體方法以下
1)將根結點的全部孩子結點的fail指向根結點,而後將根結點的全部孩子結點依次入列。
2)若隊列不爲空:
2.1)出列,咱們將出列的結點記爲curr, failTo表示curr的fail指向的結點,即failTo = curr.fail
2.2) a.判斷curr.child[i] == failTo.child[i]是否成立,
成立:curr.child[i].fail = failTo.child[i],
不成立:判斷 failTo == null是否成立
成立: curr.child[i].fail == root
不成立:執行failTo = failTo.fail,繼續執行2.2)
b.curr.child[i]入列,再次執行再次執行步驟2)
若隊列爲空:結束
3.2 經過一個例子來理解構造AC自動機的原理
每一個結點fail指向的解決順序是按照廣度優先遍歷的順序完成的,或者說層序遍歷的順序進行的,也就是說咱們是在解決當前結點的孩子結點fail的指向時,當前結點的fail指針必定已指向了正確的位置。
爲了說明問題,咱們再次強調「每一個結點的fail指針表示:由根結點到該結點所組成的字符序列的全部後綴 和 整個目標字符串集合(也就是整個Trie樹)中的全部前綴 二者中最長公共的部分」。
以上圖所示爲例,咱們要解決結點x1的某個孩子結點y的fail的指向問題。已知x1.fail指向x2,依據x1結點的fail指針的含義,咱們可知紅色實線橢圓框內的字符序列必然相等,且表示了最長公共部分。依據y.fail的含義,若是x2的某個孩子結點和結點y表示的字符相等,那麼y.fail就該指向它。
若是x2的孩子結點中不存在結點y表示的字符,這個時候該怎麼辦?因爲x2.fail指向x3,根據x2.fail的含義,咱們可知綠色方框內的字符序列必然相等。顯然,若是x3的某個孩子結點和結點y表示的字符相等,那麼y.fail就該指向它。
若是x3的孩子結點中不存在結點y表示的字符,咱們能夠依次重複這個步驟,直到xi結點的fail指向null,這時說明咱們已經到了最頂層的根結點,這時,咱們只須要讓y.fail = root便可。
構造的過程的核心本質就是,已知當前結點的最長公共前綴的前提下,去肯定孩子結點的最長公共前綴。這徹底能夠類比於KMP算法的next數組的求解過程。
3.2.1 肯定圖中h結點fail指向的過程
如今咱們假設咱們要肯定圖中結點c的孩子結點h的fail指向。圖中每一個結點都應該有表示fail的虛線,但爲了表示方便,按照本文約定的原則,全部指向根結點的 fail虛線均未畫出。
左圖表示h.fail肯定以前, 右圖表示h.fail肯定以後
左圖中,藍色實線框住的結點的fail都已肯定。如今咱們應該怎樣找到h.fail的正確指向?因爲且結點c的fail已知(c結點爲h結點的父結點),且指向了Trie樹中全部前綴與字符序列‘a’‘b’‘c’的全部後綴(「bc」和「c」)的最長公共部分。如今咱們要解決的問題是目標字符串集合的全部前綴中與字符序列‘a’‘b’‘c’ ‘h’的全部後綴的最長公共部分。顯然c.fail指向的結點的孩子結點中存在結點h,那麼h.fail就應該指向c.fail的孩子結點h,因此右圖表示了h.fail肯定後的狀況。
3.2.2 肯定圖中i.fail指向的過程
左圖表示i.fail肯定以前, 右圖表示i.fail肯定以後
肯定i.fail的指向時,顯然h.fail(h指圖中i的父結點的那個h)已指向了正確的位置。也就是說咱們如今知道了目標字符串集合全部前綴中與字符序列‘a’‘b’‘c’ ‘h’的全部後綴在Trie樹中的最長前綴是‘c’‘h’。顯然從圖中可知h.fail的孩子結點是沒有i結點(這裏h.fail只有一個孩子結點n)。字符序列‘c’‘h’的全部後綴在Trie樹中的最長前綴可由h.fail的fail獲得,而h.fail的fail指向root(依據本博客中畫圖的原則,這條fail虛線並未畫出),root的孩子結點中存在表示字符i的結點,因此結果如右圖所示。
在知道i.fail的狀況下,你們能夠嘗試在紙上畫出j.fail的指向,以加深AC自動機構造過程的理解。
package datastruct; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map.Entry; public class AhoCorasickAutomation { /*本示例中的AC自動機只處理英文類型的字符串,因此數組的長度是128*/ private static final int ASCII = 128; /*AC自動機的根結點,根結點不存儲任何字符信息*/ private Node root; /*待查找的目標字符串集合*/ private List<String> target; /*表示在文本字符串中查找的結果,key表示目標字符串, value表示目標字符串在文本串出現的位置*/ private HashMap<String, List<Integer>> result; /*內部靜態類,用於表示AC自動機的每一個結點,在每一個結點中咱們並無存儲該結點對應的字符*/ private static class Node{ /*若是該結點是一個終點,即,從根結點到此結點表示了一個目標字符串,則str != null, 且str就表示該字符串*/ String str; /*ASCII == 128, 因此這裏至關於128叉樹*/ Node[] table = new Node[ASCII]; /*當前結點的孩子結點不能匹配文本串中的某個字符時,下一個應該查找的結點*/ Node fail; public boolean isWord(){ return str != null; } } /*target表示待查找的目標字符串集合*/ public AhoCorasickAutomation(List<String> target){ root = new Node(); this.target = target; buildTrieTree(); build_AC_FromTrie(); } /*由目標字符串構建Trie樹*/ private void buildTrieTree(){ for(String targetStr : target){ Node curr = root; for(int i = 0; i < targetStr.length(); i++){ char ch = targetStr.charAt(i); if(curr.table[ch] == null){ curr.table[ch] = new Node(); } curr = curr.table[ch]; } /*將每一個目標字符串的最後一個字符對應的結點變成終點*/ curr.str = targetStr; } } /*由Trie樹構建AC自動機,本質是一個自動機,至關於構建KMP算法的next數組*/ private void build_AC_FromTrie(){ /*廣度優先遍歷所使用的隊列*/ LinkedList<Node> queue = new LinkedList<Node>(); /*單獨處理根結點的全部孩子結點*/ for(Node x : root.table){ if(x != null){ /*根結點的全部孩子結點的fail都指向根結點*/ x.fail = root; queue.addLast(x);/*全部根結點的孩子結點入列*/ } } while(!queue.isEmpty()){ /*肯定出列結點的全部孩子結點的fail的指向*/ Node p = queue.removeFirst(); for(int i = 0; i < p.table.length; i++){ if(p.table[i] != null){ /*孩子結點入列*/ queue.addLast(p.table[i]); /*從p.fail開始找起*/ Node failTo = p.fail; while(true){ /*說明找到了根結點尚未找到*/ if(failTo == null){ p.table[i].fail = root; break; } /*說明有公共前綴*/ if(failTo.table[i] != null){ p.table[i].fail = failTo.table[i]; break; }else{/*繼續向上尋找*/ failTo = failTo.fail; } } } } } } /*在文本串中查找全部的目標字符串*/ public HashMap<String, List<Integer>> find(String text){ /*建立一個表示存儲結果的對象*/ result = new HashMap<String, List<Integer>>(); for(String s : target){ result.put(s, new LinkedList<Integer>()); } Node curr = root; int i = 0; while(i < text.length()){ /*文本串中的字符*/ char ch = text.charAt(i); /*文本串中的字符和AC自動機中的字符進行比較*/ if(curr.table[ch] != null){ /*若相等,自動機進入下一狀態*/ curr = curr.table[ch]; if(curr.isWord()){ result.get(curr.str).add(i - curr.str.length()+1); } /*這裏很容易被忽視,由於一個目標串的中間某部分字符串可能正好包含另外一個目標字符串, * 即便當前結點不表示一個目標字符串的終點,但到當前結點爲止可能剛好包含了一個字符串*/ if(curr.fail != null && curr.fail.isWord()){ result.get(curr.fail.str).add(i - curr.fail.str.length()+1); } /*索引自增,指向下一個文本串中的字符*/ i++; }else{ /*若不等,找到下一個應該比較的狀態*/ curr = curr.fail; /*到根結點還未找到,說明文本串中以ch做爲結束的字符片斷不是任何目標字符串的前綴, * 狀態機重置,比較下一個字符*/ if(curr == null){ curr = root; i++; } } } return result; } public static void main(String[] args){ List<String> target = new ArrayList<String>(); target.add("abcdef"); target.add("abhab"); target.add("bcd"); target.add("cde"); target.add("cdfkcdf"); String text = "bcabcdebcedfabcdefababkabhabk"; AhoCorasickAutomation aca = new AhoCorasickAutomation(target); HashMap<String, List<Integer>> result = aca.find(text); System.out.println(text); for(Entry<String, List<Integer>> entry : result.entrySet()){ System.out.println(entry.getKey()+" : " + entry.getValue()); } } }
運行結果以下,從結果中咱們能夠看出文本串中bcd出現了二次,分別是文本串下標爲3和下標爲13的位置,……。
bcabcdebcedfabcdefababkabhabk bcd : [3, 13] cdfkcdf : [] cde : [4, 14] abcdef : [12] abhab : [23]
[1] AC自動機算法