String.hashCode()为什么使用31作为乘数【深度长文】
文章目录
String.hashCode()源码
公式: H ( k e y ) = k e y m o d p H(key) = key\ mod \ p H(key)=key mod p
源码:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
在获取hashCode
的源码中可以看到,有一个固定值31
,在for循环每次执行时进行乘积计算,循环后的公式如下;s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
这里的乘数也就是公式中的模/除数,那么为什么这里是31
呢?我们先来看看散列函数和散列表的性质。
散列函数
所有散列函数都有如下一个基本特性:如果两个散列值是不相同的(根据同一函数),那么这两个散列值的原始输入也是不相同的。
https://zh.wikipedia.org/wiki/%E6%95%A3%E5%88%97%E5%87%BD%E6%95%B8
散列表
散列表是散列函数的一个主要应用,使用散列表能够快速的按照关键字查找数据记录。(注意:关键字不是像在加密中所使用的那样是秘密的,但它们都是用来“解锁”或者访问数据的)。举个英文字典的例子,开头相同字母对应多个单词,相同字母越多(规则越严格),匹配结果越少(冲突越少)。
一个好的散列函数具有均匀的真正随机输出,因而平均只需要一两次探测(依赖于装填因子)就能找到目标。同样重要的是,随机散列函数不太会出现非常高的冲突率。但是,少量的可以估计的冲突在实际状况下是不可避免的(参考生日悖论或鸽巢原理)。
《Effective Java》上的回答
https://stackoverflow.com/questions/299304/why-does-javas-hashcode-in-string-use-31-as-a-multiplier
这是个比较有争议的答案,分为三部分来讨论:
为什么要选择奇数
如果用2
作乘数,则所有的数会落在0
和1
两个位置(余0或余1)。
2
不能作为乘数,则剩下的素数肯定是奇数。
为什么要选择素数
实践角度:
1. 取模
取6
和7
为候选乘数,6
的因子集合为{1,2,3,6}
,7
的因子集合为{1,7}
2. 选取数列
数列的选取很重要,有些文章将验证的数列间隔选为1,发现素数与合数并没有什么区别。
这是因为素数与合数最大的区别不是间隔为1,而是因子的个数,我们可以做这样的一个假设,取模运算产生的碰撞冲突与乘数的因子相关。
3. 验证
{1,3,5,7,9,11,13,15,17}
,取模6
余数 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
哈希表 | 1 | 3 | 5 | |||
冲突1 | 7 | 9 | 11 | |||
冲突2 | 13 | 15 | 17 |
{1,3,5,7,9,11,13,15,17}
,取模7
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 7 | 1 | 9 | 3 | 11 |
冲突1 | 15 | 17 |
{2,4,6,8,10,12,14,16,18}
,取模6
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 6 | 2 | 4 | ||
冲突1 | 12 | 8 | 10 | ||
冲突2 | 18 | 14 | 16 |
{2,4,6,8,10,12,14,16,18}
,取模7
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 14 | 8 | 2 | 10 | 4 |
冲突1 | 16 | 18 |
{1,4,7,10,13,16,19,22,25,28}
,取模6
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 1 | 4 | |||
冲突1 | 7 | 10 | |||
冲突2 | 13 | 16 | |||
冲突3 | 19 | 22 | |||
冲突4 | 25 | 28 |
{1,4,7,10,13,16,19,22,25,28}
,取模7
余数 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
哈希表 | 7 | 1 | 16 | 10 | 4 | 19 | 13 |
冲突1 | 28 | 22 | 25 |
{1,7,13,19,25,31,37,43,49}
,取模6
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 1 | ||||
冲突1 | 7 | ||||
冲突2 | 13 | ||||
冲突3 | 19 | ||||
冲突4 | 25 | ||||
冲突5 | 31 | ||||
冲突6 | 37 | ||||
冲突7 | 43 | ||||
冲突8 | 49 |
{1,7,13,19,25,31,37,43,49}
,取模7
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 7 | 1 | 37 | 31 | 25 |
冲突1 | 49 | 43 |
{1,8,15,22,29,36,43,50,57}
,取模6
余数 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
哈希表 | 36 | 1 | 8 | 15 | 22 |
冲突1 | 43 | 50 | 57 |
{1,8,15,22,29,36,43,50,57}
,取模7
余数 | 0 | 1 | 2 | 3 | |
---|---|---|---|---|---|
哈希表 | 1 | ||||
冲突1 | 8 | ||||
冲突2 | 15 | ||||
冲突3 | 22 | ||||
冲突4 | 29 | ||||
冲突5 | 36 | ||||
冲突6 | 43 | ||||
冲突7 | 50 | ||||
冲突8 | 57 |
4. 结论
-
如果有一个数列s,间隔为1,那么不管模数为几,都是均匀分布的,因为间隔为1是最小单位
-
如果一个数列s,间隔为模本身,那么在哈希表中的分布仅占有其中的一列,也就是处处冲突
-
数列的冲突分布间隔为因子大小,同样的随机数列,因子越多,冲突的可能性就越大
理论角度:
举例
给定一个整数的集合 A = { a 0 , a 1 , a 2 , … , a i } A=\{a_0,a_1,a_2,…,a_i\} A={a0,a1,a2,…,ai},我们要对该集合里面的所有元素对数字 j j j 取余,那么 A A A 中所有元素取余后的值的集合 B = { 0 , 1 , 2 , … , j − 1 } B=\{0,1,2,…,j-1\} B={0,1,2,…,j−1},取余实际上就是将集合 A A A 映射到集合 B B B 。
**例1:**取 i = 12 i=12 i=12, j = 8 j=8 j=8,则有
**例2:**取 i = 12 i=12 i=12, j = 8 j=8 j=8,则有
**例3:**取 i = 12 i=12 i=12, j = 7 j=7 j=7,则有
数学证明
现在考虑 q q q 的情况:
总结:虽然说不能只考虑特定序列的一组数字,而是要考虑到更普遍的情况。但合数能应用的场景素数都能应用,素数能以不变应万变,稳赚不赔。。。
为什么要选择31
我们先看Jdk
开发者之一兼《Effective Java》作者joshua.bloch
在 https://bugs.java.com/bugdatabase/view_bug.do?bug_id=4045622 的回复(jdk1.1.1
使用37
处理短字符串,使用39
处理长字符串):
源码
public int hashCode() {
int h = 0;
int off = offset;
char val[] = value;
int len = count;
if (len < 16) {
for (int i = len ; i > 0; i--) {
h = (h * 37) + val[off++];
}
} else {
// only sample some characters
int skip = len / 8;
for (int i = len ; i > 0; i -= skip, off += skip) {
h = (h * 39) + val[off];
}
}
return h;
}
存在的问题
大意是Java语言规范 JLS
使用乘数39
处理超过15个字符的字符串时会抛出异常,从而影响性能。
处理小字符串为什么用37
已不可考。
解决方案
将乘数修正为31
,是他经过大量研究的结果。而且对大字符串进行散列将更有效。
几种候选乘数的比较
数据集:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HqD2YV4U-1649521270567)(image-20220409161718976.png)]
哈希表平均查找次数(越小越好):
结果分析:
小结
通过以上分析,我们知道了31
是开发者权衡了计算成本、兼容性(Java是个跨平台语言,考虑兼容小系统)、规范复杂性的综合选择,同时它又恰好是个素数(如序列{11,44,77}取31
作为乘数比33
更好)
那么用31
作为乘数的哈希函数是好的吗?什么才是理想的哈希函数?它们有多大差距呢?
理想的哈希函数
int
类型占 4 字节 32 位,有
2
32
2^{32}
232 种取值,但一个String
常量可以包含任意数量的字符,考虑极端情况,由于鸽巢原理,碰撞/冲突必然存在。
著名的、反直觉的生日问题指出,对于365种可能的哈希值,在达到50%的碰撞概率时,至少存在23个唯一的哈希值,即使对于最理想的哈希函数也是如此。
如果有 2 32 2^{32} 232 种可能的哈希值,在达到50%的碰撞概率时,至少存在约77164个唯一的哈希值。
这个数字怎么来的?下面进行推导。
公式推导
假设散列函数非常好——它在可用范围内均匀分布散列值。
在这种情况下,为输入集合生成哈希值与生成随机数集合非常相似。那么,我们的问题可以转化为下面的问题:
我们从反向思路着手,先计算它们都是唯一的概率。
给定一个由 N 个可能的散列值组成的序列。
假设已经选定一个值,还有 N-1 个值,它们都不等于选定的值,则随机两数相互不等的概率 p 1 = N − 1 N p_1=\frac{N-1}{N} p1=NN−1。
假设已经选定两个值,还有 N-2 个值,它们都不等于选定的值,则随机两数相互不等的概率 p 2 = N − 1 N × N − 2 N p_2=\frac{N-1}{N} \times \frac{N-2}{N} p2=NN−1×NN−2。(事件相互独立)
往后推到,得到所有数互相不等的概率 P 反 = N − 1 N × N − 2 N × … × N − ( k − 2 ) N × N − ( k − 1 ) N P_反=\frac{N-1}{N} \times \frac{N-2}{N} \times … \times \frac{N-(k-2)}{N} \times \frac{N-(k-1)}{N} P反=NN−1×NN−2×…×NN−(k−2)×NN−(k−1)。
该公式近似为: e − k ( k − 1 ) 2 N e^{\frac{-k(k-1)}{2N}} e2N−k(k−1),原理是 e x e^x ex 泰勒展开式+等差公式,推导过程如下:
公式应用
令 N = 2 32 N=2^{32} N=232,则下图反映了使用 32 位哈希值时的冲突概率。
当哈希数为77163时,发生冲突的几率为50%,这个数字可以作为一个基准。
python代码:
from math import exp
def prob(x,N):
return 1.0 -exp(-0.5 * x * (x-1) / N)
prob(77163,2**32) # 0.4999978150170551
prob(77164,2**32) # 0.500006797931095
换句话说,针对不同的数据集,碰撞最少的理想状态乘数分布在(0,77163)
这个区间中,可以写个循环找到最少碰撞的乘数,代码如下:
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.util.*;
import java.nio.charset.StandardCharsets;
public class HashTest {
//计算给定乘数x和数据长度N时的碰撞率
public static double prob(int k, long N) {
return 1 - Math.exp(-0.5 * k * (k - 1) / N);
}
//按碰撞率获取乘数的边界值
public static int getBorder(long N, double rate) {
int i = 0;
int k = 0;
while (i < N) {
double result = prob(i, N);
if (result > rate) {
k = i - 1;
break;
}
i++;
}
return k;
}
//获取理想状态的哈希值
public static int getIdeaHashCode(String value, int k) {
int hash = 0;
for (int i = 0; i < value.length(); i++) {
hash = k * hash + value.charAt(i);
}
return hash;
}
//统计碰撞集合,去重
private static Map<Integer, Set> collisions(Collection values, int k, Boolean idea_on) {
Map<Integer, Set> result = new HashMap<>();
for (Object value : values) {
Integer hc = (value.toString()).hashCode();//调用String.hashCode()
if (idea_on) { //如果要统计理想哈希值,调用该方法
hc = HashTest.getIdeaHashCode((String) value, k);
}
Set bucket = result.get(hc);
if (bucket == null)
result.put(hc, bucket = new TreeSet<>());
bucket.add(value);
}
return result;
}
//读入文本文件,寻找最少碰撞情况的乘数k,打印碰撞检测结果
public static void test(String path, Boolean idea_on) throws IOException {
System.err.println("Loading lines from stdin...");
Set lines = new HashSet<>();
try (BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
for (String line = r.readLine(); line != null; line = r.readLine())
lines.add(line);
}
System.err.println("Computing collisions...");
Map<Integer, Set> collisions;
int max = 0;
double rate = 0.5;
for (double i = 0.5; i > 0; i -= 0.01) { //调用循环找理想乘数
collisions = collisions(lines, getBorder(lines.size(), i), idea_on);
if (collisions.size() > max) {
max = collisions.size();
rate = i;
}
}
int border = getBorder(lines.size(), rate);
if (idea_on) {
System.out.println("k = " + border);
}
collisions = collisions(lines, border, idea_on);
int maxsize = 0;
for (Map.Entry<Integer, Set> e : collisions.entrySet()) {
Set bucket = e.getValue();
if (bucket.size() > maxsize) {
maxsize = bucket.size();
}
}
System.out.println("Total unique lines: " + lines.size());
System.out.println("Total unique hashcodes: " + collisions.size());
System.out.println("Total collisions: " + (lines.size() - collisions.size()));
System.out.println("Collision rate: " + String.format("%.8f", 1.0 * (lines.size() - collisions.size()) / lines.size()));
if (maxsize != 0)
System.out.println("Max collisions: " + maxsize);
}
public static void main(String[] args) throws IOException {
test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\words.txt", false); //乘数31
test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\words.txt", true); //理想状态下的乘数
test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\shakespeare.txt", false); //乘数31
test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\shakespeare.txt", true); //理想状态下的乘数
}
}
数据集:
https://sigpwned.com/wp-content/uploads/2018/08/words.txt
https://sigpwned.com/wp-content/uploads/2018/08/shakespeare.txt
结果:
String.hashCode()的目的
https://www.reddit.com/r/programming/comments/967h8m/stringhashcode_is_not_even_a_little_unique/
哈希表/散列表一般有两种用途:加密 or 索引,这里将hashCode用作索引方便查找,没有必要花费额外的性能成本(比如调用安全散列函数)。
总结
- Java是一门跨平台的语言,
31
能提高小系统的运算效率(乘法转换为:移位+加法) - 与合数相比,选择素数普适性更好
31
不一定是最好的,但至少不差(与理想乘数相比)- Java中String类型的对象是常量,它的hashCode()只用作索引,没有必要花费更多计算成本提高安全性或保证无冲突,它是速度、碰撞次数、平台兼容性等多方面综合考虑的结果。