字符串全家桶

【前言】

字符串问题一直是我比较薄弱的环节,而各大类型考试均有涉及,所以特此记录。

我见过的常见的处理字符串的算法有:

  1. 字符串 Hash。
  2. KMP。
  3. ex-KMP。
  4. Manacher。
  5. AC 自动机。
  6. PAM。
  7. SA。
  8. SAM。

其中 3,4 仅简单介绍,7 不会。

【字符串 Hash】

【思想简述】

性价比最高的字符串算法,代码简短而且适用于很多匹配问题。

该算法不需过多介绍,我习惯的实现方式是利用 unsigned long long 自然溢出,乘数取 131 / 13331

在很多需要高级字符串算法处理的问题中,Hash 往往能作为骗分利器。(甚至因为常数小而吊打标算)

【适用范围】

几乎所有匹配相关的字符串问题。

优点:代码简单,常数小,时间 \(O(1)\)

缺点:可能被卡,处理棘手的字符串问题时往往力不从心。

【简单例题】

[NOIP2020] 字符串匹配

求所有将 \(S\) 分割为形如 \(S=(AB)^iC\) ,且 \(F(A)\leq F(C)\) 的方案数,\(F\) 表示出现奇数次字符的个数。

这是一道很好的题目。

枚举第一个 \(AB\) 的长度,根据奇偶性分类讨论发现 \(F(C)\) 只有两种情况。

再枚举 \(i\),利用 Hash 判断是否循环,结合树状数组统计答案,时间复杂度为调和级数 \(O(n\log n)\)

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;

typedef unsigned long long ULL;
const int N = 1050010;
const ULL P = 13331;

int T, n, num[N], t[N], sum[30];
char str[N];
ULL p[N], f[N];

void Change(int x){x ++; for(; x <= 27; x += x & -x) t[x] ++;}
int Ask(int x){x ++; int sum = 0; for(; x; x -= x & -x) sum += t[x]; return sum;}

void work(){
    scanf("%s", str + 1);
    n = strlen(str + 1);
    for(int i = 1; i <= n; i ++) f[i] = f[i - 1] * P + str[i];
    int tot = 0;
    memset(sum, 0, sizeof(sum));
    for(int i = n; i >= 1; i --){
        if(sum[str[i] - 'a'] ^= 1) tot ++;
        else tot --;
        num[i] = tot;
    }
    long long ans = 0;
    memset(t, 0, sizeof(t));
    memset(sum, 0, sizeof(sum));
    sum[str[1] - 'a'] ^= 1; tot = 1; Change(1);
    for(int i = 2; i < n; i ++){
        int even = 0, odd = 0;
        odd = Ask(num[i + 1]);
        if(i + i < n) even = Ask(num[i + i + 1]);
        for(int j = 1; j <= n; j ++){
            if(i * j >= n) break;
            if(f[i] != f[i * j] - f[i * (j - 1)] * p[i]) break;
            ans += (j & 1) ? odd : even;
        }
        if(sum[str[i] - 'a'] ^= 1) tot ++;
        else tot --;
        Change(tot);
    }
    printf("%lld\n", ans);
}

int main(){
    scanf("%d", &T);
    p[0] = 1;
    for(int i = 1; i <= N - 10; i ++) p[i] = p[i - 1] * P;
    while(T --) work();
    return 0;
}

[NOI2016] 优秀的拆分

这是一道很经典的题目,其正解的思想可以很好的与 SA 结合。

然而观察数据范围,发现写个 \(O(n^2)\) 的算法就能拿到 \(\rm 95\ pts\),这是一个很可观的分数。

考虑处理出 \(f(i)\)\(g(i)\) 分别表示以 \(i\) 开头 / 结尾的形如 \(AA\) 的串的个数, 这个直接结合 Hash 处理。

那么答案就是 \(\sum f(i)\times g(i+1)\),于是利用简单的 Hash 就可以轻松地获得 \(\rm 95\ pts\)

代码不放了,估计不用 5 分钟。

【KMP】

【思想简述】

KMP 的重点在失配指针 nxt 的求解。

