网站上的敏感词过滤是怎么实现的呢?

实际上,这些功能最基本的原理就是字符串匹配算法,也就是通过维护一个敏感词的字典,当用户输入一段文字内容后,通过字符串匹配算法来检查用户输入的内容是否包含敏感词。

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 数组,只不过此处的数组是构建在树上罢了

  1. class ACNode
  2. {
  3. public:
  4. char data;
  5. bool is_ending_char; // 是否结束字符
  6. int length; // 当前节点为结束字符时记录模式串长度
  7. ACNode *fail; // 失败指针
  8. ACNode *children[26]; // 字符集只包含 a-z 这 26 个字符
  9. ACNode(char ch)
  10. {
  11. data = ch;
  12. is_ending_char = false;
  13. length = -1;
  14. fail = NULL;
  15. for (int i = 0; i < 26; i++)
  16. children[i] = NULL;
  17. }
  18. };

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 的失败指针指向根节点。

  1. // 构建失败指针
  2. void build_failure_pointer()
  3. {
  4. queue<ACNode *> AC_queue;
  5. AC_queue.push(root);
  6. while (!AC_queue.empty())
  7. {
  8. ACNode *p = AC_queue.front();
  9. AC_queue.pop();
  10. for (int i = 0; i < 26; i++)
  11. {
  12. ACNode *pc = p->children[i];
  13. if (pc == NULL) continue;
  14. if (p == root) pc->fail = root;
  15. else
  16. {
  17. ACNode *q = p->fail;
  18. while (q != NULL)
  19. {
  20. ACNode *qc = q->children[pc->data - 'a'];
  21. if (qc != NULL)
  22. {
  23. pc->fail = qc;
  24. break;
  25. }
  26. q = q->fail;
  27. }
  28. if (q == NULL) pc->fail = root;
  29. }
  30. AC_queue.push(pc);
  31. }
  32. }
  33. }

通过按层来计算每个节点的子节点的失败指针,例中最后构建完之后的 AC 自动机就是下面这个样子。

接下来,我们看如何在 AC 自动机上匹配子串?首先,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。

  • 如果 p 指向的节点有一个等于 a[i] 的子节点 x,我们就更新 p 指向 x,这时候我们还要检查这个子节点的一系列失败指针对应的路径是否为一个完整的模式串,之后我们将 i 增 1,继续重复这两个过程;

  • 如果 p 指向的节点没有等于 a[i] 的子节点,我们就更新 p = p->fial,继续重复这两个过程。

  1. // 在 AC 自动机中匹配字符串
  2. void match_string(const char str[])
  3. {
  4. ACNode *p = root;
  5. for (unsigned int i = 0; i < strlen(str); i++)
  6. {
  7. int index = int(str[i] - 'a');
  8. while (p->children[index] == NULL && p != root)
  9. {
  10. p = p->fail;
  11. }
  12. p = p->children[index];
  13. if (p == NULL) p = root; // 没有可匹配的,从根节点开始重新匹配
  14. ACNode *temp = p;
  15. while (temp != root)
  16. {
  17. if (temp->is_ending_char == true)
  18. {
  19. int pos = i - temp->length + 1;
  20. cout << "Fing a match which begins at position " << pos << ' '
  21. << "and has a length of " << temp->length << '!'<< endl;
  22. }
  23. temp = temp->fail;
  24. }
  25. }
  26. }

