0%

浅谈Java中的HashMap以及红黑树

前言

本篇博客涉及以下几个内容:

  1. HashMap的部分源码讲解
  2. 红黑树的数据结构

HashMap的结构和源码

 Java中集合一共可以分为两类,一类是Collection,代表是ArrayList;一类是Map,代表是HashMap。而HashMap存储结构是哈希表

哈希表

 哈希表的本质是一个数组,但是性能比数组高很多,因为动态数组的插入和删除操作对应的时间复杂度是O(n),而哈希表的增删改查操作时间复杂度均为O(1)。
 为什么快?
 哈希表利用函数映射的方式来将数据映射到其维护的数组上,这样执行增删改查操作时只需要通过这个函数就能找到数组的下标,从而实现快速定位。相比数组的顺序存储,哈希表是无序的,因此在执行删除操作时,并不需要移动其他下标的元素。用来执行元素映射的函数叫做哈希函数:hashcode()。
 存储方式如下图:
哈希表

哈希冲突

 由上述的存储方式可知,如果两个不同元素通过哈希函数的值相同,则单纯用数组存储就会有冲突。这种方法最直接的解决办法就是设计优秀的哈希函数,把冲突的可能性降至最低,但是再优秀的哈希函数,都会有可能发生冲突。因此,需要有一种方法来解决冲突问题。
 常见的方法有:

  1. 线性探测法,发生冲突后顺序找下一块空的地址。(运气差的话增删改查都会变O(n))
  2. 对冲突的地址使用另一个Hash函数
  3. 链地址法:数组的每个位置都存一个链表。(运气差的话,查找会变O(n))

 HashMap解决冲突的办法就是链地址法,数组的每个位置我们称之为桶。如果运气不好的话,所有的数字都在一个桶里,那么查找一个链表的时间复杂度是O(n),这很低效。所以JDK8之后,当一个桶里存放的元素超过8个时,则这个桶里会自动变成红黑树,这样查找的效率就成了O(logn)。

HashMap的实现原理

HashMap主干数组

1
2
//下面是HashMap内部维护的一个数组,这个数组初始值为空,主干长度是2的次幂
transient Node<K,V>[] table;

Node<K,V>是HashMap的一个静态内部类(使用静态内部类是为了方便继承,TreeNode就继承了这个Node类),源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Map.Entry是一个接口
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //此Node的hash值
final K key; // 此Node的key
V value; // 此Node的value
Node<K,V> next; //指向下一个Node

Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}

/* ------- 以上是重点 ---------*/
// get,set,toString,hashcode,equels
}

 其存储结构如下图:
Node存储结构

 当数组中某个桶存储的元素超过一定的数量后(默认是8个),则Node会被转化为TreeNode进行存储,TreeNode继承自LinkedHashMap.Entry<K,V>,关于LinkedHashMap可以移步我的博客:浅谈Java中LinkedHashMapTreeNode的解析将在下文的红黑树种讲解。

几个重要字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//默认字段值:
//1.数组的默认初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//2.数组的最大容量为2的30次方(int最大是 2^31-1)
static final int MAXIMUM_CAPACITY = 1 << 30;
//3.当数组中存储的值超过目前容量的75%,执行数组的扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//4.当数组中一个桶存储的元素超过8个时,list将会转化为tree
static final int TREEIFY_THRESHOLD = 8;
//5.红黑树的最小容量为64
static final int MIN_TREEIFY_CAPACITY = 64;
//6.执行扩容操作时会将小于元素小于6的数还原
static final int UNTREEIFY_THRESHOLD = 6;

//重要字段:
//1.HashMap中实际存储的'key-value'数量
transient int size;
//2.数组负载因子,默认是75%
final float loadFactor;
//3.数组存储阈值,如果key-value的数量大于该阈值,则进行数组扩容
int threshold;
/** 4.被修改次数,由于HashMap线程不安全,如果对HashMap进行迭代的时候,其他线程改变了HashMap的结构(增、删、改),则就会抛出ConcurrentModificationException异常 */
transient int modCount;

HashMap构造器

 HashMap一共四种初始化的方式

  • 我们最常用的构造器是无参构造器:
