字符串匹配算法之 AC 自動機

2021-02-16 算法愛好者

我們經常用的字符串方法indexOf,都是判定兩個字符串的包含關係,底層使用類似KMP,BM, Sunday這樣的算法。如果我們要判斷一個長字符串是否包含多個短字符串呢?比如在一篇文章找幾個敏感詞,在DNA串中找幾個指定的基因對pattern進行預處理,如果我們的模式串存在多個,則不適合了,我們就需要用到一種多模式匹配算法。

最著名的多模式匹配算法為AC自動機,它是由貝爾實驗室的兩位研究人員 Alfred V. Aho 和 Margaret J.Corasick 於1975年發明的,幾乎與KMP算法同時問世,至今日仍然在模式匹配領域被廣泛應用。

AC自動機的核心算法仍然是尋找模式串內部規律,達到在每次失配時的高效跳轉。這一點與單模式匹配KMP算法是一致的。不同的是,AC算法尋找的是模式串之間的相同前綴關係。

在KMP算法中,對於模式串」abcabcacab」,我們知道非前綴子串abc(abca)cab是模式串的一個前綴(abca)bcacab,而非前綴子串ab(cabca)cab不是模式串abcabcacab的前綴,根據此點,我們構造了next數組,實現在匹配失敗時的跳轉。

而在多模式環境中,AC自動是使用前綴樹來存放所有模式串的前綴,然後通過失配指針來處理失配的情況。它大概分為三個步驟:構建前綴樹(生成goto表),添加失配指針(生成fail表),模式匹配(構造output表)。下面,我們拿模式集合[say, she, shr, he, her]為例,構建一個AC 自動機。

構建前綴樹

將模式串逐字符放進Trie樹。

class Trie {
      constructor() {
          this.root = new Node("root");
      }
      insert(word) {
          var cur = this.root;
          for (var i = 0; i < word.length; i++) {
              var c = word[i];
              var node = cur.children[c];
              if (!node) {
                  node = cur.children[c] = new Node(word[i]);
              }
              cur = node;
          }
          cur.pattern = word; //防止最後收集整個字符串用
          cur.endCount++; //這個字符串重複添加的次數
      }
  }
  function createGoto(trie, patterns) {
      for (var i = 0; i < patterns.length; i++) {
          trie.insert(patterns[i]);
      }
  }

然後我們嘗試用它處理字符串sher。理想情況下是這樣:

很遺憾,前綴樹只會順著某一路逕往下查找,最多到葉子節點折回樹節點,繼續選擇另一條路徑。因此我們需要添加一些橫向的路徑,在失配時,跳到另一個分支上繼續查找,保證搜索過的節點不會冗餘搜索。

添加失配指針

AC自動機的前綴樹的節點都應該存在fail指針。下圖中,紅色的箭頭就是失配指針。它表示文本串在當前節點失配後,我們應該到哪個節點去繼續匹配。

很顯然,對於每個節點,其失配指針應該指向其他子樹中的表示同一字符的那些節點,並且它與其子樹能構成剩下的最長後綴。即,我們要匹配sher, 我們已經在某一子樹中命中了sh,那麼我們希望能在另一個子樹中命中er。

到這裡,你是不是發現fail指針和KMP中的next指針簡直一毛一樣?它們都被稱為「失配指針」。將Trie樹上的每一個點都加上fail指針,它就變成了AC自動機。AC自動機其實就是Trie + KMP。

因此根據補上一些失配指針,我們的AC自動機應該長成這樣的。

現在的問題是,如何求fail指針?聯繫KMP算法的next數組的意義,容易發現root的每個兒子的fail都指向root(前綴和後綴是不會包含整個串的)。也就是上圖中root所連的s和h的fail都指向root。若已經求得sh所在點的fail,我們來考慮如何求she所在點的fail。根據sh所在點的fail得到h是sh的最長後綴,而h又有兒子e,因此she的最長後綴應該是he,其fail指針就指向he所在點。

概括AC自動機求fail指針的過程:

1.對整個字典樹進行寬度優先遍歷。
2.若當前搜索到點x,那麼對於x的第i個兒子(也就是代表字符i的兒子),一直往x的fail跳,直到跳到某個點也有i這個兒子,x的第i個兒子的fail就指向這個點的兒子i。

function createFail(ac) {
    var root = ac.root;
    var queue = [root]; //root所在層為第0層
    while (queue.length) {
        //廣度優先遍歷
        var node = queue.shift();
        if (node) {
            //將其孩子逐個加入列隊
            for (var i in node.children) {
                var child = node.children[i];
                if (node === root) {
                    child.fail = root; //第1層的節點的fail總是指向root
                } else {
                    var p = node.fail; //第2層以下的節點, 其fail是在另一個分支上
                    while (p) {
                        //遍歷它的孩子,看它們有沒與當前孩子相同字符的節點
                        if (p.children[i]) {
                            child.fail = p.children[i];
                            break;
                        }
                        p = p.fail;
                    }
                    if (!p) {
                        child.fail = root;
                    }
                }
                queue.push(child);
            }
        }
    }
}

