引入
現在我們有需求了,我要檢查一篇文章中是否有某些敏感詞,這其實就是多模式匹配的問題。當然你可以用KMP(Knuth-Morris-Pratt algorithm)算法求出,那麼它的時間複雜度爲O(c*(m+n)),c:爲模式串的個數。m:爲模式串的長度,n:爲正文的長度,那麼這個複雜度就不再是線性了,我們學算法就是希望能把要解決的問題優化到極致,這不,AhoChorasick自動機就派上用場了。
其實AC自動機就是Trie樹的一個活用,活用點就是灌輸了kmp的思想,從而再次把時間複雜度優化到線性的O(N),剛好我前面的文章已經說過了Trie樹和KMP,這裏不再贅述。
思路說明
同樣我也用網上的經典例子,現有say she shr he her 這樣5個模式串,主串爲yasherhs,我要做的就是哪些模式串在主串中出現過?
構建trie樹
如果看過我前面的文章,構建trie樹還是很容易的。
失敗指針
構建失敗指針是AC自動機的核心所在,玩轉了它也就玩轉了AC自動機,失敗指針非常類似於KMP中的next數組,也就是說,當我的主串在trie樹中進行匹配的時候,如果當前節點不能再繼續進行匹配,那麼我們就會走到當前節點的failNode節點繼續進行匹配,構建failnode節點也是很流程化的。
①:root節點的子節點的failnode都是指向root。
②:當走到在“she”中的”h“節點時,我們給它的failnode設置什麼呢?此時就要走該節點(h)的父節點(s)的失敗指針,一直回溯直到找到某個節點的孩子節點也是當初節點同樣的字符(h),沒有找到的話,其失敗指針就指向root。舉個栗子,比如:h節點的父節點爲s,s的failnode節點爲root,走到root後繼續尋找子節點爲h的節點,恰好我們找到了,(假如還是沒有找到,則繼續走該節點的failnode,嘿嘿,是不是很像一種回溯查找),此時就將 ”she”中的“h”節點的fainode”指向”her”中的“h”節點,好,原理其實就是這樣。(看看你的想法是不是跟圖一樣)
針對圖中紅線的”h,e“這兩個節點,我們想起了什麼呢?對”her“中的”e“來說,e到root距離的n個字符恰好與”she“中的e向上的n個字符相等,我也非常類似於kmp中next函數,當字符失配時,next數組中記錄着下一次匹配時模式串的起始位置。
代碼詮釋
Trie樹節點
public TrieNode trieNode = new TrieNode();
/// <summary>
/// 用光搜的方法來構建失敗指針
/// </summary>
public Queue<TrieNode> queue = new Queue<TrieNode>();
/// <summary>
/// Trie樹節點
/// </summary>
public class TrieNode {
/// <summary>
/// 26個字符,也就是26叉樹
/// </summary>
public TrieNode[] childNodes;
/// <summary>
/// 詞頻統計
/// </summary>
public int freq;
/// <summary>
/// 記錄該節點的字符
/// </summary>
public char nodeChar;
/// <summary>
/// 失敗指針
/// </summary>
public TrieNode faliNode;
/// <summary>
/// 插入記錄時的編號id
/// </summary>
public HashSet<Integer> hashSet = new HashSet<Integer>();
/// <summary>
/// 初始化
/// </summary>
public TrieNode() {
childNodes = new TrieNode[26];
freq = 0;
}
}
剛纔我也說到了parent和current兩個節點,在給trie中的節點賦failnode的時候,如果採用深度優先的話還是很麻煩的,因爲我要實時記錄當前節點的父節點,相信寫過樹的朋友都清楚,除了深搜,我們還有廣搜。
構建失敗指針
(這裏我們採用BFS的做法)
/// <summary>
/// 構建失敗指針(這裏我們採用BFS的做法)
/// </summary>
/// <param name="root"></param>
public void BuildFailNodeBFS(TrieNode root) throws InterruptedException {
//根節點入隊
queue.enqueue(root);
while (!queue.isEmpty()) {
//出隊
TrieNode temp = queue.dequeue();
//失敗節點
TrieNode failNode = null;
//26叉樹
for (int i = 0; i < 26; i++) {
//代碼技巧:用BFS方式,從當前節點找其孩子節點,此時孩子節點
//的父親正是當前節點,(避免了parent節點的存在)
if (temp.childNodes[i] == null)
continue;
//如果當前是根節點,則根節點的失敗指針指向root
if (temp == root) {
temp.childNodes[i].faliNode = root;
} else {
//獲取出隊節點的失敗指針
failNode = temp.faliNode;
//沿着它父節點的失敗指針走,一直要找到一個節點,直到它的兒子也包含該節點。
while (failNode != null) {
//如果不爲空,則在父親失敗節點中往子節點中深入。
if (failNode.childNodes[i] != null) {
temp.childNodes[i].faliNode = failNode.childNodes[i];
break;
}
//如果無法深入子節點,則退回到父親失敗節點並向root節點往根部延伸,直到null
//(一個回溯再深入的過程,非常有意思)
failNode = failNode.faliNode;
}
//等於null的話,指向root節點
if (failNode == null)
temp.childNodes[i].faliNode = root;
}
queue.enqueue(temp.childNodes[i]);
}
}
}
模式匹配
所有字符在匹配完後都必須要走failnode節點來結束自己的旅途,相當於一個迴旋,這樣做的目的防止包含節點被忽略掉。
舉個栗子
我匹配到了”she”,必然會匹配到該字符串的後綴”he”,要想在程序中匹配到,則必須節點要走失敗指針來結束自己的旅途。
從上圖中我們可以清楚的看到“she”的匹配到字符”e”後,從failnode指針撤退,在撤退途中將其後綴字符“e”收入囊腫,這也就是爲什麼像kmp中的next函數。
檢索
/// <summary>
/// 根據指定的主串,檢索是否存在模式串
/// </summary>
/// <param name="root"></param>
/// <param name="s"></param>
/// <returns></returns>
public void SearchAC(TrieNode root, String word, HashSet<Integer> hashSet) {
TrieNode head = root;
for (int i = 0; i < word.length(); i++) {
//計算位置
int index = word.charAt(i) - 'a';
//如果當前匹配的字符在trie樹中無子節點並且不是root,則要走失敗指針
//回溯的去找它的當前節點的子節點
while ((head.childNodes[index] == null) && (head != root))
head = head.faliNode;
//獲取該叉樹
head = head.childNodes[index];
//如果爲空,直接給root,表示該字符已經走完畢了
if (head == null)
head = root;
TrieNode temp = head;
//在trie樹中匹配到了字符,標記當前節點爲已訪問,並繼續尋找該節點的失敗節點。
//直到root結束,相當於走了一個迴旋。(注意:最後我們會出現一個freq=-1的失敗指針鏈)
while (temp != root && temp.freq != -1) {
//將找到的id追加到集合中
for(Integer k: temp.hashSet){
hashSet.add(k);
}
temp.freq = -1;
temp = temp.faliNode;
}
}
}
好了,到現在爲止,我想大家也比較清楚了。具體完整代碼見我的github。