哈希算法
多项式哈希
一般我们使用的哈希算法是多项式哈希。对于某个向量(a0,…,an−1),其哈希值为:
poly(a)=n−1∑i=0aixi其中x是一个选定的常数。很显然上面的计算结果是非常大的,为了保证计算和存储的效率,需要将结果模上一个数m。其中m需要选择一个素数,之后会提为什么。
上面的哈希算法有一个致命的问题,就是当a中所有元素都是0时,poly(a)=0一定成立。可以发现对于长度为0,1,…的0向量,其哈希值一定是0,但是很显然这些长度不同的零向量是不同的。换言之,我们可以轻易找到一些值对我们的哈希算法进行碰撞攻击。
上面的问题的解决方式,就是在所有序列尾部加上一个1。即
poly(a)=xn+n−1∑i=0aixi这样我们就能成功地将长度信息也一同存进我们的哈希值中。
下面我们来估计一下碰撞的概率是多少。考虑一个与a不同的向量b=(b0,…,bn−1)。如果a和b拥有相同的哈希值,则一定有
poly(a)=poly(b)⇒xn+n−1∑i=0aixi=xn+n−1∑i=0⇒n−1∑i=0(ai−bi)xi=0现在考虑这样的问题,给定多项式f(x)=∑n−1i=0(ai−bi)xi,其中f≠0。当f(x)=0的时候,这时候x一定是f的一个零点。
这里我们需要了解到,当模素数m的前提下,Zm构成一个域。而对于Zm[X]上的非零n−1阶多项式,其最多仅有n−1个零点。
因此我们只需要在0到m−1之间随机取x,这时候f(x)为0的概率为n−1m。换言之,我们在一开始的时候随机取x,就可以保证任意两个长度为n的不同向量,拥有相同的哈希值的概率为n−1m。
哈希攻击
下面讨论几种情况下如何进行哈希攻击。
第一种情况,固定的m和x。我们考虑如何构造f(x),使得x0是它的一个根。实际上我们让f(x)=x−x0即可。
第二种情况,固定的m,随机选择x,但是m是合数。这时候我们考虑m的最小素因子p,选择f(x)=mpx。可以发现对于所有p的倍数,都是f(x)的根,考虑到p≤√m,因此在x随机选择的情况下f(x)=0的概率不小于1√m。
生日悖论
一年有m天,一个班级里有n个学生,问里面至少有两个人有相同的生日的概率为多少。
我们可以通过计算任意两个学生生日不同的概率来计算我们想要的结果。其概率为:
m(m−1)⋯(m−n+1)mn=n−1∏i=0m−im=n−1∏i=0(1−im)这里我们需要注意到当x的绝对值足够小的时候,有ln(1+x)∼x。因此在m远大于n的前提下:
n−1∏i=0(1−im)=eln∏n−1i=0(1−im)=e∑n−1i=0ln(1−im)∼e−∑n−1i=0im=e−(n−1)n2m要让概率碰撞的概率大于等于p,可以推出:
e−(n−1)n2m≤1−p(n−1)n≥−2mln(1−p)稍微简化一下可以得出:
√−2mln(1−p)+1≥n≥√−2mln(1−p)考虑移除其中的常数项,则有n=O(√m)。
多次哈希
根据生日悖论,对于n个槽,向其中随机投√n个球,就有超过50%的概率会发生碰撞。因此这就产生了一个问题,即使我们取模数为m,只需要O(√m)个向量,就会产生至少一次的碰撞。
对于哈希表这种碰撞允许的情况,我们需要做的就是尽量分摊碰撞的位置,这时候是允许碰撞的。但是有的时候我们是不允许碰撞,比如我们直接用哈希值来表示一个对象进行判重。对于后面这种情况,这需要有O(√m)个向量(这还是在向量极短的情况下,如果向量很长,则需要的向量数目会减少)就能保证高概率发生冲突。
并且需要注意√m一般也不是特别大的数值,比如当取m=109+7的时候,√m≈31623。
这里我们可以使用另外一个计数,为每个向量生成两个32位哈希值,并拼接为一个64位整数值。假设前一个哈希值碰撞概率为p1,后一个哈希值碰撞概率为p2,则64位整数碰撞的概率为p1p2。这个数一般足小。比如当p1=p2=1109+7,则此时我们预计需要109+7个对象才会发生哈希碰撞。
对64位整数的哈希
一般哈希值都是32位整数(如果是自然溢出的话,可以使用64位整数,否则使用64位整数作为哈希值,在做乘法取模运算的时候需要使用O(64)的手动模拟的方法或一些其余黑科技)。
现在考虑一种情况,我们手头有n个对象的64位的哈希值,现在希望压缩到32位整数,该怎么做。
这样的压缩无疑会使得一些消息丢失,但是我们需要在意的是保证原本不同的64位整数值产生的32位整数依旧不同,原本相同的64位整数值产生的32位整数相同。
提几种做法,测试题目。
1.直接采用系统自带的哈希算法:
public static int hashCode(long value) {
return (int)(value ^ (value >>> 32));
}
WA在第5个测试样例。看来哈希效果不是很好。
2.使用http://xorshift.di.unimi.it/splitmix64.c中的哈希算法。
uint64_t next() {
uint64_t z = (x += 0x9e3779b97f4a7c15);
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9;
z = (z ^ (z >> 27)) * 0x94d049bb133111eb;
return z ^ (z >> 31);
}
WA在第49个测试样例。效果比较好,但是还是不够。
3.将64位整数拆分成两个32位整数,并进行哈希计算。
public int hash(long x) {
long high = x >>> 32;
long low = x & ((1L << 32) - 1);
return (high * 31L + low) % mod;
}
AC。效果看来非常好。其实这个是多重哈希的一个应用,将64位整数拆分成两个32位整数,并用多项式哈希的方式进行哈希,可以证明其发生冲突的概率为2p,用双重哈希后可以进一步降低冲突的概率。
多重集合哈希
考虑计算多重集合的哈希值。我们有很多种方案,最简单的就是将多重集合中的元素进行排序后使用多项式哈希。但是这种方法有些大材小用了,而且必须引入排序的时间复杂度。
时间上多重集合哈希和一般的字符串哈希有着本质区别,前者不关心元素的排列顺序,而后者关心,换言之后者的哈希值中能夹带上顺序的信息,而前者的哈希值不需要带这部分额外的信息。
考虑选择一个素数p。之后我们为每个多重集合中的元素从1到p−1随机选择一个整数,表示它们的哈希值。之后我们定义一个多重集合中的哈希为它的所有元素的哈希值的和(模p意义下)。
我们考虑哈希冲突的概率。假设A,B是两个不同的多重集合,但是h(A)≡h(B)。记C表示A与B的交集,则h(A−C)≡h(B−C)。考虑任意一个仅在A−C中出现的元素x,记它的出现次数为y。则有h(x)=1y[h(B−C)−h(A−C−{x}×y](这里认为y的出现次数不超过p,且大于0)。由于h(x)可以等概率取[1,p)中的数,因此等式成立的概率为1p−1。
题目1:考虑给定n多重集合,一开始的时候所有集合都是空集。之后处理q个请求,每个请求分为两类:
- 选中某个集合,向其中加入元素x
- 选中某个集合,删除其中的一个元素x(保证删除的元素存在)
要求在处理完每次请求后报告目前有多少个不同的多重集合。
多重集合哈希的裸题,维护n个集合的哈希值,之后每个操作对应某个哈希集合的哈希值的加减操作。
题目2:考虑一个整数序列a1,…,an,之后处理q个请求。
- 将ai替换为x
- 给定两个区间,判断两个区间形成的两个多重集合是否相同。
把多重集合丢到区间查询结构上去即可。
树哈希
树哈希分成有根树哈希和无根树哈希,前者给定根,后者不给定。对于无根树哈希,考虑到每棵树最多只有两个重心,因此只需要对每个重心作为根进行考虑,即每个无根树都可以转换为最多两个有根树来处理。
树哈希的方式非常简单,对于一颗树,我们可以利用多重集合哈希的技术来完成。给定哈希函数H,考虑树根r,其下挂载的孩子为c1,c2,…,ck,且树的大小为n,则树的哈希值h(r)为H(∑ki=1h(ci))。
在不存在哈希碰撞的情况下,可以根据归纳法证明两个树相同,当且仅当它们的哈希值相同。