1. 算法背景
之前介绍过单模式串匹配的高效算法:BM和KMP 以及 基于多模式串的匹配数据结构Trie树。
当有这样的需求:对于输入的主串,需要替换其中的敏感词为其他字符,也就是需要对trie树中的字符串逐个匹配,来判断该字符串是否在主串中,从而将其替换为制定字符。比如输出主串:abacdab , 使用Trie树构建的字符集如下:abc, ac,dab,最终替换的主串为: aba***** 。
如果用Trie树的匹配方式:
会拿着每一个trie树中的字符串 来和主串匹配,如果主串中没有这个字符串,则匹配trie树中的下一个字符串。
以root的26个字符为外层循环进行遍历,如果root->children_[des[i]-‘a’]不为空,则循环当前的trie树中的字符,且主串的i++,直到Trie树的叶子结点,发现当前trie树的这个字符串没法匹配,则从trie树的root重新开始匹配下一个trie树中的字符串。
基本的代码如下:
其中的AcNode也就是Trie-tree 中的TrieNode,下文的代码其实已经完成了Trie树的构建,相关的代码可以在开头的Trie树实现中查看。
// 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 input
while(i < des_len) {
int index = des[i] - 'a';
AcNode *p = tmp->children_[index];
// Match every single str in trie tree
while(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算法一样,让模式串在主串中的每一次移动尽可能多的位置,来降低匹配次数。kmp通过一个失效函数来达到这样的目的,同理Trie中也可以用一种算法,让主串尽可能多得匹配trie 树中的模式字符串,降低模式字符串的匹配次数,这个思想也就是AC自动机的算法思想,了解这个算法思想之前需要非常熟悉Trie树以及KMP算法的原理,文章开头有链接。
2. AC自动机实现原理
AC自动机全称Aho-Corasick 算法,实际原理就是在Trie树之上构建类似KMP的next数组,这里则是在Trie树上构建而已。
AcNode数据结构如下:
class AcNode {
public:
char data_; // AcNode char
bool isEndingChar_; // Ending pos
int length_; // Length of the string
AcNode *children_[26];
AcNode *fail; // fail pointer, to construct
// the next array on Trie-tree
AcNode(char data='/')
:data_(data),isEndingChar_(false), length_(0){
memset(children_, 0, sizeof(AcNode *)* 26);
};
};
主要增加了一个失败指针,构建Ac自动机的过程 主要是将多个模式串构建成一个Trie树,并在其上构建失败指针(类似KMP的失效函数next数组)。
还是之前描述过的,AC自动机是为了减少模式串的匹配次数,每次Trie树中的模式串失配之后不需要从Trie的root节点重新开始匹配,而是跳转到下一个可能匹配的模式串的指定位置继续匹配,这个位置就是由失败指针来控制的。
举个例子:
一下Trie树为模式串构建的,包含字符串abcd, bcf, c
有一个从 从abcd字符串中C 指向bcf中的c 的指针,当我们在abcd中 d处匹配失效的时候可以继续从bcf 开始匹配,因为之前的bc已经在主串中存在了,所以不需要再从b开始进行匹配了。
2.1 构建失败指针
前面的例子 能够对失败指针在Trie树中的匹配作用有一个整体的了解,会减少Trie树中的匹配次数。
注意,Ac自动机的场景是在一个主串中匹配多个模式串,一般用于敏感词过滤,匹配的过程就是减少在模式串中构成的Trie树的匹配次数。
在KMP算法构建失效函数的过程中提到了一个最长可匹配前缀子串,因为KMP是从左向右匹配,所以在遇到坏字符的时候每次模式串的滑动需要滑动大最长可匹配前缀子串的位置。这里也是类似的,可以看到如上例子: 模式串匹配到abcd的d字符时 发现和主串的f不匹配,这个时候c为失效指针起作用的位置,即在abc是已经和主串达成匹配的字符串了,只需要找到abc中的最长可匹配后缀子串即可。
后缀子串 就是最后一个字符串相同的子串,比如 abc 的后缀子串是 c, bc;最长可匹配后缀子串就是 Trie树中的其他字符串能够和bc匹配的前缀子串,比如案例中的bcf字符串 ,其前缀子串为b,bc ,则bc最长且和abc的后缀子串匹配。所以只需要让每一个失败指针保证指向的是最长可匹配后缀子串的结束位置即可。
可以像kmp算法那样,当我们要求某个节点的失败指针的时候,可以通过已经求得的、深度更小的节点的失败指针来推导,从而能够逐层求解每个节点的失败指针,也就是失败指针的构建会层次遍历Trie树。
构建失效指针的过程如下:
变更案例Trie树如下
其中root节点的失败指针为null,假设我们知道了字符c对应的TrieNode p
的失败指针为 q
,那我们想要求p的子节点pc的失败指针。如果pc->data == qc->data,即p的子节点字符和q的子节点字符相同,则将节点pc的失败指针指向qc, pc->fail = qc
如果节点q没有子节点 或 q的子节点字符和pc的字符不想等,则让q = q->fail,再看看q的子字符是否和pc的字符相等,依次直到q==root,也就是找不到子节点和pc匹配的节点,就让pc->fail=root就好了。
构建的过程需要层次遍历Trie树,依赖当前节点的上一个节点构建失败指针,将处于当前层的所有失败指针都构建完成。
// 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 pointer
root_->fail = nullptr;
Q.push(root_);
while (!Q.empty()) {
// Get the first element from queue, the element will be
// removed later
AcNode *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 pointer
AcNode *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 依赖失败指针过滤敏感词
有了失败指针的完整位置,也就知道了能够快速匹配的捷径。
如下已经完成了所有节点失败指针构建的 Trie树
主串为 abcdhe
匹配过程中 输入的主串从 i=0
开始,AC自动机从指针 p=root
开始,假设主串是b
- case1: 如果p指向的节点 的一个子节点x的字符串 等于b[i],我们就更新p指向x。同时,通过其p的一系列失败指针,检查以失败指针为结尾的路径是否是模式串(是否是被打上了ending标记,这个标记是在构建trie树是标识每个模式串的结束)。处理完成之后,i++, 继续这两个过程。
- case2: 如果p 指向的节点没有等于b[i]字符的子节点,可以通过失败指针检查以该字符结尾的其他模式串是否有等于b[i]字符的子节点,并重复这两个过程。
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 pointer
while(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. 复杂度及完整代码
-
时间复杂度:
-
构建Trie树的时间复杂度是O(m*len),len表示敏感词的平均长度,m表示敏感词的个数
-
构建失败指针的复杂度:构建时 需要循环q = q->fail,这里每一次q指向的节点深度都会减1,也就是不会循环超过len次,。假设Trie树中总共k个节点,则每个节点构建失败指针的时间复杂度是O(len) ,总共O(k*len)次。
需要注意的是AC自动机会预先构建好,并不会频繁更新。
-
AC自动机匹配主串时的复杂度,
match
函数中的两个while循环需要遍历当前节点的失败指针,时间复杂度为O(len),而外层的for循环时主串的长度,也就是O(n*len),因为敏感词其实都是一些短词语,实际上时间复杂度接近于O(n)。
-
-
空间复杂度的话,也就是之前说的Trie的内存消耗,本来也就很大,对于每一个节点都会消耗26个AcNode 的空间,这里有一些构建Trie树时的时间和空间消耗的Trad-off 优化。
完整代码:
https://github.com/BaronStack/DATA_STRUCTURE/blob/master/string/ac_alg.cc
总的来说,AC自动机在敏感词过滤的场景性能非常高效,但是由于Trie树带来的存储空间的消耗缺不可避免,不过整个算法实现思想还是非常有趣的。