1
2
3
4
//这个构造器不初始化table,为table分配空间会在内部的resize()函数中进行,下文会讲。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
  • HashMap也可以指定容量和负载因子进行初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: "+
loadFactor);
this.loadFactor = loadFactor;
//此步将initalCapacity规范成2的次幂
this.threshold = tableSizeFor(initialCapacity);
}
  • 剩余两种构造器比较容易
1
2
3
4
5
6
7
8
9
10
11
//这个不讲了
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//通过一个Map实例化HashMap
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
//相当于一个拷贝工作,这个false我目前不是很理解,如果为true的话,在内部会执行afterNodeInserction函数,但是我看这个函数是空的,所以不是很理解。
putMapEntries(m, false);
}

put函数

 上面我们看到,如果使用缺省的构造器,我们是不会初始化table的,而真正初始化table需要等到我们第一次使用put操作。这是一种懒加载的机制,用来节约内存资源。
 以下是源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
/*定义一个数组tab,一个链表p表示桶里的链表情况,n表示数组长度,i表示key在数组中的索引*/
Node<K,V>[] tab; Node<K,V> p; int n, i;
/*如果table没有初始化,则resize(),下文会讲*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*如果key所在桶里为空,则为这个key-value新建一个Node存放*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
/*如果桶里不为空,则新建一个节点e用来寻找新插入或者修改的Node的位置*/
Node<K,V> e; K k;
/*如果key相等,则不插入新节点,e指向要修改val的位置*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
/*如果桶里是红黑树,则执行红黑树的插入操作*/
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
/*若桶里只是链表,则遍历这个链表*/
for (int binCount = 0; ; ++binCount) {
/*在表尾插入新的节点*/
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
/*如果链表的长度大于阈值,将链表转换成红黑树*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
/*若在遍历链表时,发现有Node的key与插入的key相等,则退出遍历*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
/*p指向的Node沿着链表一直向后*/
p = e;
}
}
/*如果e不为null,则e指向要修改val的Node*/
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
/*新插入节点后增加HashMap的修改次数,*/
++modCount;
/*key-value的数量大于阈值后对数组进行扩容*/
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

 我看了一下网上的流程图都不是很准确,我这里用文字表示一下,最近实在是太累了,等我写LinkedHashMap的时候把图补上。
 HashMap中put的流程如下:

  1. 判断table是否为空,为空则进行resize()初始化table
  2. 计算插入的Key的在table中的索引i
  3. 若table[i]中没有值,则新建一个Node插入该位置
  4. 若有值,则判断插入的key是否与table[i]中首节点的key相等,若相等则直接替换val
  5. 若key不等,则判断table[i]存放的是否为红黑树,若为红黑树,则进行红黑树的插入操作
    1. 红黑树中也会进行key值的比较,若相等则直接修改val,若不等则插入新节点
  6. 若table[i]中存放的是链表,则遍历链表
    1. 若遍历过程中发现有key相等,则直接替换
    2. 若遍历到链表的结尾,则在结尾插入新的Node
      1. 若此时table[i]内存放的元素大于8,则将链表转化成红黑树,并对红黑树进行扩容
  7. 插入成功后,若数组中存放的key-value数值大于数组阈值后,则进行resize()扩容操作。

get函数

 有put就有get,这是HashMap最常用的两个函数,其主要的难点在于红黑树的查找,这在下文会讲。
 以下是源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
/*定义数组,链表和待查找的节点,n表示数组的长度,k表示遍历节点的key*/
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
/*若不满足查找条件比如数组不存在,桶里没值,返回null*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
/*检查第一个节点,若想等则返回*/
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
/*若不等,则看其下一个有没有值,没有则返回null,TreeNode继承的爷爷辈是Node,因此可以调用父类对象的next,即使其已经是TreeNode了*/
if ((e = first.next) != null) {
/*若有值则寻找,红黑树查找或列表遍历*/
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

remove函数

 最后一项删操作,如果增和查的操作都了解的话,删操作也就通俗易懂。其大体的思路就是:查到需要删除的节点,然后执行红黑树的删除或者链表的删除。
 源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}

