关于滚动 Hash 和反 Hash 测试背后的数学原理
本博客假设读者熟悉滚动 Hash 的基本概念。有些部分涉及大量数学知识,但无需了解每个细节即可掌握大部分概念。
本博客主要关注如何选择滚动 Hash 参数以避免被 Hack ,以及如何选择不当的参数 Hack hash。
设计难以破解的滚动 Hash
回顾滚动 Hash 和 Hash 碰撞
回想一下,滚动 Hash 有两个参数 (p,a),其中 p 是模数,0 ≤ a < p 是基数。 (我们将看到 p 应该是一个大素数,并且 a 应该大于字母表的大小。)
字符串 S = s0⋯sn − 1 的 Hash 值由以下公式给出:
现在,让我们考虑一个简单的问题:给定两个长度相等的字符串 S, T,通过比较它们的 Hash 值 h(S), h(T) 来判断它们是否相等。如果 h(S) = h(T),我们的算法将 S 和 T 声明为相等。大多数滚动 Hash 解决方案都是基于对此子问题的多次调用或依赖于此类调用的正确性。
让我们将两个长度相等的字符串 S, T 称为 等长碰撞(或 等长冲突),其中 S ≠ T 和 h(S) = h(T)。我们希望避免 等长冲突,因为它们会导致我们的算法错误地将 S 和 T 评估为相等。(请注意,我们的算法绝不会错误地将字符串评估为不同的。)对于固定参数和相当小的长度,字符串的数量远多于可能的 Hash 值,因此总是存在 等长冲突。因此,您可能会认为,对于任何滚动 Hash ,都有一些 Hack 导致 等长冲突。
幸运的是,随机化可以拯救我们。我们的算法不必固定 (p,a),它可以根据某种方案随机选择。如果我们可以证明对于任意两个字符串 S, T,S ≠ T,该方案会以高概率选择 (p,a),使得 h(S) ≠ h(T),则该方案是可靠的。请注意,概率空间仅包括方案内部的随机选择;输入 (S,T) 是任意的、固定的,不一定是随机的。 (如果您认为输入来自 Hack ,那么这意味着无论输入是什么,我们的解决方案都不会以高概率失败。)
我将向您展示两个可靠的方案。(请注意,方案可靠并不意味着您的实现很好。必须小心使用随机数生成器。)
随机化基础
该方案使用 a 个固定的 素数 p(即 109 + 7 或 4 ⋅ 109 + 7)并从 [0,p−1] 中均匀随机地选取 a。令 A 为选择 a 的随机变量。
为了证明该方案是好的,考虑两个长度相等的字符串 (S,T) 并进行一些计算:
因此,我们有:
我们让 P(A) 表示 A 中次数为 ≤ n − 1 的多项式:
当 S ≠ T 时,P 非零。计算表明,当且仅当 A 是 P(A) 的根时,h(S) = h(T)。
由于 p 是素数,并且我们正在进行 mod p 计算,因此我们正在一个域中工作。在一个域上,任何次数为 ≤ n − 1 的多项式最多有 n − 1 个根。因此,最多有 n − 1 个 a 选项导致 h(S) = h(T)。因此:
因此,对于任何两个长度相等的字符串 (S,T),它们形成等长碰撞的概率最多为
边界的紧密性
目前,这部分仅适用于具有平滑 p − 1 的素数,因此它不适用于例如 p = 109 + 7。找到一种可计算且在一般情况下有效的构造将会很有趣。
如果 n − 1|p − 1,则此方案的边界
由于 p 为素数,
随机化模数
该方案固定基数 a ≤ |∑| 和边界 N > a,并从 [N,2N−1] 中均匀随机地选取一个 素数 p。
为了证明该方案是好的,再次,考虑两个长度相等的字符串 (S,T) 并进行一些计算:
因此,我们有:
所以:
当 X ≡ 0 (mod p), p|X 时,我们选择一个足够大的值,X ≠ 0。此外,|X| < an。[N,2N−1] 中 X 的不同素因数的数量上限由
请注意,此界限比随机化基数的界限稍差。当 n = 105、a = 26、N = 109 时,该界限约为 3 ⋅ 10 − 4。
如何正确随机化
以下是初始化随机数生成器的好方法。
高精度时间。
cpp1
2chrono::duration_cast<chrono::nanoseconds> (chrono::high_resolution_clock::now().time_since_epoch()).count();
chrono::duration_cast<chrono::nanoseconds>(chrono::steady_clock::now().time_since_epoch()).count();两者之一都可以。(理论上,
high_resolution_clock
应该更好,但它在 codeforces 上的精度却低于steady_clock
??)处理器周期计数器
cpp1
__builtin_ia32_rdtsc();
一些堆地址转换为整数
cpp1
(uintptr_t) make_unique<char>().get();
处理器随机性(需要 pragma 或 asm)(感谢 halyavin
cpp1
2
3
4
5
6
7// pragma 版本
uint32_t rdrand32(){
uint32_t ret;
assert(__builtin_ia32_rdrand32_step (&ret));
return ret;
}cpp1
2
3
4
5
6// asm 版本
uint32_t rd() {
uint32_t ret;
asm volatile("rdrand %0" :"=a"(ret) ::"cc");
return ret;
}
如果您使用 C++11 样式的 rng(您应该使用),则可以使用上述组合:
1 | seed_seq seq{ |
请注意,这确实会在内部丢弃参数中的高 32 位,但这实际上并不重要,因为低位更难预测(尤其是在第一种情况下使用 chrono)。
请参阅 “滥用不良随机化” 部分,了解一些不良示例。
扩展到多个 Hash
我们可以使用多个 Hash (即使使用相同的方案和相同的固定参数),并且只要随机样本是独立的, Hash 就是独立的。如果单个 Hash 失败的概率最多为 a1, ⋯, ak,则所有 Hash 失败的概率最多为
例如,如果我们使用两个 Hash ,其中 p = 109 + 7 和随机化基数,则发生碰撞的概率最多为 10 − 8;对于四个 Hash ,它最多为 10 − 16。这里,稍大素数的常数更为重要,对于 p = 231 − 1,概率约为 2.1 ⋅ 10 − 9 和 4.7 ⋅ 10 − 18。
更大的模数
使用更大的(即 60 位)素数将使碰撞的可能性更小,并且不会受到错误界限内 n 累积因子的影响。但是,滚动 Hash 的计算变得更慢、更困难,因为 codeforces 上没有 __int128
。
一个例外是梅森素数 p = 261 − 1;我们可以改用位移位来减少 mod p。 (感谢 dmkz 提出此建议。)以下代码不使用 __int128
计算 a ⋅ b mod p,仅比使用模数的 30 位 Hash 慢约 5%。
对于参数,条件 0 ≤ a, b < mod 应该成立。然后返回值也满足此条件。
1 | constexpr uint64_t mod = (1ull<<61) - 1; |
使用无符号类型和 p = 4 ⋅ 109 + 7 可以获得较小的因子。
请注意,p = 264(unsigned long long 溢出)不是素数,无论随机化如何都可以被破解(见下文)。
扩展到多个比较
通常,滚动 Hash 值用于多个比较。如果我们仅基于 m 次比较,并且一次比较失败的概率为 p,则任何一次失败的概率最多为 m ⋅ p(根据联合界限)。请注意,当 m = 105 时,我们至少需要两个或三个 Hash 才能使这个概率很小。
在估计成功所需的比较次数时,必须非常小心。如果我们对 Hash 进行排序或将它们放入一个集合中,我们需要有成对不同的 Hash ,因此对于 n 个字符串,总共需要
扩展到不同长度的字符串
如果我们处理不同长度的字符串,我们可以通过在 Hash 中存储长度来避免比较它们。但是,如果我们假设 没有字符 Hash 为 0,则情况并非如此。在这种情况下,我们可以简单地想象我们在较短的字符串前面添加空字节以获得相同长度的字符串而不改变 Hash 值。那么上面的理论就适用了。(如果某个字符(即 a
) Hash 为 0,我们可能会生成看起来相同但在添加过程中并不相同的字符串(即 a
和 aa
)。)
计算反 Hash 测试
本节介绍一些利用滚动 Hash 实现中常见错误的技术,主要用于破解其他解决方案。这里有一个表格,其中简要总结了这些方法。
名称 | 用例 | 运行时间 | 字符串长度 | 注释 |
---|---|---|---|---|
Thue-Morse 序列 | 带溢出的 Hash | O(1) | 210 | 可同时适用于所有基数。 |
生日攻击 | 小模数 | 可以找到多个碰撞。 | ||
树攻击 | 大模数 | 更快;更长的字符串 | ||
多树攻击 | 大模数 | 更慢;更短的字符串 | ||
格子缩减 | 中大型字母表,多重 Hash | O(length3) | 对于 | |
组合 | 多重 Hash | 单个运行时间的总和 | 单个字符串长度的乘积 | 结合了两种攻击。 |
单个 Hash
Thue–Morse 序列:带有无符号溢出的 Hash (p = 264,q 任意)
一种适用于任何基数的反 Hash 测试是 Thue–Morse 序列,由以下代码生成。
1 | const int Q = 10; |
无论选择哪个基数,S、T 都会形成等长碰撞。
有关详细讨论,请参阅此 博客。请注意,链接博客上的界限可以略微改进,因为对于奇数 X,X2 − 1 总是可以被 8 整除。 (因此我们可以使用 Q = 10 而不是 Q = 11。)
生日攻击:使用 32 位素数和固定基数的 Hash (p < 232 固定,q 固定)
具有单个小素数的 Hash 可以通过生日悖论进行攻击。固定长度 l 和字母表 d 的大小,并随机均匀地选择长度为 l 的 k 个字符串。如果 l 不太小,则生成的 Hash 值将大致均匀分布。根据生日悖论,我们挑选的所有字符串 Hash 值都不同的概率是:
因此,概率为
更一般地,我们可以使用与
树攻击:使用较大素数和固定基数(p 固定,q 固定)进行 Hash 运算
感谢 Kaban-5 和 pavel.savchenkov 提供的 链接 一些描述此想法的俄罗斯评论。
对于较大的素数,生日攻击速度太慢。回想一下,对于两个长度相等的字符串 (S,T):
h(S) = h(T)
我们设置 αi = Si − Ti 满足 − |∑| ≤ αi ≤ |∑|。树攻击试图找到 αi ∈ { − 1, 0, 1} 使得:
攻击维护系数簇 C1, ..., Ck。集群 C 的总和 S(C) 由以下公式给出:
S(C) = ∑i ∈ C(an − i − 1 mod p) ⋅ αi
我们可以将两个集群 C1 和 C2 合并为总和为 S(C1) − S(C2) 的集群 C3,方法是将 C2 中的所有 αi 乘以
最初,我们从 n = 2k 开始,每个 αi = 1 都在自己的集群中。在一个阶段中,我们首先按总和对集群进行排序,然后合并相邻的集群对。如果我们在任何时候遇到总和为 0 的集群,我们将通过将不在该集群中的所有 αj 设置为 0 来完成。如果我们在 k 个阶段后仍未完成,请使用更大的 k 值重试。
对于哪些 k 值,我们可以期望这有效?如果我们假设总和最初在 [0,p−1] 中均匀分布,则在阶段 i 中,最大总和应该减少因子 ∼ 2k − i。经过 k 个阶段后,最大和约为
多树攻击
虽然树攻击运行速度非常快,但生成的字符串可能会变得有点长。 (n = 2048,p = 261 − 1。) 我们可以花费更多时间来搜索更短的碰撞,方法是存储每个集群中可以获得的最小 m 个和。(单树攻击只使用 m = 1。)合并两个集群可以在 O(mlogm) 中完成,使用最小堆和 2m 指针遍历。为了尽快获得 m 个字符串,我们允许所有值 αi ∈ [−|∑|,|∑|] 并排除所有 αi 都为零的简单情况。
分析 k 的预期值以使其发挥作用非常困难。乐观地假设我们在 log|∑|m 步之后每个节点达到 m 个总和,并且总和会像单树攻击一样减少,而且根据生日悖论,当它们变得小于 m2 时我们可以预期会发生碰撞,我们得到
在实践中,我们可以使用 m ≈ 105 来找到长度为 128 的碰撞,其中 |∑| = 2,p = 261 − 1 大约需要 0.4 秒。
格子缩减攻击:对不太小的字母表进行单个或多个 Hash
与树攻击一样,我们正在寻找 αi ∈ [−|∑|,|∑|],使得:
换成集合形式:
形成一个格(嵌入在 ℝn 子空间中的自由 ℤ 模块。)我们在格中寻找一个元素,使得 β = 0 且 |αi| ≤ |∑|。我们可以通过考虑以下因素来惩罚 β 的非零值:
然后我们寻求最小化 max {|α0|, ⋯, |an − 1|, |β|}。不幸的是,这个优化问题相当困难,所以我们尝试最小化:
α02 + ⋯ + αn − 12 + β2
结果问题仍然很难,可能是 NP 完全的,但有一些很好的近似算法可用。
与向量空间类似,我们可以在格中定义一个基。对于我们的情况,基础由以下公式给出:
{eβ + 105(an − i − 1 mod p)eai|0 ≤ i < n} ∪ {p ⋅ 105eβ}
格简化算法采用此基础并将其(通过具有行列式 ± 的可逆矩阵)转换为具有近似最短向量的另一个基础。实现它们非常困难(并且会受到精度错误或大数减速的影响),因此我决定使用 sage 中的内置实现。
1 | """通过格子归约生成反滚动 Hash 测试 |
输入取自格式为 hash.in
的文件。要使用 BKZ 算法,请设置 block_size
参数。
输入格式:
1 | n lenth sigma |
Sage 提供两种算法:LLL 和 BKZ,前者速度更快,但近似值更差,尤其是对于较长的字符串。分析它们很困难,所以我做了一些实验,固定 |∑| = 26、p = 261 − 1 并随机固定 a1, ⋯, an,并使用这两种算法搜索简短的反 Hash 测试。结果非常好。
LLL 算法:
n | 最小长度 | 花费的时间(秒) |
---|---|---|
1 | 12 | 0.02 |
2 | 23 | 0.04 |
3 | 34 | 0.08 |
4 | 48 | 0.18 |
5 | 60 | 0.34 |
6 | 77 | 0.75 |
7 | 301 | 5.14 |
8 | > 1500 | > 990 |
对于 n = 8,算法无法找到长度为 ≤ 1500 的碰撞。
BKZ 算法,其中 block_size = 10
:
n | 最小长度 | 所用时间(秒) |
---|---|---|
1 | 12 | 0.02 |
2 | 23 | 0.04 |
3 | 33 | 0.09 |
4 | 45 | 0.21 |
5 | 57 | 0.58 |
6 | 72 | 1.12 |
7 | 88 | 2.59 |
8 | 108 | 5.33 |
block_size = 15
的 BKZ 算法:
n | 最小长度 | 所用时间(秒) |
---|---|---|
1 | 12 | 0.02 |
2 | 23 | 0.04 |
3 | 33 | 0.09 |
4 | 45 | 0.21 |
5 | 57 | 0.52 |
6 | 70 | 1.24 |
7 | 85 | 2.41 |
8 | 102 | 5.20 |
block_size = 25
的 BKZ 算法:
n | 最小长度 | 所用时间(秒) |
---|---|---|
1 | 12 | 0.02 |
2 | 23 | 0.04 |
3 | 33 | 0.10 |
4 | 46 | 0.58 |
5 | 57 | 1.14 |
6 | 70 | 3.67 |
7 | 84 | 6.96 |
8 | 96 | 26.70 |
请注意,当 n > 1 时,此攻击对小(即二进制)字母表效果不佳,并且字符必须散列为连续值,因此如果用于组合攻击,这必须是第一次攻击。
组合攻击:多个 Hash
使用两个或更多 Hash 通常足以防止直接生日攻击。 对于两个素数,有 N = p1 · p2 个可能的 Hash 值。 生日攻击在
破解多个 Hash 的关键思想是逐个破解它们。
- 首先为第一个 Hash h1 找到一个等长碰撞(通过生日攻击),即两个长度相等的字符串 S, T, S ≠ T,其中 h1(S) = h1(T)。请注意,在字母表 S, T 上构建的等长字符串(即通过将 S 的一些副本与 T 的一些副本连接起来,反之亦然)现在将在 h1 下散列为相同的值。
- 然后在为第二个 Hash h2 搜索等长碰撞(再次通过生日攻击)时使用 S 和 T 作为字母表。结果也将自动成为 h1 的碰撞,因为我们使用 S, T 作为字母表。
这减少了运行时间
另一件需要注意的事情是,字符串长度会随着 Hash 数的增加而迅速增长。 (大约
滥用不良随机化
在 codeforces 上,很多人会随机化他们的 Hash 值。(不幸)的是,他们中的许多人都以次优的方式进行。本节介绍了人们搞砸 Hash 随机化的一些方法以及破解其代码的方法。
本节更广泛地适用于其他参与者可以破解您的解决方案的环境中任何类型的随机算法。
固定种子
如果 rng 的种子是固定的,它总是会产生相同的随机数序列。您只需运行代码即可查看哪些数字是随机生成的,然后找到这些数字的反 Hash 测试。
从一小群基数中挑选(rand()%100
)
请注意,rand() % 100
最多产生 100 个不同的值 [0,99]。我们可以为每个值找到一个单独的反 Hash 测试,然后将这些测试合并为一个。 (您的组合测试方式是针对特定问题的,但它适用于大多数问题。)
rand()
的更多问题
在 codeforces 上,rand()
仅产生 15 位值,因此最多产生 215 个不同的值。虽然运行 215 个生日攻击可能需要一段时间(在我的笔记本电脑上使用单个线程估计 p = 109 + 7 需要 111 分钟),但这可能会导致其他一些随机算法出现一些大问题。
编辑:如果我们使用多树攻击,这种类型的 Hack 攻击可能是可行的。对于
在 C++11 中,您可以使用 mt19937
和 uniform_int_distribution
代替 rand()
。
低精度时间(Time(NULL)
)
Time(NULL)
每秒仅更改一次。可以按以下方式利用它
- 选择一个时间跨度 ΔT。
- 找到生成测试所需时间的上限 T。
- 通过自定义调用找出
Time(NULL)
的当前值 T0。 - 对于 t = 0, ⋯, (ΔT) − 1,将
Time(NULL)
替换为 T0 + T + t,并针对此固定种子生成反测试。 - 在时间 T0 + T 提交 hack。
如果您的 hack 在接下来的 ΔT 秒内执行,Time(NULL)
将是您为其生成反测试的值,因此解决方案将失败。
MinGW 上的随机设备(std::random_device
)
请注意,在 codeforces 上,std::random_device
是确定性的,将产生相同的数字序列。使用它的解决方案可以像固定种子解决方案一样被破解。
本文翻译自 On the mathematics behind rolling hashes and anti-hash tests
转载请注明原文出处
警告:本文部分使用了 AI 翻译,可能存在不准确或错误的地方。