起因
最近我使用 Doris 的 Bitmap 类型做用户统计时,发现 Doris 字符串转数字的 Hash 函数并不能很好地满足需求。因此,我想寻找一种更合适的 Hash 函数。我问同事:“你知道有没有一种 Hash 函数,既能够生成只有 32 位的数字,又能将碰撞率控制在千万分之一以下?” 他听完后说:“如果你真的找到了这样的函数,请务必再帮我找找有没有又大又便宜的房子,重点是大,而且便宜。” 我听完只能哈哈大笑,觉得这似乎是不可能的。但是,经过一番研究,我发现,事情并没有那么简单,这样的函数是可以存在的。
什么是 Hash
Hash 这个词对我来说真的是既熟悉又陌生,一直都知道 Hash 算法,但从来不知道它究竟是怎么做到的。
Hash 这个单词的含义是:
n. 剁碎的食物;混杂,拼凑;重新表述
vt. 搞糟,把……弄乱;切细;推敲
Hash 函数是一种将任意长度的输入数据映射为固定长度输出数据的函数。
怎么 Hash
我们以 MurmurHash 算法生成 32 位整型为例讲一下 Hash 算法具体的计算过程。
MurmurHash 是一种非加密哈希函数,广泛用于基于哈希的查找。它由 Austin Appleby 于 2008 年创建,旨在快速高效,同时仍能提供良好的分布和低碰撞率。它的名字来自于它内部循环中使用的两个基本操作:乘法(MU)和旋转(R)。
算法输入数据的种子和长度将散列值 h
初始化为随机值。然后以 4 字节块处理输入数据,应用一系列按位操作,并通过常量 m
对每个块进行乘法、通过常量 r
对每个块进行旋转,以生成哈希值 k
。然后使用 k
更新哈希值 h
。在处理所有 4 字节块后,该函数处理输入数据的剩余字节(如果有的话),并对散列值 h
应用额外的按位运算和 m
乘法。最后,该函数返回散列值 h
。下面是具体的算法:
public static int hash32(final byte[] data, int length, int seed) {
// 'm' and 'r' are mixing constants generated offline.
final int m = 0x5bd1e995;
final int r = 24;
// Initialize the hash to a random value
int h = seed^length;
int length4 = length/4;
for (int i=0; i<length4; i++) {
final int i4 = i*4;
int k = (data[i4+0]&0xff) +((data[i4+1]&0xff)<<8)
+((data[i4+2]&0xff)<<16) +((data[i4+3]&0xff)<<24);
k *= m;
k ^= k >>> r;
k *= m;
h *= m;
h ^= k;
}
// Handle the last few bytes of the input array
switch (length%4) {
case 3: h ^= (data[(length&~3) +2]&0xff) << 16;
case 2: h ^= (data[(length&~3) +1]&0xff) << 8;
case 1: h ^= (data[length&~3]&0xff);
h *= m;
}
h ^= h >>> 13;
h *= m;
h ^= h >>> 15;
return h;
}
一些计算细节
m
和 r
分别用于进行乘法和旋转运算,也是 MurMurHash 名字的来源,它们的值是开发者调试出来的、认为效果比较好的值。
&0xff
操作用于确保字节值被视为无符号值。 &
是与运算,0xff
是十进制的255,二进制的 11111111
。本质上做完与运算后,所有的1和0都是不变的。但是在 Java 中,bytes 是有符号的,&0xff
操作后,Java 会把它当作一个无符号的数字看待,因此可以实现转为无符号值
length&~3
等同于将 length
向下舍入到 4 的倍数,具体逻辑如下:
假设 length=5
,用二进制表示是 101
,将3的二进制表示取反,得到~3的二进制表示为:11111111111111111111111111111100
。
将 5 的二进制表示和~3 进行按位与操作,即:
101 (5的二进制表示)
& 11111111111111111111111111111100 (~3的二进制表示)
----------------------------------
100 (结果,二进制表示)
按位与的操作规则是若相应位都为1
,则结果该位为1
,否则该位为0
,因此,只有在5的二进制表示的前2位与 ~3
的二进制表示的前 2 位都为 1
时才会得到 1
,其他位都是 0
。因此,所得到的结果为 100
,转化为十进制就是 4
。
length&~3
等同于将 length
的二进制表示的最后两位全部清零,相当于将 length
向下舍入到 4
的倍数。
Hash 碰撞率
了解了 Hash 计算的过程,我们再来看看怎么计算 Hash 碰撞率。
Hash 碰撞是指两个不同的输入数据,经过 Hash 函数计算后,得到相同的哈希值的情况。Hash 函数的设计目的是尽可能地减少碰撞率,即使输入数据发生微小的变化,也能够得到截然不同的哈希值。然而,由于哈希值的长度是固定的,而输入数据的长度是不确定的,因此,Hash 碰撞是不可避免的。
Hash 计算的过程是根据输入在一个给定的空间中寻找一个数字,碰撞率就是找到相同数字的概率。为了方便计算 Hash 碰撞率,可以将其转换为这样一个问题:
假设有
k
个随机生成的值,其中每个值都是小于N
的非负整数,则至少有两个值相等的概率是多少?
计算两个值相等的概率,可以通过计算其补集,然后用 1
减去就好了。给定一个包含 N
个可能哈希值的空间,假设你已经选择了一个单一的值。之后,剩下 N-1
个值与第一个值不同。因此,随机生成两个互不相同的整数的概率是
N
−
1
N
\frac{N-1}{N}
NN−1。之后,有
N
−
2
N-2
N−2 剩余值与前两个值是唯一的,这意味着随机生成三个都是唯一的整数的概率是
N
−
1
N
×
N
−
2
N
\frac{N-1}{N}\times\frac{N-2}{N}
NN−1×NN−2。
以此类推可以得到:
N − 1 N × N − 2 N × ⋯ × N − ( k − 2 ) N × N − ( k − 1 ) N \frac{N-1}{N}\times\frac{N-2}{N}\times\dots\times\frac{N-(k-2)}{N}\times\frac{N-(k-1)}{N} NN−1×NN−2×⋯×NN−(k−2)×NN−(k−1)
随着 N 的增大,上面的式子约等于下面的式子:
e − k ( k − 1 ) 2 N e^{\frac{-k(k-1)}{2N}} e2N−k(k−1)
因此碰撞概率计算公式如下,其中 k
是生成的个数,N
是总的空间
1 − e − k ( k − 1 ) 2 N 1 - e^{\frac{-k(k-1)}{2N}} 1−e2N−k(k−1)
对应
1
−
e
−
X
1 - e^{-X}
1−e−X 形式的式子,当 X
比较小的时候,
1
−
e
−
X
≈
X
1 - e^{-X} \approx X
1−e−X≈X。X
小意味着生成的个数 k
比较小,或者总的空间 N
比较大,因此,对于小碰撞概率,我们可以使用简化表达式:
k ( k − 1 ) 2 N \frac{k(k-1)}{2N} 2Nk(k−1)
由于 k(k - 1)
和
k
2
k^2
k2 之间的差别不是很大,可以进一步将其简化为如下形式:
P ( A ) = k 2 2 N P(A)=\frac{k^2}{2N} P(A)=2Nk2
这样,我们就得到了一个十分简洁的计算 Hash 碰撞率的式子。回到文章开头的那个问题:有没有一种 Hash 函数,既能够生成只有 32 位的数字,又能将碰撞率控制在千万分之一以下?
我们计算一下,代入式子:
1 10000000 = k 2 2 ∗ 2 32 \frac{1}{10000000}=\frac{k^2}{2*2^{32}} 100000001=2∗232k2
k ≈ 29 k \approx 29 k≈29
换句话说,如果仅生成 29 个数字,那么 32 位数字的 Hash 值碰撞率可以在千万分之一以下。虽然这个答案可能有点鸡肋,但至少回答了一个不知道的问题。此外,这也学到了不少东西,这才是重要的。
参考资料
- https://github.com/tnm/murmurhash-java
- https://preshing.com/20110504/hash-collision-probabilities
我正在使用免费的纯真社区版IP库。纯真(CZ88.NET)自2005年起一直为广大社区用户提供社区版IP地址库,只要获得纯真的授权就能免费使用,并不断获取后续更新的版本。
纯真除了免费的社区版IP库外,还提供数据更加准确、服务更加周全的商业版IP地址查询数据。纯真围绕IP地址,基于 网络空间拓扑测绘 + 移动位置大数据 方案,对IP地址定位、IP网络风险、IP使用场景、IP网络类型、秒拨侦测、VPN侦测、代理侦测、爬虫侦测、真人度等均有近20年丰富的数据沉淀。