AC自动机

   算法目的:

       AC自动机主要用于解决多模式串的匹配问题,是字典树(trie树)的变种,一种伪树形结构(主体是树形的,但是由于加入了失败指针,使得它变成了一个有向图);trie图(我的理解^_^)是对AC自动机的一种改造,使得图中每个结点都有MAXC条出边(MAXC表示该图的字符集合大小), trie图上的每个结点代表一个状态,并且和AC自动机的结点是一一对应的。

       算法核心思想:

       学习AC自动机(AC-Automan?艾斯奥特曼?-_-|||)之前,首先需要有字典树和KMP的基础,这是每一篇关于AC自动机的文章都会论及的,所以我也就例行提一下。

       例如,有四个01字符串(模式串),"01"、"10"、"110"、"11",字符集合为{'0', '1'}。那么构造trie图分三步,首先建立字典树,然后构造失败指针,最后通过失败指针补上原来不存在的边,那么现在就分三步来讨论如何构建一个完整的trie图。

 

图1

       1) 字典树

       字典树是一种树形结构,它将所有的模式串组织在一棵树的树边上,它的根结点是一个虚根,每条树边代表一个字母,从根结点到任意一个结点的路径上的边的有序集合代表某个模式串的某个前缀

       如图1,绿色点为虚根,蓝色点为内部结点,红色点为终止结点,即从根结点到终止结点的每条路径代表了一个模式串,由于"11"是"110"的前缀,所以在图中"11"这两条边是这两个字符串路径的共用部分,这样就节省了存储空间,由于trie树的根结点到每个结点的路径(边权)都代表了一个模式串的前缀,所以它又叫前缀树。

       字典树实际上是一个DFA(确定性有限状态自动机),通常用转移矩阵表示。行表示状态,列表示输入字符,(行, 列)位置表示转移状态。这种方式的查询效率很高,但由于稀疏的现象严重,空间利用效率很低。所以一般采用压缩的存储方式即链表来表示状态转移,每个结点存储至少两个域:数据域data、子结点指针域next[MAXC](其中MAXC表示字符集总数)。

       构造字典树的前提一般是给定一系列的模式串,然后对每个模式串进行插入字典树的操作,初始情况下字典树只有一个虚根,如图2所示,进行四个模式串的插入后就完成了图1中的字典树的构造,每次插入在末尾结点打上标记(图中红色部分),可以注意到,第四次操作实际上没有生成新的结点,只是打了一个结尾标记,由于它的这个性质,使得字典树的结点数目不会很多,大大压缩了存储结构。具体实现方式和编码会在下文中详细讲解。

 

 

图2

       2) 失败指针

       给定一个目标串,要求在由模式串构建的字典树中查找这个目标串中有多少个模式串,我们可以设定一个指针p,初始状态下它指向根结点,然后从前往后枚举目标串,对每一个目标串中的字符c,如果在p指向结点的出边集合中能够找到字符c对应的边,那么将p指向c对应边的子结点,循环往复,直到匹配失败,那么退回到p结点的fail指针指向的结点继续同样的匹配,当遇到一个终止结点时,计数器+1。

       这里的fail指针类似KMP的next函数,每个trie结点都有一个fail指针,如图3,首先将根结点的fail指针指向NULL,根结点的直接子结点的fail指针指向根结点,这一步是很显然的,因为当一个字符都不能匹配的时候肯定是要跳到字符串首重新匹配了,每个结点的fail指针都是由它父结点的fail指针决定的,所以一次BFS就可以把所有结点的fail指针逐层求解出来了,具体实现方式和编码会在下文中详细讲解。

图3

       3) trie

       为了方便描述,我们先把所有trie树上的结点进行编号,编号顺序为结点的插入顺序,根结点编号为0。如图4的第一个图,我们发现如果现在是1号状态(状态即结点),当接收一个'1'这个字符,那么它应该进入哪个状态呢?答案很显然,是2号状态,因为沿着字符'1'的出边到达的状态正好是2号状态;但是如果接受的是'0'字符,我们发现1号状态没有'0'字符代表的出边,所以我们需要补上这条'0'边,但是这条边指向哪个状态呢?答案是1号状态的fail指针指向的状态的'0'出边对应的状态。我们发现这个状态正好是它自己,所以向自己补一条边权为'0'的边(图中的橙色边,边指向的结点称为当前状态的后继状态)。同样是利用BFS的方式逐层求解所有结点的后继状态。我们发现所有结点遍历完后,每个结点都有且仅有两条出边,这样一个trie图就诞生了。

 

图4

       今后几乎所有关于状态机的问题都是围绕图4的那个图展开的。

       新手初看算法的时候总是一头雾水,即使看懂了也要花很大力气才能把代码写出来(至少我是这样的),所以没有什么比直接阐述代码更加直观的了。

一、结构定义

     #define MAXC 26
     // 结点结构
     struct ACNode {
     public:
         ACNode *fail;    // fail指针,指向和当前结点的某个后缀匹配的最长前缀的位置
         ACNode *next[MAXC];  // 子结点指针

         // 以下这些数据需要针对不同题目,进行适当的注释,因为可能内存会吃不消

         int id;   // 结点编号(每个结点有一个唯一编号)
         int val;  // 当前结点和它的fail指针指向结点的结尾单词信息的集合
         int cnt;  // 有些题中模式串有可能会有多个单词是相同的,但是计数的时候算多个

         void reset(int _id) {
             id = _id;
             val = 0;
             cnt = 0;
             fail = NULL;
             memset(next, 0, sizeof(next));
         }

         // 状态机中的 接收态 的概念
         // 模式串为不能出现(即病毒串)的时候才有接收态
         bool isReceiving() {
             return val != 0;
         }
     };

 

    对于trie树上的每个结点,保存了以下数据域:

       1) 结点编号 int id;

       每个结点的唯一标识,用于状态转移的时候的下标映射。

       2) 子结点指针 ACNode *next[MAXC];

       每个结点的子结点的个数就是字符串中字符集的大小,一般为26个英文字母,当然也有特殊情况,比如说和DNA有关的题,字符集为{ 'A'、'C'、'G'、'T' },那么字符集大小就为4;和二进制串有关的题,字符集大小就为2;而有的题则包含了所有的可见字符,所以字符集大小为256(有可能有中文字符...太BT了),这个就要视情况而定了。

       3) 失败指针 ACNode *fail;

       它的含义类似KMP中的最长前后缀的概念,即目标串在trie树上进行匹配的时候,如果在P结点上匹配失败,那么应该跳到P->fail继续匹配,如果还是失败,那么跳到P->fail->fail继续匹配,对于这个fail指针的构造,下文会详细讲解,这里先行跳过。

       4) 结尾标记 int cnt, val;

       每个模式串在进行插入的过程中,会对模式串本身进行一次线性的遍历,当遍历完毕即表示将整个串插入完毕,在结尾结点需要一个标记,表示它是一个模式串的末尾,有些问题会出现多个相同的模式串,所以用cnt来表示该串出现的次数,每插入一次对cnt进行一次自增;而有的题中,相同的模式串有不同的权值,并且模式串的个数较少(<= 15),那么可以将该结点是否是模式串的末尾用2的幂来表示,压缩成二进制的整数记录在val上(例如当前结点是第二个模式串和第四个模式串的结尾,则val = (1010)2)。

 

posted @ 2017-05-08 13:38  _tham  阅读(294)  评论(0编辑  收藏  举报