nxt[1] = 0;
for(int i = 2, j = 0; i <= n;i ++){
    while(j > 0 && a[i] != a[j + 1]) j = nxt[j];
    if(a[i] == a[j + 1]) j ++;
    nxt[i] = j;
}
for(int i = 1, j = 0; i <= m; i ++){
    while(j > 0 && b[i] != a[j + 1]) j = nxt[j];
    if(b[i] == a[j + 1]) j ++;
    f[i] = j;
    // if(f[i] == n) puts("Success");
}

【适用范围】

KMP 的经典应用是解决单模式匹配问题,但是如果单纯的想解决这个问题,或许 Hash 更为方便。

所以 KMP 的如今更多用于利用 nxt 求出最长前缀后缀,从而求解某些问题。

优点:便于扩展(例如与 DP / 数据结构 结合),时间 \(O(n)\)

缺点:较难理解。

【简单例题】

[NOI2014]动物园

将失配状态改为 \(\rm j\times2 > i\) 即可。

[POI2005]SZA-Template

KMP 结合 DP,设 \(f(i)\) 表示覆盖前 \(i\) 个字符的最小长度。

若最近一次出现 \(f(nxt(i))\) 的位置 \(pos \geq i - nxt(i)\),则 \(f(i)=f(nxt(i))\),因为可以覆盖到。

否则 \(f(i)=i\)

Censoring

利用栈维护匹配状况即可,同样可以用 Hash 乱搞。

【AC 自动机】

【思想简述】

trie 树与 KMP 的结合,关键同样是构建出 fail 指针。

个人习惯用桶排序,而不是 BFS。

int insert(char *str){
    int len = strlen(str + 1), p = 0;
    for(int i = 1; i <= len; i ++){
        int ch = str[i] - 'a';
        if(!tr[p][ch]) tr[p][ch] = ++ tot;
        p = tr[p][ch];
        dep[p] = i;
    }
    return p;
}

void build(){
    for(int i = 1; i <= tot; i ++) bac[dep[i]] ++;
    for(int i = 1; i <= tot; i ++) bac[i] += bac[i - 1];
    for(int i = 1; i <= tot; i ++) arc[bac[dep[i]] --] = i;

    for(int i = 1; i <= tot; i ++){
        int u = arc[i];
        for(int v = 0; v < 26; v ++)
            if(tr[u][v]) fail[tr[u][v]] = tr[fail[u]][v];
            else tr[u][v] = tr[fail[u]][v];
    }
}

【适用范围】

AC 自动机的功能强大,适用的范围也很广。

值得一提的是,我们根据 fail 指针可以构造出 fail 树,在 fail 树上,一个节点的子树对应着这个节点

由于每个点都只连出一条 fail 边,且连到的点对应的字符串长度更小,所以 fail 边构成了一棵 fail 树

fail 树是 Trie 上的每个前缀(也就是 AC 自动机上的所有节点)。

与某个模式串匹配(以某个模式串为后缀)的那些状态,就是那个串在 Trie 树上的终止节点在 fail 树上的子树。

匹配方式:建出 fail 树,记录自动机上的每个状态被匹配了几次,最后求出每个模式串在 Trie 上的终止节点在 fail 树上的子树总匹配次数就可以了。

引自:AC自动机学习笔记

优点:功能强大,扩展丰富。(已经没有什么词能够赞美它了)

缺点:然而并没有。

【简单例题】

AC自动机(简单版)

AC自动机(加强版)

AC自动机(二次加强版)

三道模板题。

[POI2000]病毒

因为只有 0 / 1 串,所以每次只有两种选择。

存在无限长的安全代码,对应于 AC 自动机上就是有可以不经过模式串终点的环,DFS 找环即可。

得出结论:如果在 fail 树上某个节点代表的状态是模式串的终点,那么它整棵子树都是不可进入的

阿狸的打字机

题目已经帮你把 trie 树建好了。

对于询问 \((x,y)\),考虑将 \(x\) 在 trie 树上的所有前缀状态在 fail 树上的节点标记。

那么答案就是 fail 树上 \(y\) 的子树中被标记的个数。

考虑离线算法,只需要一遍 DFS,用树状数组统计答案即可。

