目录

字符串 hash

一 字符串哈希

1.1 性质

  • 哈希值不同,字符串一定不同。
  • 哈希值相同,字符串不一定相同。(但大概率相同,并且我们希望它相同)

1.2 模数

根据一些数论知识,模数取质数是好的。

一个例子是 \(ax + b \bmod p\) 以 \(gcd(a, p)\) 为间隔分布,可以说明模数取合数是坏的。

模数需要保证乘起来不超过 i64 ,通常需要常备两个模数。我的模数是 \(1E9\) + \(123, 403\) 。第二个数是 WXU 第一个 ACM 实验室的编号。

1.3 底数

显然底数需要大于字符集,通常要大于 256 即 ascii 码范围。随机取就行。

1.4 双重哈希

数据可能存在一些生日攻击(生日攻击可能很难理解,但原理基于生日悖论,是个容易理解且有意思的问题),可能需要双重哈希。

为了方便好写,哈希的时候开 i64 。

下面是一个模板:

const int MAXN = 2E5+5;
const int HA = 2;
const int BB[HA] = {1234, 5678};
const int PP[HA] = {int(1E9) + 123, int(1E9) + 403};
i64 pw[HA][MAXN], ipw[HA][MAXN];
i64 hdw[HA][MAXN], hup[HA][MAXN];
i64 ksm(i64 a, i64 n, int P) {
    i64 res = 1;
    for (;n;n>>=1, a=a*a%P) if (n&1)
        res=res*a%P;
    return res;
}
void init_hash() {
    for (int h = 0; h < HA; h++) {
        pw[h][0] = 1;
        for (int i = 1; i < MAXN; i++) {
            pw[h][i] = pw[h][i - 1] * BB[h] % PP[h];
        }
        ipw[h][MAXN - 1] = ksm(pw[h][MAXN - 1], PP[h] - 2, PP[h]);
        for (int i = MAXN - 1; i; --i) {
            ipw[h][i - 1] = ipw[h][i] * BB[h] % PP[h];
        }
        assert(ipw[h][0] = 1);
    }
}
void make_hash(std::string s) { // s[0] == ' '
    int n = s.size();
    for (int h = 0; h < HA; h++) {
        for (int i = 1; i <= n; i++) {
            hdw[h][i] = (hdw[h][i - 1] * BB[h] % PP[h] + s[i]) % PP[h];
            hup[h][i] = (hup[h][i - 1] + s[i] * pw[h][i - 1] % PP[h]) % PP[h];
        }
    }
}
std::array<int, 2> get_hdw(int l, int r) {
    assert(l <= r);
    std::array<int, 2> res;
    for (int h = 0; h < HA; h++) {
        res[h] = (hdw[h][r] - hdw[h][l - 1] * pw[h][r - l + 1] % PP[h] + PP[h]) % PP[h];
    }
    return res;
}
std::array<int, 2> get_hup(int l, int r) {
    assert(l <= r);
    std::array<int, 2> res;
    for (int h = 0; h < HA; h++) {
        res[h] = (hup[h][r] - hup[h][l - 1] + PP[h]) % PP[h] * ipw[h][l - 1] % PP[h];
    }
    return res;
}

二 字符串 hash 公式

对一个字符串 \(s\) ,不妨把字符权值视为它的 \(ascii\) 码,第 \(i\) 个字符权值令为 \(c_i\) 。考虑一个前缀的 hash 的表现形式: \(ha(x) = \sum_{i = 1}^{x} c_i b^{x - i}\) 。

模数: 一般选 \(10^{9} \sim 2 \times 10^{9}\) 级的数。

系数: 一般随机成 \(> 256\) 的数,但不超过模数。

2.1 降幂 hash

降幂 hash 是更常用的 hash ,较为简单。

\[\begin{aligned} h_{n} &= c_{1} \times b^{n - 1} + c_{2} \times b^{n - 2} + c_{3} \times b^{n - 3} + \cdots + c_{n} \times b^{0} = \sum_{i = 1}^{n} c_i \times b^{n - i} \\ h_{n} &= \sum_{i = 1}^{n - 1} c_i \times b^{n - i} + c_n = h_{n - 1} \times + c_n \ s.t.\ n \geq 1 \\ h_{l, r} &= h_{r} - h_{l - 1} \times b^{r - l + 1} \\ \end{aligned} \]

2.2 升幂 hash

升幂哈希需要处理一次 \(O(n + \log n)\) 的逆元。

