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

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

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

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 自动机的构建包含两个操作:

  • 将多个模式串构建成 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 的失败指针指向根节点。

// 构建失败指针
    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。

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

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

    // 在 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;
}

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. Reading Notes : 180213 计算机的硬件构成与处理流程

    读书<计算机组成原理>,<鸟哥的Linux私房菜基础篇> 基本上接触过计算机的人,都多少知道计算机的具体构成,但是真正能讲明白的却说了很多,本节将讲解一下计算机的基本硬件构成和 ...

  2. 有了SSL证书,如何在IIS环境下部署https?

    昨天各位小伙伴都很开心的领取了自己的SSL证书,但是大部分小伙伴却不知道如何部署,也许是因为第一次接触SSL这种高端的东西吧,不过个人觉得就是懒懒懒...本来小编也挺懒的,但是答应了各位小伙伴的,那么 ...

  3. 『C++』Temp_2018_12_06

    #include <iostream> #include <string> using namespace std; class Type{ public: string Na ...

  4. Spring的jar包不同版本的下载地址

    http://repo.spring.io/release/org/springframework/spring/ 可以直接下载不同版本的spring jar包

  5. shell定时统计Nginx下access.log的PV并发送给API保存到数据库

    1,统计PV和IP 统计当天的PV(Page View) cat access.log | sed -n /`date "+%d\/%b\/%Y"`/p |wc -l 统计某一天的 ...

  6. idea配置SpringBoot热部署之自动Build

    一.pom.xml文件导入所需依赖文件 SpringBoot热部署插件 <dependency> <groupId>org.springframework.boot</g ...

  7. QP之QK原理

    QK是一个很小的抢占式微内核调度程序,它专用用QP中. QK的思想源于SST,Miro Samek重写了自己前期编的SST(Super Simple Task)代码. QK循环查询AO队列的状态表QK ...

  8. QOS-配置拥塞避免机制

    QOS-配置拥塞避免机制 2018年7月7日 20:29 尾丢弃及其导致的问题: 队列满时路由器进行尾丢弃,即新到的所有数据包都全部丢弃 丢弃的结果造成高延迟.高抖动.丧失服务保证.TCP全局同步.T ...

  9. RDO与RLO

    RDO: 平均误差(SSD/SSE).均方误差(MSE).绝对误差和(SAD).峰值信噪比(PSNR) min D subject to R < Rc 拉格朗日优化(λ为拉格朗日乘子): min ...

  10. 【8086汇编-Day5】第二次实验

    debug的使用 偷个懒,之前写过了这里不再赘述 实验 1)实验1 要求:用e将一些数据写入内存,用a写入一段程序,t 逐条执行 观察具体参数变化,并探究现象 1.e写入,d检查 2.a写入程序 3. ...