得出结论:

  1. fail 树上的节点对应的是某个模式串的前缀,每个节点的子树对应以其为后缀的前缀

  2. 求匹配就是求整棵子树被覆盖的个数,可以利用 DP 传递信息

  3. 求多次匹配就是对于每个文本串的前缀都标记,然后利用树状数组进行数点

根据 1 可以看出,其实 KMP 也是一个自动机,是 AC 自动机只插入一个模式串的特殊情况,它同样拥有 fail 树。

[COCI2015]Divljak

对于 \(S\) 建立 AC 自动机,每次加入 \(T\) 时统计贡献。

不难发现 \(T\) 上的每个前缀在 fail 树上的节点到根的链都有 \(1\) 的贡献,但最多也只能有 \(1\) 的贡献,不能重复覆盖。

考虑对于每个 \(T\),先处理出代表每个前缀的 trie 树节点,然后按照 fail 树上的 dfs 序排序。

然后利用树状数组插入,两两之间的 \(\rm lca\)\(-1\),这样就能够避免重复,这是一个常用的树上问题技巧。

给一下代码吧:

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 2000010;

int n, cnt, num;
int head[N], dfn[N], t[N], pos[N];
char str[N];
struct Edge{int nxt, to;} ed[N];

int read(){
	int x = 0, f = 1; char c = getchar();
	while(c < '0' || c > '9') f = (c == '-') ? -1 : 1, c = getchar();
	while(c >= '0' && c <= '9') x = x * 10 + c - 48, c = getchar();
	return x * f;
}

void add(int u, int v){
    ed[++ cnt] = (Edge){head[u], v};
    head[u] = cnt;
}

int top[N], sz[N], fa[N], dep[N], son[N];

void dfs1(int u, int Fa){
    sz[u] = 1, fa[u] = Fa, dep[u] = dep[Fa] + 1;
    for(int i = head[u], v; i; i = ed[i].nxt)
        if((v = ed[i].to) != Fa){
            dfs1(v, u), sz[u] += sz[v];
            if(!son[u] || sz[v] > sz[son[u]]) son[u] = v;
        }
}

void dfs2(int u, int Top){
    top[u] = Top; dfn[u] = ++ num;
    if(!son[u]) return; dfs2(son[u], Top);
    for(int i = head[u], v; i; i = ed[i].nxt)
        if((v = ed[i].to) != fa[u] && v != son[u]) dfs2(v, v);
}

int Lca(int x, int y){
    while(top[x] != top[y]){
        if(dep[top[x]] < dep[top[y]]) swap(x, y);
        x = fa[top[x]];
    }
    return dep[x] < dep[y] ? x : y;
}

void Change(int x, int v){for(; x <= num; x += x & -x) t[x] += v;}
int Ask(int x){int sum = 0; for(; x; x -= x & -x) sum += t[x]; return sum;}

bool cmp(int x, int y){return dfn[x] < dfn[y];}

struct ACAM{
    int tot, tr[N][26], fail[N], dep[N], bac[N], arc[N], now[N];
    
    int insert(char *str){
        int len = strlen(str + 1), p = 0;
        for(int i = 1; i <= len; i ++){
            int ch = str[i] - 'a';
            if(!tr[p][ch]) tr[p][ch] = ++ tot;
            p = tr[p][ch]; dep[p] = i;
        }
        return p;
    }

    void build(){
        for(int i = 1; i <= tot; i ++) bac[dep[i]] ++;
        for(int i = 1; i <= tot; i ++) bac[i] += bac[i - 1];
        for(int i = 1; i <= tot; i ++) arc[bac[dep[i]] --] = i;
        for(int i = 1; i <= tot; i ++){
            int u = arc[i]; add(fail[u], u);
            for(int v = 0; v < 26; v ++)
                if(tr[u][v]) fail[tr[u][v]] = tr[fail[u]][v];
                else tr[u][v] = tr[fail[u]][v];
        }
    }

    void solve(char *str){
        int len = strlen(str + 1), p = 0;
        int cnt = 0;
        for(int i = 1; i <= len; i ++)
            p = tr[p][str[i] - 'a'], now[++ cnt] = p;
        sort(now + 1, now + cnt + 1, cmp);
        cnt = unique(now + 1, now + cnt + 1) - (now + 1);
        for(int i = 1; i <= cnt; i ++){
            Change(dfn[now[i]], 1);
            if(i > 1) Change(dfn[Lca(now[i], now[i - 1])], -1);
        }
    }
} T;

