經典算法題10-AhoChorasick

引入

現在我們有需求了,我要檢查一篇文章中是否有某些敏感詞,這其實就是多模式匹配的問題。當然你可以用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,我要做的就是哪些模式串在主串中出現過?

  1. 構建trie樹

    如果看過我前面的文章,構建trie樹還是很容易的。
    這裏寫圖片描述

  2. 失敗指針

    構建失敗指針是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

結果

這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章