字符串哈希

引入

定义:

把字符串映射到整数的函数 \(f\),称 \(f\) 为 Hash 函数

从定义可以看出,字符串 Hash 函数的实质是:把每个不同的字符串转化为不同的整数,希望 \(O(1)\) 判断两个字符串是否相等。

然而事实上,经常会出现两个不同的字符串映射到相同的 Hash 值的现象,称为 哈希冲突。由此引出哈希函数两条最重要的性质。

性质:

在 Hash 函数值不一样时,两个字符串一定不一样;

在 Hash 函数值一样时,两个字符串不一定一样。

对于一个长度为 \(l\) 的字符串 \(s\),定义其多项式 Hash 函数为:$$f(s) = \sum_{i=1}^l s[i] \times p^{l-i} \pmod M$$

可以类比 \(p\) 进制数来帮助理解。例如字符串 \(xyz\),其哈希函数值为 \(xp^2+yp+z\)。下文中的 Hash 函数均采用这种定义方式。

生日悖论

考虑这样一个问题:多少个人里有两个生日相同的人的概率有 50% 呢?答案是反直觉的 23 个人。

证明: 设房间里共有 \(n\) 个人,排除闰年 \(366\) 天的情况。第一个人的生日是 \(365\) 选 \(365\),第二个人的生日是 \(365\) 选 \(364\),第三个人的生日是 \(365\) 选 \(363\)。以此类推,第 \(n\) 个人的生日是 \(365\) 选 \(365 - n + 1\)。

\[P(所有人生日不同) = \frac{365}{365} \times \frac{364}{365} \times \frac{365 - n + 1}{365} = \frac{365!}{365^n(365-n)!} \]

\[P(至少有两人生日相同) = 1 - P(所有人生日不同) \]

说明:

当元素的个数增多时,哈希冲突的概率会以很快的速度增长。

再考虑模数 \(M\) 应满足什么条件。因为质数不与其他数字存在公因数,可以减少因取模操作带来的周期性冲突,所以通常选取足够大的质数来作为模数 \(M\)。

方法

对于读入的字符串,习惯于在前加一个空格符调整下标,一般直接采用其 ASCII 码,用数组预处理基数的幂。

自然溢出法

顾名思义,利用无符号长整形 unsigned long long自然溢出的特性。若数据超出 ull 的存储范围,则结果自然 \(\pmod {2^{64}}-1\)。Hash 公式如下:

复制代码
typedef unsigned long long ull;
ull hs[N];
hs[i] = hs[i] * p + s[i];

其中,基数 \(p\) 是一个较大的质数,如 \(233\),\(271\),\(2333\) 等,否则唯一性也难以保证。

例题:
产奶模式

题目要求出现了至少 \(k\) 次的最大连续子段的长度。

若最终答案为 \(m\)。则这些连续子段去掉末尾元素后仍然相同,即:序列中长为 \(m-1\) 的相同连续子段也会出现至少 \(k\) 次。可见,连续子段长度为 \(0\) ~ \(m\) 时都可行,长度为 \((m+1)\) ~ \(n\) 时都不可行。答案满足单调性,考虑二分答案。

预处理序列前缀 Hash,在 check 函数里,遍历所有长度为 \(mid\) 的连续子段,对其 Hash 值出现的次数计数。由于 Hash 值较大,不能使用桶数组,可使用 map 计数。时间复杂度 \(O(n \log ^ 2 n)\)。代码如下:

复制代码
#include <iostream>
#include <map>
using namespace std;
typedef unsigned long long ull;
const int N = 2e4 + 8, p = 233;
ull ppow[N], hs[N];
int n, k, a[N];
ull geths(int l, int r) {
    return hs[r] - hs[l - 1] * ppow[r - l + 1];
}
bool check(int mid) {
    map<ull, ull> mp;
    for (int l = 1; l + mid - 1 <= n; l++) {
        ull h = geths(l, l + mid - 1);
        if (++mp[h] >= k) return true;
    }
    return false;
}
int main() {
    ppow[0] = 1;
    for (int i = 1; i < N; i++) ppow[i] = ppow[i - 1] * p;
    cin >> n >> k;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        hs[i] = hs[i - 1] * p + a[i];
    }
    int l = 0, r = n, mid, ans;
    while (l <= r) {
        int mid = l + r >> 1;
        if (check(mid)) l = mid + 1, ans = mid;
        else r = mid - 1;
    }
    cout << ans;
    return 0;
}

