AC自动机原理及其在query意图理解中的应用

概述

AC自动机是一种基于前缀树的多模匹配算法,名称的由来是创作人Alfred V. Aho和Margaret J.Corasick两人的名字:AhoCorasick。
是一种以空间换时间的算法,主要在于匹配失败时能够依赖失配指针实现快速的状态转移。在query理解中应用较多,例如借助AC自动机判断数据query是否包含敏感词、黑名单;查找query中是否包含指定的槽位词;由AC自动机形成更加复杂的算法例如lexparser。

组成

AC自动机主要包含两个东西,一个是前缀树,一个是失配指针。

前缀树

又称trie树、字典树。
以{novel,nova,nol,nasa,nola,art,arm,as}为例,前缀树可能为下面这种结构(红色节点表示该节点为一个单词的结尾):
前缀树

  • 根节点为空
  • 除根节点外其他节点只包含一个字符
  • 任意节点下的所有子节点不重复
  • 从根节点到指定节点的路径组成的字符串表示该节点的key值

失配指针

失配指针的作用是当前节点不匹配的时候跳转到失配指针处继续匹配,节点S的失配指针需要满足:

  1. 设从根节点到S节点类路径组成的字符串为:str(S), str(S)的所有后缀组合为:set(str(S))
  2. 失配指针指向的节点T,从根节点到T的路径组成字符串为: str(T)
  3. 则str(T)为set(str(S))集合上的最长公共前缀
    接下来我们简单分析一下:
    失配指针解释

假设当前我们正在遍历的节点为上图的C节点,C节点的失配指针指向P节点,P节点的失配指针指向pp节点,顶层为根节点。现在我们分析一下N节点的失配指针在哪?
从根节点到P节点的路径组成字符串S2,记作S2=L(Root, p), 同理 S4=L(Root, pp)
根据失配指针定义,我们知道S2为 set(str©)集合上的最长公共前缀, 假设这一前缀为图中的S1,S1=S2,同理S3=S4。

  • 如果p节点的子节点{pc1, pc2}包含N节点值,假设为pc1节点,则 S2+pc1 = S1+N, 根据失配指针定义此时N的失配指针指向pc1节点。
  • 如果p节点的子节点{pc1,pc2}不包含N节点的值,则S2通路是不包含N节点的,需要向更小的集合转移。而最小集合中的最长公共前缀为S3,p节点的失配指针为pp,所以我们转移去判断pp的所有子节点是否满足条件
  • 若不满足,继续沿着失配指针转移,满足则找到失配指针,否则直到失配指针为空,目标节点N失配指向根节点。
    显然上述过程是一个从上到下的过程。那么我们可以层序遍历所有节点,依次赋值失配指针。

失配指针

实现

建树

设当前需要插入的词为input,则一次遍历input中的元素,加入到树中,假设当前的元素为ele,当前的节点为current,若current的子节点中存在ele,则继续遍历input,current指向对应子节点;若不存在,则新建一个节点,作为current节点的子节点。

连接失配指针

当前节点current, current.failed = p, n节点为current节点的子节点

  1. current.failed的字节x的值 == n节点的值, 则n.failed = current.failed.children[x],跳出
  2. current = current.failed, 继续1步骤
  3. 若遍历过程中current = nullptr(根节点),则 n.failed = root;

搜索过程

ac自动机搜索过程
注意图中,当该节点的子节点包含目标字符时,此时要判断该子节点child以及child->failed是否为词尾

使用map的一种实现

  • 树的节点
    树中的每个节点至少包含:当前节点的值、失配指针、当前节点的val(用于记录是否为一个词的结尾)、子节点集合。这里我们考虑到中文字符大于1字节,这里我们使用std::string 表示一个单字,作为节点的key, 那么子节点集合我们要使用map表示。当然如果一定要用英文字符表示最小单元,也可以配置使用定长数组(assic码范围较小且固定)表示子节点集合。
struct Node {
  int val = -1;                                     // 当前节点的val
  std::string key;                                  // 当前节点的key
  Node *failed = nullptr;                           // 失配指针
  std::unordered_map<std::string, Node *> children; // 子节点,key为子节点的val
};
  • AC自动机定义
    至少应该包含插入、连接失配指针、搜索匹配几个方法。注意这里的Insert方法,第二参数为int类型,实际上通常是真正数据list的索引下标。