全部代码如下:

  1. #include <iostream>
  2. #include <cstring>
  3. #include <queue>
  4. using namespace std;
  5. class ACNode
  6. {
  7. public:
  8. char data;
  9. bool is_ending_char; // 是否结束字符
  10. int length; // 当前节点为结束字符时记录模式串长度
  11. ACNode *fail; // 失败指针
  12. ACNode *children[26]; // 字符集只包含 a-z 这 26 个字符
  13. ACNode(char ch)
  14. {
  15. data = ch;
  16. is_ending_char = false;
  17. length = -1;
  18. fail = NULL;
  19. for (int i = 0; i < 26; i++)
  20. children[i] = NULL;
  21. }
  22. };
  23. class AC
  24. {
  25. private:
  26. ACNode *root;
  27. public:
  28. // 构造函数,根节点存储无意义字符 '/'
  29. AC()
  30. {
  31. root = new ACNode('/');
  32. }
  33. // 向 Trie 树中添加一个字符串
  34. void insert_string(const char str[])
  35. {
  36. ACNode *cur = root;
  37. for (unsigned int i = 0; i < strlen(str); i++)
  38. {
  39. int index = int(str[i] - 'a');
  40. if (cur->children[index] == NULL)
  41. {
  42. ACNode *temp = new ACNode(str[i]);
  43. cur->children[index] = temp;
  44. }
  45. cur = cur->children[index];
  46. }
  47. cur->is_ending_char = true;
  48. cur->length = strlen(str);
  49. }
  50. // 构建失败指针
  51. void build_failure_pointer()
  52. {
  53. queue<ACNode *> AC_queue;
  54. AC_queue.push(root);
  55. while (!AC_queue.empty())
  56. {
  57. ACNode *p = AC_queue.front();
  58. AC_queue.pop();
  59. for (int i = 0; i < 26; i++)
  60. {
  61. ACNode *pc = p->children[i];
  62. if (pc == NULL) continue;
  63. if (p == root) pc->fail = root;
  64. else
  65. {
  66. ACNode *q = p->fail;
  67. while (q != NULL)
  68. {
  69. ACNode *qc = q->children[pc->data - 'a'];
  70. if (qc != NULL)
  71. {
  72. pc->fail = qc;
  73. break;
  74. }
  75. q = q->fail;
  76. }
  77. if (q == NULL) pc->fail = root;
  78. }
  79. AC_queue.push(pc);
  80. }
  81. }
  82. }
  83. // 在 AC 自动机中匹配字符串
  84. void match_string(const char str[])
  85. {
  86. ACNode *p = root;
  87. for (unsigned int i = 0; i < strlen(str); i++)
  88. {
  89. int index = int(str[i] - 'a');
  90. while (p->children[index] == NULL && p != root)
  91. {
  92. p = p->fail;
  93. }
  94. p = p->children[index];
  95. if (p == NULL) p = root; // 没有可匹配的,从根节点开始重新匹配
  96. ACNode *temp = p;
  97. while (temp != root)
  98. {
  99. if (temp->is_ending_char == true)
  100. {
  101. int pos = i - temp->length + 1;
  102. cout << "Fing a match which begins at position " << pos << ' '
  103. << "and has a length of " << temp->length << '!'<< endl;
  104. }
  105. temp = temp->fail;
  106. }
  107. }
  108. }
  109. };
  110. int main()
  111. {
  112. //char str[][8] = {"how", "he", "her", "hello", "so", "see", "however"};
  113. char str[][5] = {"abce", "bcd", "ce"};
  114. AC test;
  115. for (int i = 0; i < 7; i++)
  116. {
  117. test.insert_string(str[i]);
  118. }
  119. test.build_failure_pointer();
  120. //test.match_string("however, what about her boyfriend?");
  121. test.match_string("abcfabce");
  122. return 0;
  123. }

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」!

