[字符串学习笔记] 1. 字符串 Hash

1.1. 定义

定义函数 \(f(s)\)\(s\) 以某种方式映射为整数 \(h\)\(f\) 被称为 Hash 函数,\(h\) 被称为 Hash 值。

在使用同样的 Hash 函数的情况下,\(s, t\) 的 Hash 值可以用来判断 \(s, t\) 是否相等。

1.2. 性质

对于任意字符串 \(s, t\),有

  • \(f(s) \neq f(t)\)一定有 \(s \neq t\)
  • \(f(s) = f(t)\)大概率 情况下 \(s = t\),但如果 \(s \neq t\),称为 Hash 碰撞

1.3. 实现

多项式 Hash 是最广泛运用,且实现简单的一种 Hash 方法。

对于字符串 \(s\),通常采用 \(f(s) = \left( \sum_{i = 0}^{|s| - 1} b^{|s| - i - 1} \cdot s[i] \right) \bmod P\)。如 \(|s| = 3\) 时,\(f(s) = b^2 s[0] + bs[1] + s[2]\)

此时可以将 Hash 值理解为一个 \(b\) 进制数。\(P\) 最好选择一个大质数。

const int B = 131, P = 998244353;

int gethash(const string s) {
    int h = 0;
    for (char i : s.size())
        h = ((long long)h * B + i) % P;
    return h;
}

另外,可以根据 C++ 特性,使用 无符号 整型的 自然溢出,来实现取模。

对于使用 \(l\) 位无符号整型的情况,相当于自动对 \(2^l\) 取模。

typedef unsigned long long ull;
const ull B = 131;

ull gethash(const string s) {
    ull h = 0;
    for (char i : s)
        h = h * B + i;
    return h;
}

例题 P3370 字符串哈希

题解

模版题。对字符串进行 Hash 后存到 \(h\) 中,接着排序 \(h\) 并计算结果。

参考代码:

const int N = 1e4 + 10, B = 131;
int n, res = 1;
vector<unsigned long long> h;
string s;

signed main() {
    cin >> n;
    while (n--) {
        cin >> s;
        int hsh = 0;
        for (char i : s)
            hsh = hsh * B + i;
        h.push_back(hsh);
    }
    sort(h.begin(), h.end());
    for (int i = 0; i < h.size() - 1; i++)
        if (h[i] != h[i + 1]) res++;
    cout << res;
}

1.4. Hash 碰撞

假定 Hash 函数随机映射 \(n\) 个字符串到 \(P\) 种不同值中。

处理第 \(i + 1\) 个字符串时,不发生碰撞的概率为 \(\frac{P - i} P\)。根据乘法原理,所有字符串不发生碰撞的概率为 \(\prod_{i = 0}^{n - 1} \frac{P - i} P\)

在随机数据下,若 \(n = 10^5\)\(P = 998244353\),不发生碰撞的概率只有约 \(6.7\%\)。而若使用 64 位整型自然溢出的办法(即 \(P = 2^{64}\)),就算 \(n = 10^7\) 也几乎不可能有碰撞。

1.5. 多重 Hash

多重 Hash 是针对数据范围过大时出现 Hash 碰撞 导致无法正确判断的情况。在使用随机模数的情况下,多重 Hash 也能有效地应对卡特定模数的数据。

一般情况下,两个模数完全足够。具体地,定义 \(b_1, b_2\) 以及模数 \(P_1, P_2\),设立两个 Hash 函数 \(f_1(s), f_2(s)\)。若字符串 \(s, t\) 相同,则 \(f_1(s) = f_1(t)\)\(f_2(s) = f_2(t)\)

多重 Hash 的碰撞几率极低

1.6. 查询子串 Hash

字符串 Hash 算法的时间复杂度为 \(\Theta(|s|)\)。若涉及到 \(m\) 次子串匹配时,直接求 Hash 的总复杂度无法接受。

考虑使用 类前缀和算法 完成。涉及到查找子串 Hash 时,默认字符串的下标从 \(1\) 开始

