1. TreeMap 底层数据结构
TreeMap 是 Java 集合框架中基于 红黑树(Red‑Black Tree)实现的一个 有序映射。
它的数据结构非常简单,只使用了红黑树一种数据结构,不像HashMap
和LinkedHashMap
那么复杂。
Entry 内部类字段:
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; //节点颜色:BLACK=true, RED=false
}
复制代码
key、value 和 color 为当前对象的值,其它字段为构成红黑树所需的节点对象。数据结构如图:
在 Java 中所有集合都是保存引用对象的引用,而非对象的值。TreeMap
也是如此,在Entry
对象中的Entry<K,V> left、Entry<K,V> right、Entry<K,V> parent
都只是保存着对象的引用(可以理解为地址指向),而非具体的值。
Map
集合的共性,只要知道key
如何计算,便可知道value
所在位置。在TreeMap
中也是如此,最需要关注的是key
在红黑树中的处理。
例如下图 key 在 TreeMap 中的存储:
2. TreeMap 的特点
TreeMap 是 基于 红黑树(Red‑Black Tree)实现的一个 有序映射,特点是有序。这是由底层数据结构所带来的有序特性。
红黑树是一种自平衡二叉查找树,是一种有序的二叉树,按照左中右从小到大排序。
HashMap
同样也使用了红黑树,那是不是HashMap
关于红黑树的那部分元素是有序的?没错,在HashMap
中红黑树部分的元素是有序的,但是,它的有序性是根据key
的hash
值进行的排序,并且hash
值在计算的过程中进行了扰动,就算没有扰动,hash
值的有序对于使用者来说也没有意义,这种有序性仅用于维持红黑树。
而TreeMap
集合的有序性是 key 值的有序,是根据 key 值进行的排序,这种有序性对于使用者来说才有实际性的价值。
哪么如何根据 key 值进行的排序呢?
2.1. TreeMap 如何比较 key 值大小?
默认情况的比较,又称为自然顺序比较。TreeMap
内部假定所有键类型都实现了 Comparable
接口,会直接调用 key 对象的中比较方法进行比较。
在插入、查找或删除时,会执行强制类型转换并调用:
((Comparable<? super K>) key).compareTo(existingKey)
复制代码
以确定 key
与树中节点 existingKey
的相对顺序(<0:key 更小;=0:相等;>0:key 更大)。
如果 key 对象未实现 Comparable
,或尝试比较不同类型但不具备可比性的对象,将在运行时抛出 ClassCastException
。
重点:
默认情况 key 值对象必须实现 Comparable
接口,并重写比较方法compareTo
;
默认情况下 不允许 null
键,因为对 null
调用 compareTo
会导致 NullPointerException
。
例如需要使用Person
类作为键值key
,实现Comparable
接口:
public class Person implements Comparable<Person>{
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Person other) {
int ageCmp = Integer.compare(other.age, this.age); // 降序
if (ageCmp != 0) return ageCmp;
return this.name.compareTo(other.name); // 升序
}
}
复制代码
2.2. 如何自定义比较器(Comparator)?
如果 key 没有实现Comparable
接口,那么需要自定义比较器,并通过TreeMap
的构造方法传入比较器 Comparator<? super K>
,例如:
// 组合排序:先按 name 排序,再按 age 排序
Comparator<Person> cmp = Comparator
.comparing(Person::getName)
.thenComparingInt(Person::getAge);
Map<Person,Object> treeMap = new TreeMap<>(cmp);
复制代码
此时所有键的比较都由指定的自定义比较器方法决定。
比较键值 key 的大小分为两种:
2.3. 为何选择红黑树?
在自平衡二叉查找树中,还有一种比较典型的 AVL 树,它相对于红黑树来说,平衡性的要求更严格,能够保持更高的平衡度。
AVL 树在插入和删除时会进行更多的旋转操作,以确保任意节点左右子树的高度差不超过 1。而红黑树允许一定程度的不平衡,以减少调整频率,提高插入删除的效率。
对比两者:
数据结构的选择是一种取舍的抉择。 一种语言下的数据结构,一般都是以通用情况进行考量,而做出的选择。
红黑树相对于 AVL 树来说,牺牲了部分平衡性以换取插入和删除操作时少量的旋转操作,整体来说性能要优于 AVL 树。
关于 AVL 树和红黑树的选择:
3. TreeMap 在 Java 集合框架中扮演什么角色?
TreeMap
与其他 Map 的对比
TreeMap
填补了无序(HashMap
)和插入/访问顺序(LinkedHashMap
)之外的“键有序”需求。
应用场景
需要范围查询:提供了对应的方法subMap、headMap、tailMap
,例如 map.subMap(fromKey, toKey)
可以高效获取区间内所有条目。
最小/最大元素快速访问:firstKey()
, lastKey()
, ceilingKey()
, floorKey()
等导航方法。
按排序顺序敏感的场景:中序遍历天然保证从小到大。
需要按键排序的缓存或索引。
4. 核心 API 与功能
put
, get
, remove
方法在此不演示,感受下有差异的方法。
如何通过subMap
, headMap
, tailMap
等视图方法获取子区间?
firstKey
, lastKey
, ceilingKey
等导航方法的使用?
通过使用这些方法完成下面的案例。
4.1. 案例--商品订单量统计
假设我们要对一款电商商品的每小时下单量进行统计,并且在任意时刻都能快速获取:
这时,使用 TreeMap<LocalDateTime, Integer>
,键按时间自然排序,就能轻松实现以上功能。
在此特别说明LocalDateTime
为什么可以做键值 key:
该类实现了Comparable
接口可以做自然排序;
其次该类是final
类不可被继承,同时该类的成员变量被final
修饰,为不可变的变量。
实例源码如下:
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.NavigableMap;
import java.util.TreeMap;
public class MinuteOrderStats {
// 按分钟升序存储:key = 每分钟的起始时间(秒、纳秒都为0),value = 该分钟的订单数量
private final TreeMap<LocalDateTime, Integer> stats = new TreeMap<>();
private final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
/** 记录一次新订单,orderTime 精确到秒或更高 */
public void recordOrder(LocalDateTime orderTime) {
// 截断到分钟(保留年/月/日/时/分,秒和纳秒置零)
LocalDateTime minute = orderTime.withSecond(0).withNano(0);
// 累加同一分钟的新订单数,每次加1
stats.merge(minute, 1, Integer::sum);
}
/** 统计从 now 向前 lookbackMinutes 分钟内的总订单量 */
public int totalInPastMinutes(int lookbackMinutes) {
LocalDateTime nowMin = LocalDateTime.now()
.withSecond(0)
.withNano(0);
LocalDateTime from = nowMin.minusMinutes(lookbackMinutes);
// 包含 from 和 nowMin
NavigableMap<LocalDateTime, Integer> sub = stats.subMap(from, true, nowMin, true);
System.out.println("subMap [" + fmt.format(from) + ", " + fmt.format(nowMin) + "):");
sub.forEach((k, v) -> System.out.println(" " + fmt.format(k) + " → " + v));
return sub.values()
.stream()
.mapToInt(Integer::intValue)
.sum();
}
/** 调试时打印所有分钟统计 */
public void dumpAll() {
stats.forEach((time, count) ->
System.out.println(fmt.format(time) + " → " + count));
}
public void demo() {
LocalDateTime now = LocalDateTime.now().withSecond(0).withNano(0);
LocalDateTime start = now.minusMinutes(8); // 8 分钟前
LocalDateTime mid = now.minusMinutes(5); // 5 分钟前
LocalDateTime end = now.minusMinutes(2); // 2 分钟前
// 1. subMap(fromKey, toKey) — [from, to)
System.out.println("\n—— 过去 5 分钟总订单量 ——");
System.out.println("总订单量: "+totalInPastMinutes(5));
System.out.println("\n—— 从8分钟前到2分钟前的分布 ——");
NavigableMap<LocalDateTime, Integer> sub =stats.subMap(start, true, end, false);
System.out.println("subMap [" + fmt.format(start) + ", " + fmt.format(end) + "):");
sub.forEach((k, v) -> System.out.println(" " + fmt.format(k) + " → " + v));
// 2. headMap(toKey) — (< toKey)
System.out.println("\n—— 从头到5分钟前的分布 ——");
NavigableMap<LocalDateTime, Integer> head = stats.headMap(mid, false);
System.out.println("headMap (< " + fmt.format(mid) + "):");
head.forEach((k, v) -> System.out.println(" " + fmt.format(k) + " → " + v));
// 3. tailMap(fromKey) — [fromKey, ∞)
System.out.println("\n—— 从5分钟前到末尾的分布 ——");
NavigableMap<LocalDateTime, Integer> tail = stats.tailMap(mid, true);
System.out.println("tailMap (≥ " + fmt.format(mid) + "):");
tail.forEach((k, v) -> System.out.println(" " + fmt.format(k) + " → " + v));
// 4. firstKey() — 最早的键
System.out.println("\n—— 最早的键分布 ——");
LocalDateTime first = stats.firstKey();
System.out.println("firstKey(): " + fmt.format(first) + " → " + stats.get(first));
// 5. lastKey() — 最晚的键
System.out.println("\n—— 最晚的键分布 ——");
LocalDateTime last = stats.lastKey();
System.out.println("lastKey(): " + fmt.format(last) + " → " + stats.get(last));
// 6. ceilingKey(key) — ≥ key 的最小键
System.out.println("\n—— ≥ key 的最小键的键分布 ——");
LocalDateTime query = now.minusMinutes(6).plusSeconds(30);
LocalDateTime ceil = stats.ceilingKey(query);
System.out.println("ceilingKey(" + fmt.format(query) + "): "
+ (ceil != null
? fmt.format(ceil) + " → " + stats.get(ceil)
: "null"));
}
public static void main(String[] args) throws InterruptedException {
MinuteOrderStats os = new MinuteOrderStats();
LocalDateTime now = LocalDateTime.now();
LocalDateTime hourAgo = now.minusMinutes(10);
// 模拟插入:跨 3 分钟,每隔几秒记录一次
for (int i = 0; i < 100; i++) {
// 让时间分布在 now.minusMinutes(3) ~ now
os.recordOrder(hourAgo.minusSeconds((3 * 60) - i * 7));
Thread.sleep(5);
}
System.out.println("—— 全部分钟统计 ——");
os.dumpAll();
// 常见的集中操作
os.demo();
}
}
复制代码
案例测试结果
—— 全部分钟统计 ——
2025-06-29 23:16 → 2
2025-06-29 23:17 → 9
2025-06-29 23:18 → 8
2025-06-29 23:19 → 9
2025-06-29 23:20 → 9
2025-06-29 23:21 → 8
2025-06-29 23:22 → 9
2025-06-29 23:23 → 8
2025-06-29 23:24 → 9
2025-06-29 23:25 → 8
2025-06-29 23:26 → 9
2025-06-29 23:27 → 9
2025-06-29 23:28 → 3
—— 过去 5 分钟总订单量 ——
subMap [2025-06-29 23:24, 2025-06-29 23:29):
2025-06-29 23:24 → 9
2025-06-29 23:25 → 8
2025-06-29 23:26 → 9
2025-06-29 23:27 → 9
2025-06-29 23:28 → 3
38
—— 从8分钟前到2分钟前的分布 ——
subMap [2025-06-29 23:21, 2025-06-29 23:27):
2025-06-29 23:21 → 8
2025-06-29 23:22 → 9
2025-06-29 23:23 → 8
2025-06-29 23:24 → 9
2025-06-29 23:25 → 8
2025-06-29 23:26 → 9
—— 从头到5分钟前的分布 ——
headMap (< 2025-06-29 23:24):
2025-06-29 23:16 → 2
2025-06-29 23:17 → 9
2025-06-29 23:18 → 8
2025-06-29 23:19 → 9
2025-06-29 23:20 → 9
2025-06-29 23:21 → 8
2025-06-29 23:22 → 9
2025-06-29 23:23 → 8
—— 从5分钟前到末尾的分布 ——
tailMap (≥ 2025-06-29 23:24):
2025-06-29 23:24 → 9
2025-06-29 23:25 → 8
2025-06-29 23:26 → 9
2025-06-29 23:27 → 9
2025-06-29 23:28 → 3
—— 最早的键分布 ——
firstKey(): 2025-06-29 23:16 → 2
—— 最晚的键分布 ——
lastKey(): 2025-06-29 23:28 → 3
—— ≥ key 的最小键的键分布 ——
ceilingKey(2025-06-29 23:23): 2025-06-29 23:24 → 9
复制代码
4.2. 关于 NavigableMap 接口
TreeMap
实现了NavigableMap
接口,NavigableMap
接口是 Java 在 SortedMap
基础上扩展出来的一个接口,代表可导航的有序映射表。
它扩展了 SortedMap
,支持更丰富的范围查询与方向遍历操作。
TreeMap
实现了NavigableMap
接口,使得TreeMap
具有更丰富的查询操作,已将方法汇总如下,可自行尝试。
方法总览
5. 源码阅读
TreeMap
源码的学习本质是红黑树数据结构的学习,关键源码为红黑树插入、删除和调平衡(染色和旋转),重点关注TreeMap.put
, TreeMap.remove
, rotateLeft
, rotateRight
和fixAfterInsertion
, fixAfterDeletion
方法。
这些方法中最为主要的是:fixAfterInsertion
和 fixAfterDeletion
方法。
5.1. 插入平衡:fixAfterInsertion
当你往红黑树里插入一个新节点时,通常分为两大步:
1.新节点染成红色(保证不破坏从根到叶子的黑色节点数一致性)。
2.如果它的父节点也是红色,就会违反“红色节点不能有红色子节点”这一性质,需进行修复:
Case 1(叔叔节点也红)父、叔都染黑,祖父染红,指针上移到祖父,继续检查上层。
Case 2(叔黑,且当前节点与父节点在同一侧)对父节点做一次旋转(左旋或右旋),把自己变成父的位置,再归为 Case 3。
Case 3(叔黑,且当前节点与父节点在“外侧”)将父染黑、祖父染红,然后对祖父做一次与父同方向的旋转。
fixAfterInsertion(Entry<K,V> x)
就是把这三个 Case 全部编码在一个 while 循环里,最终把根节点染黑,恢复所有红黑树性质。
可视化过程:
5.2. 删除平衡:fixAfterDeletion
删除节点更复杂,因为可能产生“双重黑”(double-black)问题。大致流程:
1.如果被删节点或被删替换节点是红色,则简单染黑,完事。
2.否则,当前“替代”节点(可能为 null
代表叶子)就相当于带了一个额外的黑色,需要通过一系列 Case:
Case 1(兄弟节点是红色)将兄弟染黑、父染红,然后对父做一次旋转,使兄弟变成新的兄弟(变为黑色兄弟的场景)。
Case 2(兄弟黑,且兄弟的两个子节点都黑)将兄弟染红,上移到父,继续在父节点处做平衡。
Case 3(兄弟黑,兄弟的外侧子节点黑,内侧子节点红)将兄弟内侧子节点染黑、兄弟染红,然后对兄弟做一次旋转,转为 Case 4。
Case 4(兄弟黑,兄弟的外侧子节点红)将兄弟染成父颜色、父染黑、兄弟外侧节点染黑,对父做一次旋转,结束。
fixAfterDeletion(Entry<K,V> x)
同样也是把以上 Case 都写一个 while 循环里,最终把根节点染黑。
删除根节点的可视化过程:
5. 总结
TreeMap
底层数据结构、特点、与其他 Map 集合的差异,并提供一个简单案例感受 TreeMap 带来的高效处理。如果只关心 快速存取,且对顺序无要求,首选 HashMap
; 如果需要按 插入或访问顺序 遍历,用 LinkedHashMap
; 若需 按键排序、范围查询 或访问 最小/最大值,则应使用 TreeMap
。
文章转载自:渊渟岳
原文链接:https://www.cnblogs.com/dennyLee2025/p/18994785
体验地址:http://www.jnpfsoft.com/?from=001YH
评论