ZHQ 字符串讲课笔记

ZHQ 字符串讲课笔记

一、Hash

定义

作用:用于快速比较两个字符串是否相同

准确来说,我们希望有一个函数 int hash (string s),用于把字符串映射到一个整数:

如果字符串 $ s_1 = s_2 $,那么 $ hash (s_1) = hash (s_2) $

如果 $ hash (s_1) = hash (s_2) $,我们希望有极大地概率满足 $ s_1 = s_2 $

具体做法

我们可以把字符串当成 $ b $ 进制数,然后对一个大质数 $ m $ 取模

如果 $ m $ 太小的话,就会出现哈希值相同但是字符串不同的情况

单模哈希,当 $ n $ 在 $ 10^5 $ 左右,几乎一定会炸

自然溢出哈希:使用 unsigned long long 进行计算,炸掉的概率很小,而且速度很快,卡的人很少

双模哈希:同时取两个模数,值域会变成 $ M_1 \times M_2 $,可以取一个单模,一个自然溢出

细节

  1. 进制 $ b $,要大于字符集大小

  2. 不要把字符映射到 $ 0 $

子串哈希

给定字符串 $ s_1 \dots s_n $,希望做 $ O(n) $ 预处理以后可以 $ O(1) $ 求出子串的哈希值

考虑递推实现

令 $ f_i $ 表示 $ s_1 \dots s_i $ 的哈希值,然后可以很轻松的递推出 $ f_i = f_{i - 1} \times b + s_i $

求 $ s_l \dots s_r $ 的哈希值就可以很容易得出:

$ f_r - f_{l - 1} \times b^{r - l + 1} $

然后把 $ b^i $ 预处理一下就可以做到 $ O(1) $

这个很容易证明,举个例子就可以了

应用

给定文本串 $ S $,模式串 $ T $,求 $ T $ 在 $ S $ 中出现的所有位置

很简单,预处理 $ S $,求出 $ T $ 的哈希值,找 $ S $ 所有长度为 $ |T| $ 的子串的哈希进行比较

复杂度线性

求出最长回文子串

考虑枚举每一个位置,然后求出以这个点往左看,往右看,能够得到的最长的回文

这个预处理两遍哈希,然后找最长用二分来做,复杂度 $ O(nlogn) $

求多个字符串的最长公共子串

首先二分最长公共子串的长度

然后考虑 check 怎么写

这里我们可以把所有长度为 $ mid $ 的哈希值都拿出来,然后看看有没有公共的即可

做完了

复杂度是 $ O(nlogn) $

哈希表

也就是 unordered_map < typename1, typename2 >

然后这个就是挂一个哈希映射,加上链表

和 $ map $ 差不多,更快,但是会出现错误的概率

二、Manacher

基本用法

求最长回文子串

首先考虑如何区分奇偶回文串,这里可以在每一个字符之间加上一个特殊字符,比如 #

这里我们考虑求出 $ m_x $,表示以 $ m $ 为中心,最靠右的端点,保证回文

如果枚举新的端点,并且之前最靠右的端点要大,就可以重复利用

因为我们可以考虑对称,也就是说可以直接考虑对称位置的 $ m_x $ 和当前的点是相同的

这样就做完了

总结:两步

  1. 继承对称位置的 $ m_x $ 值

  2. 尝试扩展

代码:

# include <bits/stdc++.h>

# define int long long

using namespace std;

int m[22000210], n;

int a[22000210];

int rt, mid;

inline int Manacher () {

    int ans = 0;

    for (int i = 1; i <= n; ++ i) {

        if (i <= rt) m[i] = min (m[mid - (i - mid)], rt - i + 1);

        else m[i] = 1;

        while (a[i - m[i]] == a[i + m[i]]) ++ m[i];

        if (m[i] + i - 1 > rt) rt = m[i] + i - 1, mid = i;

        ans = max (ans, m[i]);

    }

    return ans;

}

char s[11000105];

signed main () {

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

    n = strlen (s + 1);

    a[0] = '@', a[1] = '#';

    for (int i = 1; i <= n; ++ i) a[i << 1] = s[i], a[(i << 1) | 1] = '#';

    n = (n << 1) | 1;

    cout << Manacher () - 1 << endl;

    return 0;

}

三、Trie 树

用途

可以体现出前缀关系

0/1 Trie

和值域线段树很像,用于处理亦或问题

例题

给定三个输入框,每次操作可以在任意一个输入框内输入或者删除

给一堆人,每个人有三个字符串

每次操作后求出输入框内的字符串分别是一个人的三个字符串的前缀的个数

70 pts:

把每个人看成一个点 $ (x, y, z) $,举个例子,这个 $ x $ 表示这个人第一个字符串在 Trie 上的终止点

然后把三个输入框内的字符串构造成一个正方体,求里面有多少个点

复杂度 $ O(nlog^2n) $

100 pts:

咕咕咕

四、KMP

基本实现

给定文本串 $ S $,模式串 $ T $,求 $ T $ 在 $ S $ 中出现的所有位置

这里我们先考虑暴力去匹配,复杂度是 $ O(nm) $

然后我们发现,每次匹配都要从头开始,大大提高了复杂度,所以我们考虑优化

假设现在匹配了 $ S[i - j + 1, i] $ 和 $ T[i, j] $,我们希望从 $ T $ 的最长的,等于以 $ j $ 结尾的真后缀的前缀开始匹配

也就是说,我们要求出最长的既是前缀又是后缀的子串,也叫做 border

首先定义 $ nxt_i $ 为 $ T[1, i] $ 的最长 border

如何求 border 呢?我们考虑这是一个延伸的过程,也就是从上一个 border 考虑能不能延伸,可以的话就在上一个的基础上 $ +1 $,否则的话就找一个较小的 border 这其实是一个跳跃的过程,求法如下:

int j = 0, nxt[100005];

for (int i = 2; i <= t; ++ i) {

    while (j > 0 && T[j + 1] != T[i]) j = nxt[j];

    if (b[j + 1] == b[i]) ++ j;

    nxt[i] = j;

}

然后拿着 $ S $ 在 $ T $ 上匹配

如果匹配不上就尝试跳 nxt

j = 0;

for (int i = 1; i <= s; ++ i) {

    while (j > 0 && b[j + 1] != a[i]) j = nxt[j];

    if (b[j + 1] == a[i]) ++ j;

    if (j == t) cout << i - t + 1 << endl;

}

nxt 数组的有趣性质

  1. $ T $ 的最小循环节是 $ n - nxt_n, n = |T| $,其他的最小循环节都是它的倍数

  2. $ T $ 的所有 border 是 $ nxt[i],nxt[nxt[i]],nxt[nxt[nxt[i]]]\dots $

posted @ 2023-06-23 15:25  __Tzf  阅读(20)  评论(0编辑  收藏  举报