PHP巨量关键词的匹配


假设有 100万 条短消息记录日志,每条约 50 字,5万 关键词,长度 2-8 字,绝大部分为中文。要求将这 100万 条记录中包含的关键词全部提取出来并统计各关键词的命中次数。

    在面对如此巨大的词量时,很多人都会一脸茫然,常见的grep 、正则、拆词等方法很难快速得到结果。可是有没有想过用 字典树 来解决呢?

    【字典树】

    又称前缀树或 trie 树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。

    我们可以类比字典的特性:我们在字典里通过拼音查找晃(huang)这个字的时候,我们会发现它的附近都是读音为huang的,可能是声调有区别,再往前翻,我们会看到读音前缀为huan的字,再往前,是读音前缀为hua的字… 取它们的读音前缀分别为 h qu hua huan huang。我们在查找时,根据 abc...xyz 的顺序找到h前缀的部分,再根据 ha he hu找到 hu前缀的部分…最后找到 huang,我们会发现,越往后其读音前缀越长,查找也越精确,这种类似于字典的树结构就是字典树,也是前缀树。


【设计】

    那么 trie 树怎么实现关键字的匹配呢? 这里以一幅图来讲解 trie 树匹配的过程。

image.png

【构造trie树】

    1、将关键词用上面介绍的preg_split()函数拆分为单个字符。如科学家就拆分为科、学、家三个字符。

    2、在最后一个字符后添加一个特殊字符`,此字符作为一个关键词的结尾(图中的粉红三角),以此字符来标识查到了一个关键词(不然,我们不知道匹配到科、学两个字符时算不算匹配成功)。

    3、检查根部是否有第一个字符(科)节点,如果有了此节点,到步骤4。 如果还没有,在根部添加值为科的节点。

    4、依次检查并添加学、家 两个节点。

    5、在结尾添加 ` 节点,并继续下一个关键词的插入。


【匹配】

    然后我们以 这位科学家很了不起!为例来发起匹配。

    ·首先我们将句子拆分为单个字符 这、位、...;

    ·从根查询第一个字符这,并没有以这个字符开头的关键词,将字符“指针”向后移,直到找到根下有的字符节点科;

    ·接着在节点科下寻找值为 学节点,找到时,结果子树的深度已经到了2,关键词的最短长度是2,此时需要在学结点下查找是否有`,找到意味着匹配成功,返回关键词,并将字符“指针”后移,如果找不到则继续在此结点下寻找下一个字符。

    ·如此遍历,直到最后,返回所有匹配结果。


【核心代码】

$node = array(
    'depth' => $depth, // 深度,用以判断已命中的字数
    'next' => array(
        $val => $node, // 这里借用php数组的哈希底层实现,加速子结点的查找
        ...
    ),
);

然后是树构建时子结点的插入:

// 这里要往节点内插入子节点,所以将它以引用方式传入
private function insert(&$node, $words) {
    if (empty($words)) {
        return;
    }
    $word = array_shift($words);
    // 如果子结点已存在,向子结点内继续插入
    if (isset($node['next'][$word])) {
        $this->insert($node['next'][$word], $words);
    } else {
        // 子结点不存在时,构造子结点插入结果
        $tmp_node = array(
            'depth' => $node['depth'] + 1,
            'next' => array(),
        );
        $node['next'][$word] = $tmp_node;
        $this->insert($node['next'][$word], $words);
    }
}

最后是查询时的操作:

// 这里也可以使用一个全局变量来存储已匹配到的字符,以替换$matched
private function query($node, $words, &$matched) {
    $word = array_shift($words);
    if (isset($node['next'][$word])) {
        // 如果存在对应子结点,将它放到结果集里
        array_push($matched, $word);
        // 深度到达最短关键词时,即可判断是否到词尾了
        if ($node['next'] > 1 && isset($node['next'][$word]['next']['`'])) {
            return true;
        }
        return $this->query($node['next'][$word], $words, $matched);
    } else {
        $matched = array();
        return false;
    }
}


【结果】

    结果当然是喜人的,如此匹配,处理一千条数据只需要3秒左右。找了 Java 的同事试了下,Java 处理一千条数据只需要1秒。

    这里来分析一下为什么这种方法这么快:

    正则匹配:要用所有的关键词去信息里匹配匹配次数是 key_len * msg_len,当然正则会进行优化,但基础这样,再优化效率可想而知。

    而 trie 树效率最差的时候是 msg_len * 9(最长关键词长度 + 1个特殊字符)次 hash查找,即最长关键词类似 AAA,信息内容为 AAA...时,而这种情况的概率可想而知。


【多进程】

    匹配方法的优化结束了,这时候就要考虑一些其他方法了。

    我们一提到高效,必然想到的是 并发,那么接下来的优化就要从并发说起。PHP 是单线程的(虽然也有不好用的多线程扩展),这没啥好的解决办法,并发方向只好从多进程进行了。

    那么一个日志文件,用多个进程怎么读呢?这里当然也提供几个方案:

    进程内添加日志行数计数器,各个进程支持传入参数 n,进程只处理第 行数 % n = n的日志,这种 hack 的反向分布式我已经用得很熟练了,哈哈。这种方法需要进程传参数,还需要每个进程都分配读取整个日志的的内存,而且也不够优雅。

    使用 linux 的 split -l n file.log output_pre 命令,将文件分割为每份为 n 行的文件,然后用多个进程去读取多个文件。此方法的缺点就是不灵活,想换一下进程数时需要重新切分文件。

    使用 Redis 的 list 队列临时存储日志,开启多个进程消费队列。此方法需要另外向 Redis 内写入数据,多了一个步骤,但它扩展灵活,而且代码简单优雅。

    最终使用了第三种方式来进行。

    这种方式虽然也会有瓶颈,最后应该会落在 Redis 的网络 IO 上。我也没有闲心开 n 个进程去挑战本机 Redis 的性能,运行 10 个进程三四分钟就完成了统计。即使再加上 Redis 写入的耗时,10分钟以内也妥妥的。

上一篇 下一篇

评论

登录后可发表评论