首頁 > 軟體

Java資料結構之AC自動機演演算法的實現

2022-12-05 14:00:48

1 概念和原理

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

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

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

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自動機演演算法內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com