二叉搜索树(Binary Search Tree)
回顾与思考
我们来思考这么一个问题,如何在n 个动态的整数中搜索某个整数?(查看其是否存在)
看着还是很简单的,以动态数组存放元素,从第0个位置开始遍历搜索,运气好的话,第一个就找到了,运气差的话,可能找到最后都找不到,算一下的话平均时间复杂度是 O(n),数据规模大的话,是比较慢的
再好一点的话,上一篇 二分查找及其变种算法 说到了,使用二分查找的话,效率是很高的,最坏时间复杂度:O(logn),不怕你数据规模大,但是我们要注意一点,这是一个动态的序列,而前面也说到了二分查找针对的是有序集合,那么维护这样的一个有序的集合,每次修改数据,都需要重新排序,添加、删除的平均时间复杂度是O(n),对于这种动态的数据集合,二分查找的优势并不明显。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。
那么针对这个需求,有没有更好的方案?能将添加、删除、搜索的最坏时间复杂度均可优化至:O(logn),主角登场,二叉搜索树可以办到。
概念
定义:是二叉树的一种,是应用非常广泛的一种二叉树,英文简称为BST又被称为:二叉查找树、二叉排序树
图解:
性质:
- 任意一个节点的值都大于其左子树所有节点的值
- 任意一个节点的值都小于其右子树所有节点的值
- 它的左右子树也是一棵二叉搜索树
使用二叉搜索树可以大大提高搜索数据的效率,同时也需要注意一点,二叉搜索树存储的元素必须具备可比较性,同时不能为null
, 比如int
、double
等,如果是自定义类型,需要指定比较方式,这一点在后面会仔细讲到
设计
提示Tip
阅读下面的文章之前,我希望你是读过我的上一篇文章 — 深入理解二叉树 的,因为二叉搜索树并不是一中的新的数据结构,它是有二叉树衍生出来的概念,也就是说它同样是二叉树,只不过是在二叉树的基础上,我们对其加入了一些逻辑规则,我希望它的添加是这样的,比我小的往左拐,比我大的往右拐。
也就是说二叉树与二叉搜索树的节点设计都是一样的,同样,二叉树的通用方法也能够被二叉搜搜索树使用,因为我们的二叉搜索树会继承二叉树,在其基础上封装一些新的特性,规则。所以,一些通用方法,比如说判断叶子节点,寻找前驱、后继节点,获取树的高度、节点数量,包括最有趣的遍历都是写在二叉树中,这些通用方法,是我们接下来会用到的,包括节点类的设计,这些都已经在上篇文章了,这里不会占用篇幅写了,不熟悉的话,回去翻一翻,知识这东西,就要多过一过脑子
属性与方法
属性:
//接收用户自定义比较器
private Comparator<E> comparator;
公开方法:
void add(Eelement)
—— 添加元素void remove(Eelement)
—— 删除元素boolean contains(Eelement)
—— 是否包含某元素
在基于二叉树的基础上,只需要增加上面这3个接口方法,看完这些方法设计,与之前编写的动态数组,链表是不是有一些区别,没错,少了index
,对于我们现在使用的二叉树来说,它的元素没有索引的概念,为什么?我们不是可以按照从上到下,从左到右,进行编号吗?例如下图:
但是这样不对,没有意义,比如,我们再一个添加,11,15的元素进来,按照二叉排序树,11 > 8 —> 往右子树走,11 > 10 —> 在往右走,11 < 14 —> 往左走,11 < 13 —>往左走,发现没有元素,插入该位置,15也是一样,那么得出来的结果应该是:
这样的编号索引与我们之前数组与链表的先入先编号是不一样,所以在二叉搜索树中没有索引的概念
Add方法
方法步骤:
1、找到父节点parent
2、创建新节点node
3、parent.left = node
或者parent.right=node
注意点:如果要插入的值晕倒相等的元素该如何处理?
- 直接return,不作处理
- 覆盖原有节点 (建议)
我们一步一步来,首先,我们前面说到,添加的元素必须具备可比较性,所以不能为null
,这样我们需要一个元素非空检查的方法
/**
* 新节点元素非空检查
* @param element
* @return
*/
private void elementNotNullCheck(E element){
if (element == null){
throw new IllegalArgumentException("element must not be null");
}
}
接下来我们开始找父节点parent
,这里要注意的是,遍历查找父节点时,我们是从根节点root
开始,如果当前是空树,那么不用找,直接新节点就是根节点,如果树不为空,那么我们就要从根节点开始找父节点,这是我们重点分析的地方
前面说到了,二叉搜索树存储的元素必须具备可比较性,在这里就体现了,找寻父节点的过程就是我们不断比较的过程,所以我们还需要一个比较方法,用于比较两个元素的大小
/**
* 比较函数,返回0,e1==e2;返回值大于0,e1>e2;返回小于0,e1<e2
* @param e1
* @param e2
* @return
*/
private int compare(E e1,E e2){
return 0;
}
方法的逻辑我们先不写,这是因为我们的二叉树在设计上是泛型类,是支持存储任意类型的。对于Java
官方提供的int
、double
这种基本的数值类型,或者是Integer
、Double
、String
这些实现了比较接口的Comparable
来说,比较逻辑是很好写的,但是对于我们自定义的类,比如Person
来说,这是不行的,因为不具备可比较性,同时我们也不知道比较规则
public class BinarySearchTree<E> {
//...
}
public class Person {
/**
* 年龄
*/
private int age;
public Person(int age) {
this.age = age;
}
}
针对上面说到缺点,我们可以通过以下方法解决:
1、强制要求实现java.lang.Comparable
接口,重写public int compareTo(T o);
方法,自定义比较规则
public class BinarySearchTree<E extends Comparable<E>>{
//....
}
例如:Person
类
public class Person implements Comparable<Person> {
private int age;
public Person(int age) {
this.age = age;
}
/**
* 自定义比较规则
* @param p
* @return
*/
@Override
public int compareTo(Person p) {
return Integer.compare(age, p.age);
}
}
这样子就可以在BinarySearchTree
二叉搜索树中的compare
方法中调用重写的compareTo
方法,实现比较逻辑,但是这样写有一些不好的地方,比如说,对于传入的类型进行了强制要求,同时,由于比较规则是编写在Person
类中的,对于一个类来说只能自定一种比较规则,很不方便。
2、编写实现java.util.Comparator
接口的匿名内部类,重写int compare(T o1, T o2);
方法
同时在BinarySearchTree
类中,添加接收用户自定义比较器的属性,这样做能可以实现按照自定义规则,编写不同的比较器
//接收用户自定义比较器
private Comparator<E> comparator;
/**
* 构造函数
* @param comparator
*/
public BinarySearchTree2(Comparator comparator) {
this.comparator = comparator;
}
这时候,只需要在实例化二叉搜索树时,传入比较器就行,例如;
BinarySearchTree<Person> bSTree = new BinarySearchTree<>(new Comparator<Person>() {
//自定义比较规则
@Override
public int compare(Person o1, Person o2) {
return o1.getAge() - o2.getAge();
}
});
但是,这样子还是不好,因为在实例化时,如果没有传入比较器,编译器检测就会报错,那么就有了第3种方法
3、保留Comparable
和Comparator
接口,同时提供无参构造,与带参构造
/**
* 无参构造
*/
public BST() {
this(null);
}
/**
* 构造函数
* @param comparator
*/
public BST(Comparator<E> comparator) {
this.comparator = comparator;
}
这样的话,如果用户有传入比较器的话,就用比较器,没有的话默认用户实现了Comparable
接口,对传入的类强转为Comparable
,如果都没有,自然会编译错误,抛出异常。这样BinarySearchTree
中的compare
应该这么写:
/**
* 比较函数,返回0,e1==e2;返回值大于0,e1>e2;返回小于0,e1<e2
* @param e1
* @param e2
* @return
*/
private int compare(E e1,E e2){
if (comparator != null){
return comparator.compare(e1,e2);
}
return ((Comparable)e1).compareTo(e2);
}
完成了这些就是添加节点方法了,实现了上面的函数后,其实就很好写了,无非就是小的往左,大的往右,等于的的覆盖
add方法
/**
* 向二叉树添加节点
* @param element
*/
public void add(E element){
elementNotNullCheck(element);
//空树,添加第一个节点
if (root == null){
root = new Node<>(element,null);
size++;
return;
}
//非空树情况,找到其父节点
Node<E> node = root;
//记住找到的父节点,默认根结点
Node<E> parent = root;
//记住最后一次的比较情况
int cmp = 0;
while (node != null){
cmp = compare(element,node.element);
if (cmp > 0){
parent = node;
//大于父节点值,取右子节点比较
node = node.right;
}else if (cmp < 0){
parent = node;
//小于父节点值,取左子节点比较
node = node.left;
}else {
//相等,第1种处理方法,不处理
//return;
//相等,第2种处理方法,覆盖原有节点
node.element = element;
}
}
//插入新节点
Node<E> newNode = new Node<>(element,parent);
if (cmp > 0){
parent.right = newNode;
}else {
parent.left = newNode;
}
size++;
}
Remove方法
方法步骤:
1、根据传入的元素查找节点
2、将找到的节点删除
大体上是这两个步骤,先分析一下第一个步骤,实际上就是我们前面思考题中说到的查找算法嘛,实现起来也比较简单,因为我们的二叉搜索树都是排序好的,上代码:
/**
* 查找元素为element的节点
* @param element
* @return
*/
private Node<E> node(E element) {
Node<E> node = root;
while (node != null) {
int cmp = compare(element, node.element);
if (cmp == 0) return node;
if (cmp > 0) {
node = node.right;
} else {
// cmp < 0
node = node.left;
}
}
return null;
}
有了node
方法,我们的contains
方法也很好写了,直接调用就可以了
/**
* 判断树是否包含值为element的节点
* @param element
* @return
*/
public boolean contains(E element) {
return node(element) != null;
}
删除找到的节点,这里比较复杂了,根据节点的度,有以下三种情况:
1、叶子节点
2、度为 1 的节点:
2、度为 2 的节点:
删除节点度为2的节点,做法是找到它的前驱节点,或者后继节点,例如上图,要删除的节点是5,按照二叉搜索树的规则,要找到一个节点代替5的位置,使其程成为一个新的二叉搜索树,那么这个节点就是要删除的节点的左子树节点的最大值,或者右子树的最小值,也就是器前驱节点,或者后继节点,这里如果不熟悉的回去上一篇深入理解二叉树 翻一翻相关概念
上代码咧:
/**
* 删除元素为element的节点
* @param element
*/
public void remove(E element) {
remove(node(element));
}
/**
* 删除传入的节点
* @param node
*/
private void remove(Node<E> node) {
if (node == null) return;
size--;
// 删除度为2的节点,实际上是转化为删除度俄日1或者0node节点
if (node.hasTwoChildren()) {
// 找到后继节点
Node<E> s = successor(node);
// 用后继节点的值覆盖度为2的节点的值
node.element = s.element;
// 删除后继节点
node = s;
}
// 删除node节点(node的度必然是1或者0)
Node<E> replacement = node.left != null ? node.left : node.right;
// node是度为1的节点
if (replacement != null) {
// 更改parent
replacement.parent = node.parent;
// 更改parent的left、right的指向
if (node.parent == null) { // node是度为1的节点并且是根节点
root = replacement;
} else if (node == node.parent.left) {
node.parent.left = replacement;
} else { // node == node.parent.right
node.parent.right = replacement;
}
} else if (node.parent == null) { // node是叶子节点并且是根节点
root = null;
} else { // node是叶子节点,但不是根节点
if (node == node.parent.left) {
node.parent.left = null;
} else { // node == node.parent.right
node.parent.right = null;
}
}
}
小结
到这里,二叉搜索树的相关内容就学习完了,也可以解释文章开头的思考题了,二叉搜索树能将添加、删除、搜索的最坏时间复杂度均可优化至:O(logn),由于二叉搜索树的排序性质,无论是添加、删除、查找,从根节点开始,根据小向左,大向右的情况,每向下一层,都会淘汰掉令一半的子树,这是不是跟二分搜索特别的像,再差就是查找到树的最底层,所以说添加、删除、搜索的时间复杂度都可优化到O(logn)
声明
文章为原创,欢迎转载,注明出处即可
个人能力有限,有不正确的地方,还请指正
本文的代码已上传github
,欢迎star —— GitHub地址