int main(){
    n = read();
    for(int i = 1; i <= n; i ++){
        scanf("%s", str + 1);
        pos[i] = T.insert(str);
    }
    T.build();
    dfs1(0, 0), dfs2(0, 0);
    int q = read();
    while(q --){
        int opt = read();
        if(opt == 1){
            scanf("%s", str + 1);
            T.solve(str);
        }
        else{
            int x = read(); x = pos[x];
            printf("%d\n", Ask(dfn[x] + sz[x] - 1) - Ask(dfn[x] - 1));
        }
    }
    return 0;
}

【SA】

【思想简述】

后缀数组解决的最基本问题是后缀排序,通常使用倍增 + 基数排序的思想。

仅靠 sa 数组,后缀数组干不了很多事,然而有了 height 数组,就可以快速求出两个子串的 lcp 长度。

\(\rm height(i)\) 的含义是,排名为 \(i\) 的后缀与排名为 \(i - 1\) 的后缀的 lcp 长度。

其计算方法是利用 \(\rm height(i)\geq height(i-1)-1\) 这个很显然的结论。

不难证明 \(lcp(i,j)=\min_{i<k\leq j}\{height(k)\}\),于是利用 ST 表就可以 \(O(1)\) 求了。

void Get_Sa(){
    for(int i = 1; i <= 256; i ++) bac[i] = 0;
    for(int i = 1; i <= n; i ++) bac[str[i]] ++;
    for(int i = 1; i <= 256; i ++) bac[i] += bac[i - 1];
    for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
    for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);

    for(int p = 1; p <= n; p <<= 1){
        for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
        for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
        for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
        #define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
        for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
        for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
        if(rk[sa[n]] == n) break;
    }
}
// 这里用 lcp 代替 height
void Get_H(){
    for(int i = 1; i <= n; i ++){
        int j = sa[rk[i] - 1], k = max(lcp[rk[i - 1]] - 1, 0);
        while(str[i + k] == str[j + k] && str[i + k]) k ++;
        lcp[rk[i]] = k;
    }
}

【适用范围】

主要用于处理 lcp 相关问题。

优点:快速处理 lcp 相关问题。

缺点:预处理 \(O(n\log n)\)

【简单例题】

不同子串个数

经典应用了,一个后缀的贡献个数就是 总长度 - 与前一个后缀的 lcp 长度。

DNA

显然长度为 \(m\) 的子串就只有 \(n\) 个,枚举每个子串的起点,问题转变为能否更改至多 \(3\) 个字符使两串相等。

这个问题看上去挺匹配的样子,每次可以后移的位数为 lcp + 1。(这个 \(1\) 就是更改的位置)

看后移 \(4\) 次后能否 \(>m\)(最后一次多加了 \(1\) 所以是严格大于),lcp 可以将两串拼接后利用 SA + ST \(O(1)\) 求。

注意拼接时要加一个标识符(一定不会出现的符号)表示间隔,这是个常用技巧。

预处理 \(O(n\log n)\),查询 \(O(n)\)

当然这题用 Hash + 二分 求 lcp 也是好的,预处理 \(O(n)\),查询 \(O(n\log n)\),可能常数还小一点。

[NOI2016] 优秀的拆分

上文提到过 Hash 的 \(\rm 95\ pts\),再看看最后 \(\rm 5\ pts\) 怎么拿。

一个很好的思想是切片:将长度为 \(n\) 的字符串划分为长度为 \(len\) 的很多片,查询是否有长度为 \(len\)\(AA\) 串。

求出不同片之间的 \(\rm lcp\)\(\rm lcs\),如果相邻三片之间的 \(\rm lcp+lcs\geq len\),那么就出现了形如 \(AA\) 的串。

利用差分思想统计答案,总时间复杂度为调和级数 \(O(n\log n)\)

值得注意的是,\(\rm lcs\) 的处理方法为翻转字符串后再求一遍 \(\rm lcp\),但是在观察点处可能出现重复的统计。

