AC 自動機——多模式串匹配
網站上的敏感詞過濾是怎麼實現的呢?
實際上,這些功能最基本的原理就是字串匹配演算法,也就是通過維護一個敏感詞的字典,當用戶輸入一段文字內容後,通過字串匹配演算法來檢查使用者輸入的內容是否包含敏感詞。
BF、RK、BM、KMP 演算法都是針對只有一個模式串的字串匹配演算法,而要實現一個高效能的敏感詞過濾系統,就需要用到多模式匹配演算法了。
1. 基於單模式和 Trie 樹實現的敏感詞過濾
多模式匹配演算法,就是在多個模式串和一個主串之間做匹配,也就是在一個主串中查詢多個模式串。
敏感詞過濾,也可以通過單模式匹配演算法來實現,那就是針對每個敏感值都做一遍單模式匹配。但如果敏感詞很多,並且主串很長,那我們就需要遍歷很多次主串,顯然這種方法是非常低效的。
而多模式匹配演算法只需要掃描一遍主串,就可以一次性查詢多個模式串是否存在,匹配效率就大大提高了。那如何基於 Trie 樹實現敏感詞過濾功能呢?
我們可以首先對敏感詞字典進行預處理,構建成 Trie 樹。這個預處理的操作只需要做一次,如果敏感詞字典動態更新了,我們只需要在 Trie 樹中新增或刪除一個字串即可。
使用者輸入一個文字內容後,我們把使用者輸入的內容作為主串,從第一個字元開始在 Trie 樹中進行匹配。當匹配到葉子節點或者中途遇到不匹配字元的時候,我們就將主串的匹配位置後移一位,重新進行匹配。
基於 Trie 樹的這種處理方法,有點類似單模式匹配的 BF 演算法。我們知道 KMP 演算法在 BF 演算法基礎上進行了改進,每次匹配失敗時,儘可能地將模式串往後多滑動幾位。同樣,在這裡,我們是否也可以對多模式串 Trie 樹進行同樣的改進呢?這就要用到 AC 自動機演算法了。
2. AC 自動機多模式匹配演算法
AC 自動機演算法,全稱是 Aho-Corasick 演算法。AC 自動機實際上就是在 Trie 樹之上,加了類似於 KMP 演算法的 next 陣列,只不過此處的陣列是構建在樹上罷了。
class ACNode { public: char data; bool is_ending_char; // 是否結束字元 int length; // 當前節點為結束字元時記錄模式串長度 ACNode *fail; // 失敗指標 ACNode *children[26]; // 字符集只包含 a-z 這 26 個字元 ACNode(char ch) { data = ch; is_ending_char = false; length = -1; fail = NULL; for (int i = 0; i < 26; i++) children[i] = NULL; } };
AC 自動機的構建包含兩個操作:
將多個模式串構建成 Trie 樹;
在 Trie 樹上構建失敗指標,就相當於KMP 演算法中的失效函式 next 陣列。
構建 Trie 樹的過程可以參考 Trie 樹——搜尋關鍵詞提示,這裡只是多了一個模式串的長度而已。假設我們的 4 個模式串分別為 c,bc,bcd,abcd,那麼構建好的 Trie 樹如下所示。
Trie 樹中的每一個節點都有一個失敗指標,它的作用和構建過程,和 KMP 演算法中 next 陣列極其相似。
假設我們沿著 Trie 樹走到 p 節點,也就是下圖中的紫色節點,那 p 的失敗指標也就是從根節點走到當前節點所形成的字串 abc,和所有模式串字首匹配的最長可匹配字尾子串,這裡就是 bc 模式串。
字串 abc 的字尾子串有 c 和 bc,我們拿它們和其它模式串進行匹配,如果能夠匹配上,那這個字尾就叫作可匹配字尾子串。在一個字串的所有可匹配字尾子串中,長度最長的那個叫作最長可匹配字尾子串。我們就將一個節點的失敗指標指向其最長可匹配字尾子串對應的模式串字首的最後一個節點。
其實,如果我們把樹中相同深度的節點放到同一層,那麼某個節點的失敗指標只有可能出現在它所在層的上面。因此,我們可以像 KMP 演算法那樣,利用已經求得的、深度更小的那些節點的失敗指標來推匯出下面節點的失敗指標。
首先,根節點的失敗指標指向 NULL,第一層節點的失敗指標都指向根節點。然後,繼續往下遍歷,如果 p 節點的失敗指標指向 q,那麼我們需要看節點 p 的子節點 pc 對應的字元,是否也可以在節點 q 的子節點 qc 中找到。如果找到了一個子節點 qc 和 pc 的字元相同,則將 pc 的失敗指標指向 qc。
如果找不到一個子節點 qc 和 pc 的字元相同,那麼我們繼續令 q = q->fail,重複上面的查詢過程,直到 q 為根節點為止。如果還沒有找到,那就將 pc 的失敗指標指向根節點。
// 構建失敗指標
void build_failure_pointer()
{
queue<ACNode *> AC_queue;
AC_queue.push(root);
while (!AC_queue.empty())
{
ACNode *p = AC_queue.front();
AC_queue.pop();
for (int i = 0; i < 26; i++)
{
ACNode *pc = p->children[i];
if (pc == NULL) continue;
if (p == root) pc->fail = root;
else
{
ACNode *q = p->fail;
while (q != NULL)
{
ACNode *qc = q->children[pc->data - 'a'];
if (qc != NULL)
{
pc->fail = qc;
break;
}
q = q->fail;
}
if (q == NULL) pc->fail = root;
}
AC_queue.push(pc);
}
}
}
通過按層來計算每個節點的子節點的失敗指標,例中最後構建完之後的 AC 自動機就是下面這個樣子。
接下來,我們看如何在 AC 自動機上匹配子串?首先,主串從 i=0 開始,AC 自動機從指標 p=root 開始,假設模式串是 b,主串是 a。
如果 p 指向的節點有一個等於 a[i] 的子節點 x,我們就更新 p 指向 x,這時候我們還要檢查這個子節點的一系列失敗指標對應的路徑是否為一個完整的模式串,之後我們將 i 增 1,繼續重複這兩個過程;
如果 p 指向的節點沒有等於 a[i] 的子節點,我們就更新 p = p->fial,繼續重複這兩個過程。
// 在 AC 自動機中匹配字串
void match_string(const char str[])
{
ACNode *p = root;
for (unsigned int i = 0; i < strlen(str); i++)
{
int index = int(str[i] - 'a');
while (p->children[index] == NULL && p != root)
{
p = p->fail;
}
p = p->children[index];
if (p == NULL) p = root; // 沒有可匹配的,從根節點開始重新匹配
ACNode *temp = p;
while (temp != root)
{
if (temp->is_ending_char == true)
{
int pos = i - temp->length + 1;
cout << "Fing a match which begins at position " << pos << ' '
<< "and has a length of " << temp->length << '!'<< endl;
}
temp = temp->fail;
}
}
}
全部程式碼如下:
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
class ACNode
{
public:
char data;
bool is_ending_char; // 是否結束字元
int length; // 當前節點為結束字元時記錄模式串長度
ACNode *fail; // 失敗指標
ACNode *children[26]; // 字符集只包含 a-z 這 26 個字元
ACNode(char ch)
{
data = ch;
is_ending_char = false;
length = -1;
fail = NULL;
for (int i = 0; i < 26; i++)
children[i] = NULL;
}
};
class AC
{
private:
ACNode *root;
public:
// 建構函式,根節點儲存無意義字元 '/'
AC()
{
root = new ACNode('/');
}
// 向 Trie 樹中新增一個字串
void insert_string(const char str[])
{
ACNode *cur = root;
for (unsigned int i = 0; i < strlen(str); i++)
{
int index = int(str[i] - 'a');
if (cur->children[index] == NULL)
{
ACNode *temp = new ACNode(str[i]);
cur->children[index] = temp;
}
cur = cur->children[index];
}
cur->is_ending_char = true;
cur->length = strlen(str);
}
// 構建失敗指標
void build_failure_pointer()
{
queue<ACNode *> AC_queue;
AC_queue.push(root);
while (!AC_queue.empty())
{
ACNode *p = AC_queue.front();
AC_queue.pop();
for (int i = 0; i < 26; i++)
{
ACNode *pc = p->children[i];
if (pc == NULL) continue;
if (p == root) pc->fail = root;
else
{
ACNode *q = p->fail;
while (q != NULL)
{
ACNode *qc = q->children[pc->data - 'a'];
if (qc != NULL)
{
pc->fail = qc;
break;
}
q = q->fail;
}
if (q == NULL) pc->fail = root;
}
AC_queue.push(pc);
}
}
}
// 在 AC 自動機中匹配字串
void match_string(const char str[])
{
ACNode *p = root;
for (unsigned int i = 0; i < strlen(str); i++)
{
int index = int(str[i] - 'a');
while (p->children[index] == NULL && p != root)
{
p = p->fail;
}
p = p->children[index];
if (p == NULL) p = root; // 沒有可匹配的,從根節點開始重新匹配
ACNode *temp = p;
while (temp != root)
{
if (temp->is_ending_char == true)
{
int pos = i - temp->length + 1;
cout << "Fing a match which begins at position " << pos << ' '
<< "and has a length of " << temp->length << '!'<< endl;
}
temp = temp->fail;
}
}
}
};
int main()
{
//char str[][8] = {"how", "he", "her", "hello", "so", "see", "however"};
char str[][5] = {"abce", "bcd", "ce"};
AC test;
for (int i = 0; i < 7; i++)
{
test.insert_string(str[i]);
}
test.build_failure_pointer();
//test.match_string("however, what about her boyfriend?");
test.match_string("abcfabce");
return 0;
}
3. AC 自動機的複雜度分析
首先,構建 Trie 樹的時間複雜度為 O(m*len),其中 len 表示敏感詞的平均長度,m 表示敏感詞的個數。
其次,假設 Trie 樹中總共有 k 個節點,每個節點在構建失敗指標的時候,最耗時的就是 while 迴圈部分,這裡 q = q->fail,每次節點的深度都在減小,樹的最大深度為 len,因此每個節點構建失敗指標的時間複雜度為 O(len),整個失敗指標構建過程的時間複雜度為 O(k*len)。不過,AC 自動機的構建過程都是預先處理好的,構建好之後並不會頻繁更新。
最後,假設主串的長度為 n,匹配的時候每一個 for 迴圈裡面的時間複雜度也為 O(len),總的匹配時間複雜度就為 O(n*len)。因為敏感詞不會很長,而且這個時間複雜度只是一個非常寬泛的上限,實際情況下,可能近似於 O(n),所以,AC 自動機匹配的效率非常高。
從時間複雜度上看,AC 自動機匹配的效率和 Trie 樹一樣,但是一般情況下,大部分節點的失敗指標都指向根節點,AC 自動機實際匹配的效率要遠高於 O(n*len)。只有在極端情況下,AC 自動機的效能才會退化為和 Trie 樹一樣。
獲取更多精彩,請關注「seniusen」!