【参考】
【1】数据结构(Java版)吕云翔,郭颖美
【2】数据结构 杨剑
【3】数据结构教程 王少波,张志
文章目录
哈希表
1.哈希(hash)表的概念
为了一次存取便能得到所查记录,在记录的存储位置和它的关键字之间建立一个确定的对应关系H,以H(key)作为关键字为key的记录在表中的位置,称这个对应关系H为哈希(Hash)函数。按这个方式建立哈希表。
1.1 哈希冲突
如果两个hash值相同,称为hash冲突。如下: H ( k 1 ) = H ( k 2 ) H (k1) = H(k2) H(k1)=H(k2)
2.哈希函数的构建
下面介绍两种构造hash函数的常用方法。
2.1.直接地址法
直接定址法即:
H ( k e y ) = a ∗ k e y + b ( a 、 b 为 常 数 ) H (key) = a*key + b (a、b为常数) H(key)=a∗key+b(a、b为常数)
它是取关键字的某个线性函数值为hash地址。
直接地址法简单,不会产生冲突,但是关键字往往是离散的,且关键字集合比hash地址大,会造成存储空间的浪费。
2.2.除留余数法
除留余数法即:
H e y ( k e y ) = k e y m o d p ( p < = m ) Hey(key)=key ~~ mod~~ p (p <= m) Hey(key)=key mod p(p<=m)
它是以关键字除 p p p的余数作为hash地址,其中 m m m为hash表的长度。
使用除留余数法 p p p的选择很重要,否则会造成严重的冲突。例如,若取 p = 2 k p = 2^k p=2k,则 H ( k e y ) = k e y m o d p H(key)=key ~~mod~~ p H(key)=key mod p的值仅仅是用二进制表示的 k e y key key右边的 k k k个位,造成了关键字的映射并不均匀,易造成冲突。通常,为了获取比较均匀的地址分布,一般令 p p p位小于等于 m m m的某个最大元素。
3.解决冲突的方法
无论如何构造哈希函数,冲突都是不可避免的,因此如何处理冲突是建hash表的一个重要方面。
3.1.开放地址法
开放地址法的基本思想是:在发生冲突时,按照某种方法继续探测基本表中的其他存储单元,直到找到空位置为止。可用下式描述:
H i ( k e y ) = ( H ( k e y ) + d i ) m o d m ( i = 1 , 2 , . . . , k ( k < = m − 1 ) ) H_{i}(key) = (H (key) + d_{i})~~ mod~~ m (i = 1, 2, ..., k (k <= m - 1)) Hi(key)=(H(key)+di) mod m(i=1,2,...,k(k<=m−1))
其中, H ( k e y ) H(key) H(key)为关键字 k e y key key的直接hash地址, m m m为hash表长, d i d_{i} di为每次再探测地址增量。
用这种方法时,首先计算出它的直接hash地址
H
(
k
e
y
)
H(key)
H(key),若该单元已被其他记录占用,继续查找地址为
H
(
k
e
y
)
+
d
1
H(key)+d_{1}
H(key)+d1的单元,若也被占用,再继续查看地址为
H
(
k
e
y
)
+
d
2
H(key)+d_{2}
H(key)+d2的单元,如此下去,当发现某个单元为空时,将关键字为key的记录存放到该单元中。
增量
d
i
d_{i}
di可以有不同的取法:
(a)
d
i
=
1
,
2
,
.
.
.
,
m
−
1
d_{i}=1,2,...,m-1
di=1,2,...,m−1
(b)
d
i
=
1
2
,
−
1
2
,
2
2
,
−
2
2
,
.
.
.
,
k
2
,
−
k
2
(
k
<
=
m
/
2
)
d_{i}=1^2,-1^2,2^2,-2^2,...,k^2,-k^2(k<=m/2)
di=12,−12,22,−22,...,k2,−k2(k<=m/2)
(c)
d
i
=
伪
随
机
序
列
d_{i}=伪随机序列
di=伪随机序列
当 d i d_{i} di采用上述3种不同的取值方法时,分别称为线性探测再散列、二次探测再散列、和伪随机再散列。
3.2.链地址法
链地址法是把具有相同哈希地址的关键字放在同一个链表中。若选定的哈希表长度为 m m m,则可以将哈希表定义为一个由 m m m个头指针组成的指针数组T,凡是哈希地址为 i i i的节点,均插入到 T [ i ] T[i] T[i]为头节点的单链表中。 T T T中各分量的初值均应该为空。
例如,给定关键字集合{ 19,01,23,14,55,68,11,82,36 },取哈希表长为
m
=
7
m=7
m=7,散列函数为
H
(
k
e
y
)
=
k
e
y
m
o
d
7
H(key)=key~~mod~~7
H(key)=key mod 7,用链地址法解决冲突所构造出来的哈希表,如下图
3.3.公共溢出区
公共溢出区域法是另建一个溢出表,当不发生冲突时,数据元素存入基本表,当发生冲突时数据元素存入溢出表。
3.4.再哈希法
再哈希法是当发生冲突时,再使用另一个哈希函数得到一个新的哈希地址,若再发生冲突,则再使用另一个函数,直到不发生冲突为止。这种方法需要预先设计一个哈希函数序列:
H
i
=
R
H
i
(
k
e
y
)
(
i
=
1
,
2
,
3
,
.
.
.
,
k
)
H_{i}=RH_{i}(key)~~(i=1,2,3,...,k)
Hi=RHi(key) (i=1,2,3,...,k)
这种方法不易产生“聚集”现象,但会增加计算的时间。
4.哈希表查找性能分析
使用平均查找长度来衡量哈希表的查找效率,在查找过程中与关键字比较的比较次数取决于hash函数的选取和处理冲突的方法。
假设hash函数是均匀的,即对同样的一组随机关键字出现冲突的可能是相同的。因此,哈希表的查找效率主要取决于处理冲突的方法。发生冲突的次数和hash表的装填因子有关,哈希表的装填因子如下:
a = 哈 希 表 中 数 据 元 素 个 数 / 哈 希 表 的 长 度 a=哈希表中数据元素个数/哈希表的长度 a=哈希表中数据元素个数/哈希表的长度
填入表中的数据元素越多, a a a越大,产生冲突的可能性越大;填入表中的数据元素越少, a a a越小,产生冲突的可能性越小, a a a通常取1和0.5之间的较小数(一般取0.75左右)。
5.习题
例题:已知关键码序列为(Jan, Feb, Mar, Apr, May, Jun, Jul ,Aug, Sep, Oct, Nov, Dec),散列表的地址空间为
0
−
16
0-16
0−16,设散列函数为
H
(
x
)
=
i
/
2
H(x)=i/2
H(x)=i/2(向下取整数),其中
i
i
i为关键码中第一个字母在字母表中的序号,采用线性探测法和链地址法处理冲突,试分别构造散列表并等概率情况下查找成功的平均查找长度。
HashMap
HashMap是一个存储键值对的集合,也就是我们常说的key,value,其中每一个存储位置都是一个Entry对象,Entry对象里有key,value,key的hash值,下一个对象的地址next,初始化的时候,每一个数组元素都为空。
hashMap的散列函数是 index = HashCode(key)& (length-1)(length必须是2的幂次,这样散列表比较均匀);
元素插入方式
在JDK1.8中采用的是尾插法。
在JDK1.6,JDK1.7中采用的是头插法。
1.put()方法:先对要插入的元素进行hash,找到数组当中对应的索引。
插入方式有四种情况(JDK1.8):
-
slot为空,直接插入
-
slot不为空
- 如果key相同,进行替换
- 如果key不同,采用尾插法
-
有红黑树的插入。
数据结构
Entry(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
- get() 方法:可以帮助我们通过key来找到value.首先会把key做一次hash得到对应的数组位置。
- Hash算法:hashMap的散列函数是 index = HashCode(key)& (length-1)(length必须是2的幂次,这样散列表比较均匀);
- hashmap扩容:由于HashMap的容量非常有限,当多次元素插入的时候,使得HashMap达到一定的饱和程度以后,key的映射发生冲突的概率比较高,这个时候HashMap需要拓展他的数组长度,也就是HashMap扩容,影响扩容的因素有两个,第一个Capacity(初始值16),这个是HashMap当前的最大数组容量。第二个LoadFactor,这个是HashMap的负载因子(默认值为0.75)。判断HashMap是否扩容条件(HashMap.size>=Capacity*LoadFactor),满足这个条件以后,那么会创建一个新的Entry数组,长度为原来的两倍,之后会遍历原来的Entry数组,把之前的Entry数组重新Hash到新的数组中。
- 红黑树:JDK1.8之前:数组+链表,JDK1.8及以后采用的是数组+链表+红黑树。当链表长度小于8的时候依然采用的是数组加链表的实现方式,但是当节点数大于8的时候(数组长度>=64),采用的是红黑树来实现,目的是提高查找效率。
Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,为啥呢?
根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
HashMap和Hashtable的区别
- 线程安全: HashMap 是线程不安全的,HashTable 是线程安全的;HashTable 内部的方法基本都用 synchronized 修饰。
- 效率:因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰。
- 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键 所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
- 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来 的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充 为2的幂次方大小(HashMap 中的 tableSizeFor() 方法保证,下面给出了源代码)。也就是说 HashMap 总 是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
- 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
Hashtable与ConcurrentHashMap
1.对比HashMap,Hashtable本身是一个线程安全的集合类,在修改数据的时候会锁住整个哈希表,执行效率比较低,在多线程进行操作的时候,基本上就等同于串行。
ConcurrentHashMap他通过把整个Map划分为16Segment,在对一Segment进行写操作的时候,会把这个Segment加锁,其他线程就无法进入这个Segment进行操作,但是其他线程的读写操作并不受影响,相比Hashtable,他的性能总体上是提升了16倍。但是读操作并不会加锁,而是采用volatile这个关键字修饰,保证可读操作的一个可见性,可以保证我们的线程不会读到脏数据,总结起来,HashMap是线程不安全的,但是他的效率比较高。 Hashtable与ConcurrentHashMap,其中Hashtable采用的是锁住这个对象效率比较低,虽然线程安全但是效率极低,ConcurrentHashMap采用了分段锁,效率高,同时也可以保证线程的安全。并发量情况较大的时候,建议采用ConcurrentHashMap。