php 字典树,[原创] Trie树 php 实现敏感词过滤

[TOC]

背景

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

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

简介

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

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

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

aed961c9cf5aea0b372690d8776c6670.png

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

参考文章

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值