资讯专栏INFORMATION COLUMN

Trie树 php 实现敏感词过滤

王笑朝 / 3685人阅读

摘要:在树中,每个节点表示一个状态,每条边表示一个字符,从根节点到叶子节点经过的边即表示一个词条。查找一个词条最多耗费的时间只受词条长度影响,因此的查找性能是很高的,跟哈希算法的性能相当。

Last-Modified: 2019年5月10日15:25:35

参考文章

c++ 使用map实现Trie树

关键词过滤扩展,用于检查一段文本中是否出现敏感词,基于Double-Array Trie 树实现

↑ 现成的php扩展, 同时支持 php5、php7

从Trie到Double Array Trie

↑ 深入浅出讲解

前缀树匹配 Double Array Trie

trie_filter扩展 + swoole 实现敏感词过滤

↑ 简单的php高性能实现方式

背景

项目中需要过滤用户发送的聊天文本, 由于敏感词有将近2W条, 如果用 str_replace 来处理会炸掉的.

网上了解了一下, 在性能要求不高的情况下, 可以自行构造 Trie树(字典树), 这就是本文的由来.

简介

Trie树是一种搜索树, 也叫字典树、单词查找树.

DFA可以理解为DFA(Deterministic Finite Automaton), 即

这里借用一张图来解释Trie树的结构:

img

Trie可以理解为确定有限状态自动机,即DFA。在Trie树中,每个节点表示一个状态,每条边表示一个字符,从根节点到叶子节点经过的边即表示一个词条。查找一个词条最多耗费的时间只受词条长度影响,因此Trie的查找性能是很高的,跟哈希算法的性能相当。

上面实际保存了

abcd
abd
b
bcd
efg
hij

特点:

所有词条的公共前缀只存储一份

只需遍历一次待检测文本

查找消耗时间只跟待检测文本长度有关, 跟字典大小无关

存储结构 PHP

在PHP中, 可以很方便地使用数组来存储树形结构, 以以下敏感词字典为例:

大傻子
大傻
傻子
↑ 内容纯粹是为了举例...游戏聊天日常屏蔽内容

则存储结构为

{
    "大": {
        "傻": {
            "end": true
            "子": {
                "end": true
            }
        }
    },
    "傻": {
        "子": {
            "end": true
        },
    }
}
其他语言

简单点的可以考虑使用 HashMap 之类的来实现

或者参考 这篇文章 , 使用 Four-Array Trie,Triple-Array Trie和Double-Array Trie 结构来设计(名称与内部使用的数组个数有关)

字符串分割

无论是在构造字典树或过滤敏感文本时, 都需要将其分割, 需要考虑到unicode字符

有一个简单的方法:

$str = "a笨蛋123";    // 待分割的文本
$arr = preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY);    // 分割后的文本
// 输出
array(6) {
  [0]=>
  string(1) "a"
  [1]=>
  string(3) "笨"
  [2]=>
  string(3) "蛋"
  [3]=>
  string(1) "1"
  [4]=>
  string(1) "2"
  [5]=>
  string(1) "3"
}

匹配规则需加 u修饰符, /u表示按unicode(utf-8)匹配(主要针对多字节比如汉字), 否则会无法正常工作, 如下示例 ↓

$str = "a笨蛋123";    // 待分割的文本
$arr = preg_split("//", $str, -1, PREG_SPLIT_NO_EMPTY);    // 分割后的文本
// array(10) {
  [0]=>
  string(1) "a"
  [1]=>
  string(1) "�"
  [2]=>
  string(1) "�"
  [3]=>
  string(1) "�"
  [4]=>
  string(1) "�"
  [5]=>
  string(1) "�"
  [6]=>
  string(1) "�"
  [7]=>
  string(1) "1"
  [8]=>
  string(1) "2"
  [9]=>
  string(1) "3"
}
示例代码 php

构建:

1. 分割敏感词
    2. 逐个将分割后的次添加到树中

使用:

分割待处理词句

