数据结构(二)——树结构模型及应用
基于树实现的数据结构,具有两个核心特征:
- 逻辑结构:数据元素之间具有层次关系;
- 数据运算:操作方法具有Log级的平均时间复杂度。
因此,树在文件系统、编译器、索引以及查找算法中有很广的应用,本节将以树-二叉树-二叉搜索树-自平衡二叉树为线索,对树及其扩展结构进行说明。
- 栈和队列在遍历树结构时的作用
- 使用二叉树对表达式进行解析
- 二叉搜索树的排序特征
- 保证最坏情况时间复杂度
- Java中的红黑树实例
一、栈和队列在遍历树结构时的作用
软件是通过数据和算法实现对现实世界的抽象,具有层次关系的数据在现实世界中能找到很多实例,比如:
- 公司组织架构:董事长-CXO-总监-经理-主管-员工;
- 中国行政区域划分:中国-省-市(县)-街道(小区)-门牌号;
- 汽车产品库:车-品牌-车系-配置。
因此,它们均可抽象为树,例如,公司组织架构可以用下图来描述:
如果对整个公司的人员进行梳理,那么就涉及到对上述架构树进行遍历。树的遍历指的是按照某种规则,不重复地访问树的所有节点的过程。由于树并非线性数据结构(比如上节所描述的线性表),因此其遍历根据访问节点的顺序,可划分为不同的方式:深度优先遍历和广度优先遍历。两者的区别在于:
- 深度优先遍历会沿着树的深度遍历树的节点,尽可能深的搜索树的分支;
- 广度优先遍历从根节点开始,沿着树的宽度遍历树的节点。
1.1 栈与深度优先遍历
深度优先遍历可进一步按照根节点与其左右子节点的访问先后顺序划分为前序遍历、中序遍历和后序遍历。根节点放在左节点的左边,称为前序遍历;根节点放在左节点和右节点的中间,称为中序遍历;根节点放在右节点的右边,称为后序遍历。树的定义通常采用递归的方式,即其节点域中包含对自身类的引用,因此树的遍历也常用递归方式来实现,下面通过伪代码对上述遍历方式进行说明。
private void traversal(TreeNode root) {
// 终止条件
if (root == null) {
return;
}
// 1. 前序遍历
// print(root.getName());
if (root.getlChild() != null) {
traversal(root.getlChild());
}
// 2. 中序遍历
// print(root.getName());
if (root.getrChild() != null) {
traversal(root.getrChild());
}
// 3. 后序遍历
// print(root.getName());
}
可见,三种遍历方式的差别仅在于对根节点与左右子节点的访问先后顺序。针对上述组织架构图,三种遍历方式的结果分别为(只有一个子节点时,默认为左节点):
- 前序遍历:A0-B0-C0-D0-E0-E1-C1-D1-E2-D2-E3-B1-C2-D3-E4-E5-C3-D4-E6
- 中序遍历:E0-D0-E1-C0-B0-E2-D1-C1-E3-D2-A0-E4-D3-E5-C2-B1-E6-D4-C3
- 后序遍历:E0-E1-D0-C0-E2-D1-E3-D2-C1-B0-E4-E5-D3-C2-E6-D4-C3-B1-A0
递归的本质是通过调用栈实现了局部变量的存储,而通过在代码中实例化栈当然也能实现该功能,所以,深度优先遍历也可采用非递归的实现,下面基于LinkedList的栈特性来实现树的前序遍历:
private void traversalWithStack(TreeNode root) {
// 1. 初始化栈并将根节点压栈
Deque<TreeNode> stack = new LinkedList<>();
stack.push(root);
// 2. 循环遍历直到栈为空
while (!stack.isEmpty()) {
// 3. 取出栈顶节点,并对其域进行访问
TreeNode head = stack.pop();
print(head.getName());
// 4. 判断右子节点、左子节点是否为空,将其入队
if (head.getrChild() != null) {
stack.push(head.getrChild());
}
if (head.getlChild() != null) {
stack.push(head.getlChild());
}
}
}
可以看出,递归与非递归的实现方式非常类似,只是前者采用方法的调用栈保存本层方法的局部变量,后者采用代码栈实现(上节已讲到LinkedList实现了Deque接口,其包含了栈和队列的常用操作方法)变量的保存而已。需要注意的是,后者需要先将右子节点进栈再将左子节点进栈。中序遍历与后序遍历也可通过非递归的方式来实现,读者可自行理解。
1.2 队列与广度优先遍历
树的广度优先遍历也称为按层次遍历,即从根节点开始,一层一层的访问。实现的核心是通过队列的入队和出队操作,具体如下:
private void layerTraver(TreeNode root) {
// 1. 初始化队列并将根节点入队
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
// 2. 循环遍历队列直到队列为空
while (!queue.isEmpty()) {
// 3. 取出头结点,并对其域进行访问
TreeNode head = queue.poll();
print(head.getName());
// 4. 判断左右子节点是否为空,将其入队
if (head.getlChild() != null) {
queue.offer(head.getlChild());
}
if (head.getrChild() != null) {
queue.offer(head.getrChild());
}
}
}
因此,采用广度优先遍历上述架构图的顺序为:A0-B0-B1-C0-C1-C2-C3-D0-D1-D2-D3-D4-E0-E1-E2-E3-E4-E5-E6。
由于树的非线性结构,从给定的某个节点出发,有多个可以前往的下一个节点,所以在顺序计算的情况下,只能推迟对某些节点的访问——即以某种方式保存起来以便稍后再访问。常见的做法是采用栈(LIFO)或队列(FIFO)。在深度优先遍历中,采用了递归的形式来说明三种遍历方式的区别,其实质可以理解为通过调用栈来实现延迟节点的保存,非递归的实现也说明了栈对延迟节点的存储作用;广度优先遍历则采用了队列来保存这些延迟节点。
二、使用二叉树对表达式进行解析
二叉树是编译器设计领域重要的数据结构之一,比如语法分析过程中使用的语法树。表达式是编程中最常见的的语法形式,比如定义一个int变量:int x = 3+6*7-(5+9)/2+4,我们能很轻松的算出x=42,可是编译器是如何计算3+6*7-(5+9)/2+4的呢?
首先,3+6*7-(5+9)/2+4是一个中缀表达式,相应的前缀表达式和后缀表达式分别为(上节中描述了如何通过栈将中缀表达式转换为后缀表达式以及后缀表达式的计算):
- 前缀表达式:+3-*67+/+5924
- 后缀表达式:367*59+2/4+-+
实际上,使用二叉树对上述表达式进行解析,就可以得到叶节点为操作数,其他节点为操作符的表达式树。前缀、中缀和后缀表达式分别对应了表达式树的前序、中序和后序遍历。在实际情况中,前缀表达式使用较少,中缀表达式符合人的理解习惯,但对计算机来讲,运算规则复杂,不能从左到右顺序进行,不利于计算机处理,而后缀表达式则更加适合。
下面演示如何通过后缀表达式来构建表达式树,这里需要用到栈和二叉树两种数据结构,从左到右依次读取后缀表达式367*59+2/4+-+,如果是数字则直接将其压入栈中;如果是操作符,则从栈中弹出两个操作数T1和T2,用该操作符(根节点)和T1(左子树)、T2(右子树)组成一个二叉树,然后将该二叉树压入栈中。
-
将3、6、7依次压入栈中;
-
乘号入栈,从栈中取出6和7,组成二叉树,并将该树压入栈中;
-
将5、9依次压入栈中;
-
加号入栈,从栈中取出5和9,组成二叉树,并将该树压入栈中,其次将2入栈;
-
除号入栈,从栈中取出二叉树(5+9)和2,组成新二叉树,并将该树压入栈中,其次将4入栈;
-
加号入栈,从栈中取出二叉树((5+9)/2)和4,组成新二叉树,并将该树压入栈中;
-
减号入栈,从栈中取出二叉树(6*7)和((5+9)/2+4),组成新二叉树,并将该树压入栈中;
- 加号入栈,从栈中取出3和二叉树(6*7-(5+9)/2+4),组成新二叉树,并将该树压入栈中;
表达式树是将我们原来可以直接由代码编写的逻辑以表达式的方式存储在树状的结构里,从而可以在运行时去解析这个树,然后执行,实现动态的编辑和执行代码。
三、二叉搜索树的排序特征
相比于普通二叉树,二叉搜索树的关键特征是:
- 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 任意节点的左、右子树也分别为二叉搜索树;
- 没有键值相等的节点。
上述特征意味着二叉搜索树中所有的项都要能够排序,在Java中,可以用Comparable接口来表示这种性质。正是因为这种排序特征,使其查找、插入的时间复杂度较低。
3.1 查找和插入
查找过程从根节点开始,比较待查找节点的值与根节点值的大小,如果小于,就递归查找左子树;如果大于,就递归查找右子树;如果等于,则查找过程结束。比如在下列二叉树中搜索32的过程如下:
- 32 > 23,查找右子树;
- 32 < 35,查找左子树;
- 32 > 30,查找右子树;
- 32 == 32,查找过程结束。
把这个过程翻译成代码如下:
private boolean searchBST(T t, BinaryNode<T> root) {
// 1.如果被比较节点为空,说明没有找到匹配项,直接返回false
if (root == null) {
return false;
}
// 2.比较节点值的大小
int compareResult = compare(t, root.getElement());
if (compareResult < 0) {
// 3.如果小于,就递归查找左子树
return searchBST(t, root.getlChild());
} else if (compareResult > 0) {
// 4.如果大于,就递归查找右子树
return searchBST(t, root.getrChild());
} else {
// 5.如果等于,则查找过程结束,返回查找成功
return true;
}
}
整个查找过程形成由根节点开始的一直向下的一条路径,假定树的高度为h,那么查找算法的时间复杂度就是O(h)。另外,和遍历一样,除了通过递归实现元素查找外,也可以通过非递归的方式实现,其核心是改变对二叉搜索树中被比较节点的引用。
private boolean searchBST(T t, BinaryNode<T> root) {
int compareResult;
// 1.当被比较节点不为空且没找匹配项时,继续查找
while (root != null && (compareResult = compare(t, root.getElement())) != 0) {
if (compareResult < 0) {
// 2.如果小于,就引用被比较节点的左子树
root = root.getlChild();
} else {
// 3.如果小于,就引用被比较节点的右子树
root = root.getrChild();
}
}
// 4.根据被比较节点的最终引用是否为空,判断是否找到匹配项
return root != null;
}
由于二叉搜索树的特性,其最小值位于树的最左侧,最大值位于树的最右侧,因此,也可以使用类似上述查找方法进行最小值和最大值的查找。下面是其非递归实现:
private BinaryNode<T> findMin(BinaryNode<T> root) {
if (root != null) {
// 1.退出条件:该节点没有左节点
while (root.getlChild() != null) {
// 2.循环:将该节点引用置为其左子节点
root = root.getlChild();
}
}
return root;
}
private BinaryNode<T> findMax(BinaryNode<T> root) {
if (root != null) {
// 1.退出条件:该节点没有右节点
while (root.getrChild() != null) {
// 2.循环:将该节点引用置为其右子节点
root = root.getrChild();
}
}
return root;
}
在设计递归/循环这类算法时,有两个关键点:
1、递归/循环主结构。通过对待求解问题的分解,抽象主问题与子问题之间相同的核心逻辑,这个逻辑就是主结构。以递归实现斐波拉契数列 f(n) = f(n-1) + f(n-2)为例,其核心的主结构即为当前项等数列前面两项的和,比如n=5,那么f(5) = f(4) + f(3) = (f(3) + f(2)) + (f(2) + f(1)) = ……= 5;
2、边界点表现。递归的上升(弹栈)和循环退出是验证算法在边界点表现的依据,比如上述查找过程中的退出递归的点就是边界点。
插入操作的关键是找到插入点,首先这个插入点一定是叶子节点(相等元素除外),因此,插入就是在查找的基础上,新增一个叶子节点。以在上述二叉查找树中插入节点20为例,下图表示具体的插入过程:
翻译成代码如下:
private BinaryNode<T> insert(T t, BinaryNode<T> root) {
// 1.如果root为空,则说明此处为插入点
if (root == null) {
return new BinaryNode<>(t, null, null);
}
// 2.比较节点值的大小
int compareResult = compare(t, root.getElement());
if (compareResult < 0) {
// 3.递归调用插入左子树
root.setlChild(insert(t, root.getlChild()));
} else if (compareResult > 0) {
// 4.递归调用插入右子树
root.setrChild(insert(t, root.getrChild()));
}
return root;
}
可见,插入与查找的核心区别就是对空节点的处理,查找遇到空节点,表示已经查找结束,没有找到被查节点;插入遇到空节点,表示找到了插入点,于是新增一个节点。
3.2 删除
在二叉搜索树中,一个节点的子节点有三种可能:1)无子节点,即该节点为叶子节点;2)有一个子节点;3)有两个子节点。删除节点需要针对这三种情况进行不同的处理:
- 首先,删除叶子节点对其它节点没有影响,因此,查找到该节点之后,直接删除;
- 对于有一个子节点的情况,删除该节点意味着将父节点对其的引用转接到其子节点上,对此外的节点无影响;
- 删除有两个子节点的节点的方法有两种:a) 从该节点的左子树中找到最大的元素;b) 从该节点的右子树中找到最小的元素,并用找到的元素来取代该节点。
下图以删除节点35为例,35有两个子节点,从右子树中找到最小的元素48,然后用48来代替35所在的位置。
private BinaryNode<T> remove(T t, BinaryNode<T> root) {
// 1.如果root为空,则可删除节点为null
if (root == null) {
return root;
}
// 2.比较节点值的大小
int compareResult = compare(t, root.getElement());
if (compareResult < 0) {
// 3.在左子树上递归删除目标节点
root.setlChild(remove(t, root.getlChild()));
} else if (compareResult > 0) {
// 4.在右子树上递归删除目标节点
root.setrChild(remove(t, root.getrChild()));
} else if (root.getlChild() != null && root.getrChild() != null) {
// 5.找到该节点,并且该节点左右子树均不空
// 将该节点的值设为右子树的最小值
root.setElement(findMin(root.getrChild()).getElement());
// 因为最小值没有左节点,所有删除操作是前两种情况之一
root.setrChild(remove(root.getElement(), root.getrChild()));
} else {
// 6.前两种情况直接改变引用即可
root = (root.getlChild() != null) ? root.getlChild() : root.getrChild();
}
return root;
}
四、保证最坏情况时间复杂度
从上节可知,由N个节点组成的二叉搜索树,其操作方法时间复杂度为O(h),h为树的高度。树的高度依赖于树的拓扑结构,如果节点均匀分布,则高度为logN;但是,如果遇到插入节点的值依次减少(或增大),则二叉搜索树退化为链表,高度变为N,那么查找、插入和删除的时间复杂度均为O(N),就失去了二叉搜索树最核心的时间复杂度优势。
那么,如何保持二叉搜索树的高度在最坏情况下依然是logN呢?自平衡二叉树是通过约束所有叶子的深度趋于平衡达到该目的的。具体实现的方法一般是对不平衡二叉搜索的节点进行旋转操作,常见的平衡二叉树类型包括AVL树、伸展树、红黑树、2-3树、AA树等。
以AVL树为例,要求任何节点的两个子树的高度最大差别为1,保证了树的高度平衡性,因此,查找、插入和删除的时间复杂度始终保持在logN的水平。在实现上,一般通过对不平衡的树进行旋转,使其重新达到平衡,旋转分为四种场景:
- 造成不平衡的节点为其父节点的左子节点,其父节点为其祖父节点的左子节点,简称左左;
上图中,节点关系:③ < ② < ①,高度为3。通过右旋,将①变为②的右子节点,同时,将②的右子节点变为①的左子节点(根据二叉搜索树的特征,图中节点B一定小于节点1)。可见,右旋后依然保持了二叉搜索树的排序特征,却使得整体的高度变为2,降低了1。翻译成代码如下:
private void rotateRight(Entry<K,V> p) {
if (p != null) {
// 获取p节点右子节点l
Entry<K,V> l = p.left;
// 将l节点的右子节点设置为p节点的左子节点
p.left = l.right;
// 如果l节点的右子节点不为空,设置p节点为其父节点
if (l.right != null) l.right.parent = p;
// 将p节点的父节点设置为l节点的父节点
l.parent = p.parent;
// p节点的父节点为空,则设置l节点为根节点
if (p.parent == null)
root = l;
// 否则设置p节点的父节点对l节点的引用
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
// 改变p节点和l节点的关系
l.right = p;
p.parent = l;
}
}
- 造成不平衡的节点为其父节点的右子节点,其父节点为其祖父节点的右子节点,简称右右;
上图中,节点关系:① < ② < ③,高度为3。通过左旋,将①变为②的左子节点,同时,将②的左子节点变为①的右子节点(根据二叉搜索树的特征,图中节点B一定大于节点1)。可见,左旋后依然保持了二叉搜索树的排序特征,却使得整体的高度变为2,降低了1。翻译成代码如下:
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
// 获取p节点右子节点r
Entry<K,V> r = p.right;
// 将r节点的左子节点设置为p节点的右子节点
p.right = r.left;
// 如果r节点的左子节点不为空,设置p节点为其父节点
if (r.left != null) r.left.parent = p;
// 将p节点的父节点设置为r节点的父节点
r.parent = p.parent;
// p节点的父节点为空,则设置r节点为根节点
if (p.parent == null)
root = r;
// 否则设置p节点的父节点对r节点的引用
else if (p.parent.left == p)
p.parent.left = r;
else p.parent.right = r;
// 改变p节点和r节点的关系
r.left = p;
p.parent = r;
}
}
- 造成不平衡的节点为其父节点的左子节点,其父节点为其祖父节点的右子节点,简称左右;
上图中,节点关系:① < ③ < ②,高度为3。先通过右旋,将②变为③的右子节点,同时,将③的右子节点变为②的左子节点(根据二叉搜索树的特征,图中节点D一定小于节点2)。后通过左旋,将①变为③的左子节点,同时,将③的左子节点变为①的右子节点(根据二叉搜索树的特征,图中节点C一定大于节点1)。可见,右左双旋后依然保持了二叉搜索树的排序特征,却使得整体的高度变为2,降低了1。
- 造成不平衡的节点为其父节点的右子节点,其父节点为其祖父节点的左子节点,简称右左;
上图中,节点关系:② < ③ < ①,高度为3。先通过左旋,将②变为③的左子节点,同时,将③的左子节点变为②的右子节点(根据二叉搜索树的特征,图中节点C一定大于节点2)。后通过右旋,将①变为③的右子节点,同时,将③的右子节点变为①的左子节点(根据二叉搜索树的特征,图中节点D一定小于节点1)。可见,左右双旋后依然保持了二叉搜索树的排序特征,却使得整体的高度变为2,降低了1。
可见,双旋(左右和右左)其实就是综合左旋和右旋操作,旋转的本质是一种在保持二叉搜索树排序特征的情况下,通过改变节点间的链接关系,降低树的高度的方法。
五、Java中的红黑树实例
在Java集合框架中,Map和Set分别有基于树的实现和基于散列的实现,其实现类如下表所示。
分类 | 基于散列实现 | 基于树实现 |
---|---|---|
Map | HashMap | TreeMap |
Set | HashSet | TreeSet |
在大多数场景下,基于散列的实现是最好的选择,除非需要强调元素的顺序,才使用基于树的实现。本节将重点说明如何基于红黑树实现TreeMap和TreeSet,HashMap和HashSet将在散列章节中说明。
HashMap/HashSet的扩展类LinkedHashMap/LinkedHashSet也能保持元素的顺序,区别在于,TreeMap/TreeSet的顺序是基于红黑树原理对Key比较实现的,而LinkedHashMap/LinkedHashSet是基于链表原理保持元素的插入顺序。
在实现上,TreeMap实现了NavigableMap接口,而NavigableMap直接继承自SortedMap,从字面上就可看出,TreeMap是一种支持节点排序的Map,其排序依据构造方法传入的Comparator比较器。如果Comparator为空,则默认按照按键的自然顺序升序进行排序。
List sequence = Arrays.asList("a", "1", "A");
Comparator<String> comparator = new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return sequence.indexOf(o1) - sequence.indexOf(o2);
}
};
Map<String, String> treeMap = new TreeMap<>(comparator);
treeMap.put("A", "This is A");
treeMap.put("1", "This is 1");
treeMap.put("a", "This is a");
for (Map.Entry<String, String> entry : treeMap.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
上述实例根据字符在线性表中的顺序自定义了比较器,通过是否在TreeMap构造器中使用该比较器,输出结果顺序分为两种:
- 使用时的输出顺序:“a”,“1”,“A”
- 不使用时的输出顺序:“1”,“A”,“a”
可见,使用比较器时,输出按照线性表中字符的顺序,不使用时则按照字符的自然顺序(ASCII码值)升序排序。实现这种顺序区别的关键在于,使用TreeMap的put方法进行对象插入时,如果Comparator不为空,则通过Comparator的compare方法实现比较,否则将key强转为Comparable对象,然后通过其compareTo方法实现比较。
Comparator<? super K> cpr = comparator;
cmp = cpr.compare(key, t.key);
Comparable<? super K> k = (Comparable<? super K>) key;
cmp = k.compareTo(t.key);
TreeMap的put方法其余的实现与上述在二叉搜索树中插入节点的原理一致,只是TreeMap基于红黑树,相比于普通二叉搜索树,红黑树的节点增加了颜色属性,且取值为黑色或红色。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;
}
在二叉搜索树的要求之外,红黑树增加了如下的额外要求:
- 节点是红色或黑色。
- 根是黑色。
- 所有叶子都是黑色(叶子是NIL节点)。
- 每个红色节点必须有两个黑色的子节点。
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
上述要求4保证了从根到叶子节点的所有路径上不能有两个连续的红色节点,因此,结合要前三点要求得出:最短的路径全是黑色节点,最长的路径是红色和黑色交替。然而第5点要求所有简单路径都包含相同数目的黑色节点,所以得出结论:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。下图是一颗红黑树实例:
相比于AVL树要求任何节点的两个子树的高度最大差别为1,保证高度平衡性,红黑树只要求部分达到平衡,降低了对旋转的要求,因此,当大量数据需要插入和删除时,AVL树需要重新平衡的频率就更高。
具体来讲,对于插入操作引起的树的不平衡,AVL树和红黑树都需要经过两次旋转,使得树重新平衡;而删除操作引起的不平衡,最坏情况下AVL树可能需要重新平衡从被删除节点到根节点的整个路径,而红黑树最多只需要三次旋转(后续说明),综合起来看,红黑树的统计性能是优于AVL树的。正是基于这一点,TreeMap才基于红黑树实现。
红黑树是一种自平衡的二叉搜索树,因此,其操作方法(插入和删除)也是在二叉搜索树操作方法的基础上,增加了自平衡修复操作来完成的,在使用TreeMap的put和remove方法插入和删除元素后,分别调用了fixAfterInsertion和fixAfterDeletion,具体包括两步:旋转和重新着色,下面将重点分析其实现原理。
5.1 插入节点后的修复
在红黑树中,新插入的节点着色为红色(不增加路径上的黑色节点),根据其位置、父节点着色等条件,需要根据不同情况对红黑树进行修复。为了方便表述,这里对节点名称做如下约定:新插入的节点定义为N节点,N节点的父节点定义为P节点,P节点的兄弟节点定义为U节点,P节点的父节点(N节点的祖父节点)定义为G节点。
首先,P节点的情况分为三种:
情形1:无P节点。即N节点为根节点,没有父节点。
修复方式:直接将它设置为黑色以满足红黑树性质2。
情形2:P节点为黑色。
修复方式:由于插入节点总会有两个黑色的叶子节点(NIL节点),所以不会破坏红黑树性质4;另外,由于没有增加黑色节点,所以红黑树性质5依然保持。综上,不需要对N节点做额外修复。
情形3:P节点为红色。且P节点为G节点的左子节点
修复方式:由于N、P节点都为红色,所以首先破坏了红黑树性质4,因此需要对其修复。具体的修复方法需要根据U节点和G节点的情况进行细分:
- 情形3.a:P节点和U节点都是红色
修复方式:将P节点、U节点都设置为黑色,并将G节点设为红色,以G节点为当前节点,对树进行递归修复。
由于从P节点、U节点到根节点的任何路径都必须通过G节点,通过上述方式修复后,这些路径上的黑节点数目没有改变(原来有叶子和G节点两个黑色节点,现在有叶子和P两个黑色节点)。
- 情形3.b:U节点为黑色(或缺失),N节点为P节点的右子节点
修复方式:将P节点设置为当前节点,对P节点进行一次左旋。
由于N、P节点都为红色,所以旋转不改变红黑树的性质。
- 情形3.c:U节点为黑色(或缺失),N节点为P节点的左子节点
修复方式:将P节点设置为黑色,将G节点设置为红色,然后对G节点进行一次右旋。
因为通过这N、P、G节点的所有路径旋转前都通过G节点,旋转都通过P节点。旋转前后,三个节点中都只有唯一的黑色节点,所以保持了红黑树的性质5。
下面看下在TreeMap中,fixAfterInsertion方法如何对上述分析过程代码化。
private void fixAfterInsertion(Entry<K,V> x) {
// 新插入节点着色为红色
x.color = RED;
// 循环修复由新插入x节点引起的不平衡,直到根节点,或其父节点为黑色
while (x != null && x != root && x.parent.color == RED) {
// x节点的父节点为其祖父节点的左子节点
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
// 获取x节点的叔节点(父节点的兄弟节点)
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
// 对应情形3.a:P节点和U节点都是红色
if (colorOf(y) == RED) {
// 设置P节点为黑色
setColor(parentOf(x), BLACK);
// 设置U节点为黑色
setColor(y, BLACK);
// 设置G节点为红色
setColor(parentOf(parentOf(x)), RED);
// 以G节点为当前节点
x = parentOf(parentOf(x));
} else {
// 对应情形3.b:P节点为红色,U节点为黑色,且N节点为P节点的右子节点
if (x == rightOf(parentOf(x))) {
// 设置P节点为当前节点
x = parentOf(x);
// 左旋当前节点
rotateLeft(x);
}
// 对应情形3.c:P节点为红色,U节点为黑色,且N节点为P节点的左子节点
// 设置P节点为黑色
setColor(parentOf(x), BLACK);
// 设置G节点为红色
setColor(parentOf(parentOf(x)), RED);
// 右旋G节点
rotateRight(parentOf(parentOf(x)));
}
// x节点的父节点为其祖父节点的右子节点
} else {
// ……
}
}
// 将根节点设置为黑色
root.color = BLACK;
}
从上述情形3.b和情形3.c可以看出,其实就是对不平衡的树进行了一次左右双旋,同样的,当P节点为G节点的右子节点,N节点为P节点的左子节点时,就需要对不平衡的树进行右左双旋。上述代码中else的部分对其进行了实现,由于原理在上节已经阐述过了,这里不再赘述。相比于AVL树需要多次旋转,红黑树通过重新着色来保持相对的平衡。
5.2 删除节点后的修复
前面已经分析过,二叉搜索树删除节点分为三种情况:1)无子节点;2)有一个子节点;3)有两个子节点。红黑树加入了颜色属性后,删除节点可能破坏红黑树的性质,因此需要分情况对其进行分析,而由于删除红色节点并不影响红黑树的特性,所以这里重点分析删除黑色节点的情况。
为了方便表述,这里对节点名称做如下约定:替代被删除的节点位置的新节点定义为N节点(被删除的节点右子树中的最小值节点),N节点的父节点定义为P节点,P节点的兄弟节点定义为U节点,U节点的左、右子节点分别定义为L节点和R节点。
情形1:N节点为红色
修复方式:直接将其着色为黑色,红黑树的性质得到修复;
情形2:N节点是黑色,且位于根节点
修复方式:无需修复,红黑树性质保留;
情形3:N节点为黑色,且不位于根节点
- 情形3.a:U节点为红色,P节点、L节点和R节点均为黑色
修复方式:将U节点设置黑色,P节点设置为红色,左旋P节点,同时将L节点指定为新的U节点。
- 情形3.b:U节点为黑色,P节点为红色,L节点和R节点均为黑色
修复方式:将U节点设为红色,同时指定P节点为新的N节点。
- 情形3.c:U节点为黑色,P节点为红色,L节点为红色,R节点为黑色
修复方式:将L节点设为黑色,将U节点设为红色,右旋U节点,同时将L节点指定为新的U节点。
- 情形3.d:U节点为黑色,P节点为红色、L节点为任意颜色,R节点为红色
修复方式:将P节点的颜色赋值给U节点,将P节点设为黑色,将R节点的右子节设为黑色,左旋P节点,设置N为根节点。
下面看下在TreeMap中,fixAfterDeletion方法如何对上述分析过程代码化。
private void fixAfterDeletion(Entry<K,V> x) {
// 循环修复不平衡,直到根节点,或其N节点为红色
while (x != root && colorOf(x) == BLACK) {
// N节点为其父节点的左子节点
if (x == leftOf(parentOf(x))) {
// 获取U节点
Entry<K,V> sib = rightOf(parentOf(x));
// 对应情形3.a:U节点为红色
if (colorOf(sib) == RED) {
// 设置U节点为黑色
setColor(sib, BLACK);
// 设置P节点为红色
setColor(parentOf(x), RED);
// 左旋父节点
rotateLeft(parentOf(x));
// 重新指定U节点
sib = rightOf(parentOf(x));
}
// 对应情形3.b:L节点和R节点均为黑色
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
// 设置U节点为红色
setColor(sib, RED);
// 指定P节点为N节点
x = parentOf(x);
} else {
// 对应情形3.c:L节点为红色,R节点为黑色
if (colorOf(rightOf(sib)) == BLACK) {
// 设置L节点为黑色
setColor(leftOf(sib), BLACK);
// 设置U节点为红色
setColor(sib, RED);
// 右旋U节点
rotateRight(sib);
// 重新指定U节点
sib = rightOf(parentOf(x));
}
// 对应情形3.d:L节点为任意颜色,R节点为红色
// 将U节点着色为P节点的颜色
setColor(sib, colorOf(parentOf(x)));
// 将P节点设置为黑色
setColor(parentOf(x), BLACK);
// 将R节点设置为黑色
setColor(rightOf(sib), BLACK);
// 左旋P节点
rotateLeft(parentOf(x));
// 将N节点设置为根节点
x = root;
}
} else {
// ……
}
}
// 设置N节点为红色
setColor(x, BLACK);
}