\(p(s, i) = f(s[1 \ldots i])\),根据原来 \(b\) 进制的定义,易证 \(f(s[l \ldots r]) = p(r) - p(l - 1) \cdot b^{r - l + 1}\)

通过递推

\[\begin{cases} p(s, 1) = s[i] \\ p(s, i) = b \cdot p(s, i - 1) + s[i] \end{cases} \]

可以使用前缀和的方法 \(\Theta(|s|)\) 处理 \(p\) 函数。\(b\) 的幂次同理。

例题 ABC284F ABCBAC

题解

此处定义 \(\operatorname{rev}(p)\)\(p\) 的逆序串,且 \(m\)\(|s|\),即 \(2n\)

可以发现,\(s[1 \ldots i] + s[i + n + 1 \ldots m] = \operatorname{rev}(s[i + 1 \ldots i + n])\)

使用两个 Hash 数组 \(l, r\),对于 \(1 \leq i \leq m\),使用前缀和的方法求出

  • \(l[i] = f(s[1 \ldots i])\)
  • \(r[i] = f(\operatorname{rev}(s[i \ldots m]))\)

枚举所有可能的 \(i\) 并判断即可。

参考代码:

using ull = unsigned __int128;

const int N = 2e6 + 10, B = 131;
int n;
char s[N];
ull b_exp[N], hsh_l[N], hsh_r[N];

ull getleft(int l, int r) {
    return hsh_l[r] - hsh_l[l - 1] * b_exp[r - l + 1];
}

ull getright(int l, int r) {
    return hsh_r[l] - hsh_r[r + 1] * b_exp[r - l + 1];
}

signed main() {
    cin >> n >> (s + 1);
    int m = n * 2;
    b_exp[0] = 1;
    for (int i = 1; i <= m; i++)
        b_exp[i] = b_exp[i - 1] * B;
    for (int i = 1; i <= m; i++)
        hsh_l[i] = hsh_l[i - 1] * B + s[i];
    for (int i = m; i >= 1; i--)
        hsh_r[i] = hsh_r[i + 1] * B + s[i];
    for (int i = 1; i <= n; i++) {
        ull pre = getleft(1, i), rev = getright(i + 1, i + n), suf = getleft(i + n + 1, m);
        if (pre * b_exp[n - i] + suf == rev) {
            for (int j = i + n; j >= i + 1; j--)
                cout << s[j];
            cout << '\n'
                 << i;
            return 0;
        }
    }
    cout << -1;
}

例题 CF113B Petr#

题面

给定字符串 \(s, a, b\),求 \(s\) 中有 \(a\) 这个前缀且有 \(b\) 这个后缀的子串个数。

题解

考虑子串 \(s[i \ldots j]\),需要满足两点要求:

  1. \(|s[i \ldots j]|\),即 \(j - i + 1\) 不小于 \(\max(|a|, |b|)\)
  2. 前缀 \(s[i \ldots i + |a| - 1] = a\),后缀 \(s[j - |b| + 1 \ldots j] = b\)

枚举所有的 \((i, j)\) 并 Hash 判断即可。最坏可能下,时间复杂度 \(\Theta({|s|}^2 \log {|s|}^2)\)。同理,可以使用 STL 的 unordered_map Hash 表进一步提升效率至 \(\Theta({|s|}^2)\)

参考代码:

using ull = unsigned long long;

const int N = 2010, B = 131;
int res;
char s[N], a[N], b[N];
ull b_exp[N], hsh_s[N], hsh_a, hsh_b;
vector<ull> h;

ull gethash(int l, int r) {
    return hsh_s[r] - hsh_s[l - 1] * b_exp[r - l + 1];
}

