哈希

哈希

哈希是利用哈希函数来将较大的值域映射到较小的值域,从而实现快速检查较大值域中的元素是否不同的算法。

字符串哈希(序列哈希)

约定与记号

  • 定义 \(|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\),其哈希函数为:

\[h(s) = \sum_{i = 1} ^ {|s|} s_i b ^ {|s| - i} \]

其中 \(b\) 是一个略大于 \(\Sigma\) 的质数。

\(\text{OI}\) 中,我们通常认为两个字符串的哈希碰撞概率为 \(\frac{1}{p}\)

当一道题目的错误率不超过千分之一时,我们就可以认为这个算法是可行的。

计算哈希值

一般我们使用秦九韶算法来递推计算 \(h(s)\),时间复杂度 \(O(n)\)

\[h(s(i)) = h(s(i - 1)) \times b + s_i \]

计算子串哈希值

如果我们在计算 \(h(s)\) 时记录了每一个前缀的哈希值,并且预处理了 \(b\) 的幂,我们就可以利用差分来 \(O(1)\) 地计算 \(s(l, r)\)

\[h(s(l, r)) = h(s(r)) - h(s(l - 1)) \times b ^ {r - l + 1} \]

计算拼接串的哈希值

对于两个字符串 \(s, t\),其拼接串 \(s + t\) 的哈希值为:

\[h(s + t) = h(s) \times b ^ {|t|} + h(t) \]

判断字符串是否相同

字符串 \(s, t\) 相同的充要条件:

\[s = t \Leftrightarrow h(s) = h(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\) 是回文串,则:

\[\begin{aligned} s + t &= r(s + t) \\ &= r(t) + r(s) \\ &= t + s \\ \Rightarrow h(s + t) &= h(t + s) \\ h(s) \times b ^ {|t|} + h(t) &= h(t) \times b ^ {|s|} + h(s) \\ h(s) \times (b ^ {|t|} - 1) &= h(t) \times (b ^ {|s|} - 1) \\ \frac{h(s)}{b ^ {|s|} - 1} &= \frac{h(t)}{b ^ {|t|} - 1} \end{aligned} \]

这样等式的左右两边都被化简为了只和一个串有关的量,对于 \(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\),其哈希函数为:

\[h(A) = \sum_{x \in A} w(x) \]

需要注意的是,基于累加的集合哈希一般不需要取模,使用 \(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\),其哈希函数为:

\[h(A) = \bigoplus_{x \in A} w(x) \]

其中 \(\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\) 位整数
posted @ 2024-10-15 14:47  yiming564  阅读(92)  评论(0)    收藏  举报