AC自动机(上)

  1. 参考:

    1. 算法训练营第三章

    2. 很全的资源:https://blog.csdn.net/dllglvzhenfeng/article/details/123012998

    3. 洛谷日报:https://baijiahao.baidu.com/s?id=1610756759406088822&wfr=spider&for=pc

    4. oi-wiki:https://oi-wiki.org/string/ac-automaton/

  2. 简介:

    1. 前置芝士:KMP和Trie树。

    2. 本质:在Trie树上跑KMP。

    3. 一些abstract:

      虽然基础是KMP,但是AC自动机和KMP不同,KMP是单模式串匹配算法,是提前预处理出模式串的“匹配规则”,这个匹配规则记录了当文本串和这个模式串相匹配的时候,如果失配了,到底要退到哪里继续进行匹配。

      而AC自动机是要在多个模式串的情况下,判断文本串失配后该怎么继续匹配。如果对于一个文本串S和k个模式串Ti,求S中包含Ti的次数,如果分别跑KMP,复杂度是O(|S|k+|T1|+...+|Tk|);但是AC自动机可以保证O(|S|+|T1|+...+|Tk|)。

      AC自动机是用多个模式串构建一棵字典树,然后在这个字典树上构建失配指针,而这个失配指针相当于KMP中的next数组,处理后,用文本串在这个字典树上进行模式匹配。

  3. 步骤:

    先放板子:

     namespace AC {
       const int SZ 100000+20;
       int tot, tr[SZ][26];
       // idx就是字典树中的节点是否是末尾,如果是值为串的id
       // fail就是失配指针
       // val是统计每个状态出现的次数
       int fail[SZ], idx[SZ], val[SZ];
       int cnt[N];  // 记录第 i 个字符串的出现次数
     
       void init() {
         memset(fail, 0, sizeof(fail));
         memset(tr, 0, sizeof(tr));
         memset(val, 0, sizeof(val));
         memset(cnt, 0, sizeof(cnt));
         memset(idx, 0, sizeof(idx));
         tot 0;
      }
     
       void insert(char *s, int id) {  // id 表示原始字符串的编号
         int 0;
         for (int 1; s[i]; i++) {
           if (!tr[u][s[i] 'a']) tr[u][s[i] 'a'] ++tot;
           tr[u][s[i] 'a'];  // 转移
        }
         idx[u] id;  // 以 u 为结尾的字符串编号为 idx[u]
      }
     
       queue<intq;
     
       void build() {
         for (int 0; 26; i++)
           if (tr[0][i]) q.push(tr[0][i]);
         while (q.size()) {
           int q.front();
           q.pop();
           for (int 0; 26; i++) {
             if (tr[u][i]) {
               fail[tr[u][i]] tr[fail[u]][i];  // fail数组:同一字符可以匹配的其他位置
               q.push(tr[u][i]);
            } else
               tr[u][i] tr[fail[u]][i];
          }
        }
      }
     
       int query(char *t) {  // 返回最大的出现次数
         int 0, res 0;
         for (int 1; t[i]; i++) {
           tr[u][t[i] 'a'];
           for (int u; j; fail[j]) val[j]++;
        }
         for (int 0; <= tot; i++)
           if (idx[i]) res max(res, val[i]), cnt[idx[i]] val[i];
         return res;
      }
     }  // namespace AC
     
     int n;
     char s[N][100], t[L];
     
     int main() {
       while (~scanf("%d", &n)) {
         if (== 0) break;
         AC::init();  // 数组清零
         for (int 1; <= n; i++)
           scanf("%s", s[i] 1), AC::insert(s[i], i);  // 需要记录该字符串的序号
         AC::build();
         scanf("%s", 1);
         int AC::query(t);
         printf("%d\n", x);
         for (int 1; <= n; i++)
           if (AC::cnt[i] == x) printf("%s\n", s[i] 1);
      }
       return 0;
     }
    1. 构建字典树。insert函数和正常的字典树暂时没有什么不同。

      构建之后的结果:

       

      板子对应:

       void insert(char *s, int id) {  // id 表示原始字符串的编号
         int 0;
         for (int 1; s[i]; i++) {
           if (!tr[u][s[i] 'a']) tr[u][s[i] 'a'] ++tot;
           tr[u][s[i] 'a'];  // 转移
        }
         idx[u] id;  // 以 u 为结尾的字符串编号为 idx[u]
       }
       
       
       AC::init();  // 数组清零
       for (int 1; <= n; i++)
         scanf("%s", s[i] 1), AC::insert(s[i], i);  // 需要记录该字符串的序号

       

    2. 构建失配指针:失配指针其实是指向当前节点的字符串在这个字典树中出现的最长后缀的节点

       

      构建的过程是一个BFS,又因为t->fail的串长度一定小于t,所以t->fail都处理之后,才会处理t。

      构建的规则是:已经构建了字典树,然后:

      1. 树根入队;

      2. 若队列不为空,取队头出队,访问该元素的每一个子节点t->ch[i]:

        1. 若t->ch[i]不为空,则t的最长后缀,对于t->ch[i],其失配指针,指向t->fail->ch[i],然后t->ch[i]入队。

        2. 不然,t->ch[i]指向t->fail->ch[i]。

      3. 队为空,算法结束。

      分析第二条规则中的两个小规则:

      1. t->fail这个节点由上知,已经处理过了,所以t->ch[i]的最长后缀一定从t->fail的ch[i]里找,因为t->fail是t中最长的后缀,所以t->fail如果存在ch[i],t->ch[i]的fail一定是要指向t->fail->ch[i]的(原来就是最长的,又续上了一个字符i,肯定还是最长的啊)。什么,你说万一t->fail->ch[i]是空(意思是t->fail在构建完字典树的时候没出现过接ch[i]的情况)呢?那就要看第二个规则了。

      2. 这个规则说了t->ch[i]为空应该怎么办。刚才留下的疑问是t->fail->ch[i]为空怎么办,同样是一个节点的ch[i]为空,其实可以看成一个问题。根据第二条规则,我们发现解决办法是将t->fail->ch[i]指向了t->fail->fail->ch[i](将t->fail看成一个整体),然后又为空怎么办,就一直t->fail->fail->...->fail->ch[i](后缀的后缀肯定还是后缀啦),其实就是在最长后缀中一直找啊找啊,看看有没有后面连着ch[i]的字符,没有说明还得往前跳,这一点就和next数组一样啦~如果一直没有其实就是这个字符压根没出现过,这时候会到根,也是合理的。

      板子对应:

       queue<intq;
       
       void build() {
         for (int 0; 26; i++)
           if (tr[0][i]) q.push(tr[0][i]);
         while (q.size()) {
           int q.front();
           q.pop();
           for (int 0; 26; i++) {
             if (tr[u][i]) {
               fail[tr[u][i]] tr[fail[u]][i];  // fail数组:同一字符可以匹配的其他位置
               q.push(tr[u][i]);
            } else
               tr[u][i] tr[fail[u]][i];
          }
        }
       }
       
       AC::build();
    3. 模式匹配

      即遍历要进行模式匹配的串的每个字符,沿着自动机的边一直走,和KMP一样,如果途径的点原本不存在,它的意思就是失配后模式串的集合该退回到哪个位置。然后不论怎样,它的最长后缀们都是要答案++的。

      板子对应:

       int query(char *t) {  // 返回最大的出现次数
         int 0, res 0;
         for (int 1; t[i]; i++) {
           tr[u][t[i] 'a'];  // 沿着自动机走,如果字典树的阶段这个位置就有,说明没失配,就该到这里;但是如果是自动机的时候才有的这个节点,说明是失配了,而这个位置是相当于KMP的next数组的位置,所以是正确的。
           for (int u; j; fail[j]) val[j]++;  // 不论哪种情况,对于最长后缀们,都应该++
        }
         for (int 0; <= tot; i++)
           if (idx[i]) res max(res, val[i]), cnt[idx[i]] val[i];
         return res;
       }
       
       scanf("%s", 1);
       int AC::query(t);
       printf("%d\n", x);
       for (int 1; <= n; i++)
         if (AC::cnt[i] == x) printf("%s\n", s[i] 1);
  4. 例题:

    1. DNA序列(POJ2778):众所周知,DNA序列是由ATGC四个字母构成的。给n和m,然后给了m个串,含有m个串中的任意一个串的DNA序列都是有遗传病的,问长度为n的DNA序列有多少种序列是没有遗传病的(mod一个大质数)。(L大概10的9次方的级别)

      转化为:m个模式串,有一个假想的长度为n的文本串,文本串中不含m个模式串,有多少种。

      相当于m个模式串组了个AC自动机,文本串不能经过end为真的节点,问有多少种。

      这里有一个图比较形象:

      遗传病片段为:"ACG"、"C"。

       

      然后根据每个节点连出去的边构造一个矩阵M,其中M[i][j]代表从i到j走一步有多少种途径,这里注意,只要fail指针指向有问题的节点,就也有问题,然后取出所有没有问题的节点组成子矩阵M'。

      然后走n步就是M'的n次方中,从0到所有状态的数的和。

      比如这里M就是

       21001
       21100
       11011
       21001
       21001

      最开始3和4是危险的节点,因为2的fail指向4,所以2也是危险的,只有0和1是不危险的,于是拿出0和1的子矩阵M':

       2 1
       2 1

      求出M'的n次方(矩阵快速幂),再求M'[0][0] + M'[0][1],即为答案。

    2. 单词情结:给N个词根,求长度不超过L的单词种数(mod2的64次方),包含至少其中一个词根,单词仅有26个小写字母构成。(L大概10的9次方的级别)

      显然由上一题知道怎么求长度为L的不含词根的数量,那长度不超过L的单词怎么办呢?注意到我们上一题最后得到的矩阵的第一行的和是长度为L的情况,对于行和,怎么处理,显然要补一列全是1。于是在矩阵的最右侧补一列1。对于上一题就是M'' =

       211
       211
       ??1

      于是由数学归纳法知右上角-1就是所求答案。

      但是前提是左上角的(n-1)x(n-1)的矩阵仍然是正确答案。也就是需要两个问号全是0,不干扰答案计算,所以是:

       211
       211
       001

      然后和上一问一样就知道不含词根的1~L长度的种数,最后用总种数-这个答案就行了。

      但是总种数是一个等比数列,可以分治求,也可以参考上面的问题,对于一个矩阵A,右边补1,下边补0。这样新矩阵的n次方,就由老矩阵的n次方、老矩阵的0次方+...+n-1次方、0、1构成。然后求右上角的值就行了。

    3. 其余,随缘再补。

     

posted on 2022-05-07 23:07  小染子  阅读(106)  评论(0)    收藏  举报