为了避免重复,要从观察点 -1 的位置统计 \(\rm lcs\)

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 30010;

int n, f[N], g[N];
char str[N];

struct SA{
    int bac[N], sa[N], rk[N], SA[N], RK[N], lcp[N], h[N];
    int rmq[N][20], bin[1 << 18];

    void Get_Sa(){
        memset(bac, 0, sizeof(bac));
        for(int i = 1; i <= n; i ++) bac[str[i]] ++;
        for(int i = 1; i <= 256; i ++) bac[i] += bac[i - 1];
        for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
        for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);

        for(int p = 1; p <= n; p <<= 1){
            for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
            for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
            for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
            #define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
            for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
            for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
            if(rk[sa[n]] == n) break;
        }
    }

    void Get_H(){
        for(int i = 1; i <= n; i ++){
            int j = sa[rk[i] - 1], k = max(h[i - 1] - 1, 0);
            while(str[i + k] == str[j + k] && str[i + k]) k ++;
            h[i] = lcp[rk[i]] = k;
        }
        for(int i = 1; i <= 15; i ++)
            for(int j = 1 << i; j < 1 << (i + 1); j ++) bin[j] = i;
        for(int i = 1; i <= n; i ++) rmq[i][0] = lcp[i];
        for(int j = 1; j <= 15; j ++)
            for(int i = 1; i <= n - (1 << j) + 1; i ++)
                rmq[i][j] = min(rmq[i][j - 1], rmq[i + (1 << (j - 1))][j - 1]);
    }

    int Query(int x, int y){
        int u = rk[x], v = rk[y];
        if(u > v) swap(u, v); u ++;
        int k = bin[v - u + 1];
        return min(rmq[u][k], rmq[v - (1 << k) + 1][k]);
    }

    void build(){
        memset(sa, 0, sizeof(sa));
        memset(rk, 0, sizeof(rk));
        memset(SA, 0, sizeof(SA));
        memset(RK, 0, sizeof(RK));
        memset(lcp, 0, sizeof(lcp));
        memset(h, 0, sizeof(h));
        Get_Sa();
        Get_H();
    }
} A, B;

int main(){
    int T; scanf("%d", &T);
    while(T --){
        scanf("%s", str + 1);
        n = strlen(str + 1);
        A.build();
        reverse(str + 1, str + n + 1);
        B.build();
        memset(f, 0, sizeof(f));
        memset(g, 0, sizeof(g));
        for(int len = 1; len <= n / 2; len ++){
            for(int l = len, r = l + len; r <= n; l += len, r += len){
                int lcp = min(A.Query(l, r), len);
                int lcs = min(B.Query(n - l + 2, n - r + 2), len - 1);
                if(lcp + lcs >= len){
                    int t = lcp + lcs - len + 1;
                    g[l - lcs] ++, g[l - lcs + t] --;
                    f[r + lcp - t] ++, f[r + lcp] --;
                }
            }
        }
        for(int i = 1; i <= n; i ++)
            f[i] += f[i - 1], g[i] += g[i - 1];
        long long ans = 0;
        for(int i = 1; i < n; i ++) ans += 1LL * f[i] * g[i + 1];
        printf("%lld\n", ans);
    }
    return 0;
}

[AHOI2013]差异

\(\sum\limits_{1\leq i<j\leq n}{\rm len}(T_i)+{\rm len}(T_j)-2\times {\rm lcp}(T_i, T_j)=\frac{1}{2}n(n-1)(n+1)-2\times \sum\limits_{1\leq i<j\leq n}{\rm lcp}(T_i, T_j)\)

所以问题就是求所有 \(\rm lcp\) 之和,利用 height 数组的转化,变为求所有区间的最小值之和。

利用单调栈算法,统计对前面的影响,不难写出 \(O(n)\) 的统计方法。

Corporate Identity

\(n\) 个串的最长公共子串。

