簡介

本文是博主自身對AC自動機的原理的一些理解和看法,主要以舉例的方式講解,同時又配以相應的圖片。程式碼實現部分也予以明確的註釋,希望給大家不一樣的感受。AC自動機主要用於多模式字串的匹配,本質上是KMP演算法的樹形擴充套件。這篇文章主要介紹AC自動機的工作原理,並在此基礎上用Java程式碼實現一個簡易的AC自動機。   

歡迎探討,如有錯誤敬請指正

如需轉載,請註明出處 http://www.cnblogs.com/nullzx/

1. 應用場景—多模字串匹配

我們現在考慮這樣一個問題,在一個文字串text中,我們想找出多個目標字串target1,target2,……出現的次數和位置。例如:求出目標字串集合{"nihao","hao","hs","hsr"}在給定文字"sdmfhsgnshejfgnihaofhsrnihao"中所有可能出現的位置。解決這個問題,我們一般的辦法就是在文字串中對每個目標字串單獨查詢,並記錄下每次出現的位置。顯然這樣的方式能夠解決問題,但是在文字串較大、目標字串眾多的時候效率比較低。為了提高效率,貝爾實驗室於1975年發明著名的多模字串匹配演算法——AC自動機。AC自動機在實現上要依託於Trie樹(也稱字典樹)並借鑑了KMP模式匹配演算法的核心思想。實際上你可以把KMP演算法看成每個節點都僅有一個孩子節點的AC自動機。

2. AC自動機及其執行原理

2.1 初識AC自動機

AC自動機的基礎是Trie樹。和Trie樹不同的是,樹中的每個結點除了有指向孩子的指標(或者說引用),還有一個fail指標,它表示輸入的字元與當前結點的所有孩子結點都不匹配時(注意,不是和該結點本身不匹配),自動機的狀態應轉移到的狀態(或者說應該轉移到的結點)。fail指標的功能可以類比於KMP演算法中next陣列的功能。

我們現在來看一個用目標字串集合{abd,abdk, abchijn, chnit, ijabdf, ijaij}構造出來的AC自動機

clip_image002_thumb4

上圖是一個構建好的AC自動機,其中根結點不儲存任何字元,根結點的fail指標為null。虛線表示該結點的fail指標的指向,所有表示字串的最後一個字元的結點外部都用紅圈表示,我們稱該結點為這個字串的終結結點。每個結點實際上都有fail指標,但為了表示方便,本文約定一個原則,即所有指向根結點的 fail虛線都未畫出

從上圖中的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”。

clip_image004_thumb2

圖中的實曲線表示了整個搜尋過程中的當前結點指標的轉移過程,結點旁的文字表示了當前結點下讀取的文字串字元。比如初始時,當前指標指向根結點時,輸入字元‘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. 構造AC自動機的方法與原理

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指標一定已指向了正確的位置。

clip_image006_thumb4

為了說明問題,我們再次強調“每個結點的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虛線均未畫出

clip_image008_thumb4clip_image010_thumb3

左圖表示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指向的過程

clip_image012_thumb3clip_image014_thumb4

左圖表示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自動機構造過程的理解。

4. AC自動機的java程式碼實現

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]

5. 參考內容

[1] AC自動機演算法