字符串匹配算法 -- AC自动机 基于Trie树的高效的敏感词过滤算法
文章目錄
- 1. 算法背景
- 2. AC自動機實現(xiàn)原理
- 2.1 構(gòu)建失敗指針
- 2.2 依賴失敗指針過濾敏感詞
- 3. 復雜度及完整代碼
1. 算法背景
之前介紹過單模式串匹配的高效算法:BM和KMP 以及 基于多模式串的匹配數(shù)據(jù)結(jié)構(gòu)Trie樹。
1. BM和KMP 單模式串匹配算法細節(jié)
2. Trie樹 多模式串的高效匹配數(shù)據(jù)結(jié)構(gòu)
當有這樣的需求:對于輸入的主串,需要替換其中的敏感詞為其他字符,也就是需要對trie樹中的字符串逐個匹配,來判斷該字符串是否在主串中,從而將其替換為制定字符。比如輸出主串:abacdab , 使用Trie樹構(gòu)建的字符集如下:abc, ac,dab,最終替換的主串為: aba***** 。
如果用Trie樹的匹配方式:
會拿著每一個trie樹中的字符串 來和主串匹配,如果主串中沒有這個字符串,則匹配trie樹中的下一個字符串。
以root的26個字符為外層循環(huán)進行遍歷,如果root->children_[des[i]-‘a(chǎn)’]不為空,則循環(huán)當前的trie樹中的字符,且主串的i++,直到Trie樹的葉子結(jié)點,發(fā)現(xiàn)當前trie樹的這個字符串沒法匹配,則從trie樹的root重新開始匹配下一個trie樹中的字符串。
基本的代碼如下:
其中的AcNode也就是Trie-tree 中的TrieNode,下文的代碼其實已經(jīng)完成了Trie樹的構(gòu)建,相關(guān)的代碼可以在開頭的Trie樹實現(xiàn)中查看。
// Match trie str with traditional method.
void matchLow(string des) {vector<pair<int,int>> match_vec;AcNode *tmp = root_;int des_len = des.size();int i = 0;// Traverse the des string which is user's inputwhile(i < des_len) {int index = des[i] - 'a';AcNode *p = tmp->children_[index];// Match every single str in trie treewhile(p != nullptr) {i ++;p = p->children_[des[i]-'a'];}if (p->isEndingChar_ == true) {int pos = i - p->length_ + 1;match_vec.push_back(make_pair(pos, p->length_));cout << "pos: " << pos << " len :" << tmp -> length_ << endl;} else {i++;}}
}
也就是每一個Trie樹中的字符串都需要在主串中匹配一遍,這樣的效率其實是比較低的。
有沒有辦法讓在Trie樹中的遍歷跳過的字符更多一些呢?就像單模式串中KMP算法一樣,讓模式串在主串中的每一次移動盡可能多的位置,來降低匹配次數(shù)。kmp通過一個失效函數(shù)來達到這樣的目的,同理Trie中也可以用一種算法,讓主串盡可能多得匹配trie 樹中的模式字符串,降低模式字符串的匹配次數(shù),這個思想也就是AC自動機的算法思想,了解這個算法思想之前需要非常熟悉Trie樹以及KMP算法的原理,文章開頭有鏈接。
2. AC自動機實現(xiàn)原理
AC自動機全稱Aho-Corasick 算法,實際原理就是在Trie樹之上構(gòu)建類似KMP的next數(shù)組,這里則是在Trie樹上構(gòu)建而已。
AcNode數(shù)據(jù)結(jié)構(gòu)如下:
class AcNode {
public:char data_; // AcNode charbool isEndingChar_; // Ending posint length_; // Length of the stringAcNode *children_[26]; AcNode *fail; // fail pointer, to construct// the next array on Trie-treeAcNode(char data='/') :data_(data),isEndingChar_(false), length_(0){memset(children_, 0, sizeof(AcNode *)* 26);};
};
主要增加了一個失敗指針,構(gòu)建Ac自動機的過程 主要是將多個模式串構(gòu)建成一個Trie樹,并在其上構(gòu)建失敗指針(類似KMP的失效函數(shù)next數(shù)組)。
還是之前描述過的,AC自動機是為了減少模式串的匹配次數(shù),每次Trie樹中的模式串失配之后不需要從Trie的root節(jié)點重新開始匹配,而是跳轉(zhuǎn)到下一個可能匹配的模式串的指定位置繼續(xù)匹配,這個位置就是由失敗指針來控制的。
舉個例子:
一下Trie樹為模式串構(gòu)建的,包含字符串a(chǎn)bcd, bcf, c
有一個從 從abcd字符串中C 指向bcf中的c 的指針,當我們在abcd中 d處匹配失效的時候可以繼續(xù)從bcf 開始匹配,因為之前的bc已經(jīng)在主串中存在了,所以不需要再從b開始進行匹配了。
2.1 構(gòu)建失敗指針
前面的例子 能夠?qū)κ≈羔樤赥rie樹中的匹配作用有一個整體的了解,會減少Trie樹中的匹配次數(shù)。
注意,Ac自動機的場景是在一個主串中匹配多個模式串,一般用于敏感詞過濾,匹配的過程就是減少在模式串中構(gòu)成的Trie樹的匹配次數(shù)。
在KMP算法構(gòu)建失效函數(shù)的過程中提到了一個最長可匹配前綴子串,因為KMP是從左向右匹配,所以在遇到壞字符的時候每次模式串的滑動需要滑動大最長可匹配前綴子串的位置。這里也是類似的,可以看到如上例子: 模式串匹配到abcd的d字符時 發(fā)現(xiàn)和主串的f不匹配,這個時候c為失效指針起作用的位置,即在abc是已經(jīng)和主串達成匹配的字符串了,只需要找到abc中的最長可匹配后綴子串即可。
后綴子串 就是最后一個字符串相同的子串,比如 abc 的后綴子串是 c, bc;最長可匹配后綴子串就是 Trie樹中的其他字符串能夠和bc匹配的前綴子串,比如案例中的bcf字符串 ,其前綴子串為b,bc ,則bc最長且和abc的后綴子串匹配。所以只需要讓每一個失敗指針保證指向的是最長可匹配后綴子串的結(jié)束位置即可。
可以像kmp算法那樣,當我們要求某個節(jié)點的失敗指針的時候,可以通過已經(jīng)求得的、深度更小的節(jié)點的失敗指針來推導,從而能夠逐層求解每個節(jié)點的失敗指針,也就是失敗指針的構(gòu)建會層次遍歷Trie樹。
構(gòu)建失效指針的過程如下:
變更案例Trie樹如下
其中root節(jié)點的失敗指針為null,假設我們知道了字符c對應的TrieNode p的失敗指針為 q,那我們想要求p的子節(jié)點pc的失敗指針。如果pc->data == qc->data,即p的子節(jié)點字符和q的子節(jié)點字符相同,則將節(jié)點pc的失敗指針指向qc, pc->fail = qc
如果節(jié)點q沒有子節(jié)點 或 q的子節(jié)點字符和pc的字符不想等,則讓q = q->fail,再看看q的子字符是否和pc的字符相等,依次直到q==root,也就是找不到子節(jié)點和pc匹配的節(jié)點,就讓pc->fail=root就好了。
構(gòu)建的過程需要層次遍歷Trie樹,依賴當前節(jié)點的上一個節(jié)點構(gòu)建失敗指針,將處于當前層的所有失敗指針都構(gòu)建完成。
// Build the fail pointer in trie node.
// The process is just like the next array in kmp alg.
void Trie::bfsBuildFailPointer() {queue<AcNode*> Q;// Init the root fail pointerroot_->fail = nullptr;Q.push(root_);while (!Q.empty()) {// Get the first element from queue, the element will be // removed laterAcNode *tmp = Q.front();Q.pop();// Build the fail pointer relationship with ervery children_for (int i = 0;i < 26; i++ ) {AcNode *pc = tmp->children_[i];if (pc == nullptr) {continue;}if (tmp == root_) {pc->fail = nullptr;} else {// Check the tmp's children_ pc and q's children_ qc// if they have the same char ,then pc -> fail = qc// Or, q while back to last fail pointerAcNode *q = tmp->fail;while(q != nullptr) {AcNode *qc = q->children_[pc->data_ - 'a'];if (qc != nullptr) {pc -> fail = qc;break;}// Let the fail pointer move forward// Until the q->data_ == qc -> data_//// Just like the getNext in kmp, k = next[k],// util you find the des[k+1] == des[i+1].// Then you can make sure you have find the best // prefix in current string.q = q->fail;}// qc's char is not equal with pc'c char in all q's fail pointer// keep pc's fail pointer to root_if (q == nullptr) {pc -> fail = root_;}}Q.push(pc);}}
}
2.2 依賴失敗指針過濾敏感詞
有了失敗指針的完整位置,也就知道了能夠快速匹配的捷徑。
如下已經(jīng)完成了所有節(jié)點失敗指針構(gòu)建的 Trie樹
主串為 abcdhe
匹配過程中 輸入的主串從 i=0開始,AC自動機從指針 p=root開始,假設主串是b
- case1: 如果p指向的節(jié)點 的一個子節(jié)點x的字符串 等于b[i],我們就更新p指向x。同時,通過其p的一系列失敗指針,檢查以失敗指針為結(jié)尾的路徑是否是模式串(是否是被打上了ending標記,這個標記是在構(gòu)建trie樹是標識每個模式串的結(jié)束)。處理完成之后,i++, 繼續(xù)這兩個過程。
- case2: 如果p 指向的節(jié)點沒有等于b[i]字符的子節(jié)點,可以通過失敗指針檢查以該字符結(jié)尾的其他模式串是否有等于b[i]字符的子節(jié)點,并重復這兩個過程。
abcdhe 為主串的匹配過程可以帶入以上兩個case, 非常容易理解。
以下邏輯為匹配模式串并輸出 模式串在主串中的起始位置,并且完成模式串在主串中的替換:
// Match the des string with fail pointer
void Trie::match(string des) {AcNode *p = root_;int des_len = des.size();int i;vector<pair<int,int>> match_vec;for (i = 0;i < des_len; i++) {int index = des[i] - 'a';// case2: try to match the des[i],if failed,traverse// fail pointerwhile(p->children_[index] == nullptr && p != root_) {p = p->fail;}// find a char match with des[i]p = p->children_[index];if (p == nullptr) {p = root_;}AcNode *tmp = p;// Keep the tmp is not nullptr// case1: check the des[i]'s fail pointer ,if the fail pointer// is endingchar, and we will know that we have find a matching// string, record it.while(tmp != nullptr && tmp != root_) {if (tmp->isEndingChar_ == true) {int pos = i - tmp->length_ + 1;cout << "pos: " << pos << " len :" << tmp -> length_ << endl;match_vec.push_back(make_pair(pos, tmp->length_));}tmp = tmp -> fail;}}// Below is to output the replace result in with match str // in trie tree.if (match_vec.size() == 0) {cout << "string : " << des << " has no match str in trie tree!" << endl;return;}int j = 0;int tmp;i = match_vec[j].first;while(i < des_len && j < match_vec.size()) {tmp = match_vec[j].second;while(tmp --) {des[i++] = '*';}j++;}cout << "string : " << des << " match !" << endl;
}
3. 復雜度及完整代碼
-
時間復雜度:
-
構(gòu)建Trie樹的時間復雜度是O(m*len),len表示敏感詞的平均長度,m表示敏感詞的個數(shù)
-
構(gòu)建失敗指針的復雜度:構(gòu)建時 需要循環(huán)q = q->fail,這里每一次q指向的節(jié)點深度都會減1,也就是不會循環(huán)超過len次,。假設Trie樹中總共k個節(jié)點,則每個節(jié)點構(gòu)建失敗指針的時間復雜度是O(len) ,總共O(k*len)次。
需要注意的是AC自動機會預先構(gòu)建好,并不會頻繁更新。
-
AC自動機匹配主串時的復雜度,
match函數(shù)中的兩個while循環(huán)需要遍歷當前節(jié)點的失敗指針,時間復雜度為O(len),而外層的for循環(huán)時主串的長度,也就是O(n*len),因為敏感詞其實都是一些短詞語,實際上時間復雜度接近于O(n)。
-
-
空間復雜度的話,也就是之前說的Trie的內(nèi)存消耗,本來也就很大,對于每一個節(jié)點都會消耗26個AcNode 的空間,這里有一些構(gòu)建Trie樹時的時間和空間消耗的Trad-off 優(yōu)化。
完整代碼:
https://github.com/BaronStack/DATA_STRUCTURE/blob/master/string/ac_alg.cc
總的來說,AC自動機在敏感詞過濾的場景性能非常高效,但是由于Trie樹帶來的存儲空間的消耗缺不可避免,不過整個算法實現(xiàn)思想還是非常有趣的。
總結(jié)
以上是生活随笔為你收集整理的字符串匹配算法 -- AC自动机 基于Trie树的高效的敏感词过滤算法的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 武藏三阶怎么打
- 下一篇: 贪心算法简单实践 -- 分糖果、钱币找零