哈希表/散列表
引言
前面所见到的查找方式:
查找方式 | 时间复杂度 |
---|---|
顺序查找 | O(N) |
二分查找 | O(log2N) |
二叉搜索树 | O(N) |
他们的共同点:都要进行比较
因此哈希(大佬)就提出了一种解决方式,该方式不用进行比较就能找到元素所处的位置。具体步骤如下所述~~
哈希概念
通过某种方式(
哈希函数
),将元素与其存储位置建立一一对应的映射关系,从而提高查找的效率;
例如:
将元素{1,7,6,4,9}放入数组如下数组中:
方法:
-
计算元素
key
在数组中的位置为index
-
将
key
插入到index
位置即可index=key%array.length
查找元素时:
- 计算元素
x
可能在数组中的位置index
(有可能查找的元素不存在) - 在
index
位置取元素即可
将上面的计算公式就称为
哈希函数
,将计算出来的位置编号就称为哈希地址
,如下所示:
常见的哈希函数
- 直接定制法
取关键字的某个线性函数作为散列(
哈希
)地址,Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
- 除留余数法(常用)
Hash(key) = key% p
,p
一般选用质数;
- 平方取中法
比如关键字为
1234
,平方就是1522756
,抽取中间的3
位227
作为哈希地址;当关键字为4321
,对
它平方就是18671041
,抽取中间的3
位671(或710
)作为哈希地址;
适用场景:不清楚关键字的分布,而位数又不是很多的情况
- 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址;
适用场景:不清楚关键字的分布且位数比较多的情况
- 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,
即H(key) = random(key)
,其中random
为随机数函数。
适用场景:通常应用于关键字长度不等时
- 数学分析法
根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址;
适用场景:适合处理关键字位数比较大的情况,
但无论哈希函数设计的多精妙,也不能完全避免产生哈希冲突,只能减少发生冲突的概率
;
哈希冲突
多个不同的元素经过相同的哈希函数就会出现相同的哈希地址,将该情况就称为哈希冲突,将发生哈希冲突的元素称为同义词;
当发生哈希冲突时,首先需要检查哈希函数设计是否合理,哈希函数设计原则有
:
- 哈希函数的定义域必须包括需要存储的全部关键码;
- 哈希函数计算出来的地址能均匀分布在整个空间中;
- 哈希函数设计应该尽比较简单;
解决冲突的两种主要解决方式
方式一:闭散列
闭散列
:也叫开放定址法,是指当发生哈希冲突时,如果哈希表中元素并没有满的情况下,那么,就可以把关键字key
按照某种方式存放到发生冲突位置的“下一个” 空位置中去;
找空位置的某种方式:
- 线性探测
- 二次探测
线性探测:
从发生冲突的位置开始,依次向后探测,直到找到下一个空位置为止
;
如下,当要插入的元素为44时,通过哈希函数 **index=key%array.length
**计算出所要插入的位置为4,而该位置已经放入元素4了,因此就需要借助线性探测依次往后找空位置,将元素44插入进去。
但存在问题:我们怎么知道该位置到底有元素还是没有元素呢?
方法:因此就需要借助标志位来帮助我们检测,初始状态下,将所有位置设为空(empty
)状态,当插入元素时,就将该状态设为exist
状态,表示已经存在元素,删除时,需要注意不能直接将元素删除完就将标志位设为空,这样的话,就会导致后面存放发生冲突的元素无法找到(原因
:状态为空就不会往后差找了,因此就需要再借助一个标志位delete
来表示是删除的元素,这样当碰到这样的标志时,就继续往后查找发生冲突的元素。
线性探测的缺点:
容易出现元素的堆积
当存放的元素为{11,21,32,43,44,65}时,就会出现元素堆积到一起了
研究表明
:该方法的载荷因子一般为0.7~0.8
,在Java中,规定载荷因子为0.75
;
为了解决线性探测存在的元素堆积的问题,引出了二次探测;
通俗的说
:二次探测找空位置的方式原理与上面的线性探测是相同的,只不过就是隔着元素去找的(取决于i的平方
);
H0为通过哈希函数计算出的散列地址,m为哈希表的容量大小,i为自然数:1,2,3…
从一定程度上解决了元素出现堆积的情况,但当哈希表中所存元素不断增多时,二次探测找元素的次数也就随之增加了;
研究表明
:二次探测的载荷因子不超过0.5
;
载荷因子:
载荷因子=哈希表中所存有效元素的个数/哈希表的长度
负载因子与冲突率之间的关系图:
因此,载荷因子越小,所存的元素就越少,发生冲突的概率就越小,但空间利用率就越低
;
方式二:开散列
开散列
:又称为链地址法(开链法),首先对关键码集合用散列函数(哈希函数
)计算散列地址,具有相同地址的关键码归于同一个子集合中,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中;
本质
:哈希表(数组)中存放链表
开散列中的每个桶都用来存放产生哈希冲突的元素;这样就可以认为是把一个在大集合中的搜索问题转化为小集合中的搜索问题了。
简单实现哈希桶
package day20211128;
public class MyHashBucket {
//定义结点
public static class ListNode{
Integer key;
Integer value;
ListNode next; //下一个引用
public ListNode(Integer key,Integer value){
this.key=key;
this.value=value;
}
}
ListNode[] table; //用来存放链表的数组
int size; //哈希桶中有效元素的个数
public MyHashBucket(int initCapacity){
initCapacity= initCapacity <= 0?16:initCapacity;
table=new ListNode[initCapacity];
}
//插入---->put
public Integer put(Integer key,Integer value){
checkCapacity();
//1.根据哈希函数计算对应的桶号
int bucketNum=hashFunc(key);
//检测所插入元素的key在哈希桶中是否已经存在----key是唯一的
//相当于在单链表中查找一个元素是否存在
ListNode cur=table[bucketNum];
while(cur!=null){
if(key.equals(cur.key)){
//key存在时,将旧值进行替换
Integer oldValue=cur.value;
cur.value=value;
return oldValue;
}
cur=cur.next;
}
//key不存在时
//2.将元素插入到哈希桶(bucketNum)中
ListNode newNode=new ListNode(key, value);
newNode.next=table[bucketNum];
table[bucketNum]=newNode;
size++;
return value;
}
public Integer get (Integer key){
//1.根据哈希函数计算对应的桶号
int bucketNum=hashFunc(key);
//2.到桶中找key
ListNode cur=table[bucketNum];
while(cur!=null){
if(key.equals(cur.key)){
return cur.value;
}
}
return null;
}
public Integer getOrDefault(Integer key,Integer value){
Integer ret=get(key);
if(ret!=null){
return ret;
}
return value;
}
//删除--->remove
public Integer remove(Integer key){
//通过哈希函数计算对应的桶号
int bucketNum=hashFunc(key);
//在桶(bucketNum)中找要删除的结点
ListNode cur=table[bucketNum];
ListNode prev=null;
while(cur!=null){
if(key.equals(cur.key)){
Integer oldValue=cur.value;
//删除的结点刚好是第一个
if(table[bucketNum]==cur){
table[bucketNum]=cur.next;
//cur.next=null;
}else{
//删除其他结点
prev.next=cur.next;
//cur.next=null;
}
cur.next=null;
size--;
return oldValue;
}
cur=cur.next;
}
return null;
}
public boolean containsKey(Integer key){
//根据哈希的特性来找
return null!=get(key);
}
public boolean containsValue(Integer value){
//每个桶进行遍历找
for(int bucketNum=0;bucketNum<table.length;bucketNum++){
ListNode cur=table[bucketNum];
while (cur!=null){
if(value.equals(cur.value)){
return true;
}
cur=cur.next;
}
}
return false;
}
public int size(){
return size;
}
private void checkCapacity(){
if(size>=table.length){
int newCapacity=table.length*2;
ListNode[] newTable=new ListNode[newCapacity];
//将就数组table中的元素搬移到扩容之后的newTable中
//每个结点都需要搬移
for(int i=0;i<table.length;i++){
//逐个桶进行搬移
ListNode cur=table[i];
while (cur!=null){
//将cur先从tablet中移除
table[i]=cur.next;
//将移除的cur往newTablet中插入
int bucketNum=cur.key % newTable.length;
cur.next=newTable[bucketNum];
newTable[bucketNum]=cur;
cur=table[i];
}
}
table=newTable;
}
}
private Integer hashFunc(Integer key){
return key % table.length;
}
}
上面代码存在的问题:
- 哈希函数采用的是除留余数法,但只是针对
key
为整形的情况,当key
不是整形时,就不能用了; - 扩容时,没有考虑负载因子的调节,直接将负载因子设为
1
了; - 虽然通过扩容可以将一部分较长的链表缓解,但随着插入元素的不断增多,查找的效率就下降了;
和Java类集的关系
Java
中利用哈希表实现Map
和Set
Java
中使用的是哈希桶方式解决冲突的Java 中
,当发生冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)Java
中计算哈希地址值实际上是调用的是类的hashCode()
方法,进行key
的相等的比较调用的是equals
方法
带你看标准库中关于HashMap的设计
1.HashMap的默认初始容量是16
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
2.HashMap的最大容量为2的30次方
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
3.HashMap的默认负载因子是0.75
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
4.链表与红黑树相互转化
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/若链表中节点个数超过8时,会将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
/// 若红黑树中节点小于6时,红黑树退还为链表
static final int UNTREEIFY_THRESHOLD = 6;
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/如果哈希桶中某条链表的个数超过8,并且桶的个数超过64时才会将链表转换为红黑树,否则直接扩容
static final int MIN_TREEIFY_CAPACITY = 64;
5.哈希表的容量转换为2的幂次方
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
6.将key转换为整形数字
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
重写了hashCode方法
;
Java8
对于数组table
的初始化,并没有直接放在构造器中完成,而是将table数组的构造延迟到了(resize
)扩容中完成;
性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突概率是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是
O(1)
;因此性能还是很优的~~
HashMap常考问题
- HashMap何时开辟
bucket
数组占用内存?
第一次插入元素时
- HashMap为什么要重写HashCode与equals?
HashMap
是泛型的,里面存放的是K-V
键值对,而它的哈希函数实质采用的是除留余数法,因此,当key
不是整形时,就需要用户提供HashCode
方法,将key
转为整形;
重写equals
主要是默认情况下,当没有重写时,会调用基类object
中的equals
方法,而object
中的equals
方法是用对象的地址来比较的;当要比较对象的值时,就需要重写。
- HashMap何时扩容?
有效元素的个数大于哈希表的容量*负载因子时,就要考虑扩容;
- new HashMap(19),bucket数组多大?
在Java1.8中,
new
一个HashMap
对象时,并没有给其开辟空间,在第一次插入时,会将容量调整到比当前容量大的最接近的2的幂次方;也就是32