开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第13天,点击查看活动详情
哈希函数
写在前面
- 建议阅读时间:5 ~ 10 分钟
- 本文提到的哈希表,推荐阅读:《哈希表是什么?》,里面介绍了哈希表以及哈希冲突相关的问题
文章摘要
- 哈希函数相关问题
- 常见类型的哈希函数
关于哈希函数
-
哈希表为什么叫做哈希表呢?因为中间有一个哈希函数
hash(key)
,需要利用这个哈希函数计算key
的索引 -
如果不同的
key
利用哈希函数计算出了相同的索引,叫做哈希冲突 -
若哈希函数设计不好,会使哈希冲突的次数变多,我们来谈谈一个良好的哈希函数长什么样
- 哈希值更加均匀分布,尽量符合泊松分布
- 也就是要尽量减少哈希冲突,也就能提升哈希表的性能
-
哈希表中哈希函数的实现步骤大概如下:
- 1、先生成key的哈希值(必须是整数)
- 2、再让哈希值跟数组的大小进行相关运算,生成一个索引值
-
用代码实现就是:
public int hash(Object key) {
int hashCode = hash_code(key); // 1、计算出key的哈希值
return hashCode % table.length; // 2、哈希值与数组长度进行相关运算 (取模)
}
复制代码
- 上面的代码不难理解,就是利用
key
的哈希值取模于数组的长度,就能算出key
在数组中的索引 - 而取模运算比较慢,为了提高效率,可以使用
位运算 &
取代取模运算 %
,但是有一个前提是数组的长度必须为2的幂次方 2^n
- 用代码实现一下再来分析:
public int hash(Object key) {
int hashCode = hash_code(key); // 1、计算出key的哈希值
return hashCode & (table.length - 1); // 2、哈希值与(数组长度 - 1)进行相关运算(位运算)
}
复制代码
- 看完了上面的代码,我们可以得出的结论是:在数组长度是2的幂次方时,
index = hashCode % 数组长度 = hashCode & (数组长度 - 1)
- 而计算机处理位运算的效率肯定比取模运算快,为什么可以这样改进呢?先来复习一下位运算:
- 应该不难,两个二进制数,进行位运算,同真才为真,其余为假
- 这样优化有一个前提是:数组长度必须是
2的幂次方
,我们来观察一下2^n
的规律:
- 上图左边是
2的幂次方
以及它对应的二进制,右边是2的幂次方 - 1
以及它的二进制 - 可以发现,只要是
2的幂次方
减去1后,它对应的二进制全部是1(【2^n - 1】,它的二进制就会有n个1)
- 那将位运算与此规律结合一下:如果一个数与二进制全为1的数进行位运算,会有什么规律呢?
-
从图中可以发现,它们有这样的规律:
0 ≤ 结果 ≤ 二进制全为1的数
- 也就是
0 ≤ 结果 ≤ 2的幂次方 - 1
- 也就是
0 ≤ index ≤ 数组长度 - 1
-
最小小不过0,最大大不过那个二进制全为1的数。这让我想到看到过的一个很形象的说法:”妈妈 & 爸爸 = 儿子。儿子像妈妈,却没有爸爸高“
-
正是因为这个规律,你才能看到这样的代码:
- 了解完这个基本知识,也优化了取模运算,求出了数组索引。可是我们还不知道该如何生成
key
的哈希值勒 - 最终计算索引的方法是一样的,那哈希函数好不好,就只由
key
的哈希值来决定了
key的哈希值
- 要生成
key
的哈希值,我们得先了解到:- 什么类型都可以做
HashMap的key
,但是常见的种类有:整数、浮点数、字符串、自定义对象 - 不同种类的
key
,生成哈希值的方式不一样,但是他们的目标是一致的:减少哈希冲突次数 - 既然都希望在之后能减少哈希冲突,提升性能,那我们生成哈希值的时候,定下两个约定
- 哈希值一样,再使用一样的算法生成
index
,肯定会产生哈希冲突嘛,所以我们的约定一:要尽量让每个key
的哈希值是唯一的 - key的信息不完整,计算出相同的值的概率更高,
(比如说key1 = “yyds” key2 = "yybc",如果key1 和 key2 都只取前半部分信息来运算,那么它们都是用 “yy” 来运算,算出的结果肯定也就相同了)
,所以我们的约定二:要尽量让key的所有信息参与运算
- 哈希值一样,再使用一样的算法生成
- 什么类型都可以做
- 好了,当你了解了这些,我们分别来看看该如何生成几种常见类型
key
的哈希值
(1)整数
- 要求整数的哈希值很简单:可以直接将整数值当做哈希值即可
(如hashCode(100) = 100)
- 为什么可以呢?来看看,有没有达成我们的约定?
- 每一个整数都是唯一的
- 也将整数的所有信息参与了运算
- 好像确实有做到哎,那么用代码实现:
public static int hashCode(int value) {
return value; // 直接将整数值当做哈希值即可
}
复制代码
(2)浮点数
- 浮点数比较特殊,既然最终想要一个整数,我们想方设法让其浮点数变成整数即可
- 浮点数也是通过二进制存储在计算机中的,将其二进制转换为十进制的整数,再将其整数值当做哈希值就可以了,如:
- 每个浮点数的二进制都是唯一的,转换出来的整数也是唯一的
- 也将浮点数的所有信息都运用到了
- 用代码实现的话,
JDK
已经提供了API
可直接使用
public static int hashCode(float value) {
return Float.floatToIntBits(value);
}
复制代码
(3)Long和Double的哈希值
- 看到这个标题,你可能会有些疑惑:Long不也是整数吗?Double不也是浮点数吗?上面都说了如何计算了,还写一遍干嘛呢?
- 是的,其实思路是一样的,只不过呢?在
Java
平台哈希值的返回值类型必须是int
,也就是占4个字节,32位大小的整数(因为数组的索引是int)
- 而
Long和Double
都是占8个字节,64位的内存大小,我们如果直接按上面的操作,会不满足我们的两个约定。所以需要这样处理:
public static int hashCode(long value) {
return (int) (value ^ (value >>> 32));
}
public static int hashCode(double value) {
long l = Double.doubleToLongBits(value);
return (int) (l ^ (l >>> 32));
}
复制代码
- 为什么需要这样处理呢?我们来探讨一下
- 它们的处理方式其实和上面是一样的,只是运用一定的算法,将大整数转换为小整数,双精度转为小整数罢了
- 而这其中的计算方法,很值得来探讨一下,先用一个数字来举个例子,顺便复习下计算机的基础知识:
- 可以看到,为了达成约定,我们通过将此数字无符号右移32位后,得到了高32位,再与原先的值进行异或运算
- 如果将最后计算所得的数值略去32位,进行窄化操作,那么得到的数值就是
0101 1001 1000 0111 0010 0110 0101 1111
,将此数值转换为整数,用此作为哈希值即可 - 因为此数值是通过原来大整数的高32位异或于大整数的低32位得到的,满足了尽量使用所有信息
- 又因为进行的是异或运算,不论大整数的高低32位值特不特殊,都最大程度的保证了不唯一,也保证了不会只利用高32bit或只用低32bit
(4)字符串的哈希值
- 大家都知道,字符的本质就是一个数字,我们可不可以就用字符串中的每个字符对应的整数作为哈希值呢?
- 其实是可以的,但是不是很好,因为很容易计算出相同的数值,也就是不满足我们的约定一
- 既然要算一个整数,我们看看整数是如何构成的。如
1314
是如何构成的1314 = 1 * 10^3 + 3 * 10^2 + 1 * 10^1 + 4 * 10^0
- 如果利用同样的方式来对字符串就行运算,是不是也可以得到一个整数呢?如计算
“ciusyan”
ciusyan = c * n^6 + i * n^5 + u * n^4 + s * n^3 + y * n^2 + a * n^1 + n * n^0
- 如上所示,通过整数的构造方式,我们将字符串也构造了出来
- 当然用于取幂的数值用什么,这个可以随意定,
JDK
中是n = 31
- 因为31是一个奇素数,素数乘以其它数值,更容易产生唯一性。而且可以将
31 * i
优化成(i << 5) - 1
,而且Java的虚拟机碰到31*i
,会自行优化 - 这个关系这么神奇?怎么推出来的啊:
31 * i = (2^5 - 1) * i = i * 2^5 - i = (i << 5) - i
- 因为31是一个奇素数,素数乘以其它数值,更容易产生唯一性。而且可以将
- 那为什么字符串计算哈希值可以用此优化呢?因为上面的字符串式子可以化简成:
ciusyan = (((((c * 31 + i) * 31 + u) * 31 + s) * 31 + y) * 31 + a) * 31 + n
- 也就是一直用
31 * i
,所以可以这样转换,用代码实现一下就是:
public static int hashCode(String s) {
if (s == null) return 0;
int hash = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
hash = hash * 31 + c; // <==> hash = (hash << 5) - hash + c
}
return hash;
}
复制代码
(5)小结
- 至此,我们已经知道如何计算整数、浮点数、字符串的哈希值了
- 只不过其实不需要我们自己写代码了,
JDK
已经提供了对应包装类的hashCode()
方法,不需要我们再次实现了 - 那我们为什么还要说呢?当然是为了了解常见类型
key
的哈希值都是如何算出来的呐~
- 我们再来简单看看,如果是一个自定义对象,比较推荐的哈希值的计算方法
(6)自定义对象的哈希值
- 有如下一个自定义对象,该如何计算它的哈希值呢?
public class Person {
private String name;
private int age;
private float height;
}
复制代码
- 别管如何计算,先来思考一个问题:
下图 打印出 p1 和 p2 的哈希值相同吗?
- 哎,先不说这个问题,我也没看到你在
Person
中写了hashCode
方法啊,它从哪来的勒?- 此方法是在
Object
里的,Java的所有类最终都继承自Object,那么就算是自定义对象不实现这两个方法,也会有一个默认的实现了。而默认的实现,有好几种实现方式,也可能和内存地址有关
- 此方法是在
- 当你知道了这些,你应该可以知道上面问题的答案了:其实是不相等的,所以如果将这两个对象放入哈希表,大概率会被放入两个桶中,当然也可能放在一个桶中
(因为不同的哈希值也可能计算出相同的索引嘛)
- 可是在大多数场景中,如果想要用此作为
HashMap的key
,我们会认为这是一个对象,计算出来的哈希值应该要相同,那我们怎么来实现呢?
@Override
public int hashCode() {
int hash = Integer.hashCode(age);
hash = hash * 31 + Float.hashCode(height);
hash = hash * 31 + (name == null ? 0 : name.hashCode());
return hash;
}
复制代码
- 可以看到,我们只需要重写hashCode方法即可,而里面的实现思路,你看看和上面字符串的思路是不是有些类似
- 我们既用到了
Person
对象的所有信息,也满足了使其值尽量唯一 - 实现完成后,如果现在再来看上面的问题,答案就会是相同了,
Person
对象的姓名、年龄、身高相同,那么算出来的哈希值就相同,如果放在哈希表中,就只会放在一个桶中了