前两篇博文
数据结构之手动实现ArrayList
数据结构之手动实现LinkedList
分别介绍了List接口的两个子类的优缺点及底层的数据结构,本篇文章将介绍一下数据结构为“数组+链表”的HashMap。
一、HashMap核心结构
查看HashMap源代码可以发现两大核心:
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
上述代码中,Node<K,V>[] table 数据便是核心,即“位桶”数组,在位桶数组中存放Node<K,V>这样的节点对象,Node对象存储了
1、hash:键对象的hash值
2、key:键对象
3、value:值对象
4、next:下一个节点
显然,由一个个Node<K,V>对象组成了单向链表,就像这样
table的结构如下
存储过程如下
二、代码实现
以下只是实现了核心方法
public class Node2<K,V> {
//hash值
int hash;
K key;
V value;
//下一个节点对象,由于是单链表,只定义指向下一个对象的指针
Node2 next;
public Node2() {}
}
public class HashMapTest<K,V> {
//定义位桶数组
Node2[] table;
//存放的键值对的个数
int size;
public HashMapTest(){
table = new Node2[16];//位桶数组的长度一般定义为2的整数幂
}
/**
* 添加元素
* @param key
* @param value
*/
public void put(K key,V value){
//考虑数组的扩容更加完善,源码中有个加载因子,为0.75f,还有个临界值,临界值=table.length*加载因子,当数组长度大于临界值时,就会触发扩容操作。
//构造节点对象
Node2 node = new Node2();
node.hash = myHashValue(key.hashCode(),table.length);
node.key = key;
node.value = value;
node.next = null;
//看看它往数组的那个位置存??
Node2 temp = table[node.hash];
//正在遍历的最后一个元素
Node2 iterLast = null;
//是否重复的标志
boolean keyRepeat = false;
//数组该位置位空
if(temp==null){
//数组为空直接将节点对象放进去
table[node.hash] = node;
size++;
}else{
//数组不为空,则遍历链表
while(temp!=null){
if(key.equals(temp.key)){
//如果key重复,则覆盖
temp.value = value;
keyRepeat = true;
//停止循环,无需找到最后的节点对象
break;
}else{
//key不重复,则遍历下一个
iterLast = temp;
temp = temp.next;
}
}
//没有发生Key重复的情况,则添加到链表的最后
if(!keyRepeat){
iterLast.next = node;
size++;
}
}
}
/**
* 根据键获取值
* @param key
* @return
*/
public V get(K key){
V value = null;
int hash = myHashValue(key.hashCode(),table.length);
Node2 temp = table[hash];
if(temp!=null){
while(temp!=null){
if(key.equals(temp.key)){
value = (V)temp.value;
break;
}else{
temp = temp.next;
}
}
}
return value;
}
/**
* 根据键删除键值对
* @param key
*/
public void remove(K key){
int hash = myHashValue(key.hashCode(),table.length);
//prev是该索引位置的链表的第一个节点
Node2 prev = table[hash];
Node2 e = prev;
//开始遍历链表
while(e != null){
Node2 next = e.next;
Object k;
//找到了给定key的节点对象
if(e.hash == hash && ((k = e.key)==key || (key != null && key.equals(k)))){
if(prev == e){
table[hash] = next;
}else{
prev.next = next;
}
size--;
}
prev = e;
e = next;
}
}
private int myHashValue(int value,int length){
return value&(length-1);//位运算,效率高 value%(length-1) 取模,效率低
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("{");
//首先遍历位桶数组
for(int i = 0; i < table.length; i ++){
//其次遍历数组每个下标位置的链表
//获取头结点
Node2 temp = table[i];
while(temp!=null){
sb.append(temp.key+":"+temp.value+",");
temp = temp.next;
}
}
sb.setCharAt(sb.length()-1,'}');
return sb.toString();
}
public static void main(String[] args){
HashMapTest<Integer,String> map = new HashMapTest<>();
map.put(10, "aa");
map.put(20, "bb");
map.put(30, "cc");
map.put(20, "ssss");
map.put(53, "gg");
map.put(69, "hh");
map.put(85, "kk");
System.out.println(map);
map.remove(53);
System.out.println(map);
}
}
在此,请注意上面的一个方法
private int myHashValue(int value,int length){
return value&(length-1);//位运算,效率高 value%(length-1) 取模,效率低
}
这个方法的主要作用就是为了将key定位到“位桶”数组的某个索引位置,这是一种为了减少哈希碰撞而产生的算法,在Java中,HashMap中定位到位桶数组位置的方法就是根据key的HashCode与数组的长度取模来计算得到的。源码中表示为
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以改变为上述代码中的:value&(length-1),即key的hashCode模数组的长度减一。
总结:
当添加一个元素(key-value)时,首先计算key的hash值(注意:此处的hash值不是大家以为的调用HashCode()获取到的int类型的数值),以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,就形成了链表,同一个链表上的Hash值是相同的,所以说数组存放的是链表。 JDK8中,插入元素的方式由JDK1.7的头插发变为尾插法。当链表长度大于8时,链表就转换为红黑树,这样又大大提高了查找的效率,当红黑树的节点数量小于6时,又转换为链表。
扩容问题:
HashMap的位桶数组,初始大小为16。实际使用时,显然大小是可变的。如果位桶数组中的元素达到(0.75*数组 length), 就重新调整数组大小变为原来2倍大小。
扩容很耗时。扩容的本质是定义新的更大的数组,并将旧数组内容挨个拷贝到新数组中。
JDK8中,HashMap在存储一个元素时,当对应链表长度大于8时,链表就转换为红黑树,这样又大大提高了查找的效率。