0.前言
最近因为实现敏感词过滤碰到了一些问题,一般的实现来说,会选择把敏感词放到Redis或者MySql中,然后直接查找给定的一个字符串是否是敏感词。但是这样的实现会有一个问题存在,就是如果用户在这个敏感词的基础上再添加点额外的词组就会使得敏感词的检查失效,例如用户将输入改为:"违禁123"、“违,,。。禁”、“违123禁”、“违123jin”等形式。
所以这样就要求敏感词的检查要有一定的模糊查找能力,如果敏感词数量较少的话,我们暴力的检查每个敏感词是否出现在了用户的输入中,但如果敏感词数量很多的时候,这样暴力的检查会导致性能非常低下。
所以在这样的考虑之下,需要寻找一种算法和数据结构来提高这个检查操作的性能。在咨询过公司的算法大佬同事后,了解到DFA算法可以实现我的需求,后面通过查找资料又发现类似的还有Aho-Corasick automation、Wu-Manber这些多模式匹配算法。最后面再看过一些这些算法的文档后,我决定还是先从最简单最直观的TrieTree实现匹配入个门先(没错,其实就是翻了那些算法的资料后发现看不懂!)。
1.TrieTree树结构
TrieTree也叫字典树,这个结构不论组织起来和操作起来都非常简单和直观,比如我有一组词:{"中国", "中国人民", "北京", "北京王府井", "北京王者荣耀"},这些词在TrieTree中的组织形式如下:
每个词的每个字符都是树中的一个节点,然后按照字符的顺序组织在树结构中,如上图,其中红色节点代表一个词的结束节点,也就是说遍历到这类结束节点代表就是一个词了。
2.PHP实现TrieTree结构
使用PHP代码表示TrieTree中的一个节点的代码如下:
class TrieTreeNode {
const ROOT_CHAR = '';
private $char;
private $isEnd;
private $children = [];
public function __construct($char)
{
$this->char = $char;
$this->isEnd = false;
}
/**
* @return string
*/
public function getChar()
{
return $this->char;
}
/**
* @return bool
*/
public function isEnd()
{
return $this->isEnd;
}
/**
* @param $isEnd bool
*/
public function setIsEnd($isEnd)
{
$this->isEnd = $isEnd;
}
/**
* @param TrieTreeNode $node
*/
public function addChild(TrieTreeNode $node)
{
$this->children[$node->getChar()] = $node;
}
/**
* @param $char
* @return TrieTreeNode|null
*/
public function findChildByChar($char)
{
if (isset($this->children[$char])) {
return $this->children[$char];
} else {
return null;
}
}
}
然后在使用一个PHP类来封装这个TrieTree结构:
class TrieTree {
private $rootNode;
public function __construct()
{
$this->rootNode = new TrieTreeNode(TrieTreeNode::ROOT_CHAR);
}
}
插入
然后再来实现插入方法,插入的流程其实很简单,假如TrieTree现在是一颗空树,我们要将“中国人”这个词插入到TrieTree树中,那么首先我们得将这个词拆成一个一个字符再挨个处理:
我们先从顶部root节点查找这个节点是是否有“中”这个字符节点,因为目前是空树,所以是新建了一个节点并保存在顶部节点中,然后我再从这个新的“中”字符节点中查找是否有“国”这个子字符节点,没有则新建“国”字符节点并添加到“中”字符节点的子节点中,按照这个流程不断类推直到添加到最后一个“民”字符节点为止,添加到最后一个节点后还要将最后一个节点标记为结束节点以表示一组词的结束。
PHP进行这个操作的代码流程如下:
public function insert($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
for ($i = 0; $i < $len; $i++) {
//按照每个字符进行遍历
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
if (is_null($targetNode)) {
$targetNode = new TrieTreeNode($char);
$searchNode->addChild($targetNode);
}
$searchNode = $targetNode;
}
//标记这是最后一个词
$searchNode->setIsEnd(true);
return $searchNode;
}
这个成员方法包含在我们列出的“ TrieTree”类中。
搜索
搜索的过程也非常简单,依旧是把要搜索的词拆成一个一个字符,然后沿着TrieTree的节点慢慢往下遍历,直到遇到结束节点。注意这里需要一些处理,以前面列的那张TrieTree的结构图为例子,“中国”和“中国人民”这两组词里面“国”和“民”都是结束节点,所以当你要查找“中国人民”时碰到的第一个结束节点是“国”字符节点,所以当碰到结束节点时,我们还要判断遍历深度来决定是不是找到了我们想找到的词。
PHP实现代码如下:
public function search($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
if (!is_null($targetNode)) {
$searchNode = $targetNode;
if ($targetNode->isEnd() && $i == $len - 1) {
return $targetNode;
}
}
}
return null;
}
分词
分词的过程和搜索的过程类似,不同的是因为分词给出的并不是一个精确的词,所以当找不到后续子节点时(也就是找不到匹配的词)会直接返回null,而分词会把搜索节点重新定位到顶部root节点再重新开始搜索是不是有匹配的词。
PHP实现代码如下:
public function split($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
$matchResult = [];
$matchChars = [];
$lastMatchIndex = 0;
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
$searchNode = $targetNode;
if (!is_null($targetNode)) {
//记录沿途遍历中的每个匹配的字符
$matchChars[] = $targetNode->getChar();
//每次匹配都记录最后一次匹配的字符索引位置
$lastMatchIndex = $i;
if ($targetNode->isEnd()) {
//遇到结束节点代表匹配了一个词,这里把记录下来
//的每个字符组成一个词
$matchWord = implode('', $matchChars);
//如果直接匹配过则自增计数匹配次数
if (isset($matchResult[$matchWord])) {
$matchResult[$matchWord] += 1;
} else {
$matchResult[$matchWord] = 1;
}
//清空匹配的字符并且把搜索节点重新定位到顶部root节点
//以开始新的匹配
$matchChars = [];
$searchNode = $this->rootNode;
}
} else {
//如果有中途有匹配字符,但是最后没有匹配到词导致结束
//则倒退到最后一次字符的匹配点再从顶部root节点重新
//开始匹配新的字符
if (!empty($matchChars)) {
$i = $lastMatchIndex;
}
$matchChars = [];
$searchNode = $this->rootNode;
}
}
return $matchResult;
}
当然这个简单的分词还有一个问题没解决,就是当TrieTree包含“中国”和“中国人民”时,而一个文本中出现“中国人民”,该代码分词出来的结果是“中国”,因为非精确匹配无法以深度判断是否是我们想找的词,所以这里简单的以碰到的一个结束节点作为我们要的词。
以一段文本来解读这段代码,比如我们传入一段文本"我是北中国人民",对着上面的代码,“我”和“是”都不满足!is_null($targetNode)
这个条件,所以略过,而当匹配到"北"的时候这个条件满足来,但当在“北”字符节点中查找“中”字符节点时却失败来,所以进到了else里面的处理代码,因为我们希望“北”后面的匹配失败不影响后面“中国”这个词的匹配,所以我们才有了这段逻辑:
if (!empty($matchChars)) {
$i = $lastMatchIndex;
}
敏感词过滤
敏感词过滤则和分词类似,同样是查找文本中是否有TrieTree中的词出现,但是敏感词匹配的操作则可以稍微在分词的基础有所改进来提升速度,因为敏感词是只要有出现则这段文本就是非法,所以我们可以在匹配了第一个词就直接返回。
PHP实现代码如下:
public function match($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
$matchChars = [];
$lastMatchIndex = 0;
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
$searchNode = $targetNode;
if (!is_null($targetNode)) {
$matchChars[] = $targetNode->getChar();
$lastMatchIndex = $i;
if ($targetNode->isEnd()) {
return true;
}
} else {
if (!empty($matchChars)) {
$i = $lastMatchIndex;
}
$matchChars = [];
$searchNode = $this->rootNode;
}
}
return false;
}
上述代码和分词类似,只不过在匹配到一个词后我们直接返回而不像分词一样继续向后匹配。
3.完整代码
char = $char;
$this->isEnd = false;
}
/**
* @return string
*/
public function getChar()
{
return $this->char;
}
/**
* @return bool
*/
public function isEnd()
{
return $this->isEnd;
}
/**
* @param $isEnd bool
*/
public function setIsEnd($isEnd)
{
$this->isEnd = $isEnd;
}
/**
* @param TrieTreeNode $node
*/
public function addChild(TrieTreeNode $node)
{
$this->children[$node->getChar()] = $node;
}
/**
* @param $char
* @return TrieTreeNode|null
*/
public function findChildByChar($char)
{
if (isset($this->children[$char])) {
return $this->children[$char];
} else {
return null;
}
}
}
class TrieTree {
private $rootNode;
public function __construct()
{
$this->rootNode = new TrieTreeNode(TrieTreeNode::ROOT_CHAR);
}
public function insert($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
for ($i = 0; $i < $len; $i++) {
//按照每个字符进行遍历
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
if (is_null($targetNode)) {
$targetNode = new TrieTreeNode($char);
$searchNode->addChild($targetNode);
}
$searchNode = $targetNode;
}
//标记这是最后一个词
$searchNode->setIsEnd(true);
return $searchNode;
}
public function search($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
if (!is_null($targetNode)) {
$searchNode = $targetNode;
if ($targetNode->isEnd() && $i == $len - 1) {
return $targetNode;
}
}
}
return null;
}
public function split($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
$matchResult = [];
$matchChars = [];
$lastMatchIndex = 0;
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
$searchNode = $targetNode;
if (!is_null($targetNode)) {
//记录沿途遍历中的每个匹配的字符
$matchChars[] = $targetNode->getChar();
//每次匹配都记录最后一次匹配的字符索引位置
$lastMatchIndex = $i;
if ($targetNode->isEnd()) {
//遇到结束节点代表匹配了一个词,这里把记录下来
//的每个字符组成一个词
$matchWord = implode('', $matchChars);
//如果直接匹配过则自增计数匹配次数
if (isset($matchResult[$matchWord])) {
$matchResult[$matchWord] += 1;
} else {
$matchResult[$matchWord] = 1;
}
//清空匹配的字符并且把搜索节点重新定位到顶部root节点
//以开始新的匹配
$matchChars = [];
$searchNode = $this->rootNode;
}
} else {
//如果有中途有匹配字符,但是最后没有匹配到词导致结束
//则倒退到最后一次字符的匹配点再从顶部root节点重新
//开始匹配新的字符
if (!empty($matchChars)) {
$i = $lastMatchIndex;
}
$matchChars = [];
$searchNode = $this->rootNode;
}
}
return $matchResult;
}
public function match($str)
{
$len = mb_strlen($str);
$searchNode = $this->rootNode;
$matchChars = [];
$lastMatchIndex = 0;
for ($i = 0; $i < $len; $i++) {
$char = mb_substr($str, $i, 1);
$targetNode = $searchNode->findChildByChar($char);
$searchNode = $targetNode;
if (!is_null($targetNode)) {
$matchChars[] = $targetNode->getChar();
$lastMatchIndex = $i;
if ($targetNode->isEnd()) {
return true;
}
} else {
if (!empty($matchChars)) {
$i = $lastMatchIndex;
}
$matchChars = [];
$searchNode = $this->rootNode;
}
}
return false;
}
}
测试代码:
insert('中国');
$segmenter->insert('中国人');
$segmenter->insert('北京王府井');
$segmenter->insert('北京王者荣耀');
echo "split words:" . implode(',', array_keys($segmenter->split('我在中国北京王府井打着北京王者荣耀')));
echo "\n";
$illegalWords = new TrieTree();
$illegalWords->insert('违禁');
$illegalWords->insert('非法');
echo "text check:" . intval($illegalWords->match('这是一个包含违禁非法的文本')) . "\n";
echo "text check:" . intval($illegalWords->match('这是一个正常文本')) . "\n";
输出:
split words:中国,北京王府井,北京王者荣耀
text check:1
text check:0
4.总结
TrieTree是一种非常简单直观并且高效的字典数据结构,并且也利用它的结构实现了一个简单的分词器和敏感词匹配。
利用这个结构实现的敏感词匹配,就能够处理当用户在违禁词周围添加其它词组时就能够正常并且高效的匹配出来,例如“违禁123”、“违禁演唱会”这样的组合。但是还是不能处理"违123禁"和“违,禁”这类组合,对于后者我们可以先过滤文本中的符号再进行处理,对于前者也可以考虑采用这样的过滤方式进行简单处理。
而对于那种带拼音的组合例如“违123jin”这类,处理起来会比较复杂,这类可以考虑将拼音的形式的组合添加到TrieTree中来简单处理。
而对于分词,只是简单的实现了一个基于词典的没有任何语义理解的粗暴分词,并且还存在一些问题待处理,比如当词典中存在“中国”和“中国人民”,而文本中也同时包含“中国”和“中国人民”时分词应该能拆出“中国”和“中国人民”两组词,而我们实现的分词器只是简单的把遇到的第一组匹配词返回回去。
参考资料:
https://en.wikipedia.org/wiki/Trie
https://www.geeksforgeeks.org/trie-insert-and-search/
https://blog.csdn.net/jijianshuai/article/details/72455736