网站上的敏感词过滤是怎么实现的呢?
实际上,这些功能最基本的原理就是字符串匹配算法,也就是经过维护一个敏感词的字典,当用户输入一段文字内容后,经过字符串匹配算法来检查用户输入的内容是否包含敏感词。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」!