二叉搜索树
何为二叉搜索树?
二叉搜索树是一种特殊的二叉树,它的左子节点总是小于或等于根节点,而右子节点 总是大于或等于根节点。
如下图,即是一颗二叉搜索树。
对于二叉搜索树来说,中序遍历可以遍历按照节点值递增的顺序遍历二叉搜索树的每个节点。比如上述中序遍历的顺序为节点4、节点5、节点6、节点7、节点8、节点9和节点10。
查找
对于普通的二叉树来说,查找一个值需要遍历整颗二叉树,因此需要O(n)的时间。但是对于二叉搜索树来说,则完全不需要如此麻烦,如果当前节点小于要查找的值,则往其右节点继续查找;如果当前节点大于要查找的值,则往其左节点继续查找。如此重复直到找到即可,那么此时搜索时间复杂度仅仅是二叉树的高度h,即O(h)。一个高度平衡的二叉搜索树的高度近似可以看作为log(n)。
代码:二叉搜索树如何查找一个值。
fun binarySearchTreeFind(node: TreeNode1, value: Int): TreeNode1? {
var currentNode: TreeNode1? = node;
while (currentNode != null) {
if (currentNode.value == value) {
break
}
if (currentNode.value > value) {
currentNode = currentNode.leftNode
} else {
currentNode = currentNode.rightNode
}
}
return currentNode
}
//使用
binarySearchTreeFind(Helper.createBinarySearchTree(),9).let {
println("value is ${it?.value?:"is null"}")
}
//结果
value is 9
红黑树(自平衡二叉树)
为什么要有红黑树?
从上我们知道二叉搜索树是一种很有用的数据结构,如果深度为H,则增删查的时间复杂度均为O(H)。如果二叉搜索树是平衡的,那么其深度近似等于logn
。但是在极端情况下,二叉搜索树均只有一个节点,那么其高度则为n-1
,此时时间复杂度就变为O(n)。所以二叉搜索树是否平衡很重要。
另外我们知道hash
表的增删查的时间复杂度都是O(1),这个也就是O(H)呀。但是hash
表有一个致命的缺点就是它只能根据具体的key
进行查找,如果需要根据数值的大小查找,如查找数据集合中比某个值大的所有数字中的最小的一个,哈希表就无能为力了。
而java
则是根据红黑树这种平衡而二叉搜索树实现了TreeSet
和TreeMap
这两种数据结构。所以我们有必要了解一下红黑树。
红黑树是一种特殊的二叉查找树,结构如其名,每个结点都要储存位表示结点的颜色,或红或黑。
红黑树需要遵守如下几个特性:
-
每个节点或红或黑
-
根节点是黑色
-
空叶子节点是黑色
-
如果一个节点是红色,那么其子节点是黑色
-
从任意一个节点出发到空的叶子节点经过的黑节点个数相同
从3和5又可以推出:
5.1: 如果一个节点存在黑子节点,那么该结点肯定有两个子节点。
只要遵守上述规则就可以了,而对于增加、删除时会破坏红黑树的规则
,其中最主要的破坏的是以下两点:
- 如果一个节点是红色,那么其子节点是黑色,红色节点是被黑色节点隔开的。
- 从任意一个节点出发到空的叶子节点经过的黑节点个数相同
显而易见,如果破坏规则了,我们就是需要按照规则还原即可。
如何还原呢?通过变色、左旋以及右旋,变色即为将节点的颜色由红色变为黑色或者由黑色变为红色。除此之外还需要了解左旋以及优选。
基本操作之旋转
旋转操作仅仅只是用来调节结点的位置的,就是为了满足红黑树的性质5。
左旋
以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父节点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。
右旋
以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父节点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。
红黑树基本操作
元素添加
操作
当作二叉搜索树一样插入节点,将节点赋为红色,判断如果不符合要求则通过旋转或者着色的方式使之重新成为一颗二叉树。
为什么将新加入的节点赋值为红色呢?
- 不违背性质5,从任意一个节点出发到空叶子节点经过的黑色节点个数相同。
- 根据性质4,可知黑色节点数至少是红色节点数的两倍,故,直接插入红色节点其父节点是黑色的可能性比较大,此时不需要进行额外进行调整。
我们现在在分析一下之前的性质,思考插入一个红色节点会违背上述那几个约定呢?很明显1、2、3、5都不会违背,只有4有可能违背。
下面具体分析一下插入的情况
-
插入的节点为根节点
直接将红色赋值为黑色
-
父节点为黑色节点
不需要进行任何操作
-
父节点为红色节点的情况下,叔叔节点为红色节点
- 将叔叔和父亲结点改为黑色,爷爷结点改为红色。继续将爷爷结点当作插入结点看待,重复之前的操作,直到当前结点为根结点,然后将根结点变成黑色。
-
父节点为红色节点的情况下,叔叔节点为黑色节点
-
父亲结点为爷爷结点的左孩子,新插入结点为父节点的左孩子(左左情况)
-
父亲结点为爷爷结点的右孩子,新插入结点为父亲结点的右孩子(右右情况)
以上两种情况,均进行这样处理:将父亲结点和爷爷结点的颜色互换,然后针对爷爷结点进行一次左旋
-
插入结点是右结点,父节点是左结点
-
插入结点是左结点,父亲结点是右结点
以上两种情况,均进行这样处理:针对父结点进行左旋,此时左旋后的情况是左左或者右右的情况,然后按照左左或者右右的的情况处理。
-
元素删除
操作
将红黑树作为二叉搜索树进行查找,找到后将该节点从树中删除,然后通过着色、旋转等操作使其重新成为一颗红黑树。
删除操作涉及到要删除的节点是否存在子节点,所以可以分为以下三个大的情况:
- 要删除的节点没有子节点
- 要删除的节点存在一个子节点
- 要删除的节点存在两个子节点
首先看,要删除的节点没有子节点
-
删除的节点为红色节点
直接删除即可
-
删除节点为黑色,其兄弟节点没有儿子。
兄弟节点变红,父亲节点变黑,然后将父亲节点当作当前节点目前的这几种情形处理,直至到根节点。
-
删除节点为黑色,兄弟节点有一个孩子不空,并且该孩子和兄弟节点在同一边(同为左子树或者同为右子树):
交换兄弟节点和父亲节点的颜色,并且把父亲节点和兄弟节点的子结点涂成黑色,之后进行如下操作
- 兄弟节点和兄弟节点的子节点都在右子树;对兄弟节点进行左旋
- 兄弟节点和兄弟节点的子节点都在左子树;对兄弟节点进行右旋
-
删除节点是黑色,兄弟节点有一个不空,该孩子和兄弟不在同一边
- 将兄弟节点和兄弟节点的子节点颜色进行互换
- 兄弟节点是左子树,兄弟节点的子节点是右子树;对兄弟节点进行左旋
- 兄弟节点是右子树,兄弟节点的子节点是左子树;对兄弟节点进行右旋
- 按照3处理
-
删除节点为黑色,兄弟节点两个孩子(空节点也算作子节点),且颜色均为黑色
父节点和兄弟节点颜色互换
-
删除节点为黑色,兄弟节点有两个孩子,且兄弟节点两个孩子为红色。
- 父节点和兄弟节点颜色互换
- 被删除的元素为左节点,对父节点进行左旋
- 被删除的元素为右节点,对父节点进行右旋
- 按照5处理
下面讨论,删除的节点存在一个子节点的情况
-
删除节点为黑色,儿子节点无论左右位置
将子节点涂为黑色,放到被删除节点的位置
-
不存在被删除节点为红色节点的情况,因为这种情况不满足性质5
最后讨论,删除的节点存在两个孩子的情况
找到删除节点的右子树中最左的节点,两两交换,然后删除节点的情况就变成了下面的情况之一了,然后进行相关的处理即可。
1.删除节点只有一个儿子的情况
2.删除节点没有儿子的情况
红黑树高度上线证明推导
从上面知道红黑树的增删查时间复杂度均为O(H)。
那么其高度H近似等于多少呢?这里列出证明
首先结论是:h≤2log2(n+1)
。
证明:
我们规定,从任意节点出发,到其子树的子叶节点的路径中黑色节点的数量称为该节点的黑高,即为bh
。
设:根节点为T
,则根节点的黑高则为bh(T)
。
根据红黑树的性质,可知红色节点不可以相邻,但是没有规定黑色节点不可以相邻,所以可以假设极端情况下一颗红黑树均为黑色节点。那么此时我们可以得出,这样一棵树的节点数n
和树高的关系为n=2^bh(T)-1
。
但是对于红黑树而言会包含红色节点,所以一定有n≥2^bh(T)-1
(1)。
另外根据红黑树的性质,黑高至少为树高的二分之一,即bh(T)≥h/2
(2)。
则根据(1)式和(2)式可得n≥2^bh(T)-1≥2^(h/2)-1
即n≥2^(h/2)-1
。
然后我们推导一下:
2^(h/2)≤n+1
log2^(n+1)≥h/2
h≤2log2(n+1)
所以最终可得:h≤2log2(n+1)
。
JAVA TreeMap的简单应用(基于红黑树)
Java根据红黑树这种平衡的二叉搜索树实现
TreeSet
和TreeMap
结构。
TreeSet
常用的函数:
序号 | 函数 | 函数功能 |
---|---|---|
1 | ceiling | 返回大于或等于给定值的最小值,没有返回null |
2 | floor | 返回小于或等于给定值的最大值,没有返回null |
3 | higher | 返回大于给定值的最小值,没有返回null |
4 | lower | 返回小于给定值的最大值,没有返回null |
TreeMap
常用的函数:
序号 | 函数 | 函数功能 |
---|---|---|
1 | ceilingEntry/ceilingKey | 返回大于或等于给定值的最小映射/值,没有返回null |
2 | floorEntry/floorKey | 返回小于或等于给定值的最大映射/值,没有返回null |
3 | higherEntry/higherKey | 返回大于给定值的最小映射/值,没有返回null |
4 | lowerEntry/lowerKey | 返回小于给定值的最大映射/值,没有返回null |
接下来写一个例子,利用TreeMap
实现日程表的功能:
题目如下
请实现一个类型MyCalendar
用来记录自己的日程安排,该类型用方法book(int start,int end)在日程表中添加一个时间区域为[start,end)的事项(这是一个半开半闭区间)。如果[start,end)中之前没有安排其他事项,则成功添加该事项并返回true;否则,不能添加该事项,并返回false。
比如:下面的3次调用book方法中,第2次调用返回false,这是因为时间[15,20)已经被第1次调用预留了。由于第1次占用的时间是一个半开半闭区间,并没有真正占用时间20,因此不影响第3次调用预留时间区间[20,30)。
val calendar = MyCalendar()
println(calendar.book(10, 20))
println(calendar.book(15, 25))
println(calendar.book(20, 30))
//结果
true
false
true
题目分析
如果待添加的事项占用的时间区间是[m,n),就需要找出开始时间小于m的所有事项中开始最晚的一个,以及开始时间大于m的所有事项中开始最早的一个。如果待添加的事项和这两个事项都没有重叠,那么该事项可以添加在日程表中。
代码
class MyCalendar {
/**
* key : start
* value : end
*/
private val events = TreeMap<Int, Int>()
fun book(start: Int, end: Int): Boolean {
val event = events.floorEntry(start)
if (event != null && event.value > start) {
return false
}
val event2 = events.ceilingEntry(start)
if (event2 != null && event2.key < end) {
return false
}
//存入
events[start] = end
return true
}
}
🙆♀️。欢迎技术探讨噢!