単 Hash 法

注意: 単 Hash 法在模数较小的时候,唯一性难以保证。

相当于没有了自动取模特性的自然溢出法,唯一不同就是需要手动加上取模。Hash 公式如下:

复制代码
hs[i] = (hs[i - 1] * p + s[i]) % mod;

其中 \(p < mod\) 且 \(p\) 与 \(mod\) 是足够大的质数。

双 Hash 法

很稳很安全的 Hash 方法。对比単 Hash 法,双 Hash 法用两个不同的基数 \(p\) 进行两次取模 \(mod\) 操作。Hash 公式如下:

复制代码
hs1[i] = (hs1[i] * p + s[i]) % mod1;
hs2[i] = (hs2[i] * p + s[i]) % mod2;

判断是否相同时,用一对 Hash 函数值来比较。只要有一个不匹配就说明字符串不相同。cmp 函数如下:

复制代码
bool cmp(string s, string t) {
    return geths1(s) == geths1(t) && geths2(s) == geths2(t);
}

获取子串的 Hash

已知字符串 \(S\) 的 Hash 值 \(hs[i]\),且 \(|S| = n\)。它的子串 \(S[l..r]\),其中 \(1 \leq l \leq r \leq n\),对应的 Hash 值为:

复制代码
((hs[r] - hs[l - 1] * ppow[r - l + 1]) % mod + mod) % mod

推导过程类似于前缀和中的区间和,感兴趣的读者请自行研究。

应用

字符串匹配

例题:

给出两个字符串 \(S\) 和 \(T\),求 \(T\) 在 \(S\) 中出现的次数。不同位置出现的 \(T\) 可重叠。

求出模式串 \(T\) 的 Hash 值后,求出文本串 \(S\) 中每个长度为 \(T\) 长度的字串的 Hash 值,分别于 \(T\) 的 Hash 值比较即可。代码如下:

复制代码
#include <iostream>
using namespace std;
typedef long long ll;
const int p = 233, mod = 1e9 + 7, N = 1e6 + 8;
string s, t;
ll ppow[N], hsh[N], ths;
ll get_hash(int l, int r) {
    return ((hsh[r] - hsh[l - 1] * ppow[r - l + 1]) % mod + mod) % mod;
}
int main() {
	ppow[0] = 1;
	for (int i = 1; i < N; i++) ppow[i] = ppow[i - 1] * p % mod;
	cin >> s >> t;
	int slen = s.size(), tlen = t.size();
	s = ' ' + s;
	t = ' ' + t;
	for (int i = 1; i <= slen; i++) hsh[i] = (hsh[i - 1] * p + s[i]) % mod;
	for (int i = 1; i <= tlen; i++) ths = (ths * p + t[i]) % mod;
    int ans = 0;
    for (int l = 1; l + tlen - 1 <= slen; l++)
        if (get_hash(l, l + tlen - 1) == ths) ans++;
    cout << ans;
    return 0;
}

最长回文子串

例题:
反对称串

问题:给定一个 \(0/1\) 序列,求其异或意义下的回文子串的数量。

由题意得,回文子串的长度必须为偶数,否则因为对称中心一定改变,所以该子串一定不是回文子串。

我们不妨枚举它的回文中心,尽可能地扩展它的回文半径。因为回文半径具有单调性,所以考虑二分答案。至于判断回文中心两侧是否相等,可以预处理正着的 Hash 值和倒着的 Hash 值,在 check 函数中比较即可。代码如下:

复制代码
#include <iostream>
using namespace std;
typedef unsigned long long ull;
const int N = 1e6 + 8, p = 131;
int n;
string s;
ull ppow[N], shs[2][N];
ull get_hash(ull h[], int l, int r) {
    return h[r] - h[l - 1] * ppow[r - l + 1];
}
bool check(int l, int r) {
    return l <= r && get_hash(shs[0], l, r) == get_hash(shs[1], n - r + 1, n - l + 1);
}
int main() {
    ppow[0] = 1;
    for (int i = 1; i < N; i++) ppow[i] = ppow[i - 1] * p;
    cin >> n >> s;
    s = ' ' + s;
    for (int i = 1; i <= n; i++) {
        shs[0][i] = shs[0][i - 1] * p + s[i];
        shs[1][i] = shs[1][i - 1] * p + (s[n - i + 1] == '0' ? '1' : '0'); // 异或的同时倒着存Hash值
    }
    int ans = 0;
    for (int i = 1; i < n; i++) {
        int l = 1, r = min(i, n - i), mid, res = 0; // 二分答案回文半径
        while (l <= r) {
            mid = (l + r) >> 1;
            if (check(i - mid + 1, i + mid)) l = mid + 1, res = mid;
            else r = mid - 1;
        }
        ans += res;
    }
    cout << ans;
    return 0;
}

