多模式匹配问题(Multi Pattern Matching):给定一个文本串 T=t1t2…tn,再给定一组模式串 P=p1,p2,…,pr,其中每个模式串 pi 是定义在有限字母表上的字符串 pi=p1ip2i…pni。要求从文本串 T 中找到模式串集合 P 中所有模式串 pi 的所有出现位置。
模式串集合 P 中的一些字符串可能是集合中其他字符串的子串、前缀、后缀,或者完全相等。解决多模式串匹配问题最简单的方法是利用「单模式串匹配算法」搜索 r
遍。这将导致预处理阶段的最坏时间复杂度为 O(|P|),搜索阶段的最坏时间复杂度为 O(r∗n)。
如果使用「单模式串匹配算法」解决多模式匹配问题,那么根据在文本中搜索模式串方式的不同,我们也可以将多模式串匹配算法分为以下三种:
- 基于前缀搜索方法:搜索从前向后(沿着文本的正向)进行,逐个读入文本字符,使用在P上构建的自动机进行识别。对于每个文本位置,计算既是已读入文本的后缀,同时也是P中某个模式串的前缀的最长字符串。
- 著名的
Aho-Corasick Automaton (AC 自动机)
算法、Multiple Shift-And
算法使用的这种方法。
- 著名的
- 基于后缀搜索方法:搜索从后向前(沿着文本的反向)进行,搜索模式串的后缀。根据后缀的下一次出现位置来移动当前文本位置。这种方法可以避免读入所有的文本字符。
Commentz-Walter
算法(Boyer-Moore
算法的扩展算法)、Set Horspool
算法(Commentz-Walter
算法的简化算法)、Wu-Manber
算法都使用了这种方法。
- 基于子串搜索方法:搜索从后向前(沿着文本的反向)进行,在模式串的长度为min(len(pi))的前缀中搜索子串,以此决定当前文本位置的移动。这种方法也可以避免读入所有的文本字符。
Multiple BNDM
算法、Set Backward Dawg Matching (SBDM)
算法、Set Backwrad Oracle Matching (SBOM)
算法都使用了这种方法。
需要注意的是,以上所介绍的多模式串匹配算法大多使用了一种基本的数据结构:「字典树(Trie Tree)」。著名的 「Aho-Corasick Automaton (AC 自动机) 算法」 就是在 KMP
算法的基础上,与「字典树」结构相结合而诞生的。而「AC 自动机算法」也是多模式串匹配算法中最有效的算法之一。
所以学习多模式匹配算法,重点是要掌握 「字典树」 和 「AC 自动机算法」 。
字典树知识
字典树(Trie):又称为前缀树、单词查找树,是一种树形结构。顾名思义,就是一个像字典一样的树。它是字典的一种存储方式。字典中的每个单词在字典树中表现为一条从根节点出发的路径,路径相连的边上的字母连起来就形成对应的字符串。
你可以看到字典树是一个存储字符串的结构。
首先对于节点,我们可以采用哈希表来定义,然后在定义一个结尾的标志
typedef struct Node {
unordered_map<char, Node*> children;
bool is_end = false;
};
接下来就是构建字典树
class Trie {
public:
Trie(); // 初始化
public:
void Insert(string &s1); //插入
bool Search(const string& s); //查找
bool StartWith(const string& prefix); //查找前缀
private:
Node *root = NULL;
};
首先是初始化,这里头节点是不存放字符的。
Trie::Trie() {
root = new Node();
}
然后是插入操作,从根部开始遍历,存在该节点,就继续往下索引,不存在,则创建索引节点。
void Trie::Insert(string& s) {
Node* cur = this->root;
for (int i = 0; i < s.size(); i++) {
if (cur->children.find(s[i]) == cur->children.end()) cur->children[s[i]] = new Node();
cur = cur->children[s[i]];
}
cur->is_end = true;
}
接下来是查找单词,和查找前缀
bool Trie::Search(const string &s) {
Node* cur = this->root;
for (int i = 0; i < s.size(); i++) {
if (cur->children.find(s[i]) == cur->children.end()) return false;
cur = cur->children[s[i]];
}
return cur && cur->is_end;
}
bool Trie::StartWith(const string& prefix) {
Node* cur = this->root;
for (int i = 0; i < prefix.size(); i++) {
if (cur->children.find(prefix[i]) == cur->children.end()) return false;
cur = cur->children[prefix[i]];
}
return cur;
}
查找单词和查找前缀基本上是一样的,区别就是查找前缀不需要判断结尾单词是否是结束标记。
最后是算法的时间复杂度上
- 插入一个单词:时间复杂度为 O(n);如果使用数组,则空间复杂度为 O(dn),如果使用哈希表实现,则空间复杂度为 O(n)。
- 查找一个单词:时间复杂度为 O(n);空间复杂度为 O(1)。
- 查找一个前缀:时间复杂度为 O(m);空间复杂度为 O(1)。
AC自动机知识
AC 自动机(Aho-Corasick Automaton):该算法在 1975 年产生于贝尔实验室,是最著名的多模式匹配算法之一。简单来说,AC 自动机是以 字典树(Trie) 的结构为基础,结合 KMP 算法思想 建立的。
AC 自动机的构造有 3 个步骤:
- 构造一棵字典树(Trie),作为 AC 自动机的搜索数据结构。
- 利用 KMP 算法思想,构造失配指针。使得当前字符失配时可以通过失配指针跳转到具有最长公共前后缀的字符位置上继续匹配。
- 扫描文本串进行匹配。
这里具体一个形象的例子。
我们来看一下
- 左下角是构建字典树的字符串
- 右边是通过失配指针构建的“next数组”,和KMP算法有相同之处。
**左上角是要匹配的字符串。**开始匹配
第一步查找a:状态为0,返回状态1;
第二步查找s:状态为0,返回状态1;
第三步查找h:状态为2,继续查找;
第四步查找e:查找完成,因为he就是要匹配的字符,故he+1;
第五步查找r:查找完成,因为her就是要匹配的字符,故her+1
第六步查找i:状态为7,继续查找;
第七步查找s:查找完成,因为is就是要匹配的字符,故is+1
第八步查找h:状态为2,继续查找,下一个字符不存在,则返回状态1;
第九步查找y:状态为9,继续查找;
下面的you相信,你已经会查找了,那么最后的his,你觉得应该如何查找呢?
他会经历1->2->5->6->8->7。
因为his包含his,is两个字符,所以失配指针将他们的返回联系到了一起。在查询完his之后,his+1,返回状态1时,经过状态8,而8又是is单词的结尾,故is+1。
这就是AC自动机知识的原理,希望大家可以耐心的阅读,而实现方法,则需要你根据自己的实际应用情况来进行实现。
老规矩,有用二连,感谢大家。