php使用前缀树实现关键词查找

之前旧的php系统有一个关键词查找和敏感词过滤的功能,直接使用了如下代码实现,

foreach ($words as $word){
    if(strrpos($content, $word) !== false){
        $tags[] = $word;
    }
}

随着关键词增多,性能上有点拖后腿,一直想优化一下性能。
刚好从网上看到一个比较简单的java版本"利用利用字典树(前缀树)过滤敏感词"(https://blog.csdn.net/qq_37050329/article/details/84276344)的算法文章,感觉按这算法实现可以提升性能。
php第一个版本前缀树过滤迅速实现:

<?php
class Words{
    /**
     * true 关键词的终结 ; false 继续
     */
    private $end = false;
    
    /**
     * key下一个字符,value是对应的节点
     */
    private $subNodes = array();
    
    /**
             * 向指定位置添加节点树
     */
    public function addSubNode($key, $node) {
        $this->subNodes[$key] = $node;
    }
    /**
             * 获取下个节点
     */
    public function getSubNode($key) {
        return isset($this->subNodes[$key]) == true ? $this->subNodes[$key] : null;
    }
    
    function isKeywordEnd() {
        return $this->end;
    }
    
    function setKeywordEnd($end) {
        $this->end = $end;
    }
}
class FilterWords{
    //根节点
    private $rootNode;
    
    function __construct() {
        $this->rootNode = new Words();
    }
    
    public function isSymbol($c){
        $symbol = array('\t', '\r\n',
            '\r', '\n', 'amp;', '&gt','。', '?','!',',','、',';',':','丨','|',':','《','》','“','”',
            '.',',',';',':','?','!','&nbsp',' ','(',')'
        );
        if(in_array($c, $symbol)){
            return true;
        }
        else{
            return false;
        }
    }
    
    /**
             * 过滤敏感词
     */
    function filter($text) {
        $mblen = mb_strlen($text);
        if ($mblen == 0) {
            return null;
        }
        $tempNode = $this->rootNode;
        $begin = 0; // 回滚数
        $position = 0; // 当前比较的位置
        
        $tagBuffer = array();
        $tempBuffer = "";
        while ($position < $mblen) {
            $c = mb_substr($text, $position, 1);
            //特殊符号直接跳过
            if ($this->isSymbol($c)) {
                if ($tempNode == $this->rootNode) {
                    ++$begin;
                }
                ++$position;
                continue;
            }
            $tempNode = $tempNode->getSubNode($c);
            // 当前位置的匹配结束
            if ($tempNode == null) {
                // 跳到下一个字符开始测试
                $position = $begin + 1;
                $begin = $position;
                // 回到树初始节点
                $tempNode = $this->rootNode;
                $tempBuffer = '';
            } else if ($tempNode->isKeywordEnd()) {
                $tempBuffer .= $c;
                $tagBuffer[] = $tempBuffer;
                $position = $position + 1;
                $begin = $position;
                $tempNode = $this->rootNode;
            } else {
                $tempBuffer .= $c;
                ++$position;
            }
        }
        return $tagBuffer;
    }
    
    /**
             * 构造字典树
     * @param lineTxt
     */
    public function addWord($lineTxt) {
        $tempNode = $this->rootNode;
        $mblen = mb_strlen($lineTxt);
        // 循环每个字节
        for ($i = 0; $i < $mblen; ++$i) {
            $c = mb_substr($lineTxt, $i, 1);
            // 过滤空格
            if ($this->isSymbol($c)) {
                continue;
            }
            $node = $tempNode->getSubNode($c);
            
            if ($node == null) { // 没初始化
                $node = new Words();
                $tempNode->addSubNode($c, $node);
            }
            
            $tempNode = $node;
            
            if ($i == mb_strlen($lineTxt) - 1) {
                $tempNode->setKeywordEnd(true);
            }
        }
    }
}

开发完,测试了下,

$filterWords = new FilterWords();
$filterWords->addWord("☺");
$filterWords->addWord("沪伦通");
$filterWords->addWord("中欧");
$tags = $filterWords->filter("?☺?沪伦通首单即将开启 中欧资本融合驶入快车道");
var_dump($tags);

输出:
array(3) {
  [0]=>
  string(3) "☺"
  [1]=>
  string(9) "沪伦通"
  [2]=>
  string(6) "中欧"
}

性能测试,关键过滤词14600个。
旧处理方式性能

<?php
function microtime_float()
{
    list($usec, $sec) = explode(" ", microtime());
    return ((float)$usec + (float)$sec);
}
function readFileByLine ($filename)
{
    $words = array();
    $fh = fopen($filename, 'r');
    while (! feof($fh)) {
        $line = fgets($fh);
        $words[] = trim($line);
    }
    fclose($fh);
    return $words;
}
function getHtml($url){
    $opts = array(
        'http'=>array(
            'method'=>"GET",
            'header'=>"Accept-Language: zh-CN,zh;q=0.9\r\n" .
            "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36\r\n"
        )
    );
    $context = stream_context_create($opts);
    $file = file_get_contents($url, false, $context);
    return $file;
}
$content = getHtml('http://baijiahao.baidu.com/s?id=1637904839202713990');
$content = strip_tags($content);
$words = readFileByLine("banned.txt");
$start = microtime_float();
$tags = array();
foreach ($words as $word){
    if(strrpos($content, $word) !== false){
        $tags[] = $word;
    }
}
var_dump($tags);
$end = microtime_float();
echo "耗时:$end - $start = ".($end - $start)."\n";

//耗时:1562250442.266 - 1562250441.2643 = 1.0016851425171

第一个版本前缀树性能

<?php
$content = getHtml('http://baijiahao.baidu.com/s?id=1637904839202713990');
var_dump(strip_tags($content));
$words = readFileByLine("banned.txt");
$filterWords = new FilterWords();
foreach($words as $word){
    $filterWords->addWord($word);
}
$start = microtime_float();
$tags = $filterWords->filter($content);
var_dump($tags);
$end = microtime_float();
echo "耗时:$end - $start = ".($end - $start)."\n";

//耗时:1562250499.7439 - 1562250484.4361 = 15.307857036591

使用前缀树的性能比原来strpos慢了10多倍。。。。。。
检查了一翻,怀疑可能是使用mb_substr来把文章分割成字符数组损耗性能利害,在Java中使用统一的Unicode,而在PHP中使用的UTF-8字符集,用1-4个字节不同的长度来表示一个字符,$text[$position]不一定能表示一个完整字符。
增加一个getChars测试方法,先把文本转成字符数组,再把字符数组传到$filterWords->filter($chars)方法,经测试,性能明显比旧的方式好。

public function getChars($lineTxt){
        $mblen = mb_strlen($lineTxt);
        $chars = array();
        for($i = 0; $i < $mblen; $i++){
            $c = mb_substr($lineTxt, $i, 1, 'UTF-8');
            $chars[] = $c;
        }
        return $chars;
    }

这样可以确定前缀树查找算法性能没问题,性能问题是在mb_substr方法上,因些需要改进获取字符的方法。可以通过判断当前字符是多少字节,然后再通过$text[$position]来获取字节拼接成完整字符。
第二个版本优化调整后的前缀树过滤实现:

class FilterWords{
    public $rootNode;
    function __construct() {
        $this->rootNode = array('_end_'=>false);
    }
   
    public function getChars($lineTxt){
        $mblen = mb_strlen($lineTxt);
        $chars = array();
        for($i = 0; $i < $mblen; $i++){
            $c = mb_substr($lineTxt, $i, 1, 'UTF-8');
            $chars[] = $c;
        }
        return $chars;
    }
  
    /**
             * 构造字典树
     * @param $word
     */
    public function addWord($word) {
        $tempNode = &$this->rootNode;
        $mblen = mb_strlen($word);
        // 循环每个字节
        for ($i = 0; $i < $mblen; ++$i) {
            $c = mb_substr($word, $i, 1);
            
            if(empty($tempNode[$c]) == true){
                $tempNode[$c] = array('_end_'=>false);
            }
            $tempNode = &$tempNode[$c];
            
            if ($i == $mblen - 1) {
                $tempNode['_end_'] = true;
            }
        }
    }

    
    function filter($text) {
        $len = strlen($text);
        if ($len == 0) {
            return null;
        }
        $tempNode = $this->rootNode;
        $position = 0;
        $tags = array();
        $temp = "";
        while ($position < $len) {
            $c = $text[$position];
            $n = ord($c);
            if(($n >> 7) == 0){       //1字节
            }
            else if(($n >> 4) == 15 ){     //4字节
                if($position < $len - 3){
                    $c = $c.$text[$position + 1].$text[$position + 2].$text[$position + 3];
                    $position += 3;
                }
            }
            else if(($n >> 5) == 7){  //3字节
                if($position < $len - 2){
                    $c = $c.$text[$position + 1].$text[$position + 2];
                    $position += 2;
                }
            }
            else if(($n >> 6) == 3){  //2字节
                if($position < $len - 1){
                    $c = $c.$text[$position + 1];
                    $position++;
                }
            }
            $tempNode = isset($tempNode[$c]) == true ? $tempNode[$c] : null;
            // 当前位置的匹配结束
            if ($tempNode == null) {
                $position = $position + 1;
                // 回到树初始节点
                $tempNode = $this->rootNode;
                $temp = '';
            } else if ($tempNode['_end_'] == true) {
                $temp .= $c;
                $tags[] = $temp;
                $temp = '';
                $position = $position + 1;
                $tempNode = $this->rootNode;
            } else {
                $temp .= $c;
                ++$position;
            }
        }
        return $tags;
    }
}

第二个版本前缀树性能:

$content = getHtml('http://baijiahao.baidu.com/s?id=1637904839202713990');
$content = strip_tags($content);
var_dump($content);
$words = readFileByLine("banned.txt");
$filterWords = new FilterWords();
foreach($words as $word){
    $filterWords->addWord($word);
}
$start = microtime_float();

$tags = $filterWords->filter($content);
var_dump($tags);
$end = microtime_float();
echo "耗时:$end - $start = ".($end - $start)."\n";

耗时:1562250534.7054 - 1562250534.6888 = 0.016629934310913

耗时0.016629934310913,比旧方式的耗时1.0016851425171性能提升了一大截。
后期继续调整代码优化。

完整测试代码下载

 

转载于:https://my.oschina.net/penngo/blog/3069700

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
前缀树(Trie树)是一种多叉树结构,用于存储字符串集合,并支持高效的字符串匹配操作。在IP地址查找中,我们可以将每个IP地址转化为字符串,然后将这些字符串插入到前缀树中。具体实现如下: ```c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #define IP_SIZE 32 // IP地址的二进制位数 #define MAX_IP_NUM 100 // 最大IP地址数 // 前缀树节点结构体 typedef struct TrieNode { int count; // 记录该节点对应的IP地址数量 struct TrieNode *next[2]; // 0表示左子树,1表示右子树 } TrieNode; // 初始化前缀树 TrieNode *initTrie() { TrieNode *root = (TrieNode*)malloc(sizeof(TrieNode)); memset(root, 0, sizeof(TrieNode)); return root; } // 插入IP地址到前缀树中 void insertTrie(TrieNode *root, uint32_t ip) { TrieNode *p = root; for (int i = 0; i < IP_SIZE; i++) { int bit = (ip & (1 << (IP_SIZE - i - 1))) ? 1 : 0; if (!p->next[bit]) { p->next[bit] = (TrieNode*)malloc(sizeof(TrieNode)); memset(p->next[bit], 0, sizeof(TrieNode)); } p = p->next[bit]; } p->count++; } // 在前缀树查找IP地址,返回IP地址数量 int searchTrie(TrieNode *root, uint32_t ip) { TrieNode *p = root; for (int i = 0; i < IP_SIZE; i++) { int bit = (ip & (1 << (IP_SIZE - i - 1))) ? 1 : 0; if (!p->next[bit]) { return 0; } p = p->next[bit]; } return p->count; } int main() { TrieNode *root = initTrie(); uint32_t ips[MAX_IP_NUM] = {0}; // 存储IP地址 int n = 0; // IP地址数量 printf("请输入IP地址数量:"); scanf("%d", &n); for (int i = 0; i < n; i++) { uint32_t ip = 0; printf("请输入第%d个IP地址:", i+1); scanf("%u", &ip); ips[i] = ip; insertTrie(root, ip); } while (1) { uint32_t ip = 0; printf("请输入要查找的IP地址(输入0退出):"); scanf("%u", &ip); if (ip == 0) { break; } int count = searchTrie(root, ip); printf("IP地址 %u 在前缀树中出现了 %d 次\n", ip, count); } return 0; } ``` 在上面的代码中,我们使用了位运算来处理IP地址的二进制位,将每个IP地址插入到前缀树中,然后可以在前缀树查找指定IP地址。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值