如果上面的二分答案只改动 \(l\) 的值为 \(0\),那么回文半径单峰而不单调,与二分答案要求严格单调性相违背。但若在此基础上,将 \(mid\) 向上取整,二分边界 \([l, r)\) 左闭右开,用 \(l\) 存答案,结果仍然正确。代码如下:

复制代码
        int l = 0, r = min(i, n - i), mid;
        while (l < r) {
            mid = (l + r + 1) >> 1;
            if (check(i - mid + 1, i + mid)) l = mid;
            else r = mid - 1;
        }
        ans += l;

最短循环节

例题:
糟糕的诗

问题:给定一个由小写英文字母组成的长度为 \(L\) 的字符串 \(S\),有 \(q\) 个询问,每次询问给定 \(S\) 的一个子串,求其最短循环节。若字符串 \(A\) 能够由字符串 B 重复若干次得到,则称字符串 \(B\) 是字符串 \(A\) 的一个循环节。

仔细思考,我们能得出以下几个很重要的结论:

  1. 若 \(n\) 是循环节的长度,则 \(hs(l + n, r) = hs(l, r - n)\)。这说明我们能够在 \(O(1)\) 的时间复杂度内判断是否为循环节。
  1. 循环节的长度 \(n\) 是总长 \(L\) 的因数。

  2. 若 \(n\) 是一个循环节的长度,\(k\) 是循环次数,则 \(k \times n\) 也是一个循环节。这说明:先把 \(n\) 分解质因数,得到循环节的因子和循环次数的因子,从 \(n\) 开始试除总长 \(L\),将循环次数的因子除尽,最后得到的就是最小循环节的长度。

分解质因数用欧拉筛,时间复杂度为 \(O(q \sqrt L)\),常数较大加快读。代码如下:

复制代码
#include <iostream>
using namespace std;
typedef unsigned long long ull;
ull read() {
    ull num = 0;
    char ch = getchar();
    while (ch < '0' || ch > '9') ch = getchar();
    while (ch >= '0' && ch <= '9') {
        num = (num << 1) + (num << 3) + ch - '0';
        ch = getchar();
    }
    return num;
}
const int N = 5e5 + 8, p = 233;
int n, q, pcnt, pri[N], mnp[N];
bool notpri[N];
string s;
ull ppow[N], shs[N];
ull geths(int l, int r) {
    return shs[r] - shs[l - 1] * ppow[r - l + 1];
}
void init() {
    notpri[0] = notpri[1] = true; // 欧拉筛
    for (int i = 2; i < N; i++) {
        if (!notpri[i]) pri[++pcnt] = i, mnp[i] = i;
        for (int j = 1; j <= pcnt && i * pri[j] < N; j++) {
            mnp[pri[j] * i] = pri[j];
            notpri[pri[j] * i] = true;
            if (i % pri[j] == 0) break;
        }
    }
    ppow[0] = 1;
    for (int i = 1; i < N; i++) ppow[i] = ppow[i - 1] * p;
}
bool check(int l, int r, int len) {
    return geths(l, r - len) == geths(l + len, r);
}
int main() {
    init();
    n = read();
    cin >> s;
    s = ' ' + s;
    q = read();
    for (int i = 1; i <= n; i++) shs[i] = shs[i - 1] * p + s[i];
    while (q--) {
        int l = read(), r = read(), len = r - l + 1, rmn = r - l + 1, fac;
        while (rmn > 1) {
            for (fac = mnp[rmn]; rmn > 1 && check(l, r, len / fac); fac = mnp[rmn]) {
                len /= fac; // len 表示循环节长度
                rmn /= fac; // rmn 表示该子串剩余长度
            }
            while (rmn > 1 && rmn % fac == 0) rmn /= fac;
        }
        cout << len << '\n';
    }
    return 0;
}

哈希表

例题:
不重复数字

问题:给定 \(n\) 个数,要求把其中重复的去掉,只保留第一次出现的数。

哈希表就是解决这个问题的数据结构。其内部采用"链地址法"解决哈希冲突。拓展一个哈希表的重要属性:负载因子 \(\alpha\)。

