字符串 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  zsxuan  阅读(88)  评论(0编辑  收藏  举报