模式匹配

我們從根節點開始查找,如果它的孩子能命中目標串的第1個字符串,那麼我們就從這個孩子的孩子中再嘗試命中目標串的第2個字符串。否則,我們就順著它的失配指針,跳到另一個分支,找其他節點。

如果都沒有命中,就從根節點重頭再來。

當我們節點存在表示有字符串在它這裡結束的標識時(如endCound, isEnd),我們就可以確認這字符串已經命中某一個模式串,將它放到結果集中。如果這時長字符串還沒有到盡頭,我們繼續收集其他模式串。

代碼如下:

function match(ac, text) {
    var root = ac.root, p = root, ret = [], unique = {};
    for (var i = 0; i < text.length; i++) {
        var c = text[i];
        while (!p.children[c] && p != root) {
            p = p.fail; // 失配指針發揮作用 by 司徒正美
        }
        p = p.children[c];
        if (!p) {
            p = root; // 如果沒有匹配的,從 root 開始重新匹配
        }
        var node = p;
        while (node != root) {
            //  收集出可以匹配的模式串
            if (node.endCount) {
                var pos = i - node.pattern.length + 1;
                console.log(`匹配模式串 ${node.pattern}其起始位置在${pos}`)
                if (!unique[node.pattern]) { //by 司徒正美
                    unique[node.pattern] = 1;
                    ret.push(node.pattern);
                }
            }
            node = node.fail;
        }
    }
    return ret;
}

var ac = new Trie();
createGoto(ac, ["she", "shr", "say", "he", "her"]);
createFail(ac);
console.log(match(ac, "one day she say her has eaten many shrimps"));