\[\begin{aligned} h_{n} &= c_{1} \times b^{0} + c_{2} \times b^{1} + c_{3} \times b^{2} + \cdots + c_{n} \times b^{n - 1} = \sum_{i = 1}^{n} c_i \times b^{i - 1} \\ h_{n} &= \sum_{i = 1}^{n - 1} c_i \times b^{i - 1} + c_{n} \times b^{n - 1} = h_{n - 1} + c_{n} \times b^{n - 1} \ s.t.\ n \geq 1 \\ h_{l, r} &= (h_{r} - h_{l - 1}) \times inv[b^{l - 1}] \end{aligned} \]

2.3 二维 hash

二维 hash 是在一维上的扩展:

将每一维(行) hash ,于是可以 \(O(1)\) 得到某个子段的 hash 值。每个矩阵的 hash 值被看成一个竖直的 序列 ,再对这个 序列 hash 。

例题:

三 hash 的常见用法

3.1 字符串匹配

平凡模式串匹配:给一个文本串 \(text\) 和一个模式串 \(pattern\) ,询问 \(pattren\) 在 \(text\) 中出现过多少次。

伪代码:

function solve(text, pattern) :
  cnt = 0
0  ha1 = get_hash(text)
  ha2 = get_hash(pattern)
  for i : 1 to n - m + 1 :
    if ha1.subhash(i, i + m - 1) == ha2 :
      cnt += 1
  return : cnt

参考列题:http://oj.daimayuan.top/course/22/problem/908

3.2 \(O(1)\) 判断回文

给定一个长度为 \(n\) 的字符串 \(s\) ,如何判断 \(s_{l, r}\) 是否为回文的?

一个简单的想法是,正向 hash 一次,反向 hash 一次,如果 \(s_{l, r}\) 的正反哈希值相等,则回文。但这个方法不能推广。

另一个想法是,下降幂哈希一次,上升幂哈希一次,若子串哈希值相等,则回文。

证明:

例题: https://atcoder.jp/contests/abc070/tasks/abc070_a

const int MAXN = 2E5+5;
const int HA = 2;
const int BB[HA] = {1234, 5678};
const int PP[HA] = {int(1E9) + 123, int(1E9) + 403};
i64 pw[HA][MAXN], ipw[HA][MAXN];
i64 hdw[HA][MAXN], hup[HA][MAXN];
i64 ksm(i64 a, i64 n, int P) {
    i64 res = 1;
    for (;n;n>>=1, a=a*a%P) if (n&1)
        res=res*a%P;
    return res;
}
void init_hash() {
    for (int h = 0; h < HA; h++) {
        pw[h][0] = 1;
        for (int i = 1; i < MAXN; i++) {
            pw[h][i] = pw[h][i - 1] * BB[h] % PP[h];
        }
        ipw[h][MAXN - 1] = ksm(pw[h][MAXN - 1], PP[h] - 2, PP[h]);
        for (int i = MAXN - 1; i; --i) {
            ipw[h][i - 1] = ipw[h][i] * BB[h] % PP[h];
        }
        assert(ipw[h][0] = 1);
    }
}
void make_hash(std::string s) { // s[0] == ' '
    int n = s.size();
    for (int h = 0; h < HA; h++) {
        for (int i = 1; i <= n; i++) {
            hdw[h][i] = (hdw[h][i - 1] * BB[h] % PP[h] + s[i]) % PP[h];
            hup[h][i] = (hup[h][i - 1] + s[i] * pw[h][i - 1] % PP[h]) % PP[h];
        }
    }
}
std::array<int, 2> get_hdw(int l, int r) {
    assert(l <= r);
    std::array<int, 2> res;
    for (int h = 0; h < HA; h++) {
        res[h] = (hdw[h][r] - hdw[h][l - 1] * pw[h][r - l + 1] % PP[h] + PP[h]) % PP[h];
    }
    return res;
}
std::array<int, 2> get_hup(int l, int r) {
    assert(l <= r);
    std::array<int, 2> res;
    for (int h = 0; h < HA; h++) {
        res[h] = (hup[h][r] - hup[h][l - 1] + PP[h]) % PP[h] * ipw[h][l - 1] % PP[h];
    }
    return res;
}
void solve() {
	init_hash();
    std::string s; std::cin >> s;
    int n = s.size(); s = " " + s;
    make_hash(s);
    int ok = 1;
    for (int h = 0; h < HA; h++) {
        ok &= get_hdw(1, n) == get_hup(1, n);
    }
    std::cout << (ok ? "Yes" : "No") << "\n";
}

