TreeMap 实现原理
在Java的集合框架中,HashMap以其O(1)的高效查找性能占据了统治地位,但在需要“有序”数据的场景下,HashMap便显得力不从心。此时,TreeMap作为SortedMap和NavigableMap接口的核心实现,凭借其基于红黑树的底层结构,成为了处理有序键值对的首选。
一、 TreeMap的定义与核心特性
TreeMap是一个基于红黑树(Red-Black Tree)实现的NavigableMap。它的核心特性可以概括为以下几点:
- 有序性:
TreeMap会根据键(Key)的自然顺序(Natural Ordering)或者指定的Comparator(比较器)对键值对进行排序。 - 高效性:由于底层采用红黑树,
TreeMap保证了put、get、remove操作的时间复杂度均为O(log n)。 - 非线程安全:与
HashMap一样,TreeMap不是线程安全的。如果在多线程环境下使用,需要通过Collections.synchronizedSortedMap进行包装或使用ConcurrentSkipListMap。 - Null键限制:
TreeMap不允许null键(除非使用了特定的Comparator允许),因为null无法参与比较排序,会抛出NullPointerException。
二、 底层原理:红黑树(Red-Black Tree)
要理解TreeMap,必须先理解红黑树。红黑树是一种自平衡的二叉查找树(BST)。它通过在每个节点增加一个存储位来表示节点的颜色(红色或黑色),并通过一系列规则确保树在操作过程中保持“近似平衡”。
1. 红黑树的五条铁律
TreeMap的底层节点(Entry)严格遵循以下五条规则:
- 颜色规则:每个节点要么是红色,要么是黑色。
- 根节点规则:根节点永远是黑色。
- 叶子节点规则:所有叶子节点(NIL节点,即空节点)都是黑色。
- 红色节点规则:如果一个节点是红色,那么它的两个子节点都必须是黑色(即不能有两个连续的红色节点)。
- 黑色平衡规则:从任意节点到其每个叶子节点的所有路径都包含相同数量的黑色节点。
2. 为什么选择红黑树?
相比于AVL树(严格平衡二叉树),红黑树的平衡要求相对宽松。
- AVL树:追求极致的平衡,查询效率极高,但插入和删除时需要大量的旋转操作来维护平衡。
- 红黑树:牺牲了一部分查询效率(树高略高),换取了插入和删除时的更低开销(旋转次数更少)。
在TreeMap这种需要频繁插入、删除且需要保持有序的场景下,红黑树是一个性能极佳的折中方案。
三、 排序机制:自然排序与定制排序
TreeMap之所以能“有序”,是因为它在插入节点时,通过比较键的大小来确定节点在树中的位置。它支持两种排序方式:
1. 自然排序
当TreeMap的键实现了Comparable接口(如Integer、String等)时,TreeMap会默认使用键的自然顺序进行排序。
- 核心逻辑:调用
key.compareTo(otherKey)。
2. 定制排序
如果在创建TreeMap实例时传入了一个Comparator对象,TreeMap将优先使用该比较器来决定键的顺序。
- 核心逻辑:调用
comparator.compare(key, otherKey)。
注意:如果键没有实现Comparable接口,且没有提供Comparator,在插入元素时会抛出ClassCastException。
四、 源码深度剖析
让我们深入JDK源码(以JDK 8+为例),看看TreeMap是如何运作的。
1. 核心内部类:Entry
TreeMap的节点定义如下,它比HashMap的节点多了一个parent引用和color属性,这是为了维护树的结构。
static final class Entry<K,V> implements Map.Entry<K,V> { K key; V value; Entry<K,V> left; // 左孩子 Entry<K,V> right; // 右孩子 Entry<K,V> parent; // 父节点(用于回溯) boolean color = BLACK; // 颜色,默认黑色 // 构造函数等省略... }2. 核心属性
// 比较器,如果为null则表示使用自然排序 private final Comparator<? super K> comparator; // 红黑树的根节点 private transient Entry<K,V> root; // 树的节点数量 private transient int size = 0;3. put方法流程解析
TreeMap的put操作主要分为三个步骤:
查找插入位置:
- 从根节点开始,利用比较器(
comparator或Comparable)将待插入的key与当前节点的key进行比较。 - 如果
key小,往左子树走;如果key大,往右子树走。 - 如果找到相同的
key,则更新value并返回旧值。 - 如果走到
null,说明找到了插入位置。
- 从根节点开始,利用比较器(
插入新节点:
- 创建新的
Entry节点。 - 关键点:新插入的节点默认颜色为红色。这是为了尽量减少对“黑色平衡规则”的破坏,从而减少平衡调整的复杂度。
- 创建新的
修复红黑树(fixAfterInsertion):
- 插入红色节点可能会破坏“红色节点规则”(即出现父子节点同为红色)。
TreeMap会通过变色和旋转(左旋、右旋)来恢复红黑树的性质。
public V put(K key, V value) { Entry<K,V> t = root; if (t == null) { // 1. 如果是空树,直接作为根节点(需校验key不为null) root = new Entry<>(key, value, null); size = 1; return null; } int cmp; Entry<K,V> parent; Comparator<? super K> cpr = comparator; // 2. 寻找插入位置 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); // Key已存在,更新值 } while (t != null); } else { // 使用自然排序(需强转Comparable) // ...省略部分代码,逻辑同上 } // 3. 插入新节点(红色) Entry<K,V> e = new Entry<>(key, value, parent); if (cmp < 0) parent.left = e; else parent.right = e; // 4. 修复红黑树平衡 fixAfterInsertion(e); size++; modCount++; return null; }4. 平衡调整策略(fixAfterInsertion)
当插入一个红色节点x时,如果其父节点p也是红色,就会发生冲突。修复逻辑主要分三种情况(假设p是祖父节点g的左孩子):
- 叔叔节点是红色:将父节点
p和叔叔节点u变为黑色,祖父节点g变为红色,然后将x指针上移到g,继续向上检查。 - 叔叔节点是黑色,且
x是右孩子:先对p进行左旋,转化为情况3。 - 叔叔节点是黑色,且
x是左孩子:将p变为黑色,g变为红色,然后对g进行右旋。
删除操作(remove)同样复杂,涉及查找、删除(如果是红色直接删,黑色需要调整)和修复(fixAfterDeletion),核心思想也是通过旋转和变色来维持黑高平衡。
五、 TreeMap vs HashMap
| 特性 | HashMap | TreeMap |
|---|---|---|
| 底层结构 | 数组 + 链表/红黑树 | 红黑树 |
| 排序 | 无序 | 有序(自然顺序或Comparator) |
| 时间复杂度 | O(1) | O(log n) |
| Null键 | 允许一个null键 | 不允许(自然排序下) |
| 适用场景 | 快速查找、缓存、通用Map | 范围查询、排行榜、有序报表 |
六、 实战应用场景
范围查询:
TreeMap实现了NavigableMap,提供了强大的范围操作方法:subMap(fromKey, toKey):返回指定范围内的子Map。headMap(toKey):返回小于指定键的所有元素。tailMap(fromKey):返回大于指定键的所有元素。
查找最邻近值:
ceilingKey(K key):返回大于等于key的最小键。floorKey(K key):返回小于等于key的最大键。higherKey(K key):返回严格大于key的最小键。lowerKey(K key):返回严格小于key的最大键。
排行榜系统:
利用TreeMap的自动排序特性,可以轻松实现按分数排序的排行榜(Key为分数,Value为用户ID)。
七、 面试高频题目
TreeMap和HashMap的区别是什么?
- 回答要点:数据结构(红黑树vs哈希表)、有序性、性能(O(log n) vs O(1))、Null键支持。
TreeMap的底层数据结构是什么?为什么选择它?
- 回答要点:红黑树。因为它在插入、删除和查找之间取得了平衡,虽然查找比AVL树稍慢,但插入删除效率更高,适合频繁变动的数据集。
TreeMap允许null键吗?为什么?
- 回答要点:通常不允许。因为在插入时需要调用
compareTo或compare方法,如果key为null,会导致NullPointerException。除非自定义Comparator专门处理了null值。
- 回答要点:通常不允许。因为在插入时需要调用
如何实现TreeMap的倒序遍历?
- 回答要点:可以使用
descendingMap()方法获取一个逆序的视图,或者在构造函数中传入Comparator.reverseOrder()。
- 回答要点:可以使用
红黑树的插入和删除是如何保持平衡的?
- 回答要点:通过变色(Color Flip)和旋转(Rotate Left/Right)。插入时新节点默认为红,若父红则调整;删除时若删黑节点则需复杂的“双黑”修复逻辑。
