为什么使用树结构
从最根本的原因来看,使用树结构就是为了提升整体的效率;插入、删除、查找(索引),尤其是索引操作。因为相比于链表,一个平衡树的索引时间复杂度是O(logn),而链表的索引时间复杂度是O(n)。
从以下的图上可以对比,两者的索引耗时情况;
- 从上图可以看到,使用树结构有效的降低时间复杂度,提升数据索引效率。
- 另外这个标准的树结构,是二叉搜索树(Binary Search Tree)。除此之外树形结构还有;AVL树、红黑树等
树(Tree)
在数据结构中,树(Tree)是一种非线性的数据结构,它由一系列节点组成,这些节点之间通过边(连接节点的线)形成层次关系。树的数据结构具有以下特点:
- 节点(Node) :树中的基本元素,可以存储数据。每个节点都可以有零个或多个子节点。
- 根节点(Root) :树中唯一没有父节点的节点,位于树的最顶层。
- 子节点(Children) :一个节点直接相连的下一层节点,每个非根节点有且只有一个父节点。
- 父节点(Parent) :除了根节点外,每个节点都有一个父节点,即直接相连的上一层节点。
- 子树(Subtree) :除了根节点外,每个节点与其所有后代节点构成的集合。
- 叶节点(Leaf) :没有子节点的节点。
- 兄弟节点(Siblings) :具有相同父节点的节点。
- 路径(Path) :从树中的一个节点到另一个节点的序列,由一系列连续的边组成。
- 深度(Depth) :一个节点到根节点的路径上的边数。
- 高度(Height) :树中从根节点到最远叶节点的最长路径上的边数。
- 度(Degree) :节点拥有的子节点数量。树的度是树中所有节点度的最大值。
树可以有多种类型,例如:
- 二叉树(Binary Tree) :每个节点最多只有两个子节点的树。
- 完全二叉树(Complete Binary Tree) :除了最后一层外,每一层的节点数都达到最大;最后一层的所有节点都靠左排列。
- 满二叉树(Full Binary Tree) :每个节点要么有0个子节点,要么有2个子节点。
- 平衡二叉树(Balanced Binary Tree) :树的左右两边的高度差不超过某一限定值,如AVL树和红黑树。
- 搜索树(Search Tree) :节点的值满足特定顺序关系,便于搜索、插入和删除操作,如二叉搜索树(Binary Search Tree)。
二叉树(Binary Tree)
树结构多种多样,不过我们最常用还是二叉树。
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点
其中,编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树。
编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树。
- 完全二叉树(Complete Binary Tree)的定义:除了最后一层外,每一层的节点数都达到最大;最后一层的所有节点都靠左排列。
树的存储
选择哪种存储方式取决于具体的使用场景和需求,例如对空间效率、查询速度、插入和删除操作的需求。例如,如果树的形状接近完全二叉树,那么顺序存储可能是一个好选择;而如果树的结构变化频繁,链式存储则可能更适合。
二叉树的存储方式
存储二叉树最常见的两种方法是基于指针的二叉链表存储和基于数组的顺序存储。这两种方法各有优势和适用场景:
基于指针的二叉链式存储法
特点:
- 每个节点由三部分组成:数据部分、指向左孩子的指针、指向右孩子的指针。
- 节点之间的关系通过指针动态链接,支持动态添加和删除节点。
- 实现灵活,适合处理各种类型的二叉树,特别是不完全二叉树。
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。
基于数组的顺序存储法
如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。
不过,刚刚的例子是一棵完全二叉树,所以仅仅“浪费”了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。
所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
特点:
- 数组的索引反映了节点在树中的位置。
- 根节点位于数组的第一个位置(通常索引为0或1,根据习惯)。
- 对于数组中任意节点i(假设从0开始编号),其左孩子节点位于2i+1,右孩子节点位于2i+2。
- 空节点通常用NULL或其他特定值表示。
优点:
- 存储密度高,不需要额外的指针空间。
- 对于完全二叉树或近似完全二叉树,访问效率高。
缺点:
- 对于非完全二叉树,可能造成数组空间的浪费。
- 插入和删除操作复杂,可能需要移动大量元素。
在实际应用中,选择哪种存储方式取决于具体需求,如树的形状(是否接近完全二叉树)、操作频率(插入、删除操作的多少)、内存管理策略等。如果需要频繁地进行树的修改,链式存储可能更为合适;如果树的结构相对固定,且主要进行查询操作,顺序存储则可能更高效。
普通树的存储结构
对于存储结构,可能会联想到前面的顺序存储和链式存储结构。但是对于树这种可能会有很多孩子的特殊数据结构,只用顺序存储结构或者链式存储结构很难实现,那么可以将这两者结合,产生主要的三种存储结构表示法:双亲表示法、孩子表示法、孩子兄弟表示法。
双亲表示法
双亲表示法定义
- 双亲表示法采用顺序表(也就是数组)存储普通树,核心思想:顺序存储各个节点的同时,给各个节点附加一个记录其父节点位置的变量。
- 根结点没有父节点,因此根结点记录父节点位置的变量一般为-1。
双亲表示法的节点结构
代码实现
//定义树中结点最大数量
#define MAX_TREE_SIZE 100
typedef int ElemeType;
typedef struct Node{
ElemeType data; // 结点数据
int parent; // 结点的父节点再数组中的位置下标
};
typedef struct { // 树结构
node arr[MAX_TREE_SIZE]; // 存放树中所有结点
int n; // 结点数
}Tree;
孩子表示法
-
孩子表示法是采用“顺序表+链表”的组合结构,其存储过程是:从树的根结点开始,使用顺序表依次存储树中各个节点,需要注意的是,与双亲表示法不同,孩子表示法会给各个节点配备一个链表,用于存储各个节点的孩子节点位于顺序表中的位置。
-
如果孩子没有叶子节点,则该节点的链表为空链表。
孩子表示法的节点结构
#define MAX_SIZE 100//定义树中结点最大数量
typedef struct node{
int child;//链表中每个结点存储的是数据再数组中存储的位置下标
struct node *next;
};
typedef struct {
char data;//结点的数据
node * FirstChild;//孩子链表的头节点
}box;
typedef struct {
box arr[MAX_SIZE];//存放树中所有结点
int n, r;//节点数和树根的位置
}Tree;
使用孩子表示法存储的树结构,正好和双亲表示法相反,适用于查找某个节点的孩子结点,不适用于查找父节点。
其实,我们也可以将双亲表示法和孩子表示法相互结合,就可以得到:
使用该结构存储普通树,既能快速找到指定节点的父节点,也能快速找到指定结点的孩子结点。
孩子兄弟表示法
以二链表作为树的存储结构,又称二叉树表示法。任意一棵树,他的结点的第一个孩子如果存在就是唯一结点,他的右兄弟如果存在,也是唯一的,因此,我们设置两个指针,分别指向该结点的第一个孩子和该结点的右兄弟。
/* 树的孩子兄弟表示法结构定义*/
#define MAX_TREE_SIZE 100
typedef int ElemeType;
typedef struct CSNode{
ElemeType data;
struct CSNode * firstchild;
struct CSNode * rightsib;
}CSNode, *CSTree;
孩子兄弟表示法的优缺点
这种表示法,给查找某个结点的某个孩⼦带来了⽅便,只需要通过该节点的孩子指针域找到此结点的⻓⼦,然后再通过⻓⼦结点的兄弟指针域找到它的⼆弟,接着⼀直下去,直到找到具体的孩⼦。
二叉树的遍历
如何将所有节点都遍历打印出来呢?经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。
- 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
- 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
- 后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
注意是子树,不是节点。
先序遍历:ABDFCEGHI
中序遍历:BFDACHGIE
后序遍历:FDBHIGECA
第一种分析方法:(此处分析先序遍历)
- 从A根节点开始,根据先序遍历的原则:首先访问根节点A,然后访问它的左子树B, 在访问右子树C,遍历顺序就是A->B->C
- 左子树B 也按照先序遍历的原则来处理, 遍历顺序就是B->D。B的右子树也按照先序遍历的原则,顺序是D->F ,就可以得到A->B->D->F->C
- 右子树C按照先序遍历的原则处理,顺序是C->E,同理C的子树得遍历顺序E->G->H->I 那么, 这棵树先序遍历的结果就是,A->B->D->F->C->E->G->H->I
这是递归思路,根据原则遍历子树,子树没了子节点遍历完,则遍历同深度。
第二种分析方法:(此处分析中序遍历)
三种遍历的实现
前序遍历的递推公式:
preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)
中序遍历的递推公式:
inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)
后序遍历的递推公式:
postOrder(r) = postOrder(r->left)->postOrder(r->right)->postOrder(r)
package com.core.algorithms.tree;
public class CreateTreeDemo {
public static void main(String[] args) {
Tree tree = createTree();
System.out.println("---------");
System.out.println("前序遍历");
frontSort(tree.getRoot());
System.out.println();
System.out.println("---------");
System.out.println("中序遍历");
middleSort(tree.getRoot());
System.out.println();
System.out.println("---------");
System.out.println("后序遍历");
postSort(tree.getRoot());
}
// 前序遍历
private static void frontSort(Node node) {
if (node == null) {
return;
}
System.out.print(node.getData() + ",");
// 遍历左子树
frontSort(node.getlNode());
// 遍历右子树
frontSort(node.getrNode());
}
// 中序遍历
private static void middleSort(Node node) {
if (node == null) {
return;
}
// 遍历左子树
middleSort(node.getlNode());
System.out.print(node.getData() + ",");
// 遍历右子树
middleSort(node.getrNode());
}
// 后序遍历
private static void postSort(Node node) {
if (node == null) {
return;
}
// 遍历左子树
postSort(node.getlNode());
// 遍历右子树
postSort(node.getrNode());
System.out.print(node.getData() + ",");
}
private static Tree createTree() {
Tree tree = new Tree();
Node fNode = new Node(null, "F", null);
Node hNode = new Node(null, "H", null);
Node iNode = new Node(null, "I", null);
Node dNode = new Node(fNode, "D", null);
Node gNode = new Node(hNode, "G", iNode);
Node eNode = new Node(gNode, "E", null);
Node cNode = new Node(null, "C", eNode);
Node bNode = new Node(null, "B", dNode);
Node aNode = new Node(bNode, "A", cNode);
tree.setRoot(aNode);
return tree;
}
static class Tree {
private Node root;
public Tree() {
}
public Node getRoot() {
return root;
}
public void setRoot(Node root) {
this.root = root;
}
}
static class Node {
// 左子节点
private final Node lNode;
// 数据域
private final String data;
// 右子节点
private final Node rNode;
public Node(Node lNode, String data, Node rNode) {
this.lNode = lNode;
this.data = data;
this.rNode = rNode;
}
public Node getlNode() {
return lNode;
}
public String getData() {
return data;
}
public Node getrNode() {
return rNode;
}
}
}
输出结果
---------
前序遍历
A,B,D,F,C,E,G,H,I,
---------
中序遍历
B,F,D,A,C,H,G,I,E,
---------
后序遍历
F,D,B,H,I,G,E,C,A,
平衡二叉树
平衡二叉树的严义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于 1。平衡二叉查找树的精髓确实在于其巧妙地保持了树形结构的均衡性,以此确保各类操作如插入、删除与查找的时间复杂度均能维持在高效的状态。您所阐述的"平衡",实质上指的是树的左右子树高度的均衡分布,避免了树形结构退化为链式结构的可能性,进而有效防止了操作效率的显著下降至线性级别。
对于平衡二叉查找树这个概念,我觉得我们要从这个数据结构的由来,去理解“平衡”的意思。
发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。所以,平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、 比较“平衡”,不要出现左子树很高、右子树很矮的情况。
这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。所以,如果我们现在设计一个新的平衡二叉查找树,只要树的高度不比 大很多(比如树的高度仍然是对数量级的),尽管它不符合我们前面讲的严格的平衡二叉查找树的定义,但我们仍然可以说,这是一个合格的平衡二叉查找树。
在现实的应用场景中,确实存在着多种多样的平衡二叉查找树类型,诸如AVL树、红黑树以及伸展树(Splay Tree)等,每种类型都有其独特的平衡策略。例如,AVL树通过严格限定任一节点的两子树高度差不超过1,以实现树的高度均衡;而红黑树则采取颜色编码和旋转调整的间接方式,确保树的高度不会超出的范围,这一策略使红黑树在实际操作中展现出更广泛的适用性和效率优势,因为它在平衡性和操作性能间达成了精妙的平衡。
红黑树
红黑树的英文是“Red-Black Tree”,简称 R-B Tree。它是一种不严格的平衡二叉查找树,我前面说了,它的定义是不严格符合平衡二叉查找树的定义的。那红黑树究竟是怎么定义的呢?
顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:
- 根节点是黑色的;
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
为什么说红黑树是“近似平衡”的?
我们前面也讲到,平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重。
二叉查找树很多操作的性能都跟树的高度成正比。一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 log2n,所以如果要证明红黑树是近似平衡的,我们只需要分析,红黑树的高度是否比较稳定地趋近 就好了。
首先,我们来看,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?
红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。
前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。
完全二叉树的高度近似 ,这里的四叉“黑树”的高度要低于完全二叉树,所以去掉红色节点的“黑树”的高度也不会超过
。
我们现在知道只包含黑色节点的“黑树”的高度,那我们现在把红色节点加回去,高度会变成多少呢?
从上面画的红黑树的例子和定义看,在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过 ,所以加入红色节点之后,最长路径不会超过
,也就是说,红黑树的高度近似
。
所以,红黑树的高度只比高度平衡的 AVL 树的高度()仅仅大了一倍,在性能上,下降得并不多。这样推导出来的结果不够精确,实际上红黑树的性能更好。
AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。
红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。
所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。
红黑树的平衡调整
一棵合格的红黑树需要满足这样几个要求:
- 根节点是黑色的;
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。
在插入、删除节点的过程中,第三、第四点要求可能会被破坏,而“平衡调整”,实际上就是要把被破坏的第三、第四点恢复过来。
前文说到当查找树的结构发生改变时,红黑树的约束条件可能被破坏,需要通过调整使得查找树重新满足红黑树的约束条件。调整可以分为两类: 一类是颜色调整,即改变某个节点的颜色;另一类是结构调整,即改变检索树的结构关系。结构调整过程包含两个基本操作 : 左旋(Rotate Left),右旋(RotateRight)。
左旋
左旋的过程是将x的右子树绕x逆时针旋转,使得x的右子树成为x的父亲,同时修改相关节点的引用。旋转之后,二叉查找树的属性仍然满足。
TreeMap中左旋代码如下:
//Rotate Left
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
Entry<K,V> r = p.right;
p.right = r.left;
if (r.left != null)
r.left.parent = p;
r.parent = p.parent;
if (p.parent == null)
root = r;
else if (p.parent.left == p)
p.parent.left = r;
else
p.parent.right = r;
r.left = p;
p.parent = r;
}
}
右旋
右旋的过程是将x的左子树绕x顺时针旋转,使得x的左子树成为x的父亲,同时修改相关节点的引用。旋转之后,二叉查找树的属性仍然满足。
TreeMap中右旋代码如下:
//Rotate Right
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;
}
}
寻找节点后继节点
后继节点
- 前驱节点:对一棵二叉树进行中序遍历,遍历后的顺序,当前节点的前一个节点为该节点的前驱节点;
- 后继节点:对一棵二叉树进行中序遍历,遍历后的顺序,当前节点的后一个节点为该节点的后继节点;
例如一颗完全二叉树(1,2,3,4,5,6,7),按照中序遍历后的顺序为:(4,2,5,1,6,3,7),1节点的前驱节点为:5,后继节点为6.
- 当前节点的右子树不为空。则一直遍历右子树的左节点,直至为null。
- 当前节点的右子树为空,则从父节点开始寻找后继节点:
-
- 如果当前节点为父节点的左子树,则后继节点即为父节点。例:9的后继节点为10。
- 如果当前节点为父节点的右子树,则依次遍历父节点,直至遍历节点的父节点为左子树为止,因为如果遍历的父节点一直为右节点,肯定是比该节点小的,只有找到节点的父节点为左子树时,找到节点的父节点才比它大,即为后继节点。
- 如果父节点为空,返回当前节点的父节点,即==null。
后继节点在红黑树的删除操作中将会用到。
TreeMap中寻找节点后继的代码如下:
// 寻找节点后继函数successor()
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
if (t == null)
return null;
else if (t.right != null) {// 1. t的右子树不空,则t的后继是其右子树中最小的那个元素
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {// 2. t的右孩子为空,则t的后继是其第一个向左走的祖先
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
TreeMap 源码解析
TreeMap Demo
public class TreeMapDemo {
public static void main(String[] args) {
TreeSet<Student> treeSet = new TreeSet<>();
treeSet.add(new Student("张三", 18));
treeSet.add(new Student("王五", 20));
treeSet.add(new Student("赵六", 21));
treeSet.add(new Student("李四", 19));
for (Student student : treeSet) {
System.out.println(student);
}
}
static class Student implements Comparable<Student> {
private String name;
private Integer age = 0;
public Student(String name, Integer age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Student o) {
if (this.age > o.age) {
return 1;
} else if (this.age < o.age) {
return -1;
}
return 0;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + ''' +
", age=" + age +
'}';
}
}
}
Student{name='张三', age=18}
Student{name='李四', age=19}
Student{name='王五', age=20}
Student{name='赵六', age=21}
TreeMap 源码解析
Java TreeMap 实现了 SortedMap 接口,也就是说会按照key的大小顺序对 Map 中的元素进行排序, key大小的评判可以通过其本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator)。
TreeMap 底层通过红黑树(Red-Black tree)实现,也就意味着containsKey(), get(), put(), remove()都有着log(n)的时间复杂度。
出于性能原因,TreeMap是非同步的(not synchronized),如果需要在多线程环境使用,需要程序员手动同步;或者通过如下方式将TreeMap包装成(wrapped)同步的:
方法剖析
get()
get(Object key)方法根据指定的key值返回对应的value,该方法调用了getEntry(Object key)得到相应的entry,然后返回entry.value。因此getEntry()是算法的核心。算法思想是根据key的自然顺序(或者比较器顺序)对二叉查找树进行查找,直到找到满足k.compareTo(p.key) == 0的entry。
具体代码如下:
//getEntry()方法
final Entry<K,V> getEntry(Object key) {
......
if (key == null)//不允许key值为null
throw new NullPointerException();
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;
}
return null;
}
put()
put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()方法;如果没有找到则会在红黑树中插入新的entry,如果插入之后破坏了红黑树的约束条件,还需要进行调整(旋转,改变某些节点的颜色)。
public V put(K key, V value) {
......
int cmp;
Entry<K,V> parent;
if (key == null)
throw new NullPointerException();
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);
Entry<K,V> e = new Entry<>(key, value, parent);//创建并插入新的entry
if (cmp < 0) parent.left = e;
else parent.right = e;
fixAfterInsertion(e);//调整
size++;
return null;
}
上述代码的插入部分并不难理解: 首先在红黑树上找到合适的位置,然后创建新的entry并插入(当然,新插入的节点一定是树的叶子)。难点是调整函数fixAfterInsertion(),前面已经说过,调整往往需要1.改变某些节点的颜色,2.对某些节点进行旋转。
调整函数fixAfterInsertion()的具体代码如下,其中用到了上文中提到的rotateLeft()和rotateRight()函数。通过代码我们能够看到,情况2其实是落在情况3内的。情况4~情况6跟前三种情况是对称的,因此图解中并没有画出后三种情况,读者可以参考代码自行理解。
//红黑树调整函数fixAfterInsertion()
private void fixAfterInsertion(Entry<K,V> x) {
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK); // 情况1
setColor(y, BLACK); // 情况1
setColor(parentOf(parentOf(x)), RED); // 情况1
x = parentOf(parentOf(x)); // 情况1
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x); // 情况2
rotateLeft(x); // 情况2
}
setColor(parentOf(x), BLACK); // 情况3
setColor(parentOf(parentOf(x)), RED); // 情况3
rotateRight(parentOf(parentOf(x))); // 情况3
}
} else {
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK); // 情况4
setColor(y, BLACK); // 情况4
setColor(parentOf(parentOf(x)), RED); // 情况4
x = parentOf(parentOf(x)); // 情况4
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x); // 情况5
rotateRight(x); // 情况5
}
setColor(parentOf(x), BLACK); // 情况6
setColor(parentOf(parentOf(x)), RED); // 情况6
rotateLeft(parentOf(parentOf(x))); // 情况6
}
}
}
root.color = BLACK;
}
remove()
remove(Object key)的作用是删除key值对应的entry,该方法首先通过上文中提到的getEntry(Object key)方法找到key值对应的entry,然后调用deleteEntry(Entry<K,V> entry)删除对应的entry。由于删除操作会改变红黑树的结构,有可能破坏红黑树的约束条件,因此有可能要进行调整。
getEntry()函数前面已经讲解过,这里重点放deleteEntry()上,该函数删除指定的entry并在红黑树的约束被破坏时进行调用fixAfterDeletion(Entry<K,V> x)进行调整。
由于红黑树是一棵增强版的二叉查找树,红黑树的删除操作跟普通二叉查找树的删除操作也就非常相似,唯一的区别是红黑树在节点删除之后可能需要进行调整。现在考虑一棵普通二叉查找树的删除过程,可以简单分为两种情况:
- 删除点p的左右子树都为空,或者只有一棵子树非空。
- 删除点p的左右子树都非空。
对于上述情况1,处理起来比较简单,直接将p删除(左右子树都为空时),或者用非空子树替代p(只有一棵子树非空时);对于情况2,可以用p的后继s(树中大于x的最小的那个元素)代替p,然后使用情况1删除s(此时s一定满足情况1.可以画画看)。
基于以上逻辑,红黑树的节点删除函数deleteEntry()代码如下:
// 红黑树entry删除函数deleteEntry()
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
if (p.left != null && p.right != null) {// 2. 删除点p的左右子树都非空。
Entry<K,V> s = successor(p);// 后继
p.key = s.key;
p.value = s.value;
p = s;
}
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {// 1. 删除点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.left = p.right = p.parent = null;
if (p.color == BLACK)
fixAfterDeletion(replacement);// 调整
} else if (p.parent == null) {
root = null;
} else { // 1. 删除点p的左右子树都为空
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;
}
}
}
上述代码中占据大量代码行的,是用来修改父子节点间引用关系的代码,其逻辑并不难理解。下面着重讲解删除后调整函数fixAfterDeletion()。首先请思考一下,删除了哪些点才会导致调整?只有删除点是BLACK的时候,才会触发调整函数,因为删除RED节点不会破坏红黑树的任何约束,而删除BLACK节点会破坏规则4。
跟上文中讲过的fixAfterInsertion()函数一样,这里也要分成若干种情况。记住,无论有多少情况,具体的调整操作只有两种: 1.改变某些节点的颜色,2.对某些节点进行旋转。
上述图解的总体思想是: 将情况1首先转换成情况2,或者转换成情况3和情况4。当然,该图解并不意味着调整过程一定是从情况1开始。通过后续代码我们还会发现几个有趣的规则: a).如果是由情况1之后紧接着进入的情况2,那么情况2之后一定会退出循环(因为x为红色);b).一旦进入情况3和情况4,一定会退出循环(因为x为root)。
删除后调整函数fixAfterDeletion()的具体代码如下,其中用到了上文中提到的rotateLeft()和rotateRight()函数。通过代码我们能够看到,情况3其实是落在情况4内的。情况5~情况8跟前四种情况是对称的,因此图解中并没有画出后四种情况,读者可以参考代码自行理解。
private void fixAfterDeletion(Entry<K,V> x) {
while (x != root && colorOf(x) == BLACK) {
if (x == leftOf(parentOf(x))) {
Entry<K,V> sib = rightOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK); // 情况1
setColor(parentOf(x), RED); // 情况1
rotateLeft(parentOf(x)); // 情况1
sib = rightOf(parentOf(x)); // 情况1
}
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED); // 情况2
x = parentOf(x); // 情况2
} else {
if (colorOf(rightOf(sib)) == BLACK) {
setColor(leftOf(sib), BLACK); // 情况3
setColor(sib, RED); // 情况3
rotateRight(sib); // 情况3
sib = rightOf(parentOf(x)); // 情况3
}
setColor(sib, colorOf(parentOf(x))); // 情况4
setColor(parentOf(x), BLACK); // 情况4
setColor(rightOf(sib), BLACK); // 情况4
rotateLeft(parentOf(x)); // 情况4
x = root; // 情况4
}
} else { // 跟前四种情况对称
Entry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK); // 情况5
setColor(parentOf(x), RED); // 情况5
rotateRight(parentOf(x)); // 情况5
sib = leftOf(parentOf(x)); // 情况5
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED); // 情况6
x = parentOf(x); // 情况6
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK); // 情况7
setColor(sib, RED); // 情况7
rotateLeft(sib); // 情况7
sib = leftOf(parentOf(x)); // 情况7
}
setColor(sib, colorOf(parentOf(x))); // 情况8
setColor(parentOf(x), BLACK); // 情况8
setColor(leftOf(sib), BLACK); // 情况8
rotateRight(parentOf(x)); // 情况8
x = root; // 情况8
}
}
}
setColor(x, BLACK);
}