从Trie树根节点开始逐个匹配

class SensitiveWordFilter
{
    protected $dict;
    protected $dictFile;

    /**
     * @param string $dictFile 字典文件路径, 每行一句
     */
    public function __construct($dictFile)
    {
        $this->dictFile = $dictFile;
        $this->dict = [];
    }

    public function loadData($cache = true)
    {
        $memcache = new Memcache();
        $memcache->pconnect("127.0.0.1", 11212);
        $cacheKey = __CLASS__ . "_" . md5($this->dictFile);
        if ($cache && false !== ($this->dict = $memcache->get($cacheKey))) {
             return;
        }

        $this->loadDataFromFile();

        if ($cache) {
            $memcache->set($cacheKey, $this->dict, null, 3600);
        }
    }

    /**
     * 从文件加载字典数据, 并构建 trie 树
     */
    public function loadDataFromFile()
    {
        $file = $this->dictFile;
        if (!file_exists($file)) {
            throw new InvalidArgumentException("字典文件不存在");
        }

        $handle = @fopen($file, "r");
        if (!is_resource($handle)) {
            throw new RuntimeException("字典文件无法打开");
        }

        while (!feof($handle)) {
            $line = fgets($handle);
            if (empty($line)) {
                continue;
            }

            $this->addWords(trim($line));
        }

        fclose($handle);
    }

    /**
     * 分割文本(注意ascii占1个字节, unicode...)
     *
     * @param string $str
     *
     * @return string[]
     */
    protected function splitStr($str)
    {
        return preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY);
    }

    /**
     * 往dict树中添加语句
     *
     * @param $wordArr
     */
    protected function addWords($words)
    {
        $wordArr = $this->splitStr($words);
        $curNode = &$this->dict;
        foreach ($wordArr as $char) {
            if (!isset($curNode)) {
                $curNode[$char] = [];
            }

            $curNode = &$curNode[$char];
        }
        // 标记到达当前节点完整路径为"敏感词"
        $curNode["end"]++;
    }

    /**
     * 过滤文本
     * 
     * @param string $str 原始文本
     * @param string $replace 敏感字替换字符
     * @param int    $skipDistance 严格程度: 检测时允许跳过的间隔
     *
     * @return string 返回过滤后的文本
     */
    public function filter($str, $replace = "*", $skipDistance = 0)
    {
        $maxDistance = max($skipDistance, 0) + 1;
        $strArr = $this->splitStr($str);
        $length = count($strArr);
        for ($i = 0; $i < $length; $i++) {
            $char = $strArr[$i];

            if (!isset($this->dict[$char])) {
                continue;
            }

            $curNode = &$this->dict[$char];
            $dist = 0;
            $matchIndex = [$i];
            for ($j = $i + 1; $j < $length && $dist < $maxDistance; $j++) {
                if (!isset($curNode[$strArr[$j]])) {
                    $dist ++;
                    continue;
                }

                $matchIndex[] = $j;
                $curNode = &$curNode[$strArr[$j]];
            }

            // 匹配
            if (isset($curNode["end"])) {
//                Log::Write("match ");
                foreach ($matchIndex as $index) {
                    $strArr[$index] = $replace;
                }
                $i = max($matchIndex);
            }
        }
        return implode("", $strArr);
    }

    /**
     * 确认所给语句是否为敏感词
     *
     * @param $strArr
     *
     * @return bool|mixed
     */
    public function isMatch($strArr)
    {
        $strArr = is_array($strArr) ? $strArr : $this->splitStr($strArr);
        $curNode = &$this->dict;
        foreach ($strArr as $char) {
            if (!isset($curNode[$char])) {
                return false;
            }
        }
//        return $curNode["end"] ?? false;  // php 7
        return isset($curNode["end"]) ? $curNode["end"] : false;
    }
}

字典文件示例:

敏感词1
敏感词2
敏感词3
...

使用示例:

$filter = new SensitiveWordFilter(PATH_APP . "/config/dirty_words.txt");
$filter->loadData()
$filter->filter("测试123文本","*", 2)
优化 缓存字典树

