浅谈字符串操作

· · 个人记录

这波乱搞

新年发博客?

不存在,只是开个坑

所以这篇文章干屁用

hashkmptrie 树,AC 自动机的学习笔记

好吧,我基本是深夜更新

话不多说,我们就进入鬼畜奇妙的字符串世界吧

1.HASH,哈希

此算法可能是最简单的了

我们就时将每个字符串看成 base 进制的数,然后比较哈希值,但是呢,我们一个个转换后相加会很容易爆炸,所以我们需要对 mod 取余

不过这个 mod 最好取孪生素数,不然就会被毒瘤出题人卡掉

下面给出代码

const ull base = 233;
const ull mod = 1e9 + 5;
inline ull hash(char s[]) {
    int len = strlen(s);
    ull sum = 0;

    for (int i = 0; i < len; i ++)
        sum = (sum * base + (ull)(s[i])) % mod;

    return sum;
}

接下来我门来看看 P3370

其实是模板题

只要算出每一个字符串的哈希值,然后从小到大排序,比较即可

注意,对于孪生素数,这里的数据卡掉了1e9+7

代码如下

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
#define ull unsigned long long

const int N = 1e5 + 7;
const ull base = 233;
const ull mod = 1e9 + 5;

using namespace std;

ull a[N];

char s[N];

int n, ans = 1;

inline ull hash(char s[]) {
    int len = strlen(s);
    ull sum = 0;

    for (int i = 0; i < len; i ++)
        sum = (sum * base + (ull)(s[i])) % mod;

    return sum;
}

inline bool cmp(int x, int y) {
    return x < y;
}

int main() {
    cin >>n;

    for (int i = 1; i <= n; i ++) {
        scanf("%s", s);

        a[i] = hash(s);
    }

    sort(a + 1, a + 1 + n, cmp);

    for (int i = 1; i < n; i ++) {
        if (a[i] != a[i + 1])
            ans ++;
    } 

    cout << ans << endl;

    return 0;
}

例题的话,这里先给几道,有机会再更