\[\alpha = \frac{已有元素个数}{桶数} \]

一般认为,当 \(\alpha\) 在 \(0.75\) 左右时,哈希表的性能优秀。

代码上采用类似于链式前向星的写法封装结构体手写哈希表。事实上,STL库提供的 unordered_map 更为常用。

复制代码
#include <iostream>
#include <cstring>
using namespace std;
const int N = 6e4 + 8;
struct hash_table {
    int sz, head[N], nxt[N], val[N];
    int geths(int x) {
        return (x % N + N) % N;
    }
    void init() {
        sz = 0;
        memset(head, 0, sizeof(head));
    }
    int find(int x) {
        int h = geths(x);
        for (int i = head[h]; i; i = nxt[i])
            if (val[i] == x) return i;
        return 0;
    }
    int insert(int x) {
        if (find(x)) return 0;
        int h = geths(x);
        nxt[++sz] = head[h];
        val[sz] = x;
        head[h] = sz;
        return sz;
    }
} ht;
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);
    int T, n;
    cin >> T;
    while (T--) {
        cin >> n;
        ht.init();
        for (int i = 1, x; i <= n; i++) {
            cin >> x;
            if (ht.insert(x)) cout << x << ' ';
        }
        cout << '\n';
    }
    return 0;
}

确定字符串中子字符串的个数

例题:
Beads 项链

问题:给定长为 \(n\) 的序列,将它划分为每段长度都为 \(k\) 的子串,求最多可以得到的不同子串的个数、取到最优解时不同的 \(k\) 值和 \(k\) 的个数。子串可反转。

正向、反向做两遍 Hash 求子串的 Hash 值。利用 set 数据结构自动去重。每次选择 Hash 值更小(大)的情况插入即可。代码如下。

复制代码
#include <iostream>
#include <set>
#include <vector>
using namespace std;
typedef unsigned long long ull;
const int N = 2e5 + 8, p = 233333;
ull hs[2][N], ppow[N];
int n, a[N];
ull geths(ull h[], int l, int r) {
    return h[r] - h[l - 1] * ppow[r - l + 1];
}
int main() {
    ppow[0] = 1;
    for (int i = 1; i < N; i++) ppow[i] = ppow[i - 1] * p;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) { // 正向、反向 Hash
        hs[0][i] = hs[0][i - 1] * p + a[i];
        hs[1][i] = hs[1][i - 1] * p + a[n - i + 1];
    }
    int ans = 0;
    vector<int> dk;
    for (int k = 1; k <= n; k++) {
        if (n / k < ans) break; // 最优性特判
        set<ull> st; // 自动去重
        for (int l = 1; l + k - 1 <= n; l += k) { // r = l + k - 1
            int h1 = geths(hs[0], l, l + k - 1), h2 = geths(hs[1], n - l - k + 2, n - l + 1);
            st.insert(min(h1, h2)); // 统一选择 Hash 值更小的子串
        }
        if (st.size() > ans) {
            dk.clear();
            dk.push_back(k);
            ans = st.size();
        } else if (st.size() == ans)
            dk.push_back(k);
    }
    cout << ans << ' ' << dk.size() << '\n';
    for (int k : dk) cout << k << ' ';
    return 0;
}
相关推荐
A尘埃2 小时前
保险公司车险理赔欺诈检测(随机森林)
算法·随机森林·机器学习
大江东去浪淘尽千古风流人物2 小时前
【VLN】VLN(Vision-and-Language Navigation视觉语言导航)算法本质,范式难点及解决方向(1)
人工智能·python·算法
努力学算法的蒟蒻3 小时前
day79(2.7)——leetcode面试经典150
算法·leetcode·职场和发展
2401_841495643 小时前
【LeetCode刷题】二叉树的层序遍历
数据结构·python·算法·leetcode·二叉树··队列
AC赳赳老秦3 小时前
2026国产算力新周期:DeepSeek实战适配英伟达H200,引领大模型训练效率跃升
大数据·前端·人工智能·算法·tidb·memcache·deepseek
2401_841495644 小时前
【LeetCode刷题】二叉树的直径
数据结构·python·算法·leetcode·二叉树··递归
budingxiaomoli4 小时前
优选算法-字符串
算法
qq7422349844 小时前
APS系统与OR-Tools完全指南:智能排产与优化算法实战解析
人工智能·算法·工业·aps·排程
A尘埃4 小时前
超市购物篮关联分析与货架优化(Apriori算法)
算法