Hash Brown 超好吃!
本文内容:
- 三类 hash(Hash 表、字符串 hash、XOR-hash)的可能存在的问题及解决方案
- Hash 的应用
- 一类用取模来 hash 的方法
引言
- hash 写对了就不会被 hack,被卡的多多少少有些问题(如果正确的 hash 真的被卡了那可以去买彩票了()。
- Codeforces 作为全网最尊重 hash 的网站,尤其需要注意 hash 的正确性。
本文以 Codeforces 不被 hack 为目标;标有 (hack) 的小标题仅在有 hack 的比赛中需要注意,无 hack 时可忽略。
Part I. 怎么 Hash
一、关于 hash
哈希的本质思想:映射。对于非整数对象,或者对于很大的整数值域,将其映射到一个较小的值域空间。这样就能用数表示,方便比较或用数组存储。
哈希冲突:两个东西映射到了同一个数。
二、Hash 表
可能存在的问题
- std::unordered 家族 (hack)
如 unordered_set、unordered_multiset、unordered_map 都是使用哈希实现的,可以被特定数据卡掉。
unordered_map 在 GCC 的实现中是维护了一个桶,每个桶对应一条单链表。正常情况下,哈希函数将元素均匀分散到不同的桶中,此时查找、插入、删除的平均复杂度为 O(1)。
GCC 的默认哈希函数是直接取模。只需要翻到这个固定的素数表,取这个素数的倍数,取模后的桶索引就恒等于 0。这样,它们就会全部塞进同一个桶里,后续每次都需要遍历这个桶的整个链表,总复杂度退化到 O(N2),从而 TLE。
- pbds (hack)
在 __gnu_pbds 中,有两种哈希表:
__gnu_pbds::cc_hash_table:cc 拉链法解决哈希冲突,和 umap 没什么区别,甚至更慢。__gnu_pbds::gp_hash_table:gp 探查法解决哈希冲突,当发生哈希冲突时,通过探测在数组中向后寻找空位,相比于 umap,更快,代价是空间复杂度略大。hack 方法:桶的数量是 2k,构造一组末尾 k 位二进制全部为 0 的数据,哈希索引都会恒等于 0。
解决方案
- 换 map:多个 log,看时间能否接受。
- 确定性方法 :能想到可以用,想不到就没必要了(废话。
- SplitMix64:一个安全的自定义哈希函数,见下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| #include <unordered_map> #include <chrono> #include <cstdint>
template <typename T> struct safe_hash { static uint64_t splitmix64(uint64_t x) { x += 0x9e3779b97f4a7c15; x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9; x = (x ^ (x >> 27)) * 0x94d049bb133111eb; return x ^ (x >> 31); }
std::size_t operator()(T x) const { static const uint64_t seed = std::chrono::steady_clock::now().time_since_epoch().count(); return splitmix64(std::hash<T>{}(x) + seed); } };
template <typename T, typename V> struct safe_unordered_map : public std::unordered_map<T, V, safe_hash<T>> { using std::unordered_map<T, V, safe_hash<T>>::unordered_map; safe_unordered_map(std::size_t n = 0) { if (n > 0) { this->reserve(n); } this->max_load_factor(0.25); } };
|
三、字符串 hash
可能存在的问题
- int 级别模数的单模数哈希
根据生日悖论,不需要特别 hack,比较次数只要达到 O(109) 级别就有超过 50% 的概率发生碰撞。
设有 N 个等概率的可能取值,随机抽取 k 个样本。则至少有两个样本相同的概率
P=1−i=0∏k−1(1−Ni)≈1−i=0∏k−1e−i/N≈1−e−k2/2N
取 k=2Nlog2,P=50%。
- 非素数模数(如自然溢出)
如果模数是素数,随机选择底数时,多项式值为 0 的概率有限。如果模数是合数,这个概率的上界就失效了,更容易产生碰撞。
设字符串长度为 L,传统的字符串 hash 是将字符串映射为域 Zp 上的多项式 f(x)=i=0∑L−1cixi(modp)。
由于在任意域中,一个 k 次多项式最多 k 个不同的根,所以上述多项式的根 x 的数量最多 L−1 个。随机选择底数,即在所有 {0,1,2,…,p−1} 中均匀随机选择一个底数 x,能选到这些根的概率不超过 pL−1。
考虑到只有当模数 p 是素数时,Zp 才是域。如果模数是合数,这是是个环,环有大量零因子,导致概率上界失效,更容易产生碰撞。
特别地,自然溢出的碰撞可以用 Thue Morse 序列 t,即 t(n)=popcount(n)mod2。设 S0=a, T0=b,递归定义 Sk=Sk−1Tk−1, Tk=Tk−1Sk−1,即 Sk[n]=a+t(n),那么
H(Sk)−H(Tk)≡(s0−t0)i=0∏k−1(H2i−1)(mod264)
利用平方差公式展开,H2i−1 每次都会贡献额外的 2 的幂次,最终导致 H(Sk)−H(Tk)≡0。
- 公开模数及底数 (hack)
有固定构造法。
例如取字符集 {a,b},先折半搜索长度相同且较小的字符串 A 与 B,满足 H(A)=H(B)(modP)。然后构造 S=T1T2…Tk,其中 Ti∈{A,B},这样就能得到 2k 个不同的字符串,且哈希值完全相同。
- 其它
注意多项式 f(x)=i=0∑L−1ciximodp 里,不能 ci=0。
注意底数不应小于 ci。
此外,效率?码量?
解决方案
- ULL 级别的模数或双模
- 模数:ULL 级别的模数 261−1,1018+3 等,或双模。
- 底数:建议用
mt19937_64 随机,防止对着卡。
二者比较:
- 碰撞概率:理论碰撞概率差不多,单大模数略好,因为双模产生的 p1p2 实际上是个合数。
- 效率:双模效率较低。
- 码量:ULL 级别的乘法需要
int128 或一些位运算技巧,较复杂。双模的比较略复杂。
代码以 ULL 级别的模数为例,供参考。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| #include <random> #include <chrono> #include <vector>
using i64 = long long; using u64 = unsigned long long; using u128 = unsigned __int128;
struct Hash { static constexpr u64 P = (1ULL << 61) - 1; static constexpr u64 add(u64 a, u64 b) { a += b; return a >= P ? a - P : a; } static constexpr u64 sub(u64 a, u64 b) { return add(a, P - b); } static constexpr u64 mul(u64 a, u64 b) { u128 x = u128(a) * b; return add(x & P, x >> 61); }
static inline const u64 H = [] { std::mt19937_64 rnd(std::chrono::steady_clock::now().time_since_epoch().count()); std::uniform_int_distribution<u64> dist(P / 2, P - 1); u64 base = dist(rnd); if (base % 2 == 0) base++; return base; } (); static inline std::vector<u64> powH {1}; std::vector<u64> pref;
template<typename T> Hash(const T& s) : pref(s.size() + 1) { while (powH.size() <= s.size()) { powH.push_back(mul(powH.back(), H)); } for (std::size_t i = 0; i < s.size(); i++) { u64 c = (s[i] % i64(P) + P) % P; pref[i + 1] = add(mul(pref[i], H), c); } }
u64 operator()(int L, int R) const { return sub(pref[R], mul(pref[L], powH[R - L])); } };
|
- 自然溢出
在无 hack 的比赛中,为简便,也可以选取
- 模数:264(自然溢出)。
- 底数:随机一个 奇数 。
四、XOR-hash
可能存在的问题
mt19937_64 是线性的 。设初始状态向量(种子)为 x,那么第 i 次生成的随机数 oi 可以写为 oi=BAix。
根据 Cayley-Hamilton 定理,对于任意方阵 A,存在一个非零多项式 p(λ)=∑aiλi 使得 p(A)=0。将这个多项式系数代入:
i∑aioi=i∑aiBAix=B(i∑aiAi)x=B⋅p(A)⋅x=0
x 被消掉了,导致计算出的异或和必然是 0。
错误地使用 uniform_int_distribution(0, L) 仍然会导致相同的问题。当范围 L=2n−1 时(例如截取低 n 位),主流 C++ 标准库实现只是简单地做了一个位掩码 rng() & mask。这依然是线性映射。
解决方案
依旧是 SM64:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| #include <random> #include <chrono> #include <cstdint>
struct safe_rng { using result_type = uint64_t;
std::mt19937_64 rng;
safe_rng() : rng(std::chrono::steady_clock::now().time_since_epoch().count()) {} explicit safe_rng(uint64_t seed) : rng(seed) {}
static uint64_t splitmix64(uint64_t x) { x += 0x9e3779b97f4a7c15; x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9; x = (x ^ (x >> 27)) * 0x94d049bb133111eb; return x ^ (x >> 31); } static constexpr uint64_t min() { return 0; } static constexpr uint64_t max() { return UINT64_MAX; }
uint64_t operator()() { return splitmix64(rng()); } } rng;
|
五、取模
取模也算是一种 hash,可以用于判定分数或大整数的相等。具体应用见下文的五。
Part II. Hash 什么
设 H 表示随机底数,H(x) 表示随机的哈希函数,P 表示大素数。
一般情况,hash 对象都可以转化为 f(H)modP,现举数例如下。
一、多重集合
随机一个哈希函数 H(x),取
f(S)=x∈S∑H(x)mod264
令 cx 是两个集合中元素 x 的出现次数之差,碰撞概率为 2−(64−minν2(cx))。说人话就是如果所有的数的出现次数都包含 2 的幂次,低位就无效,更容易碰撞。
若选用 modP,碰撞概率为 P1。
二、不重集合
采用 xor-hash,随机一个哈希函数 H(x),取
f(S)=x∈S⨁H(x)mod264
或采用上述多重集 hash 方法。
应用:
三、多项式 / 幂级数
直接取这个多项式为 hash 函数,即随机一个哈希值 H,代入 f(H)modP。
字符串 hash 本质是构造了一个多项式。
四、树
对于有根树,取
H(u)=1+v∈children(u)∑SplitMix64(H(v))modP
对于无根树,取重心为根。若有两个重心,分别计算后判定 pair(max,min)。
五、分数 / 大整数
(这个似乎鲜为人知)
将分数 qp 映射为 p⋅q−1modP,将大整数 n 映射为 nmodP。其实是评测机判断概率准确的一种方法。
举个例子:
- 题意 :已知 S=mn! 及 1≤m<n≤106,求 n,m。(2025 CCPC Harbin Practice A)
- 思路 :选模数 P(至少满足 P>n, P>m,最好是 ULL 级别),在模意义下计算并验证。
- 实现 :枚举 n,计算 m=Sn!modP。如果 m∈[1,n−1],就说明找到了答案;否则说明此时 m 不是整数,不是答案。复杂度 O(logS+nlogP)。
- 正确性 :对于一个错误的枚举值 n,其对应的阶乘模数 n!modP 与 X 无关。因此,计算出的 m=(n!⋅X−1)modP 在区间 [0,P−1] 上可视为均匀分布,恰好落在合法区间 [1,n−1] 内的概率为 Pn−1。遍历所有 n∈[2,106] 时,整个算法期望出现的错误答案总个数 E=Pn2=P1012。可见 P 最好是 ULL 级别。
一个练习:2024 ICPC Shenyang G。
六、网格
取两个独立的底 (H1,H2),格子 (i,j) 的权值为 H1iH2jAij。
参考