原始敏感词文件大小: 194KB(约20647行)

生成字典树后占用内存(约): 7MB

构建字典树消耗时间: 140ms+ !!!

php 的内存占用这点...先放着

构建字典树消耗时间这点是可以优化的: 缓存!

由于php脚本不是常驻内存类型, 每次新的请求到来时都需要构建字典树.

我们通过将生成好的字典树数组缓存(memcached 或 redis), 在后续请求中每次都从缓存中读取, 可以大大提高性能.

经过测试, 构建字典树的时间从 140ms+ 降低到 6ms 不到,

注意:

memcached 默认会自动序列化缓存的数组(serialize), 取出时自动反序列化(unserialize)

若是redis, 则需要手动, 可选择 json 存取

序列化上述生成的Trie数组后的字符长度:

serialize: 426KB

json: 241KB

提示: 因此若整个字典过大, 导致存入memcached时超出单个value大小限制时(默认是1M), 可以考虑手动 json 序列化数组再保存.

↑  ...刚发现memcache存入value时提供压缩功能, 可以考虑使用
常驻服务

若是将过滤敏感字功能独立为一个常驻内存的服务, 则构建字典树这个过程只需要1次, 后续值需要处理过滤文本的请求即可.

如果是PHP, 可以考虑使用 Swoole

由于项目当前敏感词词库仅2W条左右,  而且访问瓶颈并不在此, 因此暂时使用上述方案.

ab测试时单个

若是词库达上百万条, 那估计得考虑一下弄成常驻内存的服务了

这里有一篇 文章 测试了使用 Swoole(swoole_http_server) + trie-filter 扩展, 词库量级200W

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/31442.html

相关文章

  • 敏感检测算法小结

    摘要:序本文简单介绍下敏感词或者脏词检测算法。经典算法经典的算法由三部分构成,表,表和表,共包含四种具体的算法,分别是计算三张查找表的算法以及算法本身。表是由模式集合中的所有模式构成的状态转移自动机。 序 本文简单介绍下敏感词或者脏词检测算法。 经典AC算法 经典的AC算法由三部分构成,goto表,fail表和output表,共包含四种具体的算法,分别是计算三张查找表的算法以及AC算法本身。...

    刘厚水 评论0 收藏0
  • 如何快速实现高并发短文检索

    摘要:问龙哥,还有什么更好,更轻量级的方案么龙哥用树,数据会膨胀文档数标题长度这么多,标题越长,文档数越多,内存占用越大。 一、需求缘起某并发量很大,数据量适中的业务线需要实现一个标题检索的功能:(1)并发量较大,每秒20w次(2)数据量适中,大概200w数据(3)是否需要分词:是(4)数据是否实时更新:否 二、常见潜在解决方案及优劣(1)数据库搜索法具体方法:将标题数据存放在数据库中,使用...

    URLOS 评论0 收藏0
  • [Leetcode] Word Search 单搜索

    摘要:我们可以先用待查单词建立一个字典树,这样我们在从矩阵中某个点开始深度优先搜索时,可以直接用字典树判断当前组成的字符串是否是某个单词的前缀。字典树同样也提供接口,所以同样可以用于判断是否已经搜索到这个词了。 Word Search I 更新的思路与解法请访问:https://yanjia.me/zh/2018/11/... Given a 2D board and a word, f...

    objc94 评论0 收藏0
  • Spring Boot项目实践之问答社区

    摘要:异步事件处理本项目涉及到多种异步事件的处理。即是的粉丝,是的关注对象。模式定义优缺点推事件触发后广播给所有粉丝。具体来说,推模式就是事件触发后产生,触发事件的用户下所有粉丝的实现中都存入该的。 项目源代码已托管在 Github,欢迎 Star、Fork。 Q & A 问答社区 QA 是一个基于 B/S 架构而设计开发的社区网站。 showImg(https://segmentfault...

    binaryTree 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<