开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
二叉搜索树的分析与实现
写在前面
本文摘要
- 二叉搜索树的分析与实现【添加、删除】
- 如何查找二叉树的前驱、后继节点
- 树、二叉树的基本概念【可以略过】
一、树形结构
- 使用树形结构,可以大大的提高效率
- 下面我们一起来了解一下树形结构吧,看看它如何提高效率的
- 生活中也有很多树形结构
二、二叉搜索树(Binary Search Tree)
- 有一些关于树的基本概念,在文章的下一小节,如果有什么概念不清楚,可以看看下面的介绍
- 下面,我们一起来学习一下
二叉搜索树吧~
(1)问题引入
- 在 n 个动态的整数中查找某一个整数,如何能更高效的搜索
- 假设使用动态数组存放元素,那就是从头开始遍历搜索,平均时间复杂度:
O(n)
- 假设使用动态数组存放元素,那就是从头开始遍历搜索,平均时间复杂度:
- 如果这 n 个数是一个有序的
- 那么使用二分法查找动态数组中的元素,最坏的时间复杂度:
O(logn) - 但是添加、删除的平均复杂度是:
O(n)
- 那么使用二分法查找动态数组中的元素,最坏的时间复杂度:
- 对于这样的需求,有没有更好的方案呢?
- 这时候,我们就引入了高效的二叉搜索树
- 可以将添加、删除、搜索的最坏时间复杂度都优化至:
O(logn)
(2)概念&特点&基本构造&接口设计
概念
- 二叉搜索树是一种应用非常广泛的一种二叉树
- 英文缩写为 BST,又被称为二叉查找树、二叉排序树
特点
- 任意一个节点的值都大于其左子树所有节点的值
- 任意一个节点的值都小于其右子树所有节点的值
- 任何一个节点的左右子树也都是二叉搜索树
- 存储的元素必须具有可比较性
- 存储的元素不能为
null
- 存储的元素不能为
基本构造
public class BinarySearchTreeImpl<E> {
private int size;
/**
* 根节点
*/
private Node<E> root;
/**
* 内部节点类
*/
private static class Node<E> {
/**
* 元素
*/
E element;
/**
* 左子节点
*/
Node<E> left;
/**
* 右子节点
*/
Node<E> right;
/**
* 父节点
*/
Node<E> parent;
public Node(E element, Node<E> parent) {
this.element = element;
this.parent = parent;
}
}
}
- 同链表一样,二叉搜索树的内部也要维护一个节点对象,而具体的元素,应该放在节点的内部
- 方便后面实现,每一个节点都有三个节点的引用
左子节点、右子节点、父节点 - 而且二叉搜索树本身,还需要一个根节点的引用
接口设计
public interface BinarySearchTree<E> {
/**
* 节点数量
*/
int size();
/**
* 是否为空树
*/
boolean isEmpty();
/**
* 清空所有节点
*/
void clear();
/**
* 添加元素
* @param element:待添加元素
*/
void add(E element);
/**
* 删除节点
* @param element:待删除节点
*/
void remove(E element);
/**
* 搜索某一个元素是否存在
* @param element:带查找元素
* @return :是否存在
*/
boolean contains(E element);
}
- 你可能已经发现了,二叉搜索树不像之前的数组、链表。它是没有索引的
- 因为我们没法标索引,后添加的元素,也可能在上面一层
(3)添加节点——add(E element)
① 第一次添加 —— 根节点
- 我们想要构建一棵二叉搜索树,肯定得先找到根节点。从根节点依次往下发散延伸
- 那一开始的时候,根节点肯定是
null的,也就是,我们得先构建根节点
public void add(E element) {
elementNotNullCheck(element);
if (root == null) { // 添加根节点
root = new Node<>(element, null);
size++;
return;
}
// 来到这说明不是第一次添加
}
- 如上代码,很简单,我们刚刚说了,二叉搜索树中节点的元素不能为
null,所以我们添加元素时,得进行非null判断:elementNotNullCheck(element),判断很简单,就不贴代码了 - 如图所示,我们第一次添加
10,也就是构建出了根节点
② 不是根节点
- 那如果不是根节点,比如说我们想要继续添加
5和20,该如何添加进去呢? - 要想添加节点,肯定得先利用元素构建节点吧
- 而想要构建节点,肯定得先找到他的父节点吧
- 构建好节点之后,我们来复习一下二叉搜索树的性质:每一个节点的左子节点都比自己小,右子节点都比自己大
- ❓那既然牵扯到大小关系,是不是应该要有节点元素的大小比较呢?
- 比较完大小之后,是不是就知道,将刚刚构建好的节点,放在左边、还是右边了
- 如上图所示,我们先添加
20,那么就用20 和它的父节点 10 比大小,发现自己> 10那么将其放在父节点parent的右子节点right处 - 后添加的
5,那么就用5 和它的父节点 10 比大小,发现自己< 10,那么将其放在父节点parent的左子节点left处 - ❓这个时候你可能会发现,我们上面只有大于和小于的情况,如果值相等,怎么办呢?
- 你思考一下,如果我们添加的值是数字类型,那么比较大小,遇见和自己值相等的情况,我们需要处理吗?
- 答案是:其实可以不处理,直接返回即可
- 那如果不是数字类型,是一个
Person对象呢?- 那么就不建议直接返回,建议覆盖掉旧的值。先留一个思考,之后再解答~
- 你思考一下,如果我们添加的值是数字类型,那么比较大小,遇见和自己值相等的情况,我们需要处理吗?
public void add(E element) {
// ... 与上面一样的代码
// 来到这说明不是第一次添加
// 找到父节点
Node<E> parent = root;
Node<E> currentNode = root;
int compare = 0;
// 从根节点开始,依次往下寻找
while (currentNode != null) {
// 待添加节点与父节点比较大小
compare = compare(element, currentNode.element);
// 在比较后,先保存父节点,再改变指向
parent = currentNode;
if (compare > 0) { // 待添加的节点 > 当前节点
currentNode = currentNode.right;
} else if (compare < 0) { // 待添加的节点 < 当前节点
currentNode = currentNode.left;
} else { // 相等的情况
return;
}
}
// 构建待添加的节点
Node<E> newNode = new Node<>(element, parent);
// 拿到刚刚的比较结果,决定最后要添加到父节点的左侧还是右侧【相等的情况在上面已经处理过了】
if (compare > 0) {
parent.right = newNode;
} else {
parent.left = newNode;
}
size++;
}
- 我们最开始的时候,只能拿到根节点,那就得从根节点开始,依次往下寻找。该节点应该添加在哪里
- 并且在遍历到当前节点为
null的时候,需要找出待添加节点的父节点、并且需要记录,应该添加到父节点的左边还是右边 - 如果遇到待添加节点与某一个节点相等的情况,那我们先直接返回,之后再做覆盖操作(上面的思考继续留着~)
- ❓到现在,你应该发现了,我们还有一个很大的问题没有解决——如何比较大小?
(4)如何比较大小——compare(E e1, E e2)
① 思考
- 比较大小,我们很容易就能想到,
大的数 - 小的数 ? 0。这样即可知道,谁大谁小了。 - 然后上来balabal的将比较逻辑写到
BinarySearchTree的compare()中。 - 可是,放入二叉搜索树中的元素,不是数字类型(比如说:我们想要比较
Person,并且认定年龄较大的Person比较大)这时又该怎么办呢?难到又要改我们内部写的compare()方法吗? - 学过
Java的同学应该知道,Java给我们提供了两个接口:Comparable、Comparator。【使用自己写的也是可以的,但是Java默认的很多包装类,就不会实现自己的比较器,需要自己二次包装,所以直接使用官方的即可】那我们就分别来用Comparable、Comparator接口,实现一下如何比较吧~
② 使用 Comparable接口
- 见名知意,意味着某个类实现了此接口中的
compareTo()方法,那么就是一个可比较的类了 - 想要使用我们的二叉搜索树,必须具有可比较性
- 那么在定义泛型的时候,就可以让对方必须实现
Comparable接口
public class BinarySearchTreeImpl<E extends Comparable<E>> implements BinarySearchTree<E> {
private int compare(E e1, E e2) {
return e1.compareTo(e2);
}
}
- 在二叉搜索树的内部,仅仅需要去调用
Comparable接口中的compareTo()方法即可 - 那么在外部使用时,所传入的泛型,就必须实现
Comparable接口中的compareTo()方法
// 实体类
public class Person implements Comparable<Person> {
private int age;
private int height;
@Override
public int compareTo(Person o) {
return age - o.age;
}
}
// 外部即可直接使用
BinarySearchTree<Person> bst = new BinarySearchTreeImpl<>();
- 这样,我们是不是就将比较的逻辑,写在了外部。而且也实现了自定义比较逻辑
- 以为这样就结束了吗?那我们再来思考一个问题。
- 如果我有再进一步的需求:有两棵
Person的二叉搜索树bst1、bst2 bst1是根据年龄大的对象比较大来构建的bst2是根据身高高的对象比较大来构建的- 那按上面的办法,
Person类,是不是就不够用了
- 如果我有再进一步的需求:有两棵
// 按年龄较大的Person构建的一棵二叉搜索树
BinarySearchTree<Person> bst1 = new BinarySearchTreeImpl<>();
// 按身高较高的Person构建的一棵二叉搜索树
BinarySearchTree<Person> bst2 = new BinarySearchTreeImpl<>();
- 那么,这时候,我们就可以引出
Comparator接口了
③ 使用Comparator接口
- 上面的需求,无非是在使用我们的二叉搜索树
BinarySearchTree时,想要动态传入比较的逻辑 - 那么使用
Java提供的比较器Compartor接口,即可实现 - 在使用
BinarySearchTree时,必须传入一个比较器
public class BinarySearchTreeImpl<E> implements BinarySearchTree<E> {
/**
* 比较器
*/
private Comparator<E> comparator;
public BinarySearchTreeImpl(Comparator<E> comparator) {
this.comparator = comparator;
}
private int compare(E e1, E e2) {
return comparator.compare(e1, e2);
}
}
- 那我们在外界使用时,就需要这样使用了
BinarySearchTree<Person> bst1 = new BinarySearchTreeImpl<>(new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o1.getAge() - o2.getAge();
}
});
BinarySearchTree<Person> bst2 = new BinarySearchTreeImpl<>(new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o1.getHeight() - o2.getHeight();
}
});
- 其中使用了
匿名类的写法,能够更清楚的看出我们的需求。当然,你还可以使用Lambda的方式来简化代码 - 我们这样设计,确实解决了上面的需求,可是我们在每一次使用的时候,都需要传入一个比较器
Comparator,真的很麻烦。❓那能否中和一下刚刚的两种方式呢?
④ 中和使用Comparable 和 Comparator接口
-
Comparable的方式,可以不需要每一次都传入比较逻辑,但是不可以定制化多次比较逻辑 -
Comparator的方式,可以定制化多次比较逻辑,但是,每次使用时,都必须传入比较逻辑 -
如果把他们结合起来,是不是就完美了~
public class BinarySearchTreeImpl<E> implements BinarySearchTree<E> {
/**
* 比较器
*/
private Comparator<E> comparator;
public BinarySearchTreeImpl() {
this(null);
}
public BinarySearchTreeImpl(Comparator<E> comparator) {
this.comparator = comparator;
}
/**
* 比较两元素的大小
* @return :【返回值 > 0】说明 e1 > e2。【返回值 = 0】说明 e1 = e2。【返回值 < 0】说明 e1 < e2
*/
private int compare(E e1, E e2) {
if (comparator != null) {
return comparator.compare(e1, e2);
}
return ((Comparable<E>)e1).compareTo(e2);
}
}
- 外界使用时,如果传入了比较器。那么优先使用比较器
Comparator - 如果使用时没有传比较器,我们就默认对方实现了
Comparable接口。因为我们的二叉搜索树,本身就需要具有可比较性 - 如果外界既没有传入比较器
Comparator,也没有实现Comparable接口,那么肯定会有异常 - 这样就达到了我们的需求,在外界使用的时候,就可以这样使用了~
// 实体类
public class Person implements Comparable<Person> {
private int age;
private int height;
@Override
public int compareTo(Person o) {
return age - o.age;
}
}
// 1、使用Comparable接口
BinarySearchTree<Person> bst1 = new BinarySearchTreeImpl<>();
// 2、使用Comparator接口
BinarySearchTree<Person> bst2 = new BinarySearchTreeImpl<>(new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o1.getHeight() - o2.getHeight();
}
});
- 看完上面的比较的设计,是不是觉得豁然开朗呢?可是我们还有一个问题没有解决勒:❓值相等如何处理?
⑤ 值相等的处理
-
我们上面的实现是:如果发现值相等,不做任何处理,直接返回
else { return; } // 相等的情况 -
要说有问题吧,我觉得也没有问题。比如说原先的节点是
10、5、20 -
那么再添加一个
20时,发现已经有20这个节点了,直接返回,仿佛也没任何不妥,那如果是下面这种情况呢?
- 上图是按照
Person的age大小构建的一棵二叉搜索树 - 这时候,
P3:ZY的年龄为 20,这时候想添加一个年龄也为20的 P4:John - 按之前的逻辑,直接返回的话,那么树上就还是
P3 对象,和新添加的P4都不是同一个人,是不是有些不合理呢?
- 所以,我们最终选择了覆盖再返回,即:
else { // 相等的情况
currentNode.element = element; // 覆盖原先的值
return;
}
(5)删除元素——remove(E element)
- 我们想要删除某一元素,在外界的眼中,删除的是某一元素。而在二叉搜索树的内部其实是删除对应的节点
- 所以得先根据待删除元素查找对应的节点,再将其节点删除
private Node<E> node(E element) {
if (element == null) return null;
Node<E> node = root;
while (node != null) {
int compare = compare(element, node.element); // 对比元素的大小
if (compare == 0) return node; // 和自己相等,就是我们需要找的节点
if (compare > 0) { // 说明节点可能在右边,继续查找
node = node.right;
} else { // 说明节点可能在左边,继续查找
node = node.left;
}
}
// 没有找到对应的节点
return null;
}
- ❓❓❓查找到了节点,我们来思考一下这几个问题
- 删除的有哪几种节点呢?
- 删除节点的处理方式都是一致的吗?
- 该如何删除该节点呢?
- 二叉搜索树上的每一个节点,度只能有
0、1、2 - 那么,也就是说,待删除的节点就是这③种情况,我们试着来分析一下
① 删除度为 0 的节点
- 度为 0 的情况,咱们直接找到这个节点,让其父节点取消对它的引用即可
- 但是这棵二叉树也可能仅有一个节点
root,直接让root = null即可
- 将其思路转化为代码:
if (node.parent != null) {
if (node == node.parent.left) { // 待删除节点在它父节点的左边
node.parent.left = null;
} else { // node = node.parent.right
node.parent.right = null;
}
} else { // 仅有一个根节点
root = null;
}
② 删除度为 1 的节点
- 度为 1 的情况,咱们得先找到它的子节点
child - 然后将它子节点的父节点指向它的父节点,最后改变它父节点的子节点的引用即可
- 但是它的父节点可能为
null,也就是下图的第二种情况。那么在第②步时,直接将根节点root指向它的子节点即可
Node<E> child = node.left != null ? node.left : node.right; // 取出子节点
if (child != null) { // 度为 1 的节点 —— 情况 ②
// 将子节点的父节点指向待删除节点的父节点
child.parent = node.parent;
if (node.parent == null) {
root = child;
} else if (node == node.parent.left) { // 查看待删除节点是在它父节点的哪一边
node.parent.left = child;
} else { // node == node.parent.right
node.parent.right = child;
}
}
③ 删除度为 2 的节点
- 在谈如何删除度为 2 的节点前,我们先探讨两个概念
前驱与后继节点
-
❗注:下面所讲前驱与后继节点,不局限于二叉搜索树,只要是一棵二叉树即可
-
之前学的链表,也有前驱节点和后继节点的概念,也就是遍历时,某一节点前一个节点和后一个节点
-
那我们的二叉树的前驱节点和后继节点是什么呢?我们先来说说前驱节点,后继节点自然就懂了
-
前驱节点(predecessor):二叉树中序遍历时,节点的前一个节点
-
❗注:这里提及的中序遍历,下篇文章统一说明,可以先自行研究一下
-
如下图所示:
- ❓了解了什么是前驱节点,如果让我们求某个节点的前驱节点,思路又该如何呢?
- 先看一看我这灵魂画手秒的边,关于二叉树的中序遍历顺序
- 我们可以发现,任意节点的前驱节点,有如下几种情况:
- 那我们分别来看看这几种情况吧
- 情况①:当某一个节点拥有左子树时,那么它的前驱节点一定在左子树上面,而且是在左子树的最右边
- 情况②:没有左子树时,只能一直找父节点,当此节点在父节点的右子树中时,才有前驱节点
- 看图说话,没有左子树,向上找父节点时必须要拐弯,才有前驱节点
- 一直向上找父节点,当父节点为
null时都还没拐弯,说明没有前驱节点 - 情况③:没有前驱节点的情况【也就是中序遍历时,第一个遍历到的节点】
private Node<E> predecessor(Node<E> node) {
if (node == null) return null;
Node<E> predecessorNode = node.left;
if (predecessorNode != null) { // 来到这说明有左子树,前驱节点一定在左子树上
while (predecessorNode.right != null) {
predecessorNode = predecessorNode.right; // 找到左子树中,最靠右的节点
}
return predecessorNode;
}
// 来到这说明没有左子树【只能从父节点开始找】
while (node.parent != null && node == node.parent.left) {
node = node.parent;
}
/*
来到这里说明
1、node.parent == null
2、node == node.parent.right
*/
return node.parent;
}
- 我们上面详细分析了前驱节点,那我们下面来说说,与其对称的后继节点
- 后继节点(successor):二叉树中序遍历时,节点的后一个节点
- 看看下面的代码,就知道它和查询前驱节点,有多么对称了
-
好了,如果你了解了什么是前驱、后继节点,那我们就方便探讨,如何删除二叉搜索树度为 2 的节点了
-
我们直接看代码
- 核心思路:去删除度为 2 的节点,转换成删除度为 0 或 1 的节点【前驱节点 或者 后继节点】
④ 最终实现
private void remove(Node<E> node) {
if (node == null) return;
if (node.hasTowChildren()) { // 度为 2 的节点 —— 情况 ③
Node<E> predecessorNode = predecessor(node); // 拿到前驱节点
node.element = predecessorNode.element;
node = predecessorNode; // 删除前驱节点 【与下面删除度为 0、1的节点一致】
}
// 来到这里说明节点的度只能为 0 或 1
// 看看有没有子节点
Node<E> child = node.left != null ? node.left : node.right;
if (child != null) { // 度为 1 的节点 —— 情况 ②
// 将子节点的父节点指向待删除节点的父节点
child.parent = node.parent;
if (node.parent == null) {
root = child;
} else if (node == node.parent.left) { // 查看待删除节点是在它父节点的哪一边
node.parent.left = child;
} else { // node == node.parent.right
node.parent.right = child;
}
} else if (node.parent == null) { // 度为 0 的节点 —— 情况 ① 只有一个根节点的情况
root = null;
} else { // 度为 0 的节点 —— 情况 ①
// 看看待删除的叶子节点在它父节点的哪一边,在哪一边就删除谁
if (node == node.parent.left) {
node.parent.left = null;
} else { // node == node.parent.right
node.parent.right = null;
}
}
}
- 终于,删除也实现了~下面我们一起看看二叉搜索树的复杂度🎉🎉
(6)二叉搜索树的复杂度分析
- 我们实现完上述
增删改查的接口,那我们最后来做一下复杂度分析吧 - 如下一棵二叉搜索树,节点的添加顺序为:
[10, 5, 20, 2, 8, 14, 24]
- 按这样的添加顺序,构建好的一棵树:
高度: h = 3,元素数量:n = 7 - 那我们增删改查的操作,都需要去比较节点的大小。再决定从左子树查找?添加到右子树?还是从右子树删除?
- 一般情况下,最多只需要比较
h(树的高度)次即可。时间复杂度也就为:O(h) = O(logn) - 如果有 1000000 条数据,树的高度最低仅仅只有 20 ,是不是大大提高了效率?
- 那如果按这样的顺序添加呢?
[2, 5, 8, 10, 14, 20, 24]
- 哎呀,怎么这么眼熟呢?这不是上节课学习的链表吗,是的,它退化成了一个链表
- 按这样的添加顺序,构建好的一棵树:
高度: h = 7,元素数量:n = 7 - 那么他的复杂度就和链表的复杂度一样了。删除也可能使二叉搜索树退化成链表
- 那么,我们有没有什么办法,防止二叉搜索树退化成链表呢?也就是让其添加、删除、搜索的复杂度维持在
O(logn)
三、树的基本概念【可以略过】
(1)最基础概念
- 都是一些基本概念,很容易理解,就统一过一下
- 如下有一颗树
节点
-
每一个元素都是放在一个节点中的
-
有这样几种节点:根节点、父节点、子节点、兄弟节点
-
一颗树可以没有任何节点,这样的树被称为空树
-
叶子节点:度为0的节点
-
非叶子节点:度不为0的节点
子树
- 树的每一个节点下面,可以看做是有很多颗子树组成的
- 有左子树、右子树
度
- 节点的度:子树的个数
- 树的度:所有节点度中的最大值
层数:根节点在第一层,依次往下数到最远的叶子节点所在的层
深度:从根节点到当前节点的唯一路径上的节点总数
高度:从当前节点到最远叶子节点的路径上的节点总数
- 一整颗树的高度等于深度
有序树:树中任意节点的子节点之间有顺序关系
无序树:树中任意节点的子节点之间没有顺序关系,也叫自由树
(2)二叉树(Binary Tree)
- 这里也是说一些概念性的东西
二叉树的特点
- 每个节点的度,最大为2(每个节点最多拥有2棵子树)
- 左子树和右子树是有顺序的【有序树】
- 即使某一节点只有一棵子树,也要区分左右子树
- 上面这些,都是二叉树
- 空树也属于二叉树
二叉树的性质
- 不好写上标,只能借助外界截图
特殊的二叉树
1、真二叉树
- 所有节点的度,不能为
1,只能是0 or 2
2、满二叉树
- 必须是一棵真二叉树,并且所有的叶子节点都在最后一层
- 在同样高度的二叉树中,满二叉树的叶子节点的数量最多,总节点数量最多
- 满二叉树一定是真二叉树,真二叉树不一定是满二叉树
3、完全二叉树
- 叶子节点只会出现在最后2层,且最后一层的叶子节点都靠左对齐
- 完全二叉树从根节点至倒数第二层,是一棵满二叉树
- 满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树
- 度为 1 的节点只有左子树
- 度为 1 的节点最多有1个,要么没有
写在后面
本篇收获🦄🦄🦄
- 了解了树形结构与二叉搜索树
- 通过逐步分析,能够实现二叉搜索树的增删查
- 学会了查找二叉树的前驱、后继节点
- 复习了Java的Comparable、Compartor接口【策略模式】
读后思考✨♨️🤚
- 如何优化二叉搜索树,防止它退化成链表?
- 在删除节点时,度不同的节点,删除的操作都不一样。节点内部维护了父节点,很方便的去改变引用。如果节点内部不维护父节点。二叉搜索树又该如何去删除元素?
- 给定一棵二叉树,该如何遍历?