哈希算法,是一类「算法」。
哈希表(Hash Table),是一种「数据结构」。
哈希函数,是支撑哈希表的一类「函数」。
Map
是映射/地图的意思,在Java中Map
表示一种把K
映射到V
的「数据类型」。
HashMap
,是Java中用哈希表实现的一种「Map
」。
查下词典:
hash 英 [hæʃ] 美 [hæʃ]
n. 剁碎的食物;混杂,拼凑;重新表述
vt. 搞糟,把…弄乱;切碎;推敲
n. (Hash)人名;(阿拉伯、保、英)哈什;(西)阿什
「hash」一词我觉得叫「切碎」比较合适,但正式上会被称为「散列」,大部分时候也叫「哈希」,据说是因为最早翻译的人以为这是某个叫Hash 的人发明的算法,所以音译了其名字。
接下来我们先给出定义,Hash算法 是这样一类算法:
这类算法接受「任意长度的二进制输入值」,对输入值做换算(切碎),最终给出「固定长度的二进制输出值」。
以更好理解的方式来说,Hash算法 是摘要算法 :它从不同的输入中,通过一些计算摘取 出来一段输出数据,且这个值可以用以区分输入数据。
所以,MD5 可能是最著名的一种Hash算法 了。
回顾一下:Hash算法 不是某个固定的算法,它代表的是一类算法。
那么,具体来说Hash/摘要/散列/切碎算法 有哪些用处呢?
「信息安全」领域
Hash算法 可用作加密算法。
如文件校验:通过对文件摘要,可以得到文件的「数字指纹」。你从网络上下载的任何副本的「数字指纹」只要和官方给出的「数字指纹」一致,那么就可以知道这是未经篡改的文件。例如著名的MD5 。
「数据结构」领域
Hash算法 通常还可用作快速查找。
这是今天我想说的部分,根据Hash函数 我们可以实现一种叫做哈希表(Hash Table) 的数据结构。这种结构可以使得我们可以实现对数据进行快速的「存」和「取」。
以上我们了解了Hash算法 有什么用,接下来我们就来具体看看Hash算法 的重要的应用场景 —「数据结构-哈希表」。
首先想一个问题:我们之前是如何在「数据结构」中做「查找」的呢?
「线性表、树」: 在线性表、树 这些结构中,记录 在结构 中的相对位置是随机的,和记录的关键字之间不存在确定关系。因此,在结构中查找时需要进行一系列和关键字的「比较」,即这一类查找方法建立在「比较」的基础上。在顺序查找时,比较的结果为"="
与"≠"
2种可能;在折半查找、二叉排序树查找和B-树查找时,比较的结果为"<", "=", ">"
3种可能。此时,查找的效率依赖于查找过程中所进行的「比较次数」。
「引出Hash表」:理想的情况是希望不经过任何比较,一次存取便能得到所查记录,那就必须在记录的存储位置和它的关键字之间建立一个确定的关系 f f ,使每个关键字和结构中一个唯一的存储位置相对应。因而在查找时,只要根据这个对应关系 f f 找到给定值 K K 的像 f(K) f ( K ) 。若结构中存在关键字和 K K 相等的记录,则必定在 f(K) f ( K ) 的存储位置上,反之在这个位置上没有记录。由此,「不需要比较」便可直接取得所查记录。在此,我们称这个对应关系 f f 为:哈希(Hash)函数,按这个思想建立的映射关系表为:哈希表。
(插播:记得「理想情况」这几个字~~ 这会在后文给出解释)
这是《数据结构(C语言版)》[1]中引出哈希表的一段描述,通俗易懂。至此,我们知道了什么是哈希函数和哈希表,下面再继续扩充描述如下:
「哈希函数」的特点:
灵活
哈希函数是一个映像,因此哈希函数的设定很灵活,只要使得任何关键字由此所得的哈希函数值都落在表长允许的范围之内即可。
冲突
对不同的关键字可能得到同一哈希地址,即 key1≠key2 k e y 1 ≠ k e y 2 ,而 f(key1)=f(key2) f ( k e y 1 ) = f ( k e y 2 ) ,这种现象称为「冲突(collision)」。
冲突只能尽量地少,而不能完全避免。因为,哈希函数是从关键字集合到地址集合的映像。而通常关键字集合比较大,它的元素包括所有可能的关键字,而地址集合的元素仅为哈希表中的地址值。因此,在实现哈希表这种数据结构的时候不仅要设定一个“好”的哈希函数,而且要设定一种“处理冲突的方法”。
「哈希表」的正式定义:
根据设定的Hash函数 - H(key) H ( k e y ) 和处理冲突的方法,将一组关键字映象 到一个有限的连续的地址集(区间)上,并以关键字在地址集中的象 作为记录在表中的存储位置,这样的映射表便称为Hash表。
上面我们已经引出了并解释了哈希函数,即「哈希函数」是支撑「哈希表」的「一类函数」。实际工作中,需要视不同的情况采用不同的Hash函数,通常要考虑的因素有:
有如下一些常用的Hash函数 构造方法:
直接寻址法:
f(k)=k f ( k ) = k 或者 f(k)=a∗k+b f ( k ) = a ∗ k + b
取k 或k 的某个线性函数为Hash地址 。
特点:由于直接地址法相当于有多少个关键字就必须有多少个相应地址去对应,所以不会产生冲突,也正因为此,所以实际中很少使用这种构造方法。
数字分析法:
首先分析待存的一组关键字,比如是一个班级学生的出生年月日,我们发现他们的出生年 大体相同,那么我们肯定不能用他们的年 来作为存储地址 ,这样出现冲突 的几率很大;但是,我们发现月日 的具体数字差别很大,如果我们用月日 来作为Hash地址,则会明显降低冲突几率。因此,数字分析法就是找出关键字 的规律,尽可能用差异数据来构造Hash地址 ;
特点:需要提前知道所有可能的关键字,才能分析运用此种方法,所以不太常用。
平方取中法:
先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。
例:我们把英文字母在字母表中的位置序号作为该英文字母的内部编码。例如K的内部编码为11,E的内部编码为05,Y的内部编码为25,A的内部编码为01, B的内部编码为02。由此组成关键字“KEYA”的内部代码为11052501,同理我们可以得到关键字“KYAB”、“AKEY”、“BKEY”的内部编码。之后对关键字进行平方运算后,取出第7到第9位作为该关键字哈希地址,如下图所示:
关键字 | 内部编码 | 内部编码的平方值 | H(k)关键字的哈希地址 |
---|---|---|---|
KEYA | 11050201 | 122157778355001 | 778 |
KYAB | 11250102 | 126564795010404 | 795 |
AKEY | 01110525 | 001233265775625 | 265 |
BKEY | 02110525 | 004454315775625 | 315 |
[2]
特点:较常用。
折叠法:
将关键字分割成位数相同的几部分(最后一部分位数可以不同),然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。
随机数法:
选择一个随机函数,取关键字的随机函数值作为Hash地址,通常用于关键字长度不同的场合。即
f(key)=random(key) f ( k e y ) = r a n d o m ( k e y )
特点:通常,关键字长度不相等时,采用此法构建Hash函数 较为合适。
除留取余法:
f(k)=k f ( k ) = k mod m o d p p , p<=m p <= m
取关键字被某个不大于Hash表 长m 的数p 除后所得的余数为Hash地址 。
特点:这是最简单也是最常用的Hash函数构造方法。可以直接取模,也可以在平法法、折叠法之后再取模。
值得注意的是,在使用除留取余法 时,对p 的选择很重要,如果p 选的不好会容易产生同义词。由经验得知:p 最好选择不大于表长m 的一个质数、或者不包含小于20的质因数的合数。
如何处理冲突是哈希造表不可缺少的一个方面。现在描述一下处理冲突:
假设哈希表的地址集为 : 0−(n−1) 0 − ( n − 1 ) ,那么「冲突」是指 : 由关键字得到的哈希地址为 j(0≤j≤n−1) j ( 0 ≤ j ≤ n − 1 ) 的位置上已存有记录,而「处理冲突」: 就是为该关键字的记录找到另一个「空的哈希地址」。
在处理冲突的过程中可能得到一个地址序列 Hi,i=1,2,...,k(Hi∈[0,n−1]) H i , i = 1 , 2 , . . . , k ( H i ∈ [ 0 , n − 1 ] ) 。处理时,若得到的另一个哈希地址 Hi H i 仍然发生冲突,则再求下一个地址 H2 H 2 ,若 H2 H 2 仍然冲突,再求 H3 H 3 ,依次类推,直至 Hk H k 不发生冲突为止,则 Hk H k 为记录在表中的地址。(注意,此定义不适合链地址法)
冲突处理通常有以下4种方法:
开放定址法:
Hi=(H(key)+di)modm,i=1,2,...,k(k≤m−1) H i = ( H ( k e y ) + d i ) mod m , i = 1 , 2 , . . . , k ( k ≤ m − 1 )
H(key) H ( k e y ) 为哈希函数; m m 为哈希表表长;
di d i 为增量序列,有3种取法:
再哈希法:
Hi=RHi(key),i=1,2,...,k H i = R H i ( k e y ) , i = 1 , 2 , . . . , k
RHi R H i 均是不同的哈希函数,即在同义词产生地址冲突时计算另一个哈希函数地址,直到冲突不再发生,这种方法不易产生聚集 ,但增加了计算时间;
链地址法:
将所有关键字为同义词的记录存储在同一线性表中。即在Hash 出来的哈希地址中不直接存Key ,而是存储一个Key 的链表 ,当发生冲突 时,将同义的Key 加入链表。
公共溢出区:
可以建立一个公共溢出区,用来存放有冲突的Key。比如设立另一个哈希表,专门用来存放出现冲突的同义词。
在哈希表上进行查找的过程和哈希造表的过程基本是一致的,在此不再赘述。
接下来我们来分析一下哈希表的「查找长度」。
平均查找长度
虽然哈希表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突”的存在,使得哈希表的查找过程仍然是一个“给定值和关键字进行比较”的过程。因此,仍需以平均查找长度作为衡量哈希表的查找效率的量度。
(还记得上面我们说的“理想情况下”吗?~~ 现实告诉我们,一般情况下,还是不得不需要“比较”!)
查找过程中,需要“和给定值进行比较的关键字的个数”取决于下列三个因素:
装填因子
在一般情况下,我们设计的哈希函数肯定是尽量均匀的,没有提升空间了,所以可以不考虑它对平均查找长度的影响。那么,对于「处理冲突方法相同」的哈希表,其平均查找长度就依赖于哈希表的「装填因子」了。其定义如下:
α=表中填入的记录数哈希表的长度 α = 表 中 填 入 的 记 录 数 哈 希 表 的 长 度 ; 装填因子 α α 标志哈希表的装满程度
直观的看:
所以,对于「平均查找长度」我们的结论如下:
哈希表的「平均查找长度」是装填因子 α α 的函数,而不是n 的函数。因此,不管n 多大,我们总是可以选择一个合适的装填因子以便将平均查找长度限定在一个范围内。(Java 中HashMap 的默认装填因子是0.75)
在看Java
的HashMap
之前,插播一点重要的数据结构要点。
数据结构表达的是:用什么样的结构,组织一类数据。
数据结构分为「逻辑结构」和「物理结构」:
「数据类型」是和数据结构密切相关的,它是:值的集合和定义在这个值集上的一组操作的总称。
例如:C语言中的一种数据类型:整型变量,其值集为某个区间上的整数,定义在这些整数上的操作为加、减、乘、除和取模等算数运算。
高级语言中数据类型分为两类:
「原子类型」:值不可分解,是什么就是什么。如整型、字符型等;
「结构类型」:其值是由若干成分按某种结构组成的,因此可分解,并且它的成分可以是原子类型也可以是结构类型。比如数组,其值是由若干分量组成的,每个分量可以是整数,或者也可以是数组。
所以,结构类型可以看成:由一种数据结构和定义在其上的一组操作组成。
所以你看,数据结构仅仅代表着一种「结构」,而我们在编程语言中是使用数据「类型」,如果编程语言想要实现某种数据结构,那么必须将其封装为一种数据类型,更狭义的说是数据类型中的结构类型。
也许你还是感觉有些混沌,不过没关系,在哪里跌倒就在哪里睡着嘛~ 我再说点能让你深入理解的…
实际上,在计算机中,数据类型的概念并非局限于高级语言中,每个处理器[a]都提供了一组原子类型或结构类型:
例如,一个计算机硬件系统通常含有“位”、“字节”、“字”等原子类型,他们的操作通过计算机设计的一套指令系统直接由电路系统完成。
而高级程序语言提供的数据类型,其操作需要通过编译器或解释器转化为底层,即汇编语言或机器语言的数据类型来实现。
引入“数据类型”的目的:
从硬件角度看,是作为解释计算机内存中信息含义的一种手段。
而对使用数据类型的用户来说,实现了信息的隐蔽,即将一切用户不必了解的细节都封装在类型中。
([a]:处理数据的单元,不局限于CPU,包括硬件系统、操作系统、高级语言、数据库等)
所以,在编程语言中「运用数据结构」就是在:使用被一层一层封装起来的「某种数据类型」。
FAQ:
为什么要有HashMap
?
答:我非常期待能在Java 中使用Hash表 这种数据结构 ,因为它的快速存取特性。
Hash表 和HashMap
的关系?
答:Hash表 是一种逻辑数据结构,HashMap
是Java中的一种数据类型,它通过代码实现了Hash表 这种数据结构,并利用此结构实现了Map
的功能。去除value
部分只看key
部分就是一个Hash表 了。
这一章节我们要干嘛?
答:首先要明白我们是在干嘛?我们是在分析一个叫做哈希表的数据结构吗?
不是的!我们是在讨论Java 这个高级程序设计语言中一个数据类型Map
的实现HashMap
,它利用了哈希表这个数据结构但它不是哈希表本身,它就是它自己 - HashMap
类型。所以,我们再看一次HashMap
父接口Map
的JavaDoc描述: “An object that maps keys to values. ”,即“Map是一个键值对对象”。
Java中的数据类型
答:有些话不明白的说出来,其实容易让人想不明白。所以我想说:
原生类型(primitive8个)
、数组
、Object
。Object
并依赖于原有的这3类数据类型;其实有了以上内容,你应该可以轻松的看懂HashMap
的源码了,不过我们还是一起来详细解读如下↓
HashMap
HashMap
是基于数组
来实现哈希表的,数组就好比内存储空间,数组的index
就好比内存的地址。
HashMap
中的每个记录就是数组中存储的一个Entry
对象(链)。
HashMap
的哈希函数为 f(key) = key.hashCode() & (table.length - 1);
,这里简化了hashCode
的优化部分,后面会继续说。
HashMap
冲突方法是:链地址法,即每个数组位置上(称为bucket
)存放的实际上都是一个Entry
链而不是单个对象。这表现在Entry
对象都有一个属性next
来指向链表的下一个Entry
。
HashMap
的装填因子:默认为0.75。
基本上HashMap
就像下图这样:
HashMap
- 构造函数/*** 1. 构造方法:最终使用的是这个构造方法 ***/
// 初始容量initialCapacity为16,负载因子loadFactor为0.75
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity");
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor");
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();//init可以忽略,方法默认为空{},当你需要继承HashMap实现子类时可以重写此方法做一些事
}
/*** 2. (静态/实例)成员变量 ***/
/** 默认的容量,容量必须是2的幂 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** 最大容量2的30次方 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 默认装填因子0.75 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** 默认Entry数组 */
static final Entry,?>[] EMPTY_TABLE = {};
/** Entry数组:table */
transient Entry[] table = (Entry[]) EMPTY_TABLE;
/** table中实际的Entry数量 */
transient int size;
/**
* size到达此门槛后,必须扩容table;
* 值为capacity * load factor,默认为16 * 0.75 也就是12。
* 意味着默认情况构造情况下,当你存够12个时,table会第一次扩容
*/
int threshold;
/** 装填因子,值从一开构造HashMap时就被确定了,默认为0.75 */
final float loadFactor;
/**
* 哈希种子,实例化HashMap后在将要使用前设置的随机值,可以使得key的hashCode冲突更难出现
*/
transient int hashSeed = 0;
/*** 3. Map.Entry:数组table中实际存储的类型 ***/
static class Entry implements Map.Entry {
final K key; // "Key-Value对"的Key
V value; // "Key-Value对"的value
Entry next; // 链表的下一个Entry
int hash; // key经过优化后的hash值
Entry(int h, K k, V v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
}
为什么数组最大长度只能是MAXIMUM_CAPACITY
即2^30
?
int
最大是2^31 - 1
,意味着Java数组的length
最大只能为2^31 - 1
,继而数组index
最大只能为2^31 - 2
。
进一步,HashMap
的哈希函数非常想使用“位运算”而不想使用“取模”,因为位运算快。可是使用位运算时,要求用于运算的p
值必须是:二进制每位都是1,即2^N - 1
,这样所得结果才能铺满数组所有的index
。不过依照上一条,index
不可能是2^31 - 1
所以不符合要求。
所以,最终我们退一步让index
最大为2^30 - 1
,这样MAXIMUM_CAPACITY
最大也就只能是2^30
了。
为什么成员属性大多用transient
修饰?
静态属性不会被序列化以加transient
也没用。而加transient
的那些成员属性不应该被序列化,因为反序列化时它们应该被重新计算而不是使用旧值。
/** 存放 **/
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
// 数组长度被运算为是2的幂,默认初始长度是16,且hashSeed会被赋值
inflateTable(threshold);
}
if (key == null)
//HashMap允许key为null:永远放在table中第0个链表上,同时null的hash为0
return putForNullKey(value);
// 1). 计算key的hashCode,下面详细说
int hash = hash(key);
// 2). 根据hashCode计算index
int i = indexFor(hash, table.length);
// 3). 做覆盖,遍历在i位置的Entry链表
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// hashCode和equals都相等则覆盖并return旧value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 4). 添加Entry,并解决冲突
// 如果需要增加table长度(size>threshold)就乘2增加,并重新计算每个元素在新table中的位置和转移
addEntry(hash, key, value, i);
return null;//增加成功最后返回null
}
为什么要重新计算hash值?
/** 1. 为了防止低质量的hashCode(),HashMap在这里会重新计算一遍key的hashCode **/
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
//字符串会被特殊处理
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
这是为了防止低质量的hashCode():因为要与HashMap
的size()-1
按位与
,所以如果用原始hashCode
的话相当于只看hashCode
的低位,很容易出现冲突。所以这里就加了个“扰动函数”将高位和低位混合一下,以增加随机性。
哈希函数?
/**
* 2. 计算key的hashCode该被放入table的哪个index
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
与table
的length - 1
按位&
,就能保证返回结果在0~(length-1)
内。
覆盖的逻辑?
可以看到覆盖时要判断hashCode
和equals
都满足才算覆盖,整理下逻辑:
hashCode
相等,而equals
不满足:说明出现了冲突,这段代码不管冲突只关心覆盖。hashCode
不等,而equals
满足:说明出现了“相等的对象hashCode却不相等”的情况。注意!这是个错误状况❌!它不应该出现,因为这将导致“equals
的对象put
时却不能完成覆盖,而成为了一个新增操作”!
还记得吗Object.hashCode()
说到:equals的对象必须有相同的hashCode,这个规则就是为了保证这里的正确性的。
而Object.equals()
方法又说到:覆盖此方法,通常有必要重写hashCode()
方法,以维护其“常规协定”。这是因为如果你不覆盖equals()
方法那么它从Object
继承而来的就是直接==
比较,而native
的hashCode()
也保证正确性。这样“常规协定”就默认被保证了。
但是,一旦你覆盖了equals()
,大概率你不会使用==
了,那么“常规协定”就没有被保证。所以你必须同时实现hashCode()
以维护这个约定。
增加Entry
时的resize
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
resize
是*2
的,即如上所说要保证数组长度是2的幂。transfer(newTable, initHashSeedAsNeeded(newCapacity));
扩充后,还需要将老数组的值都重新计算一遍hashCode
并转移到新数组。这提醒我们,创建时最好能预估好HashMap
的size
从而避免频繁扩容迁移。解决冲突
/**
* 解决冲突:链地址法
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
// 链表头先取出来,可能是冲突key,也可能为null
Entry e = table[bucketIndex];
// 新Entry放在链头,将next设置为老的
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
为什么用单链表而不用数组?
因为数组的随机存取在这个场景下根本用不到,我们更需要的是链表插入和删除时的常数开销。
怎样减少Hash 冲突?
HashMap
有“扰动函数”,已经最大可能减少冲突了。
怎么防御Hash 碰撞攻击?
升级为JDK 1.8,默认链表超过8则使用(自建的)红黑树,使得可以保证O(logN)的时间复杂度。另外在1.8中最好让key
实现Comparable
因为红黑树使用自然顺序而不是equals()
,否则它就需要用自己的比较算法。
多线程会产生什么线程安全问题?
put()
会操作同一个数组下标导致覆盖。resize()
会出现循环链表,导致get()
时产生死循环。get
的逻辑基本上是put
的子集,就不放具体代码在这里了。
// 1. 根据k使用hash(k)重新计算出hashCode
// 2. 根据indexFor(int h, int length)计算出该k的index
// 3. 如果该index处Entry e满足 (e.hash == hashCode && e.key equals k) 就说明找到了,返回value。
// 否则继续看链表下一个Entry。
以上就把今天要说的关于“Hash”的东西讲完了,谢谢大家收听。
参考文献:
[1] 严蔚敏,吴伟民.数据结构(C语言版).北京:清华大学出版社,2007
[2] 哈希表及处理冲突的方法.新浪微博.2011-10-10