/**
* Implements Map.remove and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
/*定义一个数组,一个链表,n表示数组的长度,index表示要删除的数组索引位置*/
Node<K,V>[] tab; Node<K,V> p; int n, index;
/*table为空或者桶内没有数据,无法执行删除,返回null*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
/*找到要删除的节点,用node指向它*/
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
/*删除node*/
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}

resize函数

 在我们进行HashMap操作的时候会经常会有扩容的需求,因此了解扩容的机制十分重要。
 源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/*此方法不能被重写*/
final Node<K,V>[] resize() {
/*保存旧的table,并保存旧的阈值和容量*/
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
/*若超过最大容量,则将阈值设置成2^31-1*/
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
/*若当前容量在最小值和最大值之间,则把新容量和新阈值扩大一倍*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
/*若阈值不为0,但数组容量为0,将容量扩展成阈值大小*/
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
/*第一次初始化table,设置容量为16,阈值为16*0.75*/
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
/*通过新容量设置新阈值,如果新容量比最大容量小,则为'新容量*负载因子' */
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
/*执行扩容,对里面每个元素进行重新排放*/
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

红黑树讲解

 JDK8之后,HashMap引入了红黑树,红黑树是一种自平衡二叉查找树。本节将通过数据《算法》和HashMap的部分源码来解析红黑树的结构。

2-3平衡树

 理解红黑树的平衡是一件比较抽象的事情,很多博客并不讲红黑树是如何实现平衡的。本文通过2-3平衡树来讲解一下”平衡”的概念。

完美平衡

 完美平衡是指,所有空结点到根结点的的距离相等,比如下图就是一个完美平衡的二叉树:
完美平衡树
 很明显可以看出来,如果一颗普通的二叉树要达到完美平衡,其对于结点的数量是有要求的: $ n = 2^{k} - 1 $ , 如果结点数量不满足这个等式, 那么一颗二叉树始终不可能是平衡的。
 那么如何使一棵树不管有几个结点都能保持平衡呢?
 这里设想一下, 左右子树在最理想的情况下高度差是多少? 答案很明显是1, 因为任意数都可以在$ 2^{k} $和$ 2^{k+1} $之间。因此如果我们把高的子树的高度减一, 那么这棵树就实现了完美平衡。

2-3树的基本结构

 如何实现高度减一呢? 有一个特别简单的办法, 那就是新定义一个结点:3-结点。我们将普通二叉树的结点定义为:2-结点。2-结点存储一个key,并且有左右两个子树; 3-结点存储两个key, 并且有左中右三个子树。2-3树就是由2-结点和3-结点组成的树, 二叉树也是2-3树的一种。下面是一个2-3树示例:
2-3平衡树

实现完美平衡

 2-3树可以始终保持完美平衡, 因为在2-3树中插入新值不会破坏其平衡性, 下面我们仔细讲一讲如何实现2-3树的插入操作。
 一个树在第一次生成的时候一定是平衡的, 因为只有一个2-结点, 当我们在一个平衡的树的底部插入新结点的时候会破坏其平衡性。因此, 我们在讲解2-3树的插入操作前, 必须有一个前提: 2-3树在插入前是平衡的
 当我们插入一个新结点时,会遇到以下的几种情况:

  • 在一个2-结点的底部插入新键: 2-结点插入
  • 在一个没有父节点的3-结点底部插入新键: 临时构建一个4-结点。3-结点插入
  • 在一个父节点是2-结点的3-结点底部插入新键: 3-结点插入2
  • 在一个父节点是3-结点的3-结点底部插入新键: 3-结点插入3

 可以看到, 2-3树之所以能保持平衡性, 是因为其在插入的过程中, 不会在底部增加高度, 而是自下而上去寻找一个2-结点能够吸收这个高度变化, 如果没有寻找到这个2-结点, 则会通过把遇到的3-结点创建为临时4-结点并分解的方式使左右子树的高度均增加1, 以保证树的平衡。(这里稍微再说一下, 如果这个3-结点不是根结点, 则增加的高度也不会破坏其平衡性, 因为分解4-结点的操作会在顶部生成一个2-结点, 而这个增加的2-结点相当于对3-结点的父结点进行一次插入操作)

查找和插入操作的复杂度

 要理解2-3树的操作复杂度, 需要知道树的高度。因为其平衡特性, 2-3树的高度在 $ log_{2}N $ (全是2-结点)和$ log_{3}N $ (全是3-结点)之间。因此, 其查找的复杂度在 $ O(logN) $; 其插入操作需要先查找, 然后进行结点的变换。单个结点变换的复杂度为$ O(1) $, 平均要进行$logN$次变换(最少变换一次: 父结点是2-结点; 最坏变换树的高度: 一路上去全是3-结点, 最后到根结点), 因此插入的复杂度是: $O(logN)+O(logN) = O(logN)$。

红黑树

 理解了2-3平衡树后, 红黑树就好理解了。 红黑二叉树的基本思想是用标准的二叉树(全是2-结点)以及一些额外的信息(替换3-结点)来表示2-3平衡树。这个额外的信息是: 红链接将两个2-结点组成一个3-结点, 黑链接表示2-3树的普通链接。红链接指向的结点我们会标记为红色结点。关于一个3-结点和红链接的表示如下图: 红黑树结点

等价定义

 红黑树可以说是2-3树的变种, 任意的2-3树都能转换成红黑树, 我们可以用一种等价的定义来描述红黑树(和网络上一般讲解的不太一样, 但是都是对的): 红黑树是满足下列条件的二叉查找树:

  • 红链接均为左链接(即所有的红色结点都是左结点, 根结点必然是黑色);
  • 没有任何一个结点同时与两条红链接相连;
  • 该树是完美黑色平衡的

 满足上述定义的红黑树与相应的2-3树必然是一一对应的。完美黑色平衡是非常直观的,因为红结点不能单独作为2-结点,我们把红链接放平,就是一颗完美平衡的2-3树, 下面是一个简单的示例: 红黑树结点

简单的红黑树结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static final boolean RED = true;
private static final boolean BLACK = false;

private class TreeNode{
Key key;
Value val;
TreeNode left, right; // 只有左子树和右子树, HashMap中有所不同, 下面再讲
int N; //这颗子树中的结点总数
boolean color; // 表示从父结点指向它的链接的颜色

TreeNode(Key key, Value val, int N, boolean color){
this.key = key;
this.val = val;
this.N = N;
this.color = color;
}
}
  • 下面是几个需要用到的简单函数:
1
2
3
4
5
6
7
8
9
10

private boolean isRed(TreeNode x){
if(x == null) return false;
return x.color == RED;
}

private int size(TreeNode x){
if(x == null) return null;
else return x.N;
}

旋转和颜色变换

 进行插入的过程中可能会破坏上述性质: 出现红色右链接和两条连续的红链接。这些情况可以通过简单的旋转和颜色变换进行修复。其中旋转是一次进行链接变换的简单操作。

  • 左旋转: 把一条右红链接转换为左链接, 如图: 红黑树结点
  • 右旋转: 把一条左红链接转换为右链接, 如图: 红黑树结点
  • 颜色变换: 当左右链接均为红色, 则把两条链接都变黑, 并把根结点变红, 如图: 红黑树结点

 我们可以利用旋转操作帮我们保证红黑树和2-3树的一一对应, 因此我们无需担心树的平衡性。

代码实现

  • 左旋转
1
2
3
4
5
6
7
8
9
10
TreeNode rotateLeft(TreeNode h){
TreeNode x = h.right;
h.right = x.right;
x.left = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1 + size(h.left) +size(h.right);
retrurn x;
}
  • 右旋转
1
2
3
4
5
6
7
8
9
10
TreeNode rotateRight(TreeNode h){
TreeNode x = h.left;
h.left = x.right;
x.right = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1 + size(h.left) + size(h.right);
return x;
}
  • 颜色变换
1
2
3
4
5
TreeNode filpColors(TreeNode h){
h.left.color = BLACK;
h.right.color = BLACK;
h.color = RED;
}