哈希
哈希
哈希是利用哈希函数来将较大的值域映射到较小的值域,从而实现快速检查较大值域中的元素是否不同的算法。
字符串哈希(序列哈希)
约定与记号
- 定义 \(|s|\) 为 \(s\) 的长度。
- 定义 \(s_i\) 为 \(s\) 的第 \(i\) 位字符,\(1 \leq i \leq |s|\)。
- 定义 \(s(l, r)\) 为 \(s\) 的第 \(l\) 位到第 \(r\) 位截取所得的子串(闭区间)。
- 定义 \(s(p) = s(1, p)\),其含义为 \(s\) 的前 \(p\) 位前缀
- 定义 \(s + t\) 为将 \(s\) 和 \(t\) 拼接在一起的字符串。
- 定义 \(r(s)\) 为 \(s\) 的相反串。
- 定义 \(\Sigma\) 为字符集大小。
- 在计算哈希值时,所有的计算都是默认在模 \(p\) 意义下的,其中 \(p\) 通常是一个较大的质数。
一般我们使用多项式哈希来对字符串做哈希,也就是说,对于一个字符串 \(s\),其哈希函数为:
其中 \(b\) 是一个略大于 \(\Sigma\) 的质数。
在 \(\text{OI}\) 中,我们通常认为两个字符串的哈希碰撞概率为 \(\frac{1}{p}\)。
当一道题目的错误率不超过千分之一时,我们就可以认为这个算法是可行的。
计算哈希值
一般我们使用秦九韶算法来递推计算 \(h(s)\),时间复杂度 \(O(n)\)。
计算子串哈希值
如果我们在计算 \(h(s)\) 时记录了每一个前缀的哈希值,并且预处理了 \(b\) 的幂,我们就可以利用差分来 \(O(1)\) 地计算 \(s(l, r)\):
计算拼接串的哈希值
对于两个字符串 \(s, t\),其拼接串 \(s + t\) 的哈希值为:
判断字符串是否相同
字符串 \(s, t\) 相同的充要条件:
判断回文
不难证明,\(s\) 是回文串的充要条件是 \(s = r(s)\)。
因此可以分别计算 \(s\) 和 \(r(s)\) 的哈希值,若 \(h(s) = h(r(s))\) 则说明 \(s\) 是回文串。
[POI2006] PAL-Palindromes
题意:给定 \(n\) 的回文串,任取两个串拼接能获得 \(n(n - 1)\) 个字符串(允许重复),判断总共的 \(n ^ 2\) 个串中有多少回文串。
对于任意两个回文串 \(s, t\) 若 \(s + t\) 是回文串,则:
这样等式的左右两边都被化简为了只和一个串有关的量,对于 \(s\) 定义 \(\frac{h(s)}{b ^ {|s|} - 1}\) 为其特征值,任何特征值相等的两个串都可以拼接成一个回文串,简单统计即可。
Palindrome Query
[国家集训队] 等差子序列 的弱化版,使用线段树维护区间哈希值来判断回文。
[国家集训队] 等差子序列
给一个 \(1\) 到 \(n\) 的排列 \(\{a_i\}\),询问是否存在
\[1 \leq p_1 < p_2 < p_3 < \dots < p_{k} \leq n (k \geq 3) \]使得 \(a_{p_1}, a_{p_2}, a_{p_3}, \dots, a_{p_{k}}\) 是一个等差序列。
原题数据范围:\(1 \leq n \leq 10 ^ 4\)
加强后数据范围:\(1 \leq n \leq 5 \times 10 ^ 5\)
首先,我们有一个重要的简化,即我们只需要判断原序列是否存在一个长度为 \(3\) 的等差子序列即可,这比较显然,读者可以自己思考。
考虑小数据范围怎么做。我们可以枚举等差中项 \(a_i\),看左右两侧是否同时存在 \(a_i - d, a_i + d\) 即可,可以维护左右两个 bitset 暴力解决这个问题,时间复杂度 \(O(\frac{n ^ 2}{w})\)。
当数据范围扩大时,上述的时间复杂度不足以通过本题。
我们重新审题,发现我们漏了一个重要的性质:\(\{a_i\}\) 是一个 \(1 \dots n\) 的排列,这意味着当我们扫到 \(a_i\) 时,如果左侧存在 \(a_i - d\),但不存在 \(a_i + d\),则直接证明该序列是等差子序列。
看起来好像没有什么用,但我们可以正难则反,如果对于左侧任意的 \(a_i - d\),左侧都存在 \(a_i + d\),则说明 \(a_i\) 为中项的子序列一定不是等差子序列。
如果我们把 \(1 \dots n\) 是否存在抽象为一个长度为 \(n\) 的 \(01\) 字符串,则上述问题就是在检查以 \(a_i\) 为中心的字符串是否是一个回文串,直接哈希判断回文即可。
实际实现的时候,我们需要动态地单点修改,区间查询哈希值,因此要使用线段树维护。
我在代码中开了两棵线段树,分别维护正反字符串的哈希值,从而判断回文。
代码:
#include <bits/extc++.h>
#define inline __always_inline
#define lson(u) (son[(u)][0])
#define rson(u) (son[(u)][1])
template <typename T> inline void chkmax(T &x, const T &y) { if (x < y) x = y; }
template <typename T> inline void chkmin(T &x, const T &y) { if (x > y) x = y; }
template <typename T> inline void read(T &x)
{
char ch;
for (ch = getchar(); !isdigit(ch); ch = getchar());
for (x = 0; isdigit(ch); ch = getchar()) x = x * 10 + ch - '0';
}
const int MaxN = 5e5 + 5, MaxSGT = MaxN * 2, p = 1e9 + 7;
int n, a[MaxN], b[MaxN];
struct hash_t { int h, s; };
inline hash_t operator+(const hash_t &x, const hash_t &y) { return {(1l * x.h * b[y.s] + y.h) % p, x.s + y.s}; }
inline hash_t &operator+=(hash_t &x, const hash_t &y) { return x = x + y; }
struct segment_tree_t
{
int son[MaxSGT][2], top = 2;
hash_t h[MaxSGT];
inline void push_up(int u) { h[u] = h[lson(u)] + h[rson(u)]; }
void build(int u, int l, int r)
{
if (r - l == 1) { h[u] = {0, 1}; return; }
build(lson(u) = top++, l, l + r >> 1);
build(rson(u) = top++, l + r >> 1, r);
push_up(u);
}
void set(int u, int l, int r, int x)
{
if (r - l == 1) { h[u].h = 1; return; }
int mid = l + r >> 1;
if (x < mid) set(lson(u), l, mid, x);
else set(rson(u), mid, r, x);
push_up(u);
}
hash_t hash(int u, int l, int r, int pl, int pr)
{
if (pl <= l && r <= pr) return h[u];
int mid = l + r >> 1, x, y; hash_t ans = {0, 0};
if (pl < mid) ans += hash(lson(u), l, mid, pl, pr);
if (mid < pr) ans += hash(rson(u), mid, r, pl, pr);
return ans;
}
} f, g;
inline void add(int x)
{
f.set(1, 1, n + 1, x);
g.set(1, 1, n + 1, n - x + 1);
}
void test_case()
{
read(n);
f.top = g.top = 2;
f.build(1, 1, n + 1);
g.build(1, 1, n + 1);
for (int i = 1; i <= n; i++) read(a[i]);
add(a[1]);
for (int i = 2; i < n; i++)
{
add(a[i]);
int l = 1, r = std::min(a[i] * 2 - l, n);
chkmax(l, a[i] * 2 - r);
if (f.hash(1, 1, n + 1, l, r + 1).h != g.hash(1, 1, n + 1, n - r + 1, n - l + 2).h)
return printf("Y\n"), void();
}
printf("N\n");
}
int main()
{
b[0] = 1, b[1] = 131;
for (int i = 2; i < MaxN; i++)
b[i] = 1l * b[i - 1] * b[1] % p;
int t; read(t);
while (t--) test_case();
return 0;
}
集合哈希
集合与序列不同的一点在于,集合是无序的,因此我们只需要对每个元素 \(x\) 赋一个随机的权值 \(w(x)\),然后对集合的每个元素的权值计算即可。
计算的方式一般有:
- 和哈希
- 异或哈希
和哈希
对于集合 \(A\),其哈希函数为:
需要注意的是,基于累加的集合哈希一般不需要取模,使用 \(64\) 位整数作为哈希值足矣。
一般情况下,只有当哈希函数中涉及乘法时才需要取模来避免哈希碰撞。
The Number of Subpermutations
给定数组 \(\{a_n\}\),若子区间 \(a_{[l, r]}\) 满足 \([1, r - l + 1]\) 所有整数各出现一次,则称其为这个数组的一个子排列。求这个数组子排列个数
判断两个序列相同只需判断 \(h(A) = h(B)\) 即可,这里讲一下如何统计答案。
对于以 \(a_r\) 为结尾的一段子区间,设其左侧最邻近的一个 \(a_l = 1\) 的位置为 \(l\),\(\max_{l \leq i \leq r} a_i = m\),则可以断言,如果存在一段子区间是一段子排列,则该子排列的长度一定为 \(m\)。证明显然,因为 \(1 \dots m\) 的排列长度就是 \(m\)。
但这样统计答案会漏掉以 \(a_r\) 为开头的子区间,因此需要正着扫一遍,反着扫一遍,时间复杂度 \(O(n)\)。
本题统计答案的方式非常 Ad-hoc
#include <bits/extc++.h>
template <typename Tx, typename Ty> inline void chkmax(Tx &x, const Ty &y) { if (x < y) x = y; }
template <typename T> inline void read(T &x)
{
char ch; bool flag = false;
for (ch = getchar(); !isdigit(ch); ch = getchar()) flag = ch == '-';
for (x = 0; isdigit(ch); ch = getchar()) x = x * 10 + ch - '0';
if (flag) x = -x;
}
const int MaxN = 3e5 + 5;
std::mt19937_64 gen64(260403);
int n, r[MaxN];
uint64_t w[MaxN], sum[MaxN], a[MaxN], f[MaxN], ans = 0;
uint64_t diff(int l, int r) { return f[r] - f[l - 1]; }
int main()
{
read(n);
for (int i = 1; i <= n; i++) sum[i] = sum[i - 1] + (w[i] = gen64());
for (int i = 1; i <= n; i++) read(a[i]), f[i] = f[i - 1] + w[a[i]];
for (int i = 1, l = 1; i <= n; i++)
{
if (a[i] == 1) l = 1;
chkmax(l, a[i]);
if (i - l + 1 < 1) continue;
ans += diff(i - l + 1, i) == sum[l];
}
for (int i = n, l = 1; i; i--)
{
if (a[i] == 1) { l = 1; continue; }
chkmax(l, a[i]);
if (i + l - 1 > n) continue;
ans += diff(i, i + l - 1) == sum[l];
}
printf("%lld", ans);
return 0;
}
Kazaee
题意:给出一个长度为 \(n\) 的数组 \(a\) 和以下两种操作:
1 i x:将 \(a_i\) 修改为 \(x\)。2 l r k:询问在数组区间 \([l, r]\) 内是否每个出现过的正整数的出现次数都是 \(k\) 的倍数。若是则输出YES,若否则输出NO。
首先,对于一个和哈希函数 \(h(A) = \sum_{x \in A} w(x)\),我们可以断言 \(h(A)\) 可以将 \(A\) 均等概率地映射到整个值域上。
若出现次数的确为 \(k\) 的倍数,则不论如何更改哈希函数 \(h(A)\) 的定义,即不论如何改变 \(w(x)\),我们都可以发现 \(h(A) \equiv 0 \pmod{k}\)。
否则,我们相当于是在猜一个均匀随机数是否是 \(k\) 的倍数,显然有 \(\frac{1}{k}\) 的概率会错猜成是 \(k\) 的倍数。
因此可以考虑循环足够多的次数,每次循环重新赋随机权值 \(w(x)\),这样随机 \(t\) 次错判的概率就可以降至 \((\frac{1}{k}) ^ t\),即使当 \(k = 2\) 时,循环 \(30\) 次的错误率也已经足够低。
需要注意的是,本题使用线段树维护区间和会被卡常,要用树状数组。
代码:
#include <bits/extc++.h>
template <typename T> inline void read(T &x)
{
char ch;
for (ch = getchar(); !isdigit(ch); ch = getchar());
for (x = 0; isdigit(ch); ch = getchar()) x = x * 10 + ch - '0';
}
const int MaxN = 3e5 + 5, MaxM = MaxN, MaxBIT = MaxN;
std::mt19937 gen(260403);
struct query_t { int op, l, r, k; } q[MaxN];
int n, m, x[MaxN], a[MaxN], op, l, r, k;
int map[MaxN * 2], w[MaxN * 2], lim = 0;
char ans[MaxN];
struct BIT
{
int64_t c[MaxBIT]; int lim; // [1, lim]
inline int lowbit(int x) { return x & -x; }
inline void add(int x, int k) { for (; x <= lim; x += lowbit(x)) c[x] += k; }
inline int64_t sum(int x)
{
int64_t ans = 0;
for (; x; x -= lowbit(x)) ans += c[x];
return ans;
}
inline int64_t sum(int l, int r) { return sum(r) - sum(l - 1); }
} tree;
int main()
{
read(n), read(m);
for (int i = 1; i <= n; i++) read(x[i]), map[lim++] = x[i];
for (int i = 1; i <= m; i++)
{
read(op);
if (op == 1) read(l), read(k);
else read(l), read(r), read(k);
q[i] = {op, l, r, k};
map[lim++] = k;
}
std::sort(map, map + lim);
lim = std::unique(map, map + lim) - map;
for (int i = 1; i <= n; i++)
x[i] = std::lower_bound(map, map + lim, x[i]) - map;
for (int i = 1; i <= m; i++)
q[i].k = std::lower_bound(map, map + lim, q[i].k) - map;
memset(ans, 0xff, sizeof(ans));
tree.lim = n;
while (1.0 * clock() / CLOCKS_PER_SEC < 2.9) // 卡时
{
for (int i = 0; i < lim; i++) w[i] = abs((int)gen());
for (int i = 1; i <= n; i++) tree.add(i, w[x[i]] - a[i]), a[i] = w[x[i]];
for (int i = 1; i <= m; i++)
{
if (!ans[i]) continue;
auto [op, l, r, k] = q[i];
if (op == 1) tree.add(l, w[k] - a[l]), a[l] = w[k];
else ans[i] &= (tree.sum(l, r) % map[k] == 0);
}
}
for (int i = 1; i <= m; i++)
if (q[i].op == 2) printf(ans[i] ? "YES\n" : "NO\n");
return 0;
}
异或哈希
对于集合 \(A\),其哈希函数为:
其中 \(\oplus\) 为异或运算,即二进制意义下的不进位加法。
异或哈希具有性质:两个相同的元素会被抵消掉。
\(k\) 进制异或
二进制异或哈希的推广,即 \(k\) 进制意义下的不进位加法,此时 \(k\) 个相同的元素会被抵消掉。
树哈希
树哈希是快速判断树同构的一种方法。
有根树哈希
对于有根树,我们直接以根做 \(\text{DFS}\),利用 \(\text{DFN}\) 序对每棵子树做序列哈希。
对于每个节点,其默认的权值为其深度。正确性待证明
容易发现,只要规定了每个节点递归子树的顺序,则其 \(\text{DFN}\) 序列自然一样,其序列哈希值自然也一样。
无根树哈希
对于无根树,我们可以以重心为根。在每次递归时,对 \(u\) 的儿子 \(v_1 \dots v_k\) 的哈希值排序,从小到大地合并(其目的在于人工地规定一个顺序),则可以把有根树哈希推广到无根树上,单次计算哈希的时间复杂度 \(O(n \log n)\)。
【模板】树同构
给定若干棵无根树,输出每棵树的第一次出现的时间。
本题是树哈希的模板题。
代码:
#include <bits/extc++.h>
#define inline __always_inline
template <typename T> inline void chkmax(T &x, const T &y) { if (x < y) x = y; }
template <typename T> inline void read(T &x)
{
char ch;
for (ch = getchar(); !isdigit(ch); ch = getchar());
for (x = 0; isdigit(ch); ch = getchar()) x = x * 10 + ch - '0';
}
const int MaxN = 55, p = 1e9 + 7;
int n, fa[MaxN], size[MaxN], depth[MaxN], rt[2];
std::vector<int> graph[MaxN];
inline void add_edge(int u, int v) { if (u && v) graph[u].push_back(v), graph[v].push_back(u); }
void init(int u, int p)
{
int max = 0; size[u] = 1;
for (auto &&v : graph[u])
if (v != p)
{
init(v, u), size[u] += size[v];
chkmax(max, size[v]);
}
chkmax(max, n - size[u]);
if (max <= n / 2) rt[1] = rt[0], rt[0] = u;
}
int b[MaxN];
struct hash_t { int h, len; } f[MaxN];
inline bool operator<(const hash_t &x, const hash_t &y) { return x.h != y.h ? x.h < y.h : x.len < y.len; }
inline bool operator==(const hash_t &x, const hash_t &y) { return x.h == y.h && x.len == y.len; }
inline hash_t operator+(const hash_t &x, const hash_t &y) { return { (1l * x.h * b[y.len] + y.h) % p, x.len + y.len }; }
inline hash_t &operator+=(hash_t &x, const hash_t &y) { return x = x + y; }
auto cmp = [](int x, int y) { return f[x].h != f[y].h ? f[x].h < f[y].h : f[x].len < f[y].len; };
void hash(int u, int p)
{
f[u] = {depth[u] = depth[p] + 1, 1};
for (auto &&v : graph[u])
if (v != p) hash(v, u);
std::sort(graph[u].begin(), graph[u].end(), cmp);
for (auto &&v : graph[u])
if (v != p) f[u] += f[v];
}
std::map<hash_t, int> map;
void test_case(int id)
{
read(n);
for (int i = 1; i <= n; i++)
read(fa[i]), add_edge(fa[i], i);
rt[0] = rt[1] = 0, init(1, 0);
hash(rt[0], 0);
hash_t ans = f[rt[0]];
if (rt[1]) hash(rt[1], 0), chkmax(ans, f[rt[1]]);
if (!map[ans]) map[ans] = id;
printf("%d\n", map[ans]);
for (int i = 1; i <= n; i++) graph[i].clear();
}
int main()
{
b[0] = 1, b[1] = 131;
for (int i = 2; i < MaxN; i++)
b[i] = 1l * b[i - 1] * b[1] % p;
int t; read(t);
for (int i = 1; i <= t; i++) test_case(i);
return 0;
}
线性空间哈希
附录
质数表
本表收录了一些适合哈希使用的模数,使用 \(64\) 位的整数时计算乘法注意溢出,如果一定要使用,可以考虑使用 __int128_t。
| 质数 | 简化表示 | 注意事项 |
|---|---|---|
| \(5767169\) | \(11 \times 2 ^ {19} + 1\) | 当 \(\Sigma \approx 10 ^ 6\) 时取做 \(b\) |
| \(998244353\) | 无 | 当心出题人卡,该质数过于出名 |
| \(1000000007\) | \(10 ^ 9 + 7\) | 当心出题人卡,该质数过于出名 |
| \(31525197391593473\) | \(7 \times 2 ^ {52} + 1\) | 注意可能溢出 \(64\) 位整数 |

浙公网安备 33010602011771号