给一个字符串,找出字符串中指定的单词,如果是单个词,我们可以通过暴力解法,从头开始遍历字符串,判断是否存在指定的单词,当然还有一种更优的解法,那就是KMP算法,通过构造next数组,保存匹配的中间状态,在查找单词失败时,不用退回到字符串开头重新进行匹配,实现了O(m+n)的时间复杂度。这种在一个字符串中找一个单词的匹配成为单模式匹配,如果要在一个字符串中寻找多个子串了,那就是将要谈到的多模式匹配,如何实现呢?对每个子串进行KMP匹配,当然也可以。但是有一种更为优秀的多模式匹配算法,那就是AC自动机算法。
Aho-Corasick automaton,该算法在1975年产生于贝尔实验室,是著名的多模匹配算法。
AC自动机算法可以理解为树形的KMP算法,不过不了解KMP也不影响学习AC自动机算法,不过需要了解字典树和多叉树的广度遍历算法。
字典树
什么是Trie树?
Trie树,也叫字典树,是一种多叉树形数据结构,每个节点保存一个字符,一条路径上的所有节点保存一个字符串。以{"hers", "he", "his", "she"}
构造的字典树为例:
如上图所示,每个单词在一条路径上,绿色节点表示该节点是一个单词的尾字符。其中root节点不存储任何字符。
如何构造一个字典树?
看上面的图应该已经知道如何构造一个Trie树了,那代码如何实现呢?首先定义节点的结构体:
struct TrieNode {
TrieNode* fail{
nullptr}; // 失配指针
std::vector<int> exists; // 该节点出是否存在完整单词
std::unordered_map<char, TrieNode*> childs; // 子节点,用 hash 表方便快速查询
};
其中:
exists
字段用来存储以该节点字符结尾单词的长度,如单词"hers",则该节点exists = {4},用这个长度我们便可以截取出提取出字符串对应的该单词,而不用存储整个单词。
fail
指针表示失配指针,后续将匹配过程会理解它。
childs
用来存储子节点,为了快速定位子节点,我们用hash表存储子节点,键值为字符,value值为子节点其它信息
通过给定的多个单词构造Trie树代码如下:
TrieNode* buildTrie(const std::vector<std::string>& words) {
if (words.empty()) {
cout << "[buildTrie] words is empty" << endl;
return nullptr;
}
TrieNode* root = new TrieNode();
for (auto& word : words) {
TrieNode* tmp = root;
for (char ch : word) {
if (tmp->childs.find(ch) == tmp->childs.end()) {
tmp->childs[ch] = new TrieNode();
}
tmp = tmp->childs[ch];
}
tmp->exists.emplace_back(word.size());
}
return root;
}
不要忘记在每个单词的尾字符节点存储单词的长度。
失配指针
失配指针的含义
先看已构造好失配指针的图,后续讲如何构造失配指针
如图所示,每个节点的失配指针都指向指定节点,有的指向root节点,有的指向字符节点,那失配指针代表什么呢?可以理解为:
如果节点A的失配指针指向节点B,那么就表示:节点B结尾的字符串是节点A结尾的字符串的最长后缀:
- 节点
[her]s.fail = s
,s
是该字典树中hers
的最长后缀 - 节点
[sh]e.fail = [h]e
,he
是该字典树中she
的最长后缀 - 节点
[h]e.fail = root
,he
在该字典树中没有最长后缀
可以自己体会一下😂
构造失配指针
代码如下:
TrieNode* initAhoCorasick(const std::vector<std