相關焦點

  • 字符串匹配的Rabin-Karp算法
    字符串匹配,是文本編輯器的常用算法。例如我常用的vim編輯器,它的s命令就可以查找文件裡的某個字符串並替換:%s/int/long/g就可以把所有的int關鍵字替換為long。sed、gawk等程序也需要這個功能,gawk會查找指定的分隔符,sed的s命令與vim的差不多。
  • [洛穀日報第44期]強勢圖解AC自動機
    AC自動機·Aho-Corasick_automation前置技能(宣傳一下自己的Blog)KMP字符串匹配(http://www.yaoyaojy.tk/index.phpAC自動機是一種有限狀態自動機(說了等於沒說),它常被用於多模式串的字符串匹配。在學完AC自動機,筆者也總結出一句說了等於沒說的話:AC自動機是以TRIE的結構為基礎,結合KMP的思想建立的。
  • 漫畫:什麼是字符串匹配算法?
    第二輪,我們把模式串後移一位,從主串的第二位開始,把主串和模式串的字符逐個比較:主串的第二位字符是b,模式串的第二位字符也是b,兩者匹配,繼續比較:主串的第三位字符是b,模式串的第三位字符也是c,兩者並不匹配。
  • Java數據結構與算法——字符串匹配相關算法
    Java裡用的是indexOf函數,其底層就是字符串匹配算法.其中字符串匹配算法主要包括:1. BF(暴力匹配)算法1.1概念和原理Brute Force叫作暴力匹配算法,也叫樸素匹配算法。其主要實現原理就是 在主串中,檢查起始位置分別是 0、1、2…n-m 且長度為 m 的 n-m+1 個子串,看有沒有跟模式串匹配的。
  • 漫畫:如何優化 「字符串匹配算法」?
    BF算法是如何工作的?正如同它的全稱BruteForce一樣,BF算法使用簡單粗暴的方式,對主串和模式串進行逐個字符的比較。比如給定主串和模式串如下:當模式串挪動到某個合適位置,逐個字符比較,發現每一位字符都是匹配時,比較結束:
  • KMP算法文章合集
    Kmp) KMP中next和nextval算法簡析 hihoCoder#1015_KMP算法 算法導論 KMP字符串匹配 Repeated Substring Pattern【重複的子字符串【KMP】】 改進的KMP算法 字符串匹配之有限自動機&kmp算法 KMP-字符串快速查找
  • 算法連載之求解不含有重複字符的最長子串長度
    問題給定一個字符串,找出其中不含有重複字符的最長子串長度。分析程序,匹配在給定字符串中是否有重複字符isRepeating函數的時間複雜度是O(n)。因此,完成算法的時間複雜度是O(n);空間複雜度是O(min(n, m)),其中n是字符串的長度,m是相異字符的數量。
  • "暴力"字符匹配算法的C語言實現
    本篇文章主要是為講解高效字符匹配算法的一則預告文,跟大家講講暴力字符匹配算法以及匹配算法在通信中如何使用。bug菌所說的暴力匹配算法就是C標準庫函數strstr(),如果你還沒有使用過或者沒有聽到過這個函數,那就有點.。
  • php字符串函數
    ,implode()函數的別名levenshtein — 計算兩個詞的差別大小localeconv — 獲取數字相關的格式定義ltrim — 去除字符串左側的空白或者指定的字符md5_file — 將一個文件進行MD5算法加密md5 — 將一個字符串進行MD5算法加密metaphone — 判斷一個字符串的發音規則
  • 經典leetcode算法題分享(字符串)
    關鍵是怎麼找到最裡層的有效括號,其實就是找到第一個右括號,然後判斷左邊的括號是否能匹配,能匹配的話就是最裡層的有效括號,然後刪除掉。刪除後,因為字符串的長度變短了兩位,所以我們把指針往左邊移動兩位即可。這樣遍歷下來,就只會遍歷一次。最後還是判斷字符串的長度。
  • 動態規劃之 KMP 算法詳解
    KMP 算法(Knuth-Morris-Pratt 算法)是一個著名的字符串匹配算法,效率很高,但是確實有點複雜。很多讀者抱怨 KMP 算法無法理解,這很正常,想到大學教材上關於 KMP 算法的講解,也不知道有多少未來的 Knuth、Morris、Pratt 被提前勸退了。
  • NOIP複賽知識點簡述及複賽算法總結!
    9、字符串基本操作,插入、刪除、查找等。 10、經典例題變形加深:八皇后、馬的走法、背包問題等。提高組必學0、普及組的10條。 1、較難的動態規劃,多維的狀態,轉移方式較多。 2、簡單數論,如擴展GCD,歐拉函數等。
  • 字符串: KMP是時候上場了(一文讀懂系列)
    所以叫做KMPKMP有什麼用KMP主要應用在字符串匹配上。KMP的主要思想是「當出現字符串不匹配時,可以知道一部分之前已經匹配的文本內容,可以利用這些信息避免從頭再去做匹配了。」可以看出,文本串中第六個字符b 和 模式串的第六個字符f,不匹配了。如果暴力匹配,會發現不匹配,此時就要從頭匹配了。但如果使用前綴表,就不會從頭匹配,而是從上次已經匹配的內容開始匹配,找到了模式串中第三個字符b繼續開始匹配。
  • V19.使用分組和結果屬性值匹配目標字符串
    一、題目如下圖,從A1單元格中一次性提取序號姓名、序號和姓名並分三列獨立顯示:顯示結果如下:二、解題思路首先分析A1單元格中字符串的特點:3.又因為需要分兩列單獨顯示序號(不需要點號)和姓名,故此就需要用到分組的方法,分組的方法就是用圓括號()把匹配目標字符串的表達式括起來。4.Pattern的屬性值為:.Pattern = "(\d+)\.+?\s*([一-龢]+)(\s+|(?!.))"
  • php字符串函數匯總
    str_pad — 使用另一個字符串填充字符串為指定長度str_repeat — 重複一個字符串str_replace — 子字符串替換str_rot13 — 對字符串執行 ROT13 轉換str_shuffle — 隨機打亂一個字符串str_split — 將字符串轉換為數組str_word_count
  • R 字符串之 stringr
    R 字符串之 stringr前言昨天我們介紹 R 數據處理的時候,對字符串的操作都是用自帶的函數。雖然 R 的字符串並不是它的強項,看起來也不是那麼的優雅,但是字符串在數據處理和清洗過程中還是扮演者較為重要的角色。
  • 在字符串中搜索標記--labview字符串函數之一
    一直未能找到合適的字符串函數來解析出來有效數據,而昨天恰恰看到了這樣一個字符串函數——在字符串中搜索標記。默認值為0,即字符串的起始位置。運算符是字符串數組,如輸入字符串包含字符串數組,即使它們沒有被分隔符分隔,函數仍將其視為標記。如輸入字符串的一部分匹配多個運算符,函數將把最長的匹配作為標記。例如,如>、=和>=被定義為運算符,輸入字符串4>=0將生成>=作為下一個標記字符串,偏移量為1。
  • Ruby 字符串(String)
    如果 obj 不是字符串,則返回 false,如果 str <=> obj,則返回 true,返回 0。7str =~ obj根據正則表達式模式 obj 匹配 str。返回匹配開始的位置,否則返回 false。8str =~ obj根據正則表達式模式 obj 匹配 str。
  • 教你初步了解KMP算法
    由此,便產生了字符串的匹配問題。本文由簡單的字符串匹配算法開始,再到KMP算法,由淺入深,教你從頭到尾徹底理解KMP算法。來看算法導論一書上關於此字符串問題的定義:假設文本是一個長度為n的數組T[1...n],模式是一個長度為m<=n的數組P[1....m]。進一步假設P和T的元素都是屬於有限字母表Σ.中的字符。
  • 一招解決4道leetcode hard題,動態規劃在字符串匹配問題中的應用
    動態規劃的思路就是找到一個遞推的公式,由前往後或者由後往前來求解題目,在字符串匹配問題中,我們的基本的思想就是從前面開始,維護兩個指針,一個指針從前往後遍歷目標字符串,一個指針從前往後遍歷pattern字符串,並不斷判斷當前兩個指針前面的子串是否匹配。這個思想,很容易轉換成一張二維表格。我們要做的,就是找到這張二維表格的轉換關係或者說遞推公式。