[link](https://www.luogu.com.cn/blog/328170/ti-xie-uva10298-power-strings) # 2.KMP 怎么说呢 这东西是挺好玩的 主要是给你个字符串 $A$ 和 $B$,叫你求 $B$ 在 $A$ 中出现了几次 $In$ $a$ $word$,就是字符串匹配 其实 $hash$ 也可以,$but$ $as$ $we$ $know$,会被毒瘤出题人(~~noip~~) 卡 于是就诞生了 $kmp$ 这~~破烂~~好玩意 我们要先理解一个叫做 **前缀数组** 和 **后缀数组** 的玩意 前缀数组呢,就是一个字符串这东西,他的前缀,类似于 ``` abcdb 前缀数组:a, ab, abc, abcd, abcdb ``` 后缀数组就是反着来,类似于 ``` abcdb 后缀数组:b, db, cdb, bcdb, abcdb ``` 那么我们的 $nxt_i$ 就是在第 $i$ 位的时候他的 **前缀**和 **后缀** 长度 类似于 ``` ababd 先是a这个,那么nxt[1] = 0 然后ab,那么明显的nxt[2] = 0 接着是aba,那么都有a,所以nxt[3] = 1 然后是abab,那么前后缀都有ab,所以nxt[4] = 2 然后是ababd,没有相同的前缀后缀,所以nxt[5] = 0 所以abcdb的nxt数组为 0 0 1 2 0 ``` 主要是如何求 ~~明显暴力~~ ~~要是暴力就没那么有名了~~ 我们用 $j$ 来表示当前到了第几位,但是 $i$ 的话是代表现在的总长 注意这两个的区别,$i$ 是代表当前匹配的 $B$ 串的子串长度,$j$ 是当前匹配的 $B$ 串的子串的 **前缀** 和 **后缀** 到了第几位 那么操作流程 实际上我们可以完全通过 $nxt[1]$ $nxt[2]$ …… $nxt[i - 1]$,来求出这个 $nxt[i]

咋求呢?

我们可以自我匹配,当这个 b[j + 1] == b[i],时,说明匹配上了,我们来下一位,即 j++

那么匹配不上的话,我们就将当前的 j 给换到前面去,换到第 j 位最长 前缀后缀 长度,知道能够匹配上为止,即当 b[j + 1] != b[i] 时,j == nxt[j]

当然,我们每次算完肯定要记录啊,所以在每次算完后 kmp[i] == j

然后我们贴贴代码

inline void pre() {
    int j = 0;

    for (int i = 2; i <= lb; i ++) {//第一位肯定是0,无需匹配 
        while (j && b[j + 1] != b[i])//匹配不上的时候 
            j = nxt[j];//就往回搜

        if (b[j + 1] == b[i])
            j ++;//不然往前搞 

        nxt[i] = j; //记录 
    }
}

习惯性手动操作一波

B:ababd

1.j = 0, i = 2

那么b[j + 1] != b[i] 并且 j == 0

所以nxt[i] = nxt[2] = j = 0

2.j = 0, i = 3

那么b[j + 1] == b[i] == 'a'

所以j ++

即nxt[i] = nxt[3] = j = 1

3.j = 1, i = 4

那么b[j + 1] == b[i]

所以 j ++

即nxt[i] = nxt[4] = j = 2

4.j = 2, i = 5

那么 b[j + 1] != b[i] 并且 j != 0

所以j = nxt[j] = nxt[2] = 0

那么这是b[j + 1] != b[i],但是 j == 0

所以nxt[i] = nxt[5] = j = 0

很简单易懂吧

所以我们就解决了 kmp 算法的一半了

接下来就是正式的 kmp 了(什么,现在才到kmp,滚

其实这这不过是换了个东西匹配,吧 $B$ 串和 $B$ 串自个儿匹配,变成了 $A$ 串罢了 所以啊,具体的操作和上面差不多 同样枚举 $i$,但是这个 $i$ 是 $A$ 串子串长度,$j$ 是当前的 $B$ 串匹配到了第几位 如果当前这个 $b[j + 1] == a[i]$,说明匹配上了,往下搞,就是 $j ++

如果不行,就是 b[j + 1] != a[i],那匹配不上,我们和上面求 nxt 一样,退到前面,退回第 j 位最长 前缀后缀 长度,就是 j = nxt[j]

那么当我们匹配完毕,就是 j == lb 时,匹配完毕,ans ++

同样,为了继续匹配,我们每次匹配完毕时,应该吧 j 调整,就是 j == kmp[j]

注意,不是 每次操作 完,是 每次匹配完毕

贴下代码

inline void kmp() {
    int j = 0;

    for (int i = 1; i <= la; i ++) {//这个要从第一个字母匹配 
        while (j && b[j + 1] != a[i])//不匹配
            j = nxt[j];//往回搜

        if (b[j + 1] == a[i])
            j ++;//不然往前搞 

        if (j == lb) {//匹配完毕 
            ans ++;
            j = nxt[j];//为下次匹配做好准备 
        } 
    }
} 

样例懒得模拟了,反正这个就和求 nxt 差不多

然后推荐例题,有空更新

KMP模板

关于KMP的巧妙运用 link

关于KMP的巧妙运用 link

3.Trie树

关于踹树 trie

我还是先偷一棵 $Trie$ 树(为什么我每次都要偷树) ![trie树](https://cdn.luogu.com.cn/upload/image_hosting/4l2e4nd0.png) 不难得知,我们这个 $Trie$ 树,就是一个个插入节点,**相同的跟着风,不同的自己创** 所以,我们自己插入节点也就比较简单的啦 首先要理解几个变量 $tot:$ 一共有几个节点,本篇代码中根节点为 $1 $c:$ 当前这个字母的值 $trie:$ $trie$ 数组 $flag:$ 在每个字符串末尾标记用,便于统计 思路很明显,就是在插入时看看当前有没有路可走,没有就自己造一个,有就跟着老路,最后将字符串末尾给标记一下,好统计 下面给出代码 ```cpp inline void insert(char *ss) { int len = strlen(ss + 1), pos = 1; for (int i = 1; i <= len; i ++) { int c = ss[i] - 'a' if (! trie[pos][c]) trie[pos][c] = ++ tot; pos = trie[pos][c]; } flag[pos] = 1; } ``` 接下来是查找 这里贴的是判断是否是前缀 所以我们只需要看看当前搜索的子串中被标记的符号有几个,大于 $1$ 个就说明是前缀,因为自己本身也有一个,所以要大于 $1$ 个 代码如下 ```cpp inline bool find(char *ss) { int len = strlen(ss + 1), pos = 1, cnt = 0; for (int i = 1; i <= len; i ++) { int c = ss[i] - '0'; pos = trie[pos][c]; if (flag[pos]) cnt ++; } if (cnt > 1) return 1; return 0; } ``` $Trie$ 树相比 $KMP$ 较为简单,所以 $KMP$ 是讲的比较多的,$Trie$ 树会偏少 下面是例题,~~本人懒得写题解了~~ [Trie树的稍稍变形](https://www.luogu.com.cn/problem/P2580) 接下来就是 $AC$ 自动机了 剧透下,这可以说是 $KMP + Trie$ 树 # 4.AC自动机 没错他不能让你直接 $AC$ 并能让你自动 $WA

好吧我们进入正题

先来一个背景:AC自动机模板(easy)

上面说了

所以我们还是要建一个 $trie$ 树(**根节点为 $1$**) 建树的过程不再赘述,代码如下 ```cpp inline void insert(char *ss) { int len = strlen(ss + 1), pos = 1; for (int i = 1; i <= len; i ++) { int c = ss[i] - 'a'; if (! trie[pos][c]) trie[pos][c] = ++ tot; pos = trie[pos][c]; } cnt[pos] ++; } ``` 现在我们要像 $KMP$ 一样建一个 $nxt$ 数组, 但是这次我们不叫 $nxt$,叫 $fail$ 了 那么这个家伙的意思就是:**如果说从 $root$ 到 $i$ 的串我们设为 $A$,从 $root$ 到 $j$ 的串我们称为 $B$,则 $B$ 是 $A$ 的最长后缀** 先看看我们的字典树 ![](https://cdn.luogu.com.cn/upload/image_hosting/t7etqs8l.png) 开始找 $fail

很明显,由图可知,根节点所有的孩子的 fail 边所指向的点都是根节点

那么我们是不是可以这样:

把所有根节点的儿子的 fail 先预处理出来

然后接下来开始匹配

我们先遍历到这个 a 的一个儿子即 k

明显的,他没有一个是能够匹配的上的,于是他的 fail 边只能指向根节点

RT

绿色的即为第三层的 fail 边,第二层的 fail 边即为蓝色的

接下来我们遍历 b 的儿子即为 a

那么明显的有一个 a,所以这个 fail 边便可以连向 a

RT

那我们再来遍历 c 的儿子,即为 a

那么我们同样的还能找到那个 a

再来看 y

没有与之匹配的 fail

便只能连向根节点

所以然后接下来便可以遍历 k 的儿子就是 i

明显,i 也没有相同的字母, fail 边还是指向根节点

再来看 k 的另一个儿子 o,明显的,我们也只能指向根节点

RT ![](https://cdn.luogu.com.cn/upload/image_hosting/m3crmbgu.png) 然后我们再来看这个 $a$ 的儿子,就是 $o

我们从 o 的爸爸即为 afail 看,其实可以发现是没有的

所以还是连向根节点

那么这到底是什么情况呢?

偶然?

我们看看之前所有连接的

如果我们把 pos 设为当前要求的 fail 的点,那么是不是上面所有的点的 fail 边都可以看成 pos 他爹的fail 边有无这个儿子 k

若有,fail 边连向 k

若没有,连向根节点

并且,我刚刚遍历是不是按层遍历,即为 bfs

所以我们可以用队列实现

一个个儿子遍历过去,碰到的有这个儿子(真儿子),便可查询

但是如果没有呢(假儿子)

好办,把连接假儿子的边设为他父亲这个点的 fail 边所指向的与其相同字母的儿子

代码就出来了

inline void bfs() {
    queue <int> q;

    for (int i = 0; i < 26; i ++) {
        int pos = trie[1][i];//根节点的儿子(包括真假) 

        if (pos) {//真儿子 
            fail[pos] = 1;//fail数组指向根节点 
            q.push(pos);//入队 
        }
    }//把根节点的儿子设为根节点,方便查询,并把他们入队 

    while (! q.empty()) {//开始匹配 
        int u = q.front();//取出队头 
        q.pop();//弹出 

        for (int i = 0; i < 26; i ++) {//一个个儿子遍历 
            int pos = trie[u][i];//u 的为i字母的儿子(包括真假) 

            if (pos) {//如果u的这个儿子是真儿子 
                fail[pos] = trie[fail[u]][i];//就把u这个点的儿子的fail边设置为u的fail边的那个同样的儿子 

                if (! fail[pos])
                    fail[pos] = 1;

                q.push(pos);//入队 
            } else//如果是假儿子 
                trie[u][i] = trie[fail[u]][i];// 就把u的连接假儿子的边设为u这个点的fail边所指向的与其相同字母的儿子 
        }
    }
}

接下来是查询

既然是查询哪些在文本中出现且相同的位置不同即可

我们在每次查询前跳到 fail 边即可

所以查询也是极其简单的

代码

inline int find(char *ss) {
    int len = strlen(ss + 1), pos = 1, sum = 0; 

    for (int i = 1; i <= len; i ++) {
        int c = ss[i] - 'a';
        int u = trie[pos][c];

        while (u&& cnt[u] != -1) {//有这个儿子且没被搜过 
            sum += cnt[u];//加上答案 
            cnt[u] = -1;//标记 
            u = fail[u]; //跳到fail边 
        } 

        pos = trie[pos][c];//向下继续搞 
    }

    return sum; 
}

完整代码

#include <iostream>
#include <cstdio>
#include <cstring>
#include <queue>

const int N = 1e6 + 7;

using namespace std;

char s[N];

int n, tot = 1;

struct AC {
    int trie[N][27];
    int cnt[N];
    int fail[N];

    inline void insert(char *ss) {
        int len = strlen(ss + 1), pos = 1;

        for (int i = 1; i <= len; i ++) {
            int c = ss[i] - 'a';

            if (! trie[pos][c])
                trie[pos][c] = ++ tot;

            pos = trie[pos][c]; 
        } 

        cnt[pos] ++;
    }//构建trie 树 

    inline void bfs() {
        queue <int> q;

        for (int i = 0; i < 26; i ++) {
            int pos = trie[1][i];//根节点的儿子(包括真假) 

            if (pos) {//真儿子 
                fail[pos] = 1;//fail数组指向根节点 
                q.push(pos);//入队 
            }
        }//把根节点的儿子设为根节点,方便查询,并把他们入队 

        while (! q.empty()) {//开始匹配 
            int u = q.front();//取出队头 
            q.pop();//弹出 

            for (int i = 0; i < 26; i ++) {//一个个儿子遍历 
                int pos = trie[u][i];//u 的为i字母的儿子(包括真假) 

                if (pos) {//如果u的这个儿子是真儿子 
                    fail[pos] = trie[fail[u]][i];//就把u这个点的儿子的fail边设置为u的fail边的那个同样的儿子 

                    if (! fail[pos])
                        fail[pos] = 1;

                    q.push(pos);//入队 
                } else//如果是假儿子 
                    trie[u][i] = trie[fail[u]][i];// 就把u的连接假儿子的边设为u这个点的fail边所指向的与其相同字母的儿子 
            }
        }
    }

    inline int find(char *ss) {
        int len = strlen(ss + 1), pos = 1, sum = 0; 

        for (int i = 1; i <= len; i ++) {
            int c = ss[i] - 'a';
            int u = trie[pos][c];

            while (u&& cnt[u] != -1) {//有这个儿子且没被搜过 
                sum += cnt[u];//加上答案 
                cnt[u] = -1;//标记 
                u = fail[u]; //跳到fail边 
            } 

            pos = trie[pos][c];//向下继续搞 
        }

        return sum; 
    }
} ac;

int main() {
    freopen(".in", "r", stdin);
    freopen(".out", "w", stdout);

    cin >> n;

    for (int i = 1; i <= n; i ++) {
        scanf("%s", s + 1);
        ac.insert(s);
    }

    ac.bfs();

    scanf("%s", s + 1);

    cout << ac.find(s) << endl;

    fclose(stdin);
    fclose(stdout);

    return 0;
}

所以

我们的 AC 自动机就结束了

浅谈字符串结束了

除夕夜开始,止于 2021.3.6

$Atlantic$.