概述
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的失配指针需要满足:
- 设从根节点到S节点类路径组成的字符串为:str(S), str(S)的所有后缀组合为:set(str(S))
- 失配指针指向的节点T,从根节点到T的路径组成字符串为: str(T)
- 则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节点的子节点
- current.failed的字节x的值 == n节点的值, 则n.failed = current.failed.children[x],跳出
- current = current.failed, 继续1步骤
- 若遍历过程中current = nullptr(根节点),则 n.failed = root;
搜索过程
注意图中,当该节点的子节点包含目标字符时,此时要判断该子节点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意图理解中的应用
- 黑名单、敏感词识别
AC自动机节点如果记录匹配到的词语在输入中的起始位置,可以变相实现前缀、后缀、包含匹配,用于query理解中基于数据的黑名单、敏感词识别。 - 实体识别、槽位提取
丰富以下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