红黑树的解析与实现(一)二叉查找树(Binary Sort Tree)
关于红黑树
虽然可以直接看,但是个人是参考了《算法》这本书,然后关于红黑树的部分,忽略了与AVL树的相关内容,但是是按照:二叉查找树、2-3树、红黑树的顺序写的,然后我个人也就按照这样的方式实现一遍了。这三篇博文(大概)会忽略很多原理的东西,简单的查找等方法就不多说了,专注于实现以及相关解析以及比较少用的一些操作,例如向下取整等,算法复杂度分析之类的都忽略了,毕竟网上讲得也很多。然后后面的实现都是Java语言,并没有C/C++版本。
二叉查找树
实际上,如果对二叉查找树还不知道的话= =也没必要看红黑树了,关于性质之类的可以参考wiki、百度百科或者其他博客。这里说明一点:至少从我个人的角度,是因为在分析Java容器Map的时候觉得需要先看一下红黑树,也是因为这个容器才知道有红黑树这个数据结构(当然也是网上都说难的不行= =)。但是关于二叉查找树,我们一般是类比二分搜索,仿佛跟Key-Value这样的结构并不一致。但是,在Java上,如果我们能在定义节点的时候,专门声明了Key与Value,并且在Key上定义comparator,那么是否就是通过对Key的排序,获取一个顺序序列,并且根据Key可以找到对应的Value。
那么如果说红黑树是二叉查找树的升级版,因为红黑树是保证平衡的,也就可以理解为什么Map的底层是一棵红黑树。我们这样来定义树,也可以完成Key-Value的模式。从而支持了高效查找。例如下图:
对于这个节点,编号就是Key,名字就是Value,如果设定Key的comparator,那么就按照编号来排序,在搜索某个编号的时候就能以O(logn)的复杂度搜索到对应的人的名字。
二叉查找树的定义与结点的定义
key与value是必须的,此外,想要同时对key定义一个comparator。由于Java语言,我们可以声明一个BST类,其中的内部类(或者可以专门写出来)可以有Node作为结点。那么可以这样来写:
class BST<Key extends Comparable<Key>, Value>{
private Node root;
private class Node{
private Key key;
private Value value;
private Node left;
private Node right;
public Node(Key key,Value value){
this.key = key;
this.value = value;
}
}
}
这里说明一下,泛型是必要的,并且让Key继承Comparable,这样,我们在实际算法中,使用compareTo方法,就可以进行比较。使用的话,先new出来BST对象,然后调用例如put、get方法等等,这个后面再实现。
二叉查找树的查找
很简单,就跟二分一样,从根开始搜,如果比该结点的key小,那么则进入左子树,大则进入右子树。若相等,则表示搜索到了,若最终搜索到null仍未找到,那么就返回未搜索到。
自然,递归是非常简单明了的表现方式,但是递归的效率相对略低,但是比较容易理解。这里先写一个递归的写法:
public Value get(Key key){
if(key == null) return null;
return get(this.root,key);
}
public Value get(Node root,Key key){
if(root == null) return null;
int cmp = key.compareTo(root.key);
if(cmp == 0){
return root.value;
}else{
if(cmp < 0){
return get(root.left,key);
}else{
return get(root.right,key);
}
}
}
还是比较简单明了的,其实转化成非递归也非常容易:
public Value get(Key key){
if(key == null) return null;
return get(this.root,key);
}
public Value get(Node root,Key key){
while(root != null){
int cmp = key.compareTo(root.key);
if(cmp == 0) return root.value;
if(cmp < 0){
root = root.left;
}else{
root = root.right;
}
}
return null;
}
为什么要写get的两个重载方法?这样对于用户比较友好,用户只用输入Key就行了,其他一概不管。主要是这样写,root就作为引用传入重载方法,那么我们对root的操作,不会影响到这棵树的真实root值。这个是Java SE的内容,如果有疑惑的同学可以自己试试不用重载方法,会出bug的——在操作的时候,root对应的Node变了。或者改成下面的写法也可以,用一个变量引用root。就像下面的max()方法。
二叉查找树-查找最大值与最小值
更简单了,一直向右子树前进直到叶子节点,就是最大值,反之则是最小值。简单的不行,就写个查最大值的算法:
public Value max(){
if(root == null) return null;
Node n = root;
while(n.right != null){
n = n.right;
}
return n.value;
}
二叉查找树-查找的时候向下取整或者向上取整
这个略微麻烦一点,并且可能有点不太懂。这个向上取整和向下取整是书上的翻译,感觉有点不合适,其实就是对于一个二叉查找树而言,查找其中的key为x的元素对应的value。查找到了就返回,若没有查找到:返回比x小的最接近x的元素,或者返回比x大的最接近x的元素。例如对于序列:[1,2,3,4,5,7,8,9]。如果搜索6,实际上是没有6的,那么向下取整方法floor(),的返回值是5,向上取整方法ceiling()则返回7。
整个算法有点蛋疼,但是我们一floor,向下取整方法为例,分析一下在搜索过程中可能碰见的几种情况,首先当然是从根开始,这些跟正常搜索一样,关键在于对根第一次匹配,如果小或者大了该怎么处理,抽象一下,对于任意子树的根节点,有几种判定情况:
1.key = root.key。找到了,那就不多说,返回就完事了,没啥好说的,大团圆。
2.key < root.key。此时小了,那么也就是说该树的根以及右子树完全不存在答案,我们应该从左子树开始继续搜索。但是需要明确一点,左子树中一定存在答案。那么直接搜left就行了,递归的话,就可以开始递归调用了。
3.key > root.key。此时大了,那么也就是说,我们需要向右子树开始继续搜索。但是这里注意一点,root.key可能就是答案。假设树的局部是这样的:
而我们搜索的是2。那么刚开始是1,进入右子树,而右子树是3,并且是叶子了。那么最终的答案是1。但是如果3的左子树就是2呢,答案其实就是2了。所以说,在进入右子树的时候,root是有作为答案输出的可能的,所以需要记录root。
总结:找到就不说了,当进入左子树的时候,就是正常进入。当进入右子树的时候,需要临时记录root为res,如果res存在,则更新res。
这样分析,算法就比较简单了,而向上取整,就是相反,进入左子树的时候临时记录更新,右子树直接进入。同样,没有写递归算法(当然递归是真的简单),仅仅实现向下取整floor:
public Value floor(Key key){
if(root == null || key == null) return null;
return floor(root,key);
}
public Value floor(Node root,Key key){
Value res = null;
while(root != null){
int cmp = key.compareTo(root.key);
if(cmp == 0) return root.value;
if(cmp < 0){
root = root.left;
}else{
res = root.value;
root = root.right;
}
}
return res;
}
二叉查找树-插入
首先明白一点,根据二叉查找树的性质,插入的位置一定作为一个叶子结点,不可能是在中间进行插入的。但是删除是可以从中间删除,甚至是删除根。但是这个是删除需要考虑的,一般删除的算法也是最麻烦的。对于插入算法而言,首先是应该搜索——如果搜索到了,对于我们Key-Value这样的写法,是应该更新value的。如果没有搜索到,就new出来结点。
根据这样的情况,我们可以先搜索,再根据搜索结果判定如何插入。但是不能直接调用get(),而是应该手动实现。自然,下面就不用递归写法了,直接写非递归的。当然,刚开始得考虑root为空的情况,此时直接new出来作为root。
public void put(Key key,Value value){
if(key == null || value == null) return;
if(root == null){
root = new Node(key,value);
return;
}
put(root,key,value);
}
public void put(Node root,Key key,Value value){
while(root != null){
int cmp = key.compareTo(root.key);
if(cmp == 0){
root.value = value;
return;
}
if(cmp < 0){
if(root.left == null){
root.left = new Node(key,value);
return;
}else{
root = root.left;
}
}else{
if(root.right == null){
root.right = new Node(key,value);
return;
}else{
root = root.right;
}
}
}
}
嵌套可能略多了,不好看,但是懂意思就行= =。
二叉查找树-删除最小值与最大值
删除最核心的问题是:删除某个结点后,该结点的左右子树应该怎么办。1962年Hibbard提出了现在一般使用的删除算法。这个算法首先需要先了解如何删除最小值与最大值。
首先比较容易想到的就是:一直左搜索就能找到最小值,一直向右就是最大值。但是删除需要考虑的问题是,最大/小结点可能存在子树。例如找最小值,一直左搜到最后的结点,该结点如果是叶子结点——直接删除,但是如果有右子树的话,就需要一些其他处理了。
还是以删除最小值为例:在一路向左搜索直到碰见null,那么将右结点直接接到最小值的父结点即可,例:
那么代码也就显而易见了: