Java數據結構之AC自動機算法的實現

1 概念和原理

一般的字符串匹配算法都是匹配一個子串,例如KMP、Trie,那麼如果同時匹配多個子串呢?此時就需要用到AC自動機瞭。

AC自動機算法是一個多模式字符串匹配算法,在模式匹配領域被廣泛應用,例如違禁詞查找替換、搜索關鍵詞查找等等。

關於Trie樹和KMP算法,我們此前已經講解過瞭:

  • 前綴樹Trie的實現原理以及Java代碼的實現
  • KMP算法詳解以及Java代碼實現

AC自動機算法常被認為是Trie樹+KMP算法的結合體,為什麼呢?我們先看看它的構建步驟:

  • 對所有的關鍵詞構建Trie前綴樹。
  • 為Trie樹上的所有節點構建fail失配指針。

第一步,對所有的關鍵詞構建Trie前綴樹。這一步利用Trie的特點構建快速前綴查找結構,trie樹的特點是可以從字符串頭部開始匹配,並且相同前綴的詞共用前面的節點,因此它可以避免相同前綴pattern的重復匹配,但是對於相同的後綴無能為力。

第二步,為Trie樹上的所有節點構建fail失配指針,即匹配失敗後應該跳到哪個節點。所謂節點的失配指針,就是指向當前字符串最長真後綴位置的指針節點。這裡需要理解KMP的next數組的概念,這一步就是利用KMP前後綴匹配的思想實現關鍵詞前綴失配時利用相同的後綴信息快速跳轉到另一個關鍵詞繼續前綴匹配。他們的區別是:

  • 在KMP算法中,是針對單個關鍵詞匹配,求出的最長匹配長度的前綴和後綴都位於同一個關鍵詞內。例如關鍵詞abcdabc,最長匹配前後綴,為abc,他們屬於該關鍵詞。
  • 在AC自動機算法中,是針對多個關鍵詞匹配,求出的最長匹配長度的前綴和後綴分別屬於不同的關鍵詞的前綴和後綴。

例如兩個關鍵詞dabab,ababd,那麼關鍵詞dabab中第二個b位置的失敗指針應該指向關鍵詞ababd中的第二個b。即dabab的後綴與ababd的前綴能夠匹配的最長子串為abab。

2 節點定義

在這裡,我們給出一個比較簡單的節點的定義。

  • next,表示經過該節點的模式串的下層節點,這是Trie樹結構的保證,存儲著子節點的值到對應的節點的映射關系。
  • depth,表示以當前即誒單結尾的模式串的長度,也是節點的深度,默認為0。
  • failure,失配指針,其指向表示另一個關鍵詞前綴的最長後綴節點,如果當前節點沒有匹配到,則跳轉到此節點繼續匹配。如果當前節點匹配到瞭,那麼可以通過此指針找到該節點的模式串包含的最長後綴模式串。
class AcNode {
    /**
     * 經過該節點的模式串的下層節點
     */
    Map<Character, AcNode> next = new HashMap<>();

    /**
     * 模式串的長度,也是節點的深度
     */
    int depth;

    /**
     * 失配指針,如果沒有匹配到,則跳轉到此狀態。
     */
    AcNode failure;

    public boolean hashNext(char nextKey) {
        return next.containsKey(nextKey);
    }

    public AcNode getNext(char nextKey) {
        return next.get(nextKey);
    }
}

3 構建Trie前綴樹

構建Ac自動機的Trie的方法和構建普通Trie的方法幾乎一致。在添加每個模式串成功後,會為最後一個節點的depth賦值為當前模式串的長度。

/**
 * trie根節點
 */
private AcNode root;
/**
 * 加入模式串,構建Trie
 *
 * @param word 模式串,非空
 */
public void insert(String word) {
    AcNode cur = root;
    for (char c : word.toCharArray()) {
        if (!cur.next.containsKey(c)) {
            cur.next.put(c, new AcNode());
        }
        cur = cur.next.get(c);
    }
    cur.depth = word.length();
}

4 構建fail失配指針

構建fail失配指針的一種常見的方法如下,實際上是一個BFS層序遍歷的算法:

1.Trie的root節點沒有失配指針,或者說失配指針為null,其他節點都有失配指針,或者說不為null。

2.遍歷root節點的所有下一層直接子節點,將它們的失配指針設置為root。因為這些節點代表著所有模式串的第一個字符,基於KMP的next數組定義,單個字符沒有最長真後綴,此時直接指向root。

3.繼續循環向下遍歷每一層的子節點,由於bfs的遍歷,那麼上一層父節點的失配指針肯定都已經確定瞭。基於next數組的構建思想,子節點的失配指針可以通過父節點的是失配指針快速推導出來。設當前遍歷的節點為c,它的父節點為p,父節點的失配指針為pf。

  • 如果pf節點的子節點對應的字符中,包含瞭當前節點的所表示的字符。那麼基於求最長後綴的原理,此時c節點的失配指針可以直接指向pf節點下的相同字符對應的子節點。
  • 如果pf節點的子節點對應的字符中,沒有包含瞭當前節點的所表示的字符。那麼繼續獲取pf節點的失配指針節點,繼續重復判斷。直到滿足第一種情況,或者pf指向瞭根節點,並且根節點的子節點也沒有匹配,那麼此時直接將c節點的失配指針指向根節點。
/**
 * 為所有節點構建失配指針,一個bfs層序遍歷
 */
public void buildFailurePointer() {
    ArrayDeque<AcNode> queue = new ArrayDeque<AcNode>();
    //將所有root的直接子節點的failure設置為root,並且加入queue
    for (AcNode acNode : root.next.values()) {
        acNode.failure = root;
        queue.addLast(acNode);
    }
    //bfs構建失配指針
    while (!queue.isEmpty()) {
        //父節點出隊列
        AcNode parent = queue.pollFirst();
        //遍歷父節點的下層子節點,基於父節點求子節點的失配指針
        for (Map.Entry<Character, AcNode> characterAcNodeEntry : parent.next.entrySet()) {
            //獲取父節點的失配指針
            AcNode pf = parent.failure;
            //獲取子節點
            AcNode child = characterAcNodeEntry.getValue();
            //獲取子節點對應的字符
            Character nextKey = characterAcNodeEntry.getKey();
            //如果pf節點不為null,並且pf節點的子節點對應的字符中,沒有包含瞭當前節點的所表示的字符
            while (pf != null && !pf.hashNext(nextKey)) {
                //繼續獲取pf節點的失配指針節點,繼續重復判斷
                pf = pf.failure;
            }
            //pf為null,表示找到瞭根節點,並且根節點的子節點也沒有匹配
            if (pf == null) {
                //此時直接將節點的失配指針指向根節點
                child.failure = root;
            }
            //pf節點的子節點對應的字符中,包含瞭當前節點的所表示的字符
            else {
                //節點的失配指針可以直接指向pf節點下的相同字符對應的子節點
                child.failure = pf.getNext(nextKey);
            }
            //最後不要忘瞭,將當前節點加入隊列
            queue.addLast(child);
        }
    }
}

5 匹配文本

構建完AC自動機之後,下面我們需要進行文本的匹配,匹配的方式實際上比較簡單。

1.遍歷文本的每個字符,依次匹配,從Trie的根節點作為cur節點開始匹配:

2.將當前字符作為nextKey,如果cur節點不為null且節點的next映射中不包含nextKey,那麼當前cur節點指向自己的failure失配指針。

3.如果cur節點為null,說明當前字符匹配到瞭root根節點且失敗,那麼cur設置為root繼續從根節點開始進行下一輪匹配。

4.否則表示匹配成功的節點,cur指向匹配節點,獲取該節點繼續判斷:

  • 如果該節點是某個關鍵詞的結尾,那麼取出來,也就是depth不為0,那麼表示匹配到瞭一個關鍵詞。
  • 繼續判斷該節點的失配指針節點表示的模式串。因為失配指針節點表示的模式串是當前匹配的模式串的在這些關鍵詞中的最長後綴,且由於當前節點的路徑包括瞭失配指針的全部路徑,並且失配指針路徑也是一個完整的關鍵詞,需要找出來。
/**
 * 匹配文本
 *
 * @param text 文本字符串
 */
public List<ParseResult> parseText(String text) {
    List<ParseResult> parseResults = new ArrayList<>();
    char[] chars = text.toCharArray();
    //從根節點開始匹配
    AcNode cur = root;
    //遍歷字符串的每個字符
    for (int i = 0; i < chars.length; i++) {
        //當前字符
        char nextKey = chars[i];
        //如果cur不為null,並且當前節點的的子節點不包括當前字符,即不匹配
        while (cur != null && !cur.hashNext(nextKey)) {
            //那麼通過失配指針轉移到下一個節點繼續匹配
            cur = cur.failure;
        }
        //如果節點為null,說明當前字符匹配到瞭根節點且失敗
        //那麼繼續從根節點開始進行下一輪匹配
        if (cur == null) {
            cur = root;
        } else {
            //匹配成功的節點
            cur = cur.getNext(nextKey);
            //繼續判斷
            AcNode temp = cur;
            while (temp != null) {
                //如果當前節點是某個關鍵詞的結尾,那麼取出來
                if (temp.depth != 0) {
                    int start = i - temp.depth + 1, end = i;
                    parseResults.add(new ParseResult(start, end, new String(chars, start, temp.depth)));
                    //System.out.println(start + " " + end + " " + new String(chars, start, temp.depth));
                }
                //繼續判斷該節點的失配指針節點
                //因為失配指針節點表示的模式串是當前匹配的模式串的在這些關鍵詞中的最長後綴,且由於當前節點的路徑包括瞭失配指針的全部路徑
                //並且失配指針路徑也是一個完整的關鍵詞,需要找出來。
                temp = temp.failure;
            }
        }
    }
    return parseResults;
}

class ParseResult {
    int startIndex;
    int endIndex;
    String key;

    public ParseResult(int startIndex, int endIndex, String key) {
        this.startIndex = startIndex;
        this.endIndex = endIndex;
        this.key = key;
    }

    @Override
    public String toString() {
        return "{" +
                "startIndex=" + startIndex +
                ", endIndex=" + endIndex +
                ", key='" + key + '\'' +
                '}';
    }
}

6 案例演示

基於上面的方法,假如關鍵詞為:he、shes、shers、hes、h、e,那麼最終構建的AC自動機的結構如下(紅色圈表示某個關鍵詞的結束位置)。

假如我們的文本為sheshe,那麼我們來看看AC自動機匹配的過程:

遍歷文本,cur首先指向root。

nextKey=s,cur.next包含s,表示這是一個匹配成功的節點,那麼獲取到該節點s,cur=s,s不是某個關鍵詞的結尾,failure節點也不包含模式串,那麼查找完畢進行下一輪。

nextKey=h,cur=s,cur.next包含h,表示這是一個匹配成功的節點,那麼獲取到該節點h,cur=h,h節點不是某個關鍵詞的結尾,但是它的failure節點包含模式串h,因此找到瞭第1個匹配的關鍵詞h,查找完畢後進行下一輪。

nextKey=e,cur=h,cur.next包含e,表示這是一個匹配成功的節點,那麼獲取到該節點e,cur=e,e節點不是某個關鍵詞的結尾,但是它的failure節點包含模式串he,因此找到瞭第2個匹配的關鍵詞he。

而fuilure節點e又包含另一個模式串e,因此找到瞭第3個匹配的關鍵詞e,查找完畢後進行下一輪。

nextKey=s,cur=e,cur.next包含s,表示這是一個匹配成功的節點,那麼獲取到該節點e,cur=e,s節點是關鍵詞shes的結尾,因此找到瞭第4個匹配的關鍵詞shes。

繼續判斷s的failure,它的failure節點包含模式串hes,因此找到瞭第5個匹配的關鍵詞hes,查找完畢後進行下一輪。

nextKey=h,cur=s,cur.next不包含h,表示這是一個匹配失敗的節點,那麼繼續匹配它的failure節點,經過s-s-s的匹配,最終匹配到子節點h。

該節點h並不是關鍵詞的結尾,但是h的failure,它的failure節點包含模式串h,因此找到瞭第6個匹配的關鍵詞h,查找完畢後進行下一輪。

nextKey=e,cur=h,cur.next包含e,表示這是一個匹配成功的節點,那麼獲取到該節點e,cur=e,e節點不是某個關鍵詞的結尾,但是它的failure節點包含模式串he,因此找到瞭第7個匹配的關鍵詞he。

而fuilure節點e又包含另一個模式串e,因此找到瞭第8個匹配的關鍵詞e。

到此字符串遍歷完畢,查找完畢!

最終文本sheshe,匹配到關鍵詞如下:

[{startIndex=1, endIndex=1, key='h'}, {startIndex=1, endIndex=2, key='he'}, 
{startIndex=2, endIndex=2, key='e'}, {startIndex=0, endIndex=3, key='shes'}, 
{startIndex=1, endIndex=3, key='hes'}, {startIndex=4, endIndex=4, key='h'}, 
{startIndex=4, endIndex=5, key='he'}, {startIndex=5, endIndex=5, key='e'}]

7 總結

AC自動機匹配某個文本text,需要遍歷文本的每個字符,每次遍歷過程中,都可能涉及到循環向上查找失配指針的情況,但是這裡的循環次數不會超過Trie樹的深度,在最後匹配成功時,同樣涉及到向上查找失配指針的情況,這裡的循環次數不會超過Trie樹的深度。

設匹配的文本長度m,模式串平均長度n,那麼AC自動機算法的匹配的時間復雜度為O(m*n)。可以發現,匹配的時間復雜度和關鍵詞的數量無關,這就是AC自動機的強大之處。如果考慮模式串平均長度不會很長,那麼時間復雜度近似O(m)。

到此這篇關於Java數據結構之AC自動機算法的實現的文章就介紹到這瞭,更多相關Java AC自動機算法內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: