当前位置:首页 > 技术 > 正文内容

PHP Aho-Corasick 算法同时匹配多个关键字

代码老兵 技术 20

一、传统做法(循环匹配)

最常见写法:

foreach ($keywords as $word) {
   if (mb_strpos($text, $word) !== false) {
       // 命中
   }
}

假设:

  • 关键词数量:M

  • 文章长度:N

时间复杂度接近:

O(M × N)

如果:

  • 文章 10KB

  • 关键词 5000 个

那就是:

5000 × 10000 = 5000万次字符比较

而且还要重复扫描文本。


二、Aho-Corasick

AC 的特点是:

一次扫描文本,同时匹配所有关键词

复杂度:

构建阶段:O(关键词总长度)
匹配阶段:O(N + 匹配结果数)

注意:

匹配阶段与关键词数量几乎无关

这才是核心优势。

核心优势总结

1.  只扫描文本一次

传统方法:

  • 每个关键词都扫描一遍文本

AC:

  • 只扫描一遍文本


2.  关键词越多,优势越明显

关键词数量传统匹配AC算法
10个差别不大略快
1000个明显变慢稳定
10000个非常慢依然稳定
100000个基本不可用仍可用

3.  不会重复回退文本指针

传统:

每个词都会重新从头匹配。

AC:

  • 利用 failure 指针

  • 自动跳转

  • 不重新扫描已经扫描过的字符


4.  复杂度对比总结

传统

O(M × N)

AC

O(N + 总关键词长度)

当 M 很大时:AC 几乎是“降维打击”。


举个真实场景

假设:

  • 文章 20KB

  • 敏感词 30000 条

传统方法

需要扫描:

30000 × 20000 = 6亿 次比较

AC 方法

只扫描:

20000 次

差距是 30000 倍。


三、什么时候不用 AC?

如果:

  • 关键词 < 20 个

  • 只匹配一次

  • 性能要求不高

直接 strpos 反而更简单。


四、缺点

AC 也不是没有代价:

  1. 构建 Trie 比较耗内存

  2. 构建阶段较慢(一次性成本)

但:

一旦词库大于几百条
一旦是高并发
一旦是敏感词系统

AC 几乎是标准答案。

Aho-Corasick 原理简述

算法分三步:

  1. 构建 Trie(字典树)

  2. 构建 failure 指针

  3. 扫描文本匹配

 Aho-Corasick PHP 实现(支持中文)

<?php

class AhoCorasick
{
   private array $trie = [];
   private array $fail = [];
   private array $output = [];

   public function __construct(array $keywords)
   {
       $this->buildTrie($keywords);
       $this->buildFailureLinks();
   }

   private function splitChars(string $str): array
   {
       return preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
   }

   private function buildTrie(array $keywords): void
   {
       $this->trie = [[]];
       $this->output = [[]];

       foreach ($keywords as $word) {
           $node = 0;
           $chars = $this->splitChars($word);

           foreach ($chars as $char) {
               if (!isset($this->trie[$node][$char])) {
                   $this->trie[$node][$char] = count($this->trie);
                   $this->trie[] = [];
                   $this->output[] = [];
               }

               $node = $this->trie[$node][$char];
           }

           $this->output[$node][] = $word;
       }
   }

   private function buildFailureLinks(): void
   {
       $this->fail = array_fill(0, count($this->trie), 0);
       $queue = new SplQueue();

       foreach ($this->trie[0] as $node) {
           $queue->enqueue($node);
       }

       while (!$queue->isEmpty()) {
           $r = $queue->dequeue();

           foreach ($this->trie[$r] as $char => $u) {
               $queue->enqueue($u);

               $state = $this->fail[$r];

               while ($state && !isset($this->trie[$state][$char])) {
                   $state = $this->fail[$state];
               }

               if (isset($this->trie[$state][$char])) {
                   $this->fail[$u] = $this->trie[$state][$char];
               }

               $this->output[$u] = array_merge(
                   $this->output[$u],
                   $this->output[$this->fail[$u]]
               );
           }
       }
   }

   public function search(string $text): array
   {
       $results = [];
       $node = 0;
       $chars = $this->splitChars($text);

       foreach ($chars as $index => $char) {

           while ($node && !isset($this->trie[$node][$char])) {
               $node = $this->fail[$node];
           }

           if (isset($this->trie[$node][$char])) {
               $node = $this->trie[$node][$char];
           }

           foreach ($this->output[$node] as $word) {
               $wordLength = count($this->splitChars($word));

               $results[] = [
                   'word' => $word,
                   'position' => $index - $wordLength + 1
               ];
           }
       }

       return $results;
   }
}

使用示例

$keywords = ['程序员', '写代码', '老程序员'];

$ac = new AhoCorasickUtf8($keywords);

$text = "老程序员还在写代码";

$result = $ac->search($text);

print_r($result);

输出:

Array
(
   [0] => Array
       (
           [word] => 老程序员
           [position] => 0
       )

   [1] => Array
       (
           [word] => 程序员
           [position] => 1
       )

   [2] => Array
       (
           [word] => 写代码
           [position] => 6
       )

)
标签: PHPAho-Corasick

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。