其实这题 \(O(n^2)\) 可过,但是有更优的后缀数组做法 \(O(n\log n)\)。(DC3 甚至可以理论 \(O(n)\)

拼接每个串,枚举公共子序列在某个串的起点,之后用尽量短的长度涵盖 \(n\) 个串,求区间最小值。

区间右端点单调不减,这是一个经典的滑动窗口问题。

#include<cstdio>
#include<string>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 5000010;

int n, len;
int bin[N], bac[N], rk[N], sa[N];
int SA[N], RK[N], h[N], lcp[N], q[N];
char str[N];

void init(){
	for(int i = 1; i <= len; i ++){
		rk[i] = sa[i] = RK[i] = SA[i] = 0;
		bin[i] = q[i] = h[i] = lcp[i] = 0;
	}
}

void Get_Sa(char *str, int n, int mx){
    for(int i = 1; i <= mx; i ++) bac[i] = 0;
    for(int i = 1; i <= n; i ++) bac[str[i]] ++;
    for(int i = 1; i <= mx; i ++) bac[i] += bac[i - 1];
    for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
    for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);

    for(int p = 1; p <= n; p <<= 1){
        for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
        for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
        for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
        #define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
        for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
        for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
        if(rk[sa[n]] == n) break;
    }
}

void Get_H(char *str, int n){
    for(int i = 1; i <= n; i ++){
        int j = sa[rk[i] - 1], k = max(0, h[i - 1] - 1);
        while(str[i + k] == str[j + k] && str[i + k]) k ++;
        h[i] = lcp[rk[i]] = k;
    }
}

int main(){
	while(~scanf("%d", &n) && n){
		init();
		len = 0;
		for(int i = 1; i <= n; i ++){
			scanf("%s", str + len + 1);
			int tmp = strlen(str + len + 1);
			bin[len + 1] = i;
			len += tmp + 1; bin[len] = -1;
		}
		for(int i = 1; i <= len; i ++)
			if(bin[i] == 0) bin[i] = bin[i - 1];
		Get_Sa(str, len, 256); Get_H(str, len);
		
		for(int i = 1; i <= n; i ++) bac[i] = 0;
		int head = 1, tail = 0, cnt = 0;
		int ans = 0, pos;
		for(int l = 1, r = 1; l <= len; l ++){
			for(; r <= len && cnt < n; r ++){
				if(bin[sa[r]] != -1 && !bac[bin[sa[r]]] ++) cnt ++;
				while(head <= tail && lcp[q[tail]] >= lcp[r]) tail --;
				q[++ tail] = r;
			}
			while(head <= tail && q[head] <= l) head ++;
			if(cnt == n && ans < lcp[q[head]])
				ans = lcp[q[head]], pos = sa[l];
			if(bin[sa[l]] != -1 && !--bac[bin[sa[l]]]) cnt --;
		}
		if(ans)
			str[pos + ans] = 0, printf("%s\n", str + pos);
		else
			puts("IDENTITY LOST");
	}
	return 0;
}

Facer’s string

给定字符串 \(A,B\) 和常数 \(k\)。询问有多少 \(i\),满足 \(A[i..i+k-1]\)\(B\) 中出现过,但 \(A[i..i+k]\) 没有在 \(B\) 中出现过。

也就是求 \(A\) 串中,每个后缀与 \(B\) 中后缀的最大 \(\rm lcp\) 恰好 \(=k\) 的个数。

最大后缀就是前面和后面中最近的位置,正反各跑一遍就行,于是这道题就做完了。

Common substrings

给定 \(K\) 和字符串 \(A,B\)。求存在多少三元组 \((i,j,k)\) 满足 \(A[i..i+k-1]=B[j..j+k-1]\)\(k\geq K\)

根据 height 的大小可以将字符串分为很多段,每段内部统计答案。

分为 \(A\) 前面 \(B\) 的数量和 \(B\) 前面 \(A\) 的数量两种,其实可以一起处理,处理方法为上文提到的单调栈。

每个字符都是要入栈的,但是只有属于不同串的字符对答案有贡献。

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;

const int N = 200010;
int k, n, len;
int sa[N], rk[N], SA[N], RK[N], lcp[N], bac[N];
int stk[N][3], top;
char str[N];

void init(){
	memset(sa, 0, sizeof(sa));
	memset(sa, 0, sizeof(SA));
	memset(sa, 0, sizeof(rk));
	memset(sa, 0, sizeof(RK));
	memset(lcp, 0, sizeof(lcp));
	scanf("%s", str + 1);
	n = strlen(str + 1);
	str[len = n + 1] = '#';
	scanf("%s", str + n + 2);
	n += strlen(str + n + 2) + 1;
}

