深入理解Aho-Corasick自動機算法

0.前言

  我總是對那些具有狀態轉移過程的算法,心懷敬意。

  例如:遞歸、遞推、動規、DAT 以及現在要說的 AC 自動機算法。

  數學真是優美!

                                         —— 致那些牛逼到不行的數學家們


1.版權說明

商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
本文作者:Q-WHai
發表日期: 2015年10月24日
本文鏈接:http://blog.csdn.net/lemon_tree12138/article/details/49335051
來源:CSDN
更多內容:分類 >> 算法與數學


2.概述

  Aho-Corasick automaton(後面心均以AC代替),該算法在1975年產生于貝爾實驗室,是著名的多模匹配算法之一。

  AC自動機算法分爲3步:構造一棵Trie樹,構造失效指針和模式匹配過程。而這3步就是AC自動機算法的精髓所在,分別對應我們後面馬上要說的3個函數:success,failure和emits.


3.特別說明

3.0.學前導讀

  在學習本文之前,需要兩個方面的知識背景。一個是Trie樹,一個是KMP算法。大家可以移步到兩面的兩個鏈接中,學習一下。之後,回過頭來再看我們的AC自動機,就可以會比較容易消化,也能更容易理解其中的精髓。

  《數據結構:字典樹的基本使用

  《算法:模式匹配之KMP算法

3.1.本文參考

  《Aho-Corasick算法的Java實現與分析-碼農場


4.一睹爲快

  以經典的ushers爲例,模式串是he/ she/ his /hers,文本爲“ushers”。構建的自動機如圖:


5.原理說明

5.0.算法比較

  正如前面所說,AC算法是基於Trie樹且是KMP模式匹配算法的擴展。那麼這裏我們就可以從兩個方面來作爲切入點,詳細說明一下AC算法或是AC自動機究竟是何物。

  首先明白兩點:Trie樹的核心點是狀態轉移,KMP模式匹配的核心點是減少重複匹配

  先說Trie樹吧。在之前的博客中,我還是用了很多的篇幅來說Trie樹,不算這一篇的話,也有3篇文章或多或少都和Trie樹扯上點邊兒。前面的Trie樹中,每個節點既是字符本身,又是節點的狀態(DAT則不是這樣)。節點爲字符本身,這個好理解,那又是節點的狀態這個要怎麼解釋呢?因爲我們知道,當我們在遍歷的過程中,走到某一個點的時候,比如說:目前有兩個字典字符串:T1:"abcde"和T2:"abdef",當我們在遍歷的過程中走了"abcd"且停在了'd'字符上.這個時候,我們可以認定目前是處於字符串T1上的。因爲當前節點可以代表其狀態。而在T1和T2中,兩個'd'節點的狀態是不同的。而Trie樹的狀態轉移則可以理解爲,我們在遍歷到節點d的時候,動態確定節點d的下一個狀態,即節點e。

  再說說KMP模式匹配。在KMP模式匹配的過程中,我們使用到了一個next函數(如果你高興,也可以說這是一張next表)。next函數的作用是,當我們在匹配的過程中,發生了匹配失敗的時候,可以將模式串向前滑動n個字符,從而省去了n次的比較操作。而具體的操作方法及說明,我在之前的博客中也有介紹,這裏不再詳細說明。

  試想一下,如果我們要匹配一個文本文件d(舉例文件的目的是爲了說明,這個匹配字符串可能會是一個很長的字符串),使用Trie樹的匹配方式,依然需要對d進行循環遍歷,就像樸素模式匹配那樣。Trie樹減少的只是在Trie樹中重合的部分,所以時間複雜會相當高。那麼,KMP算法呢?對於KMP算法,我們要清楚一點。KMP算法是給模式串生成next函數,在多模式的情況下,我們需要生成很多的next函數,再對每個模式進行匹配。這顯然也並不理想。

  基於以上這兩點,我們的AC算法誕生了。

5.1.原理

  AC爲了克服Trie樹中無效匹配和KMP算法需要一個一個去匹配,設計了一種新的算法。算法中需要維護三個函數,分別是:

  success:從一個狀態成功轉移到另一個狀態(有時也叫goto表或是success表)。

  failure:從一個狀態按照普通流程會匹配失敗,這時我們要通過failure函數來做狀態跳轉。

  emits:命中一個模式串(也稱爲output表)。

  從上面的狀態轉移圖中就可以看出來,整個節點+實線就是success函數;而虛線就是failure函數;紅色節點則是emits函數。


