[数据结构笔记]PHP使用TrieTree结构实现简单的分词和敏感词过滤

0.前言

最近因为实现敏感词过滤碰到了一些问题,一般的实现来说,会选择把敏感词放到Redis或者MySql中,然后直接查找给定的一个字符串是否是敏感词。但是这样的实现会有一个问题存在,就是如果用户在这个敏感词的基础上再添加点额外的词组就会使得敏感词的检查失效,例如用户将输入改为:"违禁123"、“违,,。。禁”、“违123禁”、“违123jin”等形式。

所以这样就要求敏感词的检查要有一定的模糊查找能力,如果敏感词数量较少的话,我们暴力的检查每个敏感词是否出现在了用户的输入中,但如果敏感词数量很多的时候,这样暴力的检查会导致性能非常低下。

所以在这样的考虑之下,需要寻找一种算法和数据结构来提高这个检查操作的性能。在咨询过公司的算法大佬同事后,了解到DFA算法可以实现我的需求,后面通过查找资料又发现类似的还有Aho-Corasick automation、Wu-Manber这些多模式匹配算法。最后面再看过一些这些算法的文档后,我决定还是先从最简单最直观的TrieTree实现匹配入个门先(没错,其实就是翻了那些算法的资料后发现看不懂!)。

1.TrieTree树结构

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.完整代码

<?php
/**
 * author: LiZhiYang
 * email: zhiyanglee@foxmail.com
 */

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

}

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

}

测试代码:

<?php
/**
 * author: LiZhiYang
 * email: zhiyanglee@foxmail.com
 */

include "TrieTree.php";

$segmenter = new TrieTree();
$segmenter->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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容