signed main() {
    cin >> (s + 1) >> (a + 1) >> (b + 1);
    int n = strlen(s + 1), p = strlen(a + 1), q = strlen(b + 1);
    b_exp[0] = 1;
    for (int i = 1; i <= n; i++) {
        b_exp[i] = b_exp[i - 1] * B;
        hsh_s[i] = hsh_s[i - 1] * B + s[i];
    }
    for (int i = 1; i <= p; i++)
        hsh_a = hsh_a * B + a[i];
    for (int i = 1; i <= q; i++)
        hsh_b = hsh_b * B + b[i];
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++)
            if (j - i + 1 >= max(p, q))
                if (gethash(i, i + p - 1) == hsh_a && gethash(j - q + 1, j) == hsh_b)
                    h.push_back(gethash(i, j));
    sort(h.begin(), h.end());
    cout << unique(h.begin(), h.end()) - h.begin();
}

例题 CF985F Isomorphic Strings

题解

记录一个字符串中 每种 字符 \(c\) 出现的位置,那么若两个字符串同构,虽然字符不同,但出现的位置 必定重复

具体地,对于 每种 字符 \(c\),遍历原串 \(s\),某一位 \(= c\) 时记录为 \(\texttt 1\),否则记录为 \(\texttt 0\),得到一个 01 串并对其进行 Hash。每次询问时,计算出两个子串各 \(26\) 个 Hash 值,存储并排序。若排序后结果相等,则说明两个子串同构。

时间复杂度 \(O(|s|)\)。参考代码:

const int N = 2e5 + 10, B = 131, P = 1234567891;
int n, m, x, y, k;
long long b_exp[N], hsh[26][N];
char s[N];

int gethash(int c, int l, int r) {
    return (hsh[c][r] - hsh[c][l - 1] * b_exp[r - l + 1] % P + P) % P;
}

signed main() {
    cin >> n >> m >> (s + 1);
    b_exp[0] = 1;
    for (int i = 1; i <= n; i++)
        b_exp[i] = b_exp[i - 1] * B % P;
    for (int i = 0; i < 26; i++)
        for (int j = 1; j <= n; j++)
            hsh[i][j] = (hsh[i][j - 1] * B + (s[j] - 'a' == i)) % P;
    while (m--) {
        cin >> x >> y >> k;
        vector<long long> vecx, vecy;
        for (int i = 0; i < 26; i++)
            vecx.push_back(gethash(i, x, x + k - 1));
        sort(vecx.begin(), vecx.end());
        for (int i = 0; i < 26; i++)
            vecy.push_back(gethash(i, y, y + k - 1));
        sort(vecy.begin(), vecy.end());
        if (vecx == vecy)
            cout << "YES\n";
        else
            cout << "NO\n";
    }
}

1.7. 字符串匹配

先求出模式串 \(t\) 的 Hash 值 \(f(t)\)。求出待匹配串 \(s\) 中所有长度为 \(|t|\) 的子串,逐一比较即可。

例题 P8630 密文搜索

题解

可以注意到,如果字符串 \(t\) 可以重排为 \(s\),那么 \(s, t\) 排序后必然相同。

直接 Hash 并遍历 \(s\) 长度为 \(8\) 的所有子串查找即可。由于长度只有 \(8\),无需使用 查询子串 Hash 中所介绍的方法,直接暴力 Hash 即可。

时间复杂度 \(\Theta(n |s|)\)。可以使用 STL 的 unordered_map Hash 表进一步提升效率至 \(\Theta(|s|)\)

参考代码:

const int B = 131;
string s, t;
int n, res;
vector<unsigned long long> h;

signed main() {
    cin >> s >> n;
    if (s.size() < 8) {
        cout << 0;
        return 0;
    }
    while (n--) {
        cin >> t;
        sort(t.begin(), t.end());
        int hsh = 0;
        for (char i : t)
            hsh = hsh * B + i;
        h.push_back(hsh);
    }
    for (int i = 0; i < s.size() - 7; i++) {
        string p = s.substr(i, 8);
        sort(p.begin(), p.end());
        int hsh = 0;
        for (char i : p)
            hsh = hsh * B + i;
        for (int j : h)
            if (j == hsh) res++;
    }
    cout << res;
}

习题

参见 字符串 Hash 习题

posted @ 2024-06-22 12:39  Carrot-Meow~  阅读(67)  评论(0)    收藏  举报