- JDK 1.8开始,HashMap中冲突的entry数大于8,会将链表转为红黑树,以减少查询耗时
- 在学习红黑树的过程中,了解到TReeMap使用红黑树存储entry
- 为了加深对红黑树的理解,基于JDK 1.8源码,学习TreeMap
- 说实话,本人好像从未使用过TreeMap 😂
1. TreeMap与HashMap的异同
1.1 相同点
- key不能重复
(1)作为map,要求不能包含重复的key,每个key至多映射到一个value;
(2)写入相同的key,旧的value将被覆盖 - 非线程安全:可以通过工具方法
Collections.synchronizedMap(Map<K,V> m)
转化为线程安全的map - 均使用
fail-fast
迭代器:
(1)一旦创建好迭代器,除非使用迭代器的remove()
方法改变map结构,都将使迭代器抛出ConcurrentModificationException
异常
(2)在检测到map结构发生改变后,立即抛出异常,遍历失败并停止。这就是所谓的fail-fast
机制
(3)多线程修改map结构时,很容易触发fail-fast
机制(单线程也可以触发)
fail-fast
机制的验证(机制讲解)
- 下面是fail-fast机制的验证代码:遍历时,通过put操作修改map,会触发
ConcurrentModificationException
异常HashMap<Integer, String> map = new HashMap<>(); map.put(1, "lucy"); map.put(2, "jack"); map.put(3, "grace"); for (Integer key : map.keySet()) { // 遍历时,可以执行remove操作 System.out.println(key + ", " + map.get(key)); if (key == 3) { map.remove(key); } } for (Integer key : map.keySet()) { // 遍历时,执行remove之外的操作,抛出ConcurrentModificationException if (key == 1) { map.put(4, "200"); System.out.println("使用put方法添加entry"); } }
- 执行结果如下
1.2 不同点
-
底层实现不同:
(1)HashMap的实现基于哈希表,使用桶(bucket) + 链表 + 红黑树(JDK 1.8之后);
(2)TreeMap的实现基于红黑树,其entry就是红黑树的节点 -
有序性:
(1)HashMap基于哈希表实现,可以快速查找元素,但失去了元素的插入顺序;
(2)TreeMap基于红黑树实现,根据key的自然顺序或自定义的Comparator进行排序,元素遍历结果是有序的 -
null值
(1)HashMap允许最多一个key为null,允许多个value为null;
(2)TreeMap不允许key为null,允许多个value为null;自己的猜想:
- HashMap中,多个key为null,则哈希冲突后,无法通过比较key的值查找到对应的entry;因此,只允许最多一个key为null
- TreeMap中,entry的插入位置取决于key的比较,key为null无法进行比较
- HashMap和TreeMap都是通过key决定插入位置,value是否为null不影响构建
-
性能
(1)HashMap基于哈希表实现,一般情况下,插入、删除、查找都是 O ( 1 ) O(1) O(1)的时间复杂度;遇到冲突严重时,基于链表的实现将会退化成 O ( n ) O(n) O(n)的时间复杂度;因此JDK 1.8以后,采用链表 + 红黑树
(2)TreeMap基于红黑树实现,无论最好还是最坏的情况,插入、删除、查找都是 O ( l o g 2 N ) O(log_2N) O(log2N)的时间复杂度
1.3 使用选择
- 想要遍历的结果有序,使用TreeMap;否则,使用性能更好的HashMap
- PS:到目前为止,自己无论leetcode刷题,还是实际编程,几乎都使用HashMap
参考文档:
- Java TreeMap vs HashMap
- HashMap和TreeMap区别详解以及底层实现 (底层实现没怎么讲,区别说的也一般)
- HashMap与TreeMap的应用与区别
2. TreeMap的定义
2.1 TreeMap的类图
- 如果使用IntelliJ IDEA(貌似要求旗舰版),可以查看一个类的UML类图
- TreeMap的类图如下:
从类图解读TreeMap
-
直观地看:
(1)TreeMap继承了抽象类
AbstractMap
,实现了NavigableMap
、Cloneable
、Serializable
三大接口
(2)由此可见,TreeMap是一个key-value结合,并且支持clone、序列化、元素查找时的导航 -
类图的顶层是
Map
接口:(1)用于将key映射到值的对象,key不允许重复,且每个key至多能映射一个值
(2)Map以接口的形式替代Dictionary
这个完全抽象的类
(3)接口替代抽象类的原因猜测:Java是单继承,将map定义为抽象类,极大地限制了子类的拓展
(4)总结: Map接口是map体系的基础接口,定义了各种与map有关的操作(方法) -
AbstractMap
抽象类:(1)实现Map接口,提供了对Map接口的骨架实现(最简单的实现),以减少实现Map接口所需的工作量
(2)说是骨架实现,一点都不过分,put()
方法直接抛出UnsupportedOperationException
异常 -
SortedMap
接口(1)继承Map接口,在Map接口的基础上,提供一个有序的map接口
(2)按照key的自然顺序或创建时指定的比较器Comparator,对所有的key进行排序(提供key的全序)
(3)SortedMap提供了一些可以充分利用排序的方法,如firstKey()
、subMap()
等 -
NavigableMap
接口:
(1)继承SortedMap接口,增加了返回给定搜索目标最近匹配项的导航方法
(2)例如,higherEntry(K key)
方法,返回比给定key更大的、最小的键对应的键值对;没有匹配的键,则返回null
-
Cloneable
接口(1)在学习Java的浅拷贝与深拷贝时,我们提到过:要想能使用从Object类继承来的 clone()方法,必须要重写 clone() 方法;而重写 clone() 方法,要求实现Cloneable接口
(2)TreeMap实现了Cloneable接口,说明TreeMap支持clone操作 -
Serializable
接口:意味着TreeMap支持序列与反序列化
总结:
- TreeMap继承一个抽象类,实现了三个接口
AbstractMap
抽象类,使其具有普通的map类所具有的的特性NavigableMap
接口,不仅使得TeeMap中的元素有序,还支持返回给定目标最近匹配项的导航方法Cloneable
接口,使得TreeMap支持clone操作Serializable
接口,意味着TreeMap支持序列化和反序列化
参考文档: JAVA学习-TreeMap详解
2.2 成员属性
-
TreeMap的成员属性定义如下
(1)transient关键字的简单介绍:JAVA中transient关键字的使用// 维护TreeMap中entry的顺序,比较器为null,表示使用key的自然顺序进行排序 private final Comparator<? super K> comparator; // 红黑树的根节点,使用transient修饰,表示该字段不参与序列化和反序列化 private transient Entry<K,V> root; // 红黑树中的节点数 private transient int size = 0; // 红黑树结构被修改的次数(与迭代器的fail-fast机制有关) private transient int modCount = 0;
-
其中,Entry的定义如下
// 颜色变量的定义 private static final boolean RED = false; private static final boolean BLACK = true; static final class Entry<K,V> implements Map.Entry<K,V> { K key; // key V value; // value Entry<K,V> left; // 左子节点 Entry<K,V> right; // 右子节点 Entry<K,V> parent; // 父节点 boolean color = BLACK; // 初始时,节点颜色为黑色 }
-
Entry的构造函数如下:
(1)只有一个构造函数,入参为key、value和parent;left、right、color都将使用默认值或定义时的初始值
(2)因此,新的红黑树节点,其左右子节点将为null,颜色为黑色Entry(K key, V value, Entry<K,V> parent) { this.key = key; this.value = value; this.parent = parent; }
红黑树节点的默认颜色为红色?
- 至此,可能会出现这样的疑问:Entry中color字段初始值为黑色,那就是说红黑树节点的默认颜色应该为黑色?
- 为什么还有博客说红黑树节点颜色默认为红色?
- put()方法,新增红黑树节点,初始时确实是黑色;若该节点不是根节点,则会调用
fixAfterInsertion()
方法进行树的结构调整 fixAfterInsertion()
方法,首先将color改成了红色。因为,若新增节点为黑色,很容易导致黑色高度不一致- 这也是有的博客会说:红黑树节点的默认颜色为红色
- 其实,这仅限于插入操作,新增节点不是根节点且需要做树的结构调整时的情况
2.3 构造函数
- TreeMap的构造函数如下:
(1)一般,前两种构造函数更为常用:创建一个空的、支持自然顺序或指定比较器排序的TreeMap
(2)创建空的TreeMap,红黑树的也为空;即root为null、size为0、modCount为0(尚未发生过结构修改)/** * 构建一个空的、使用key的自然顺序排序的TreeMap; * 要求:key必须实现了Comparable接口,即支持比较操作 **/ public TreeMap() /** * 构建一个空的、使用自定比较器进行排序的TreeMap; * 要求:key必须实现了Comparable接口,即支持比较操作 **/ public TreeMap(Comparator<? super K> comparator) /** * 基于已有的map,构建一个使用key的自然顺序排序的TreeMap; * 要求:key必须实现了Comparable接口,即支持比较操作 **/ public TreeMap(Map<? extends K, ? extends V> m) /** * 基于已有的sortedMap,构建一个TreeMap; * 该TreeMap使用sortedMap中的比较器 **/ public TreeMap(SortedMap<K, ? extends V> m)
3. 重要方法解读
3.1 get方法
-
回想之前会红黑树的介绍:它是一棵近似平衡的二叉搜索树
-
因此,通过key查找对应的value时,可以通过比较key的大小缩小查找范围(左子树或右子树)
-
TreeMap的
get()
方法代码如下
(1)存在key的映射时,返回key对应的value(可能为null
);不存在key的映射,返回null
(2)抛出NullPointerException
异常的情况:- key为
null
且使用key的自然顺序,即未指定比较器,由getEntry()
方法主动抛出- 因为,查找entry依赖给定key与其他key的比较操作
- 自定义的比较器不支持key为
null
,由比较器的compare()
方法抛出
(3)抛出
ClassCastException
异常的情况:给定的key不能与map中的key相比较,即二者类型不兼容的情况public V get(Object key) { // 调用getEntry(),根据key查找对应的entry Entry<K,V> p = getEntry(key); // 如果返回的entry为null,说明没有key对应的entry return (p==null ? null : p.value); }
- key为
-
getEntry()
方法
(1)自定义比较器时,将调用getEntryUsingComparator()
查找key对应的Entry
(2)使用key的自然顺序,要求key不能为null
且实现了Comparable
接口
(3)Comparable
接口只有一个compareTo()
方法,属于函数式接口
(4)只有key实现了Comparable
接口,才能实现与其他key的比较final Entry<K,V> getEntry(Object key) { // 给定了比较器,则基于比较器定义的规则进行比较(使用比较器的compare()方法做比较) if (comparator != null) return getEntryUsingComparator(key); // 为指定比较器,要求key不能为null if (key == null) throw new NullPointerException(); @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) key; // 利用二叉搜索树的特性,不断缩小范围、迭代搜索红黑树 Entry<K,V> p = root; while (p != null) { int cmp = k.compareTo(p.key); if (cmp < 0) p = p.left; else if (cmp > 0) p = p.right; else return p; } // 使用key的自然顺序,未找到对应的entry return null; }
-
基于自定义比较器,查找key对应的Entry:
(1)调用比较器的compare()
方法实现比较final Entry<K,V> getEntryUsingComparator(Object key) { @SuppressWarnings("unchecked") K k = (K) key; Comparator<? super K> cpr = comparator; if (cpr != null) { Entry<K,V> p = root; while (p != null) { // 使用compare,比较给定key和当前节点的key int cmp = cpr.compare(k, p.key); if (cmp < 0) p = p.left; else if (cmp > 0) p = p.right; else return p; } } return null; }
get()
方法总结
- get是获取映射到指定key的value,并非get整个Entry
get()
方法实质:查找指定key对应的Entry,返回Entry中的value或null
getEntry()
时,分为两种情况:TreeMap指定了比较器、未指定比较器;未指定比较器时,使用key自带的排序能力(要求实现 Comparable 接口)getEntry()
在执行时,会抛出NullPointerException
异常或ClassCastException
异常
3.1.1 Comparable和Comparator的区别
-
Comparable和Comparator都是接口(在这之前,自己一直将 Comparator 理解成了泛型类)
-
实现了 Comparable 接口的类,将拥有排序能力,这种排序能力叫做自然排序能力。即天生就具备的排序能力,与后期添加的排序能力相区别
-
实现了 Comparable 接口的类,可以直接通过
Collections.sort()
或Arrays.sort()
实现排序 -
以下情况,可以考虑借助Comparator 接口实现类的自定义排序
(1)一个类天生不具备自然排序能力,又需要实现对象之间的比较或排序操作;
(2)一个类的自然排序方式不满足要求(默认升序,现需要降序),需要自定义排序方式 -
Comparator接口中有很多方法,但大都都是静态方法或
default
方法,只需要实现compare()
方法就可以让类拥有定制化的排序能力 -
注意: 并非是类去实现Comparator接口,而是将类作为泛型参数,为该类创建一个实现了Comparator接口的比较器类
// 一般使用匿名类方式实现Comparator接口 Comparator<Integer> descComparator = new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return o2 - o1; } }; // 甚至使用lambda表达式,可以简写如下 Comparator<Integer> descComparator = (o1, o2) -> o2 - o1;
总结
- 实现
Comparable
接口的类,可以实现自然排序;在创建类时,就需要做好规划,否则需要对类进行二次改动 - 实现
Comparator
接口、创建基于现有类的比较器类,可以让现有类拥有自定义的排序能力,却又无需修改现有类
参考文档:
3.2 containsKey方法
- 在
get()
方法的方法注释中,有这样几句话:- 如果返回的值为null,可能是map中不存在给定key的映射,也可能是value本身为null。
- 判断map是否存在给定key的映射,应该使用
containsKey()
,而非get()
方法
- 综上,
containsKey()
方法用于判断map中是否存在给定key的映射 - 假设让我们借鉴
get()
实现containsKey()
方法,最简单的方法:判断getEntry()
的结果是否为null
- 因为,Entry不为
null
就表明存在key的映射 - 编写JDK源码的大佬,也是这样想的:
public boolean containsKey(Object key) {
return getEntry(key) != null;
}
3.3 put方法
- 上述两个方法都是常用的查找方法,现在来看看新增节点的
put()
方法
向基于红黑树的TreeMap中新增节点,可能存在以下情况:
-
红黑树为空:
- 新增节点将成为根节点,红黑树节点数、结构发生变化
- 返回的oldValue应该为
null
,因为之前不存在映射的value)
-
红黑树中存在键为key的Entry:
- 根据key的自然顺序或自定义的比较器搜索红黑树,发现存在键为key的Entry
- 更新Entry的value并返回oldValue
-
红黑树中不存在键为key的Entry:
- 搜索红黑树时,一直搜索到
null
节点,说明不存在键为key的Entry - 在
null
节点的位置插入新增节点,调整红黑树,红黑树的节点数、结构均发生变化 - 返回的oldValue为
null
,因为之前不存在映射的value
- 搜索红黑树时,一直搜索到
-
源代码如下:
public V put(K key, V value) { Entry<K,V> t = root; // 红黑树为空,新增节点作根节点 if (t == null) { // 巧妙借助compare方法,检查key是否为null、类型是否正确(兼容) compare(key, key); // type (and possibly null) check // 根节点的父节点为null root = new Entry<>(key, value, null); // 红黑树的结构有所变化 size = 1; modCount++; return null; } int cmp; Entry<K,V> parent; // split comparator and comparable paths Comparator<? super K> cpr = comparator; // 使用自定义比较器搜索红黑树 if (cpr != null) { do { parent = t; // 先记录父节点 cmp = cpr.compare(key, t.key); if (cmp < 0) t = t.left; else if (cmp > 0) t = t.right; else return t.setValue(value); // 更新value并返回oldValue } while (t != null); } else { // 使用key的自然顺序搜索红黑树 // 先检查key是否为null,若为null,无法进行后续比较 if (key == null) throw new NullPointerException(); @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) key; do { parent = t; cmp = k.compareTo(t.key); if (cmp < 0) t = t.left; else if (cmp > 0) t = t.right; else return t.setValue(value); } while (t != null); } // 未找到已存在的映射,需要在null节点处插入新增节点 Entry<K,V> e = new Entry<>(key, value, parent); if (cmp < 0) parent.left = e; else parent.right = e; // 新增节点为红色,需要调整红黑树的结构 fixAfterInsertion(e); // 新增节点,红黑树size和结构均发生变化 size++; modCount++; return null; }
-
其中
compare()
方法,自认为叫做check方法更为恰当。它负责检查key的类型是否正确,同时顺带检查key是否为nullfinal int compare(Object k1, Object k2) { return comparator==null ? ((Comparable<? super K>)k1).compareTo((K)k2) : comparator.compare((K)k1, (K)k2); }
-
Entry的
setValue()
方法:更新Entry的value、返回oldValuepublic V setValue(V value) { V oldValue = this.value; this.value = value; return oldValue; }
-
插入新节点后的,调用
fixAfterInsertion()
方法调整红黑树。该方法是对新增节点,红黑树调整的代码实现。
3.4 remove方法
3.4.1 remove方法分析
-
remove()
方法:
(1)若给定的key在map中存在映射,则删除对应的Entry并返回oldValue(可能为nulll
)
(2)若给定的key在map中不存在映射,则不存在oldValue,直接返回null
。 -
因此,
remove()
方法的逻辑也非常简单
(1)根据key查找对应的Entry
(2)Entry为null
,直接返回null
,结束操作
(3)Entry不为null
,记录oldValue,从红黑树中删除该Entry,再返回oldValue -
remove()
方法的代码如下:public V remove(Object key) { Entry<K,V> p = getEntry(key); // 查找key对应的entry if (p == null) return null; V oldValue = p.value; deleteEntry(p); // 删除key对应的entry return oldValue; }
-
deleteEntry()
是remove()
方法的核心:
(1)被删除节点存在左右子节点,后继节点替代被删除节点:被删除节点的key、value更新为后继节点的key、value,后继节点成为新的被删除节点
(2)被删除节点存在左子节点,则左子节点作为替换节点;否则,右子节点作为替换节点(后继节点的选择,使得被删除节点不可能同时存在左右子节点)
(3)替换节点不为null
:将被删除节点的父节点与替换节点做关联;将被删除节点变成孤立的节点;被删除节点颜色为黑色,则会影响红黑树的黑色高度,需要进行删除调整
(4)父节点为null
,说明被删除节点是根节点,直接将根节点置为null
(5)不存在替换节点、也不是根节点,说明被删除节点是叶子节点;被删除节点为黑色,从被删除节点开始进行删除调整;断开被删除节点与其父节点的关联private void deleteEntry(Entry<K,V> p) { modCount++; size--; // 存在左右子节点,查找后继节点更新被删除节点(更新key、value和指向) if (p.left != null && p.right != null) { Entry<K,V> s = successor(p); p.key = s.key; p.value = s.value; p = s; } // p has 2 children // 从替换节点开始,做删除调整 Entry<K,V> replacement = (p.left != null ? p.left : p.right); // 替换节点不为null,替换节点代替被删除节点;被删除节点为黑色,需要从替换节点开始做删除调整 if (replacement != null) { // Link replacement to parent replacement.parent = p.parent; if (p.parent == null) root = replacement; else if (p == p.parent.left) p.parent.left = replacement; else p.parent.right = replacement; // Null out links so they are OK to use by fixAfterDeletion. p.left = p.right = p.parent = null; // Fix replacement if (p.color == BLACK) fixAfterDeletion(replacement); } else if (p.parent == null) { // 被删除节点是根节点,直接返回null(整棵树被删除) root = null; } else { // 被删除节点是叶子节点,从自身开始删除调整并断开与父节点的关联 if (p.color == BLACK) fixAfterDeletion(p); if (p.parent != null) { if (p == p.parent.left) p.parent.left = null; else if (p == p.parent.right) p.parent.right = null; p.parent = null; } } }
3.4.2 后继节点
- 之前,学习二叉搜索树时,删除节点采用的是leetcode上的方法:
- 被删除节点是叶子节点,直接删除
- 被删除节点存在右子树,后继节点上提,递归删除右子树中的后继节点
- 本删除节点只存在左子树,前驱节点上提,递归删除左子树中的前驱节点
- 后继节点:右子树中的最左节点,前驱节点:左子树中的最右节点
- 在JDK源码中,前驱节点与后继节点是严格定义的:按某种次序遍历时,该节点的前一个或后一个节点
- 红黑树是一个近似平衡的二叉搜索树,具备中序遍历为升序的特性,即 v a l ( 前 驱 节 点 ) < v a l ( 当 前 节 点 ) < v a l ( 后 继 节 点 ) val (前驱节点) < val(当前节点) < val(后继节点) val(前驱节点)<val(当前节点)<val(后继节点)
后继节点的定义:
- 右子树不为空,则后继节点为右子树中的最左节点
- 右子树为空,则沿父节点向左上查找,第一个向右拐的祖先节点
- 注意: 沿父节点向左上查找,即遍历父节点的右子节点
-
示意图如下,该树中序遍历的结果为:9, 18, 21, 30, 35, 45, 50, 60, 64, 69, 90
-
节点35的后继节点为45,与按照上述定义查找的后继节点一致
-
节点30的后继节点为35,与按照上述定义查找的后继节点一致
-
successor()
方法,查找后继节点static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) { if (t == null) // 当前节点为null,无后继节点 return null; else if (t.right != null) { // 存在右子树,后继节点为右子树的最左节点 Entry<K,V> p = t.right; while (p.left != null) p = p.left; return p; } else { // 不存在右子树,后继节为点左上第一个向右拐的祖先节点 Entry<K,V> p = t.parent; Entry<K,V> ch = t; while (p != null && ch == p.right) { ch = p; p = p.parent; } return p; } }
3.4.3 前驱节点
- 知道了后继节点的定义,则前驱节点的也可以照葫芦画瓢
前驱节点的定义
- 左子树不为空,则前驱节点为左子树的最右节点
- 左子树为空,沿父节点向右上查找,第一个向左拐的节点为前驱节点
- 注意: 沿父节点向右上查找,即遍历父节点的左子节点
-
寻找及节点35、45的前驱节点,示意图如下,具体分析过程不再赘述
-
predecessor()
方法的代码如下static <K,V> Entry<K,V> predecessor(Entry<K,V> t) { if (t == null) // 当前节点为null,无前驱节点 return null; else if (t.left != null) { // 存在左子树,前驱节点为左子树的最右节点 Entry<K,V> p = t.left; while (p.right != null) p = p.right; return p; } else { // 不存在左子树,沿父节点向右上查找,第一个向左拐的节点 Entry<K,V> p = t.parent; Entry<K,V> ch = t; while (p != null && ch == p.left) { ch = p; p = p.parent; } return p; } }
3.5 重要方法的总结
- TreeMap底层使用红黑树,其查找、删除、新增节点的操作,时间复杂度都是 O ( l o g 2 N ) O(log_2N) O(log2N)
- get()方法:获取映射到key的value;内部核心方法:
getEntry()
获取key的Entry,分为按照给定的比较器或key的自然顺序进行查找 - containsKey()方法:判断map中是否存在key的映射;内部核心方法:
getEntry()
获取key的Entry - put()方法:向map中添加key-value,存在三种情况:(1)红黑树为空,新增Entry将作为根节点,直接返回null;(2)存在key的映射:查找到对应的Entry后,更新value并返回oldValue;(3)不存在key的映射:插入节点,调用
fixAfterInsertion()
方法对其进行插入调整 - remove()方法:通过
getEntry()
获取key的Entry,然后调用deleteEntry()
删除该Entry; - deleteEntry()的核心思想:
(1)存在右子子树,将后继节点上提,从后继节点开始进行删除操作
(2)获取替换节点:左子节点不为空,选择左子节点作替换节点;否则,选择右子节点作替换节点
(3)替换节点不为空:替换节点代替被删除节点,孤立被删除节点,根据颜色决定是否进行删除调整(fixAfterDeletion()
方法)
(4)被删除节点为根节点,直接将根节点置为null
(5)被删除节点为叶子节点:根据颜色决定是否进行删除调整,然后孤立被删除节点 - TreeMap中的前驱节点与后继节点都是严格定义的
- 后继节点:右子树的最左节点,或者左上第一个右拐的祖先节点
- 前驱节点:左子树的最右节点,或右上第一个左拐的祖先节点
参考文档
4. TreeMap的实际应用
- TreeSet是基于TreeMap实现的
- 想要map遍历的结果是有序的,可以使用TreeMap(说实话,自己好像真的没有使用过的TreeMap 😂)