3.3 回文信息合并

有时候反向哈希不容易维护,比如:容易维护从根借点开始的链上的正向哈希,不好维护叶结点开始的链上的反向哈希。

一个处理方法是,正向分别以下降幂和上升幂 hash 一次。于是区间信息和链上信息都可合并。

设下降幂 hash 数组为 \(h1\) ,上升幂 hash 数组为 \(h2\) ,假设 \(s_{l, r}\) 是回文的:

\[\begin{aligned} h1_{l, r} &= \sum_{i = 1}^{r} c_i \times b^{r - i} - \sum_{i = 1}^{l} c_i \times b^{l - i} \\ h2_{l, r} &= (\sum_{i = 1}^{r} c_i \times b^{i - 1} - \sum_{i = 1}^{l - 1} c_i \times b^{i - 1}) \times inv[c^{l - 1}] \\ h1_{l, r} &= h2_{l, r} \\ \end{aligned} \]

这意味着可以轻易在链上或区间上合并下降幂和上升幂的哈希,然后判断是否回文。

3.4 最长回文子串

实际上它的线性做法是 \(manacher\) 。

给一个长度为 \(n\) 的字符串 \(s\) ,从 \(1\) 开始编号。

求有关最长回文子串的问题,一般都会将 \(s_1 s_2 s_3 \cdots s_n\) 处理成 $ @ s_1 @ s_2 @ s_3 @ \cdots @ s_n $ 。这里的 \(@\) 常用 $ 。

好处:

回文串分奇偶讨论,所以回文半径也要分奇偶讨论。做出上述处理后,回文直径严格为二分半径。
如 \(abcba -> @a@b@c@b@a@\) 。二分半径为 \(len(@a@b@)\) ,等于回文直径。
如 \(abccba -> @a@b@c@c@b@a@\) 。二分半径为 \(len(@a@b@c)\) ,等于回文直径。

不难用下降幂和上升幂哈希,枚举中点,求最长的二分半径。

3.5 常数失配匹配

非常固定的模型:给一个长度为 \(n\) 的文本串 \(s\) ,长度为 \(m\) 的模式串 \(t\) 。询问 \(t\) 在 \(s\) 中出现的次数。其中,\(t\) 允许和 \(s\) 有不超过 \(k\) 个字符的失配。\(k\) 次失配指 \(k\) 个在不相等的情况下认为匹配。

非常常见的技巧: 枚举起点,二分哈希值查询 lcp 。

考虑 \(t\) 和一个长度相等的子串 \(p\) ,是否在不超过 \(k\) 次失配的意义下,能够让 \(t = p\) 。
容易想到 hash 后,二分出 \(t\) 和 \(p\) 的 \(lcp\) ,维护 \(t\) 的起点 \(now\) 。

如果 \(now + lcp - 1 \leq m\) ,则 \(now = lcp + 2\) ,然后继续二分,并计数一次 \(cnt\) 。若 \(cnt > k\) 可以退出以优化常数。

\(cnt \leq k\) 则在不超过 \(k\) 次失配的意义下 \(t = p\) 。事件复杂度 \(O(\log m)\) 。

于是枚举 \(s\) 的长度与 \(t\) 相同的子串,并进行 \(check\) 算法。时间复杂度为 \(O(n \log m)\) 。

四 字符串 hash 的 string 判断优化

4.1 用 string 的哈希值做 map 键值

直接使用 \(string\) 作为 map 键值,每次访问是 \(O(length(string))\) 的。访问一组 \(n\) 个 \(string\) 的总复杂度是 \(O(C)\) 。\(\sum len(string) \leq C\) 。
用哈希值替代 string ,map 访问一组 \(n\) 个 \(string\) 的总复杂度是 \(O(n)\) 。

4.2 本质不同子串数量

求长度为 \(n\) 的字符串 \(s\) 的不同字符串数量。注意两个不同位置的相同字符串本质不同,但相同。

不难想到一个暴力,伪代码如下:

function (s) :
mp={}
for len in range(1, n) :
  for i in range(1, n - len + 1) :
    mp[s[i:i+len-1]] = 1
return : len(mp)

这个算法的问题在于访问 string 是 \(O(len(string))\) 的。

优化是:字典可以使用散列表。string 可以换成哈希值。

posted @ 2024-04-22 21:00  03Goose  阅读(304)  评论(0)    收藏  举报