參考文檔
http://www.hankcs.com/program/algorithm/implementation-and-analysis-of-aho-corasick-algorithm-in-java.html
簡介
Aho-Corasick算法簡稱AC算法,通過將模式串預處理為確定有限狀態自動機,掃描文本一遍就能結束。其復雜度為O(n),即與模式串的數量和長度無關。
思想
自動機按照文本字符順序,接受字符,並發生狀態轉移。這些狀態緩存了“按照字符轉移成功(但不是模式串的結尾)”、“按照字符轉移成功(是模式串的結尾)”、“按照字符轉移失敗”三種情況下的跳轉與輸出情況,因而降低了復雜度。
基本構造
AC算法中有三個核心函數,分別是:
-
success; 成功轉移到另一個狀態(也稱goto表或success表)
-
failure; 不可順着字符串跳轉的話,則跳轉到一個特定的節點(也稱failure表),從根節點到這個特定的節點的路徑恰好是失敗前的文本的一部分。
-
emits; 命中一個模式串(也稱output表)
舉例
以經典的ushers為例,模式串是he/ she/ his /hers,文本為“ushers”。構建的自動機如圖:
其實上圖省略了到根節點的fail邊,完整的自動機如下圖:
匹配過程
自動機從根節點0出發
-
首先嘗試按success表轉移(圖中實線)。按照文本的指示轉移,也就是接收一個u。此時success表中並沒有相應路線,轉移失敗。
-
失敗了則按照failure表回去(圖中虛線)。按照文本指示,這次接收一個s,轉移到狀態3。
-
成功了繼續按success表轉移,直到失敗跳轉步驟2,或者遇到output表中標明的“可輸出狀態”(圖中紅色狀態)。此時輸出匹配到的模式串,然后將此狀態視作普通的狀態繼續轉移。
算法高效之處在於,當自動機接受了“ushe”之后,再接受一個r會導致無法按照success表轉移,此時自動機會聰明地按照failure表轉移到2號狀態,並經過幾次轉移后輸出“hers”。來到2號狀態的路不止一條,從根節點一路往下,“h→e”也可以到達。而這個“he”恰好是“ushe”的結尾,狀態機就仿佛是壓根就沒失敗過(沒有接受r),也沒有接受過中間的字符“us”,直接就從初始狀態按照“he”的路徑走過來一樣(到達同一節點,狀態完全相同)。
構造過程
看來這三個表很厲害,不過,它們是怎么計算出來的呢?
goto表
很簡單,了解一點trie樹知識的話就能一眼看穿,goto表就是一棵trie樹。把上圖的虛線去掉,實線部分就是一棵trie樹了。
output表
output表也很簡單,與trie樹里面代表這個節點是否是單詞結尾的結構很像。不過trie樹只有葉節點才有“output”,並且一個葉節點只有一個output。下圖卻違背了這兩點,這是為什么呢?其實下圖的output會在建立failure表的時候進行一次拓充。
以上兩個表通過一個dfs就可以構造出來。關於trie樹的更詳細內容,請參考:《Ansj分詞雙數組Trie樹實現與arrays.dic詞典格式》,《Trie樹分詞》,《雙數組Trie樹(DoubleArrayTrie)Java實現》。
failure表
這個表是trie樹沒有的,加了這個表,AC自動機就看起來不像一棵樹,而像一個圖了。failure表是狀態與狀態的一對一關系,別看圖中虛線亂糟糟的,不過你仔細看看,就會發現節點只會發出一條虛線,它們嚴格一對一。
這個表的構造方法是:
-
首先規定與狀態0距離為1(即深度為1)的所有狀態的fail值都為0。
-
然后設當前狀態是S1,求fail(S1)。我們知道,S1的前一狀態必定是唯一的(剛才說的一對一),設S1的前一狀態是S2,S2轉換到S1的條件為接受字符C,測試S3 = goto(fail(S2), C)。
-
如果成功,則fail(S1) = goto(fail(S2), C) = S3。
-
如果不成功,繼續測試S4 = goto(fail(S3), C)是否成功,如此重復,直到轉換到某個有效的狀態Sn,令fail(S1) = Sn。
Java實現
原理誰都可以說幾句的,可是優雅健壯的代碼卻不是那么容易寫的。我考察了Git上幾個AC算法的實現,發現robert-bor的實現非常好。一趟代碼看下來,學到了不少設計上的知識。我fork了下來,針對Ascii做了優化,添加了中文注釋。
另外,我實現了基於雙數組Trie樹的AC自動機:《Aho Corasick自動機結合DoubleArrayTrie極速多模式匹配》。性能更高,內存可控。
開源項目
開源在https://github.com/hankcs/aho-corasick。
調用方法
1
2
3
4
5
6
7
|
Trie trie =
new
Trie();
trie.addKeyword(
"hers"
);
trie.addKeyword(
"his"
);
trie.addKeyword(
"she"
);
trie.addKeyword(
"he"
);
Collection<Emit> emits = trie.parseText(
"ushers"
);
System.out.println(emits);
|
輸出:
1
|
[2:3=he, 1:3=she, 2:5=hers]
|
此外,還有一些配置選項:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
/**
* 大小寫敏感
* @return
*/
public
Trie caseInsensitive()
{
this
.trieConfig.setCaseInsensitive(
true
);
return
this
;
}
/**
* 不允許模式串在位置上前后重疊
* @return
*/
public
Trie removeOverlaps()
{
this
.trieConfig.setAllowOverlaps(
false
);
return
this
;
}
/**
* 只匹配完整單詞
* @return
*/
public
Trie onlyWholeWords()
{
this
.trieConfig.setOnlyWholeWords(
true
);
return
this
;
}
|
org.ahocorasick.trie包
這里封裝了Trie樹,其中比較重要的類是Trie樹的節點State:
我重構了State,將其異化為UnicodeState和AsciiState類。其中UnicodeState類使用 Map<Character, State> 來儲存goto表,而AsciiState類使用數組 State[] success = new State[256]來儲存,這樣在Ascii表上面,AsciiState的匹配要稍微快一些,相應的在構建時會慢一些,內存占用也會多一些。
從對萬字的英語詞典的測試結果來看,AsciiState的確有那么一點優勢:
1
2
3
4
5
6
7
8
|
asciiTrie adding time:1013ms
unicodeTrie adding time:96ms
asciiTrie building time:903ms
unicodeTrie building time:312ms
asciiTrie parsing time:355ms
unicodeTrie parsing time:463ms
|
org.ahocorasick.interval包
這里封裝了一棵線段樹,關於線段樹的介紹請查看:線段樹。
線段樹用於修飾最后的匹配結果,匹配結果中有一些可能會重疊,比如she和he,這棵線段樹對匹配結果(一系列區間)進行索引,能夠在log(n)時間內判斷一個區間與另一個是否重疊。詳細的實現請看代碼,都有中文注釋,應該很好懂。
基於雙數組Trie樹的Aho Corasick自動機
AC自動機能高速完成多模式匹配,然而具體實現聰明與否決定最終性能高低。大部分實現都是一個Map<Character, State>了事,無論是TreeMap的對數復雜度,還是HashMap的巨額空間復雜度與哈希函數的性能消耗,都會降低整體性能。
雙數組Trie樹能高速O(n)完成單串匹配,並且內存消耗可控,然而軟肋在於多模式匹配,如果要匹配多個模式串,必須先實現前綴查詢,然后頻繁截取文本后綴才可多匹配,這樣一份文本要回退掃描多遍,性能極低。
如果能用雙數組Trie樹表達AC自動機,就能集合兩者的優點,得到一種近乎完美的數據結構。具體實現請參考《Aho Corasick自動機結合DoubleArrayTrie極速多模式匹配》。
Reference
部分圖片和介紹來自:
http://www.cnblogs.com/zzqcn/p/3525636.html
http://blog.csdn.net/sealyao/article/details/4560427