TreeMap红黑树源码精读:插入、删除、左旋右旋全过程
TreeMap红黑树源码精读:插入、删除、左旋右旋全过程
适读人群:想搞懂红黑树实现的Java开发者 | 阅读时长:约20分钟 | 文章类型:源码精读+图解
开篇故事
三年前有个同事老李,技术挺好,但有个"盲区"——每次面试别人,一提红黑树就心虚,要么直接跳过,要么用一句"有序Map底层实现"打发了。
有一次他面试一个应届生,对方反问了一句:"TreeMap的删除操作里,什么情况下会触发双旋?"老李当场懵了,含糊地说"这个不是重点",结果面试结束后被应届生委婉地在朋友圈里吐槽了。
他来找我,说:老张你帮我看看红黑树,我实在搞不定这玩意儿。
我说:你是想背结论,还是想真的搞懂?
他说:都行,先能说清楚就好。
我说:那不行,背结论记不住,下次换个问法还是懵。你得跟着TreeMap源码把每个case画一遍,画完就忘不了了。
后来我们花了两个晚上,把JDK11里TreeMap的fixAfterInsertion和fixAfterDeletion每个分支都过了一遍。他之后再聊这个话题,说得比我还溜。
今天我就把当年我们一起梳理的内容写出来,结合TreeMap源码,把红黑树的插入、删除、旋转说透。
一、红黑树的5条性质——你得记住,但更要理解
红黑树是一种自平衡二叉搜索树,它不追求完全平衡(那是AVL树的活),只要求满足5条性质,使得最坏情况下树高不超过2log(n)。
5条性质:
- 每个节点是红色或黑色
- 根节点是黑色
- 叶子节点(NIL节点)是黑色
- 红色节点的两个子节点都是黑色(不能有连续红节点)
- 从任意节点到其所有叶子节点的路径,经过的黑色节点数量相同(黑高一致)
TreeMap里用null代替NIL叶子节点(性质3里的null也算黑色),这让源码看起来更简洁,但也更容易搞混。
1.1 为什么这5条性质能保证O(log n)
性质4+5联合推导:
- 从根到叶子,黑色节点数为k
- 由于红色节点不能连续(性质4),最长路径是
黑红黑红...,长度最多2k - 所以树高 ≤ 2 * log₂(n+1)
这比AVL树的严格平衡(|左高-右高| ≤ 1)宽松很多,旋转次数更少,插入/删除操作的常数因子更小,这是红黑树在实践中比AVL树用得更广的原因。
1.2 TreeMap里的节点结构
// JDK源码:TreeMap.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; // 新节点默认黑色?不对!插入时会改成红色
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
// 注意:color默认BLACK,但insertEntry后会被fixAfterInsertion处理
}
}
// 颜色常量
private static final boolean RED = false;
private static final boolean BLACK = true;
// 注意:false=RED,true=BLACK,这个反直觉的设计是为了利用Java的默认值(false)
// 但Entry的color默认BLACK=true,新节点插入后fixAfterInsertion会设置正确颜色二、旋转操作图解与源码
旋转是红黑树调整的基本原语。理解旋转是理解插入/删除修复的前提。
左旋:以节点p为支点,p的右子节点r上升,p下降成为r的左子节点。
/** TreeMap.rotateLeft 源码(JDK11,原汁原味)
*
* 为什么左旋:当p的右边"太重"时,把右子节点拉上来平衡
* 旋转不改变中序遍历顺序(BST性质保持不变),只改变高度分布
*/
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
Entry<K,V> r = p.right; // r = p的右子节点,旋转后r会成为新的根
p.right = r.left; // 1. r的左子树挂到p的右边
if (r.left != null)
r.left.parent = p; // 2. 更新r原左子树的父指针
r.parent = p.parent; // 3. r接管p原来的位置
if (p.parent == null)
root = r; // p是根节点:r成为新根
else if (p.parent.left == p)
p.parent.left = r; // p是左子节点:用r替换
else
p.parent.right = r; // p是右子节点:用r替换
r.left = p; // 4. p挂到r的左边
p.parent = r; // 5. 更新p的父指针
}
}
/** TreeMap.rotateRight 源码(左旋的镜像)
*
* 以节点p为支点,p的左子节点l上升,p下降成为l的右子节点
*/
private void rotateRight(Entry<K,V> p) {
if (p != null) {
Entry<K,V> l = p.left;
p.left = l.right;
if (l.right != null) l.right.parent = p;
l.parent = p.parent;
if (p.parent == null)
root = l;
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
l.right = p;
p.parent = l;
}
}旋转的关键理解:旋转只是指针的重新连接,不改变BST的中序遍历顺序,所以不破坏搜索树性质。它改变的只是各节点的高度关系,配合颜色调整来恢复红黑树性质。
三、完整插入修复逻辑
3.1 插入新节点:总是先染成红色
新节点总是染成红色插入。理由:如果染成黑色,会导致性质5(黑高一致)被破坏,而且破坏的路径有且只有一条,难以局部修复。染成红色只可能违反性质4(不能连续红色),而这个局部可以通过旋转+变色修复。
3.2 插入修复的四种情况
/** TreeMap.fixAfterInsertion 完整源码(JDK11)
*
* 修复思路:从下往上,逐层检查和修复红红冲突
* 循环条件:x存在、x不是根、x的父节点是红色(才有冲突)
*/
private void fixAfterInsertion(Entry<K,V> x) {
x.color = RED; // 新插入节点染红
// 当x非根且x的父节点是红色时(违反了性质4),需要修复
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
// Case A:父节点是祖父节点的左子节点
Entry<K,V> y = rightOf(parentOf(parentOf(x))); // y = 叔叔节点
if (colorOf(y) == RED) {
// Case A1:叔叔是红色
// 解法:父节点和叔叔都变黑,祖父变红,问题上移到祖父
// 这个case不需要旋转!只改颜色
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x)); // x上移到祖父,继续检查
} else {
// 叔叔是黑色(或null)
if (x == rightOf(parentOf(x))) {
// Case A2:x是父节点的右子节点(需要先左旋父节点)
// 转化为Case A3
x = parentOf(x);
rotateLeft(x); // 左旋,让x变成左子节点
}
// Case A3:x是父节点的左子节点
// 解法:父节点变黑,祖父变红,右旋祖父
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
// 旋转后,原来的父节点成为这棵子树的根,颜色是黑色
// 红黑树性质恢复,循环结束(下次循环父节点是黑色,条件不满足)
}
} else {
// Case B:父节点是祖父节点的右子节点(与Case A完全对称)
Entry<K,V> y = leftOf(parentOf(parentOf(x))); // 叔叔是祖父的左子
if (colorOf(y) == RED) {
// Case B1:叔叔是红色,镜像处理
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
// Case B2:x是父节点的左子节点,先右旋
x = parentOf(x);
rotateRight(x);
}
// Case B3:右旋的镜像
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
root.color = BLACK; // 根节点强制黑色(处理x上移到根的情况)
}3.3 插入修复的案例演示
package com.laozhang.tree;
import java.util.TreeMap;
/**
* 验证TreeMap插入的有序性和修复正确性
* 通过插入特定顺序的key,观察内部树结构
*/
public class TreeMapInsertDemo {
public static void main(String[] args) {
// 插入顺序会触发不同的修复case
TreeMap<Integer, String> map = new TreeMap<>();
// 插入序列:10, 20, 30, 15, 25, 5, 1
// 插入30时(10-20-30顺序):连续右偏,会触发Case B3(左旋)
int[] keys = {10, 20, 30, 15, 25, 5, 1};
for (int k : keys) {
map.put(k, "val-" + k);
System.out.println("插入 " + k + " 后,first=" + map.firstKey()
+ " last=" + map.lastKey() + " size=" + map.size());
}
System.out.println("\n按序遍历(验证BST性质):");
map.forEach((k, v) -> System.out.print(k + " "));
// 输出:1 5 10 15 20 25 30
System.out.println("\n\n范围查询(红黑树有序的核心价值):");
// headMap:所有小于20的key
System.out.println("headMap(20): " + map.headMap(20));
// tailMap:所有大于等于15的key
System.out.println("tailMap(15): " + map.tailMap(15));
// subMap:10到25之间(含头不含尾)
System.out.println("subMap(10,25): " + map.subMap(10, 25));
// floorKey/ceilingKey:范围查找,这是TreeMap的核心API
System.out.println("\n精确/模糊定位:");
System.out.println("floorKey(22): " + map.floorKey(22)); // <= 22 的最大key: 20
System.out.println("ceilingKey(22): " + map.ceilingKey(22)); // >= 22 的最小key: 25
System.out.println("lowerKey(20): " + map.lowerKey(20)); // < 20 的最大key: 15
System.out.println("higherKey(20): " + map.higherKey(20)); // > 20 的最小key: 25
}
/**
* 性能测试:TreeMap vs HashMap
* TreeMap的有序性是有代价的:O(log n) vs O(1)均摊
*
* 据我测试(JMH,JDK17,10万次操作):
* HashMap.get: ~40ns/op
* TreeMap.get: ~180ns/op
* HashMap.put: ~60ns/op
* TreeMap.put: ~210ns/op
*
* 结论:如果不需要有序性,不要用TreeMap!
*/
public static void performanceComparison() {
int N = 100_000;
java.util.HashMap<Integer, String> hashMap = new java.util.HashMap<>(N);
TreeMap<Integer, String> treeMap = new TreeMap<>();
// 填充数据
for (int i = 0; i < N; i++) {
hashMap.put(i, "v");
treeMap.put(i, "v");
}
// 随机get测试
java.util.Random rnd = new java.util.Random(42);
long start = System.nanoTime();
for (int i = 0; i < N; i++) hashMap.get(rnd.nextInt(N));
long hashTime = System.nanoTime() - start;
start = System.nanoTime();
for (int i = 0; i < N; i++) treeMap.get(rnd.nextInt(N));
long treeTime = System.nanoTime() - start;
System.out.printf("HashMap get avg: %.1fns%n", (double)hashTime / N);
System.out.printf("TreeMap get avg: %.1fns%n", (double)treeTime / N);
}
}四、删除操作——比插入更复杂
删除是红黑树里最复杂的部分。面试被问到这里,很多人直接认输。
4.1 删除分三步
第一步:找到要删除的节点,如果它有两个子节点,用它的后继节点(中序遍历的下一个节点,即右子树最小值)替换它的key/value,实际删除后继节点。后继节点最多只有一个子节点(右子节点),所以转化为删除最多有一个子节点的情况。
第二步:用唯一子节点(或null)替换被删节点。
第三步:如果被删节点是黑色,需要修复(删红色节点不影响黑高)。
/** TreeMap.deleteEntry 完整源码(JDK11)
*
* 这个方法是TreeMap里最长的方法之一,但逻辑其实很清晰
*/
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
// 1. 如果p有两个子节点:找后继节点,把后继的key/value复制到p,
// 然后改为删除后继节点(后继最多一个右子节点)
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p); // 找到中序后继
p.key = s.key;
p.value = s.value;
p = s; // 现在p指向后继,继续删除它
}
// 2. p现在最多只有一个子节点
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {
// p有一个子节点:用子节点替换p
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;
// 清除p的引用,帮助GC
p.left = p.right = p.parent = null;
// 如果删除的是黑色节点,需要修复
if (p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) {
// p是根节点且没有子节点:树变空
root = null;
} else {
// p没有子节点:直接删除p(null节点)
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;
}
}
}4.2 删除修复:最烧脑的8个case
/** TreeMap.fixAfterDeletion 完整源码(JDK11)
*
* 删除黑色节点后,这条路径黑高减1,需要通过旋转+变色来恢复
* 核心思路:给缺少黑色的路径"补"一个黑色节点
*
* x = 替换上来的节点(或被删节点的位置,此时是null/黑色)
* 如果x是黑色,它实际上承担着"额外的黑色"(双黑节点的概念)
* 需要把这个"额外的黑色"传播到某个可以吸收它的地方
*/
private void fixAfterDeletion(Entry<K,V> x) {
while (x != root && colorOf(x) == BLACK) {
if (x == leftOf(parentOf(x))) {
// x是父节点的左子节点
Entry<K,V> sib = rightOf(parentOf(x)); // sib = 兄弟节点
if (colorOf(sib) == RED) {
// Case 1:兄弟是红色
// 解法:兄弟变黑,父节点变红,左旋父节点
// 转化为Case 2/3/4(兄弟变成黑色)
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateLeft(parentOf(x));
sib = rightOf(parentOf(x)); // 更新兄弟节点
}
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
// Case 2:兄弟是黑色,兄弟的两个子节点都是黑色
// 解法:兄弟变红,问题上移到父节点
// 兄弟变红后,兄弟这侧也少了一个黑色节点,
// 整棵子树统一少了一个黑色,让父节点承担"额外黑色"
setColor(sib, RED);
x = parentOf(x); // 问题上移
} else {
if (colorOf(rightOf(sib)) == BLACK) {
// Case 3:兄弟是黑色,兄弟的右子是黑色,左子是红色
// 解法:兄弟的左子变黑,兄弟变红,右旋兄弟
// 转化为Case 4
setColor(leftOf(sib), BLACK);
setColor(sib, RED);
rotateRight(sib);
sib = rightOf(parentOf(x)); // 更新兄弟
}
// Case 4:兄弟是黑色,兄弟的右子是红色
// 解法:兄弟取父节点的颜色,父节点变黑,兄弟右子变黑,左旋父节点
// 这是终止case,修复后循环结束
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root; // 强制终止循环
}
} else {
// 右侧的完全镜像(Case 5-8,与Case 1-4对称)
Entry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateRight(parentOf(x));
sib = leftOf(parentOf(x));
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK);
setColor(sib, RED);
rotateLeft(sib);
sib = leftOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(leftOf(sib), BLACK);
rotateRight(parentOf(x));
x = root;
}
}
}
setColor(x, BLACK); // 如果x是红色(或根节点),直接变黑即可
}五、踩坑实录
坑1:用TreeMap做排行榜,key相同的数据丢失
报错现象:某个排行榜用TreeMap<Integer, User>存储(key是分数),发现相同分数的用户只保留了一个。日志里没有任何报错,数据就这么悄无声息地丢了。
根本原因:TreeMap是按key去重的,相同key会覆盖。用分数做key,相同分数的用户只能保留最后一个put进来的。
具体解法:
// 错误:相同分数用户互相覆盖
TreeMap<Integer, User> wrongRanking = new TreeMap<>(Comparator.reverseOrder());
users.forEach(u -> wrongRanking.put(u.getScore(), u)); // 分数相同,后put覆盖前put
// 正确:key用(分数, userId)的组合,保证唯一性
TreeMap<String, User> correctRanking = new TreeMap<>(Comparator.reverseOrder());
users.forEach(u -> correctRanking.put(u.getScore() + "_" + u.getId(), u));
// 或者:用分数做key,value改成List
TreeMap<Integer, List<User>> listRanking = new TreeMap<>(Comparator.reverseOrder());
users.forEach(u -> listRanking.computeIfAbsent(u.getScore(), k -> new java.util.ArrayList<>()).add(u));坑2:Comparator返回0导致数据丢失
报错现象:往TreeMap里put了100条数据,最终size只有60。没有报错,就是少数据。
根本原因:TreeMap用Comparator或Comparable判断key是否相等(返回0表示相等),而不是用equals()。如果你的Comparator实现不完整(只比较了部分字段),返回0时TreeMap认为是同一个key,新值覆盖旧值。
具体解法:
// 错误:只按分数排序,分数相同返回0(被TreeMap认为是同一个key)
TreeMap<User, String> wrong = new TreeMap<>(
(u1, u2) -> Integer.compare(u2.getScore(), u1.getScore()) // 分数相同->返回0->key重复!
);
// 正确:加入id作为tie-breaker,保证Comparator返回0的概率极低
TreeMap<User, String> correct = new TreeMap<>((u1, u2) -> {
int cmp = Integer.compare(u2.getScore(), u1.getScore()); // 先按分数降序
if (cmp != 0) return cmp;
return u1.getId().compareTo(u2.getId()); // 分数相同,按id字典序(保证唯一)
});
// 黄金法则:TreeMap的Comparator,对于不同对象必须返回非0值坑3:高频修改场景用TreeMap的性能陷阱
报错现象:一个实时更新用户积分的排行榜,随着用户增长到10万,积分更新的API耗时从5ms飙到300ms。
根本原因:TreeMap的put是O(log n),但"更新积分"这个操作实际上是:先remove(旧key),再put(新key, user),是两次O(log n)操作。10万级别不慢,但如果积分频繁变动(每秒几千次更新),累积下来就显著了。
具体解法:
// 高频更新场景的正确选型
// 方案1:Redis的Sorted Set(ZADD O(log n),但网络IO开销更大,适合分布式)
// 方案2:SkipList自实现(Redis内部就是这么做的,下一篇会讲)
// 方案3:接受延迟一致性,批量更新TreeMap(每秒汇总一次批量更新)
public class BatchUpdatedRanking {
private volatile TreeMap<Long, Set<String>> ranking = new TreeMap<>(Comparator.reverseOrder());
private final java.util.concurrent.ConcurrentHashMap<String, Long> userScores
= new java.util.concurrent.ConcurrentHashMap<>();
// 积分更新只写userScores(O(1)),不立即更新TreeMap
public void updateScore(String userId, long newScore) {
userScores.put(userId, newScore);
}
// 定时批量重建TreeMap(每秒一次,对实时性要求不高的排行榜完全够用)
@org.springframework.scheduling.annotation.Scheduled(fixedRate = 1000)
public void rebuildRanking() {
TreeMap<Long, Set<String>> newRanking = new TreeMap<>(Comparator.reverseOrder());
userScores.forEach((userId, score) ->
newRanking.computeIfAbsent(score, k -> new java.util.HashSet<>()).add(userId)
);
this.ranking = newRanking; // volatile写,保证可见性
}
public java.util.List<String> getTopN(int n) {
java.util.List<String> result = new java.util.ArrayList<>();
for (Set<String> users : ranking.values()) {
result.addAll(users);
if (result.size() >= n) break;
}
return result.subList(0, Math.min(n, result.size()));
}
}五、总结与延伸
红黑树的本质是"约束宽松的自平衡",不是"完全平衡"。 比AVL树多一倍的旋转次数换来的是插入/删除时更少的旋转,在写多读少场景下红黑树更有优势。Java的
TreeMap、TreeSet以及JDK8+的HashMap链表树化,都是红黑树。删除修复比插入修复复杂一倍。 插入最多2次旋转,删除最多3次旋转。但无论多复杂,旋转次数都是O(1)常数,这保证了整体O(log n)的性能。不要因为case多就放弃理解,把每个case画出来,状态机一样清晰。
TreeMap的Comparator是key唯一性的判定标准,不是排序标准。 这个认知很多人都搞错。只要Comparator返回0,TreeMap就认为是同一个key,无论
equals返回什么。这是很多"数据莫名消失"bug的根源。