AC 自动机——多模式串匹配的更多相关文章

  1. AC自动机——多模式串匹配的算法思想

    标准KMP算法用于单一模式串的匹配,即在母串中寻求一个模式串的匹配,但是现在又存在这样的一个问题,如果同时给出多个模式串,要求找到这一系列模式串在母串存在的匹配个数,我们应该如何处理呢? 基于KMP算 ...

  2. AC自动机 - 多模式串匹配问题的基本运用 + 模板题 --- HDU 2222

    Keywords Search Time Limit: 2000/1000 MS (Java/Others)    Memory Limit: 65536/32768 K (Java/Others)T ...

  3. AC自动机 - 多模式串的匹配运用 --- HDU 2896

    病毒侵袭 Problem's Link:http://acm.hdu.edu.cn/showproblem.php?pid=2896 Mean: 略 analyse: AC自动机的运用,多模式串匹配. ...

  4. AC自动机 - 多模式串的匹配 --- HDU 3695 Computer Virus on Planet Pandora

    Problem's Link Mean: 有n个模式串和一篇文章,统计有多少模式串在文章中出现(正反统计两次). analyse: 好久没写AC自动机了,回顾一下AC自动机的知识. 本题在构造文章的时 ...

  5. AC自动机 - 多模式串的匹配运用 --- HDU 3065

    病毒侵袭持续中 Problem's Link:http://acm.hdu.edu.cn/showproblem.php?pid=3065 Mean: 略 analyse: AC自动机的运用. 这一题 ...

  6. 数据结构14——AC自动机

    一.相关介绍 知识要求 字典树Trie KMP算法 AC自动机 多模式串的字符匹配算法(KMP是单模式串的字符匹配算法) 单模式串问题&多模式串问题 单模就是给你一个模式串,问你这个模式串是否 ...

  7. 【转载】多模式串匹配之AC自动机

    原文地址:https://www.cnblogs.com/codeape/p/3845375.html 目录 [隐藏] 一.概述 二.AC算法思想 三.字典树tire的构造 四.搜索路径的确定 附录: ...

  8. UVA 11019 Matrix Matcher ( 二维字符串匹配, AC自动机 || 二维Hash )

    题目: 传送门 题意: 给你一个 n * m 的文本串 T, 再给你一个 r * c 的模式串 S: 问模式串 S 在文本串 T 中出现了多少次. 解: 法一: AC自动机 (正解) 670ms 把模 ...

  9. Aho-Corasick 多模式匹配算法、AC自动机详解

    Aho-Corasick算法是多模式匹配中的经典算法,目前在实际应用中较多. Aho-Corasick算法对应的数据结构是Aho-Corasick自动机,简称AC自动机. 搞编程的一般都应该知道自动机 ...

随机推荐

  1. DQL-分组查询

    一.语法   select 分组函数,分组后的字段   from 表 [ where 筛选条件]   group by 分组的字段[having 分组后的筛选][order by 排序列表] 例如 S ...

  2. Oracle记录类型(record)和%rowtype

    Oracle中的记录类型(record)和使用%rowtype定义的数据类型都是一种单行多列的数据结构,可以理解为一个具有多个属性的对象.其中属性名即为列名. 记录类型(record) 记录类型是一种 ...

  3. DB数据源之SpringBoot+MyBatis踏坑过程(二)手工配置数据源与加载Mapper.xml扫描

    DB数据源之SpringBoot+MyBatis踏坑过程(二)手工配置数据源与加载Mapper.xml扫描 liuyuhang原创,未经允许进制转载  吐槽之后应该有所改了,该方式可以作为一种过渡方式 ...

  4. vector 定义的二维数组的遍历

    之前我们分享了STL的一些容器,再介绍vector中只介绍了二维的vector的定义并没有说二维的vector怎么遍历,那么我们今天就来看下二维的vector怎么遍历 看下面的代码吧. #includ ...

  5. 关于linux‘RedHat6.9在VMware虚拟机中的安装步骤

    redhat支持多种安装方式:光盘安装,硬盘安装和网络安装等,可以根据个人的实际情况来选择.我在这里选择的是光盘安装的方式安装RHEL6.9.(以下简称6.9) 1.首先准备好6.9的光盘镜像,在安装 ...

  6. 利用ascii码生成26个英文字母

    <script> let a = ""; for (var i = 65; i < 91; i++) { a += String.fromCharCode(i); ...

  7. Asp.Net Core链接Mysql数据库

    一.新建一个Asp.Net Core WebMVC程序 添加nuget包  Mysql.Data 二.新建一个UserContext类 下面代码中的UserInfo是我自己建的一个实体,里面有俩字段: ...

  8. php无限级分类----封装函数

    public function catetree($cateRes){//传递过来的数据资源 return $this->sort($cateRes); 调用函数 } public functi ...

  9. 解决thinkphp query()执行原生SQL语句成功结果报错的问题

    1.query方法 query方法用于执行SQL查询操作,如果数据非法或者查询错误则返回false,否则返回查询结果数据集(同select方法). 2.execute方法 execute用于更新和写入 ...

  10. bit_length

    #当十进制用二进制表示时,最少使用的位数 v=2data=v.bit_length()print(data)