6.代碼實現過程及說明

6.0.整體實現過程流程圖

  

6.1.創建Trie樹

  其實AC自動機是建立在Trie的基礎之上的,從上面的狀態轉移圖中就可以獲得這一信息。而在AC算法的3個函數中的success函數就是一種Trie樹。

/**
     * 構造一棵trie樹
     * 
     * @param trieConfig
     */
    public Trie(TrieConfig trieConfig) {
        this(trieConfig, true);
    }

    public Trie(TrieConfig trieConfig, boolean ascii) {
        this.trieConfig = trieConfig;
        if (ascii) {
            this.rootState = new AsciiState();
        } else {
            this.rootState = new UnicodeState();
        }
    }

6.2.success表的創建

  從上面我們知道,success函數的功能就是構建一個棵Trie樹。關鍵是如何構建,因爲這個Trie樹的構建和我們之前說的那並不完全相同。

  在AC算法中,我們把Trie樹中的節點就直接稱爲狀態(State).在創建狀態轉移表的過程中,則是利用了遞推的思想。我們在添加字典的過程中,其實是去計算當前字符對應的下一下狀態。詳細過程,請參見如下代碼:

/**
     * 轉移到下一個狀態
     *
     * @param character       希望按此字符轉移
     * @param ignoreRootState 是否忽略根節點,如果是根節點自己調用則應該是true,否則爲false
     * @return 轉移結果
     */
    private State nextState(Character character, boolean ignoreRootState) {
        State nextState = this.success.get(character);
        if (!ignoreRootState && nextState == null && this.rootState != null) {
            nextState = this.rootState;
        }
        return nextState;
    }

    @Override
    public State nextStateIgnoreRootState(Character character) {
        return nextState(character, true);
    }

    @Override
    public State addState(Character character) {
        State nextState = nextStateIgnoreRootState(character);
        if (nextState == null) {
            nextState = new UnicodeState(this.depth + 1);
            this.success.put(character, nextState);
        }
        return nextState;
    }

6.3.failure表的創建

  failure表的創建是一個廣度優先搜索的過程。在這個過程中,我們通過不斷遍歷狀態Trie樹。詳細編碼過程如下:

/**
     * 建立failure表
     */
    private void constructFailureStates() {
        Queue<State> queue = new LinkedBlockingDeque<State>();

        // 第一步,將深度爲1的節點的failure設爲根節點
        for (State depthOneState : this.rootState.getStates()) {
            depthOneState.setFailure(this.rootState);
            queue.add(depthOneState);
        }
        this.failureStatesConstructed = true;

        // 第二步,爲深度 > 1 的節點建立failure表,這是一個bfs
        while (!queue.isEmpty()) {
            State currentState = queue.remove();

            for (Character transition : currentState.getTransitions()) {
                State targetState = currentState.nextState(transition);
                queue.add(targetState);

                State traceFailureState = currentState.failure();
                while (traceFailureState.nextState(transition) == null) {
                    traceFailureState = traceFailureState.failure();
                }
                State newFailureState = traceFailureState.nextState(transition);
                targetState.setFailure(newFailureState);
                targetState.addEmit(newFailureState.emit());
            }
        }
    }

6.4.emits命中(output表的創建)

  關於output表的創建,其實跟Trie樹中的結束結點標誌很類似。都是在模式串的末尾對狀態進行修改的過程。而output表則是在狀態節點對象中以組合的方式來體現。

/**
     * 添加一個模式串
     * 
     * @param keyword
     */
    public void addKeyword(String keyword) {
        ...
        currentState.addEmit(keyword);
    }
    
    /**
     * 添加一個匹配到的模式串(這個狀態對應着這個模式串)
     *
     * @param keyword
     */
    public void addEmit(String keyword) {
        if (this.emits == null) {
            this.emits = new TreeSet<String>();
        }
        this.emits.add(keyword);
    }

7.GitHub 源碼

https://github.com/William-Hai/J-AhoCorasick

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