class AhoCorasick {
public:
  AhoCorasick();
  virtual ~AhoCorasick();
  void Init();
  bool Insert(const std::string &key, int val);
  bool Match(const std::string &input, std::vector<int> *output);
  void Debug() const;
private:
  Node *root = nullptr;
};
#include "aho_corasick.h"

#include <deque>

#include <iostream>

std::vector<std::string> SplitWord(const std::string &input) {
  std::vector<std::string> ret;
  size_t i = 0;
  while (i < input.size()) {
    size_t unit_len = 1;
    if (input[i] & 0x80) {
      char character = input[i];
      character <<= 1;
      do {
        character <<= 1;
        ++unit_len;
      } while (character & 0x80);
    }
    ret.emplace_back(input.substr(i, unit_len));
    i += unit_len;
  }
  return ret;
}

AhoCorasick::AhoCorasick() { root = new Node(); }

AhoCorasick::~AhoCorasick() {
  std::deque<Node *> q;
  q.push_back(root);
  while (!q.empty()) {
    Node *current = q.front();
    q.pop_front();
    for (auto itr : current->children) {
      q.push_back(itr.second);
    }
    delete current;
  }
}

bool AhoCorasick::Insert(const std::string &key, int val) {
  std::vector<std::string> words = SplitWord(key);
  if (words.empty()) {
    return false;
  }
  Node *current = root;
  for (const auto &word : words) {
    // 查看当前节点的子节点是否已经包含该词
    if (current->children.count(word) == 0) {
      Node *node = new Node();
      node->key = word;
      current->children.emplace(node->key, node);
    }
    current = current->children[word];
  }
  if (current->val < 0) {
    current->val = val;
  }
  return true;
}

// 构建失配指针
void AhoCorasick::Init() {
  // 1. 根节点下的所有子节点失配指针均指向根节点
  std::deque<Node *> q;
  for (auto itr : root->children) {
    itr.second->failed = root;
    q.push_back(itr.second);
  }
  // 2. 层序遍历,为其他节点添加失配指针
  while (!q.empty()) {
    Node *current = q.front();
    q.pop_front();
    // 为当前节点的所有子节点寻找失配指针
    for (auto itr : current->children) {
      q.push_back(itr.second);
      Node *paent_node = current;
      // 当前节点的失配节点仍不包含指定值,则继续向上查找
      while (paent_node->failed != nullptr &&
             paent_node->failed->children.count(itr.second->key) == 0) {
        paent_node = paent_node->failed;
      }
      itr.second->failed = paent_node->failed == nullptr
                               ? root
                               : paent_node->failed->children[itr.second->key];
    }
  }
}

bool AhoCorasick::Match(const std::string &input, std::vector<int> *output) {
  std::vector<std::string> elements = SplitWord(input);
  if (elements.empty() || output == nullptr) {
    return false;
  }
  Node *current = root;
  for (const auto &ele : elements) {
    // 1. 当前节点已经包含ele
    if (current->children.count(ele)) {
      current = current->children[ele];
    } else {
      // 2. 当前节点不包含,转移到失配节点
      current = current->failed;
      while (current) {
        // 当前节点的某个失配直接符合要求
        if (current->children.count(ele)) {
          current = current->children[ele];
          break;
        } else {
          // 失配节点不符合要求,继续向前寻找
          current = current->failed;
        }
      }
    }
    // 找到一个词
    if (current && current->val >= 0) {
      output->push_back(current->val);
    }
    // 这里第一次写的时候就给漏掉了!!!
    if (current && current->failed && current->failed->val > 0) {
      output->push_back(current->failed->val);
    }
    if (!current) {
      current = root;
    }
  }

  return !output->empty();
}

void AhoCorasick::Debug() const {
  Node *current = root;
  std::deque<Node *> q;
  q.push_back(current);

  while (!q.empty()) {
    Node *current = q.front();
    q.pop_front();
    std::cout << "node:address=" << current << ",key=" << current->key
              << ",failed=" << current->failed << ",children:";
    for (const auto itr : current->children) {
      std::cout << itr.first << "|";
      q.push_back(itr.second);
    }
    std::cout << std::endl;
  }
}

测试

输入数据:novel nova nol nasa nola art arm as

#include <iostream>

#include <fstream>
#include <memory>
#include <set>
#include <string>
#include <vector>

#include "aho_corasick.h"

class AcMatcher {
public:
  bool Init(const std::string &path) {
    std::fstream file(path);
    if (!file.is_open()) {
      std::cerr << "invalied path=" << path << std::endl;
      return 0;
    }
    std::string line;
    std::set<std::string> words;
    while (std::getline(file, line)) {
      words.insert(line);
    }

    all_words.assign(words.begin(), words.end());
    size_t index = 0;
    for (const std::string &ele : all_words) {
      if (!trie->Insert(ele, index++)) {
        std::cerr << "invalied word=" << ele << std::endl;
        return false;
      }
    }
    trie->Init();

    return true;
  }

  std::vector<std::string> Match(const std::string &input) {
    std::vector<std::string> ret;
    std::vector<int> ouput;
    trie->Match(input, &ouput);
    for (int index : ouput) {
      ret.push_back(all_words[index]);
    }
    return ret;
  }

private:
  std::shared_ptr<AhoCorasick> trie = std::make_shared<AhoCorasick>();
  std::vector<std::string> all_words;
};

int main(int argc, char **argv) {
  if (argc < 3) {
    std::cout << "Usage:<dict_path> <query>" << std::endl;
    return 0;
  }
  const std::string path = argv[1];
  const std::string query = argv[2];

  AcMatcher ac_matcher;
  if (!ac_matcher.Init(path)) {
    return 0;
  }
  auto elements = ac_matcher.Match(query);

  std::cout << "input=" << query << ",match:";
  for (const auto &ele : elements) {
    std::cout << ele << "|";
  }
  std::cout << std::endl;
  return 0;
}

输入:as an novel-author
输出: as novel

AC自动机在query意图理解中的应用

  1. 黑名单、敏感词识别
    AC自动机节点如果记录匹配到的词语在输入中的起始位置,可以变相实现前缀、后缀、包含匹配,用于query理解中基于数据的黑名单、敏感词识别。
  2. 实体识别、槽位提取
    丰富以下index数组内容,index指向的数组如果记录了词语的槽位、分数等信息,实际上就变成了从query中抽取槽位。
    例如有以下槽位的信息:
// term	slot	score
刘德华	name	1.0
struct Info{
	std::string term;
	std::string slot;
	float score;
};
std::vector<Info> term_infos;

例如q=刘德华电影有哪些,可以提取出vec[index]中则包含匹配词word=刘德华,slot=name, score=1.0等信息。

总结

  • AC自动机的基于前缀树,失配指针的含义是当前路径的所有后缀集合与树的最长公共前缀。
  • AC自动机节点的val值通常使用数据的index值即可
  • AC自动机存在诸多可优化的地方,例如双数组trie
  • 21
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
有限自动机(Finite Automaton)是一种抽象的计算模型,它是由一个有限的状态集合、一个有限的输入字母表、一个转移函数和一个初始状态组成的。其能够接受一个输入字符串,并在每个状态上进行转移,最终根据终止状态的定义判断该字符串是否被该自动机接受。有限自动机是计算机科学的一个重要概念,广泛应用于编译器、网络安全等领域。 AC自动机是一种基于有限自动机的字符串匹配算法,它可以在一个文本串同时查找多个模式串。它的原理是将多个模式串构造成一个有限自动机,然后在文本串上进行状态转移。当某一状态为终止状态时,即表示匹配到了一个模式串。AC自动机的优点是可以大大降低匹配的时间复杂度,特别适用于需要匹配多个模式串的场合。 AC自动机的设计与实现主要包括以下步骤: 1. 构造Trie树:将所有模式串构造成一棵Trie树。 2. 构造Fail指针:对Trie树进行广度优先遍历,为每个节点构造Fail指针,使得每个节点的Fail指针指向其在Trie树上的最长后缀节点。 3. 进行状态转移:在文本串上进行状态转移,即从根节点开始,根据输入字符在Trie树上进行状态转移,同时根据Fail指针进行状态的回溯。 4. 输出匹配结果:当某一状态为终止状态时,即表示匹配到了一个模式串,将该模式串的编号输出即可。 AC自动机的时间复杂度为O(n+∑len[p]),其n为文本串长度,len[p]为所有模式串的长度之和。由于AC自动机的实现较为复杂,因此通常采用现有的AC自动机库进行开发。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值