2026年4月专项训练巩固总结

2026年4月专项训练巩固总结

KMP

给你一个字符串 s,不妨下标从 1 到 n。

考虑 kmp 算法,ne[i] 表示对于 s[1...i] 来说,最长的非平凡后缀 = 非平凡前缀的长度。具体来说,是最大的 len,满足 s[1...len] = s[i - len + 1...i]

如何快速求 ne[i]

首先,ne[0] = ne[1] = 0 是显然的。

然后,我们假设 ne[0]ne[i - 1] 都求出来了,现在想求 ne[i]

我们其实就是想找最大的一段前缀 s[1...pre],使得这个前缀和 s[1...i - 1] 的某个后缀相等,且这个前缀的下一个字符 s[pre + 1] 刚好等于 s[i],就可以充分利用之前的最长结果求出现在的 ne[i]

根据上面的指导思想,我们先保证前缀和后缀相等,故自然地令 j = ne[i - 1],则 s[1...j] = s[i - j...i - 1] 就成立了。接下来,就是看下一个字符是否相等了。

如果 s[j + 1] = s[i],则正好可以继续延续上面的相等,使得 s[1...j + 1] = s[i - j...i]ne[i] = j + 1 即可了。

如果 s[j + 1] != s[i],我们让 j = ne[j],继续往前配就好了,直到 j 变为 0。

这样就求出了 ne 数组。

假设我们求出了模式串 sne 数组,现在想求文本串 t 中出现了多少次 s,如何做?

这里仍然是假设都是 1 下标开始。

那么 i = 1, j = 0it 当前的字符,j 就是 i 之前(不包括 i)的字符匹配到的地方。

我们当然是关心 s[j + 1]t[i] 是否相等。

如果相等,那么说明又匹配上了一个字符, j++,如果 j 到末尾了说明匹配成功了一次。

如果不相等,还是 j = ne[j] 去往前跳。

根据上述思想,非常容易能写出下面的代码,都是下标为 1 开始的:

#include <bits/stdc++.h>

using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    
    int n, m;
    string p, s;
    cin >> n >> p >> m >> s;
    s = " " + s;
    p = " " + p;
    vector<int> ne(n + 2, 0);
    for (int i = 2; i <= n; i++) {
        int j = ne[i - 1];
        while (j && p[j + 1] != p[i]) {
            j = ne[j];
        }
        
        if (p[j + 1] == p[i]) {
            ne[i] = j + 1;
        }
    }
    
    int j = 0;
    for (int i = 1; i <= m; i++) {
        while (j && s[i] != p[j + 1]) {
            j = ne[j];
        }
        
        if (s[i] == p[j + 1]) {
            j++;
        }
        
        if (j == n) {
            cout << i - j << " ";
        }
    }
    
    return 0;
}

AC自动机

前面的 kmp 是一个复习(其实之前也不咋会),现在我们来看 AC 自动机,这玩意就是把 kmp 的失配后往回跳的 ne 数组搬到了 trie 树上,改了个名字叫 fail 数组。

考虑有 n 个字符串,建立一棵 trie 树,在 trie 上对每个结点求一个 fail。trie 从根到某个结点 p 构成一个字符串 s,对这个结点求 fail,其实就是看这个 s 的最长的真后缀,其是这 n 个字符串里某个字符串的真前缀,fail[p] 就是指向这个真前缀的结点。

可以注意到,fail 指针都是从 trie 的深处往浅处连的,所以只看 fail 指针的话,是一个有向无环图。

在 trie 建好之后,如何求 fail 呢?

这个过程类似 kmp 求 ne 数组。假如要求 p 的某个孩子结点 sonfail 指针,且 son 是在 p 后加了一个 ch 字符,则首先我们也是让 j = fail[p],然后看 j 结点往 ch 方向走有没有结点,有的话相当于匹配上了,fail[son] = tr[j][ch] 即可了;如果没匹配上,则得继续 j = fail[j] 往上跳,直到跳到 0 或者匹配上了。

我们注意到,在求 fail[son] 时要求比 son 浅的结点的 fail 指针都求出来了,不然没法一直往上跳,所以我们可以以 BFS 序去求 fail 指针,这样就可以保证这一点。

如何跑匹配呢?也是类似 kmp,配不上就往上跳 fail,配上了就跑到了 trie 的一个结点上,进一步统计信息的话,可能你还需要继续往上跳 fail,才能把该统计的信息统计全。

const int N = 1e6 + 10;

struct ACAM {
    int tr[N][26];
    int cnt[N], fail[N];
    int idx;

    void init(int sz) {
        memset(tr, 0, sizeof tr);
        memset(cnt, 0, sizeof cnt);
        memset(fail, 0, sizeof fail);
        idx = 0; // 我们假设 0 是根节点,并认为 0 也是完全配不上的 fail 指针的初值
    }

    void insert(string &s) {
        int p = 0;
        for (int i = 0; i < s.size(); i++) {
            int ch = s[i] - 'a';
            if (!tr[p][ch]) {
                tr[p][ch] = ++idx;
            }
            p = tr[p][ch];
        } 
        cnt[p]++;
    }

    // 做 bfs,求 fail 指针
    void get_fail() {
        queue<int> q;
        for (int ch = 0; ch < 26; ch++) {
            if (tr[0][ch]) {
                q.push(tr[0][ch]);
            }
        }

        while (!q.empty()) {
            int p = q.front(); // 本结点 p
            q.pop();

            for (int ch = 0; ch < 26; ch++) {
                if (tr[p][ch]) { // 如果本结点的下一个字符可以是 ch,才继续看
                    int son = tr[p][ch];
                    int j = fail[p]; 
                    // 拿到 p 的 fail,然后看 fail[p] 这个点的下一个字符可否是 ch
                    // 即判断 tr[fail[p]][ch] 是否存在
                    // 如果存在,则 son 的 fail 就是 tr[fail[p]][ch]
                    while (j && !tr[j][ch]) {
                        j = fail[j];
                    }
                    if (tr[j][ch]) {
                        j = tr[j][ch];
                    }
                    fail[son] = j;
                    q.push(son);
                }
            }
        }
    }
};

可以看到,甚至在 trie 树上更容易理解 fail 数组,回头再看 kmp 的 ne 数组,突然很不理解为啥之前老是记不住咋求。

赞赏