網站上的敏感詞過濾是怎麼實現的呢?
實際上,這些功能最基本的原理就是字符串匹配算法,也就是經過維護一個敏感詞的字典,當用戶輸入一段文字內容後,經過字符串匹配算法來檢查用戶輸入的內容是否包含敏感詞。ios
BF、RK、BM、KMP 算法都是針對只有一個模式串的字符串匹配算法,而要實現一個高性能的敏感詞過濾系統,就須要用到多模式匹配算法了。算法
多模式匹配算法,就是在多個模式串和一個主串之間作匹配,也就是在一個主串中查找多個模式串。數組
敏感詞過濾,也能夠經過單模式匹配算法來實現,那就是針對每一個敏感值都作一遍單模式匹配。但若是敏感詞不少,而且主串很長,那咱們就須要遍歷不少次主串,顯然這種方法是很是低效的。數據結構
而多模式匹配算法只須要掃描一遍主串,就能夠一次性查找多個模式串是否存在,匹配效率就大大提升了。那如何基於 Trie 樹實現敏感詞過濾功能呢?函數
咱們能夠首先對敏感詞字典進行預處理,構建成 Trie 樹。這個預處理的操做只須要作一次,若是敏感詞字典動態更新了,咱們只須要在 Trie 樹中添加或刪除一個字符串便可。性能
用戶輸入一個文本內容後,咱們把用戶輸入的內容做爲主串,從第一個字符開始在 Trie 樹中進行匹配。當匹配到葉子節點或者中途遇到不匹配字符的時候,咱們就將主串的匹配位置後移一位,從新進行匹配。網站
基於 Trie 樹的這種處理方法,有點相似單模式匹配的 BF 算法。咱們知道 KMP 算法在 BF 算法基礎上進行了改進,每次匹配失敗時,儘量地將模式串日後多滑動幾位。一樣,在這裏,咱們是否也能夠對多模式串 Trie 樹進行一樣的改進呢?這就要用到 AC 自動機算法了。ui
AC 自動機算法,全稱是 Aho-Corasick 算法。AC 自動機實際上就是在 Trie 樹之上,加了相似於 KMP 算法的 next 數組,只不過此處的數組是構建在樹上罷了。spa
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 自動機的構建包含兩個操做:.net
構建 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。
// 在 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; }
首先,構建 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」!