void Get_Sa(){
	for(int i = 0; i <= 256; i ++) bac[i] = 0;
	for(int i = 1; i <= n; i ++) bac[str[i]] ++;
	for(int i = 1; i <= 256; i ++) bac[i] += bac[i - 1];
	for(int i = 1; i <= n; i ++) sa[bac[str[i]] --] = i;
	for(int i = 1; i <= n; i ++) rk[sa[i]] = rk[sa[i - 1]] + (str[sa[i]] != str[sa[i - 1]]);
	
	for(int p = 1; p <= n; p <<= 1){
		for(int i = 1; i <= n; i ++) bac[rk[sa[i]]] = i;
		for(int i = n; i >= 1; i --) if(sa[i] > p) SA[bac[rk[sa[i] - p]] --] = sa[i] - p;
		for(int i = n; i > n - p; i --) SA[bac[rk[i]] --] = i;
		#define comp(x, y) (rk[x] != rk[y] || rk[x + p] != rk[y + p])
		for(int i = 1; i <= n; i ++) RK[SA[i]] = RK[SA[i - 1]] + comp(SA[i], SA[i - 1]);
		for(int i = 1; i <= n; i ++) rk[i] = RK[i], sa[i] = SA[i];
		if(rk[sa[n]] == n) break;
	}
}

void Get_H(){
	for(int i = 1; i <= n; i ++){
		int j = sa[rk[i] - 1], k = max(0, lcp[rk[i - 1]] - 1);
		while(str[i + k] == str[j + k] && str[i + k]) k ++;
		lcp[rk[i]] = k;
	}
}

int main(){
	while(~scanf("%d", &k) && k){
		init();
		Get_Sa(), Get_H();
		long long ans = 0, ans1, ans2; int cnt1, cnt2;
		for(int l = 1, r; l <= n; l = r){
			top = 0, ans1 = ans2 = 0;
			for(r = l + 1; r <= n && lcp[r] >= k; r ++){
				cnt1 = cnt2 = 0;
				while(top && lcp[stk[top][0]] >= lcp[r]){
					cnt1 += stk[top][1], ans1 -= 1LL * stk[top][1] * (lcp[stk[top][0]] - k + 1);
					cnt2 += stk[top][2], ans2 -= 1LL * stk[top][2] * (lcp[stk[top][0]] - k + 1);
					top --;
				}
				if(sa[r - 1] < len) cnt1 ++;
				if(sa[r - 1] > len) cnt2 ++;
				stk[++ top][0] = r;
				stk[top][1] = cnt1; ans1 += 1LL * cnt1 * (lcp[r] - k + 1);
				stk[top][2] = cnt2; ans2 += 1LL * cnt2 * (lcp[r] - k + 1);
				if(sa[r] < len) ans += ans2;
				if(sa[r] > len) ans += ans1;
			}
		}
		printf("%lld\n", ans);
	}
	return 0;
}

【SAM】

目前不会

【其它算法】

【manacher】

解决回文串相关问题。

利用上一次匹配的最远距离,根据回文串的对称性加快匹配速度。

和 SA 中 height 的求解有异曲同工之妙。

更详细的戳这里

【ex-KMP】

处理字符串每个后缀与自己的 \(\rm lcp\),可以说是 SA 的弱化版,但是时间是 \(O(n)\) 的。

貌似没有什么问题是只能用 ex-KMP 解决的,但多了解一些思想总没错。

更详细的戳这里

【PAM】

回文自动机/回文树是处理回文串问题的有力工具。

它仅用 \(O(|S|)\) 的状态数就记录了字符串中所有本质不同的回文串信息。

更详细的戳这里

【总结】

字符串问题是 OI 中特殊而有趣的一类,希望能在字符串问题中收获快乐。

引用资料&特别鸣谢:

  1. AC自动机学习笔记

完结撒花。

posted @ 2021-04-04 14:24  LPF'sBlog  阅读(130)  评论(0编辑  收藏  举报