探究 — 二叉搜索树

二叉搜索树(Binary Search Tree)

回顾与思考

我们来思考这么一个问题,如何在n 个动态的整数中搜索某个整数?(查看其是否存在)

看着还是很简单的,以动态数组存放元素,从第0个位置开始遍历搜索,运气好的话,第一个就找到了,运气差的话,可能找到最后都找不到,算一下的话平均时间复杂度是 O(n),数据规模大的话,是比较慢的

再好一点的话,上一篇 二分查找及其变种算法 说到了,使用二分查找的话,效率是很高的,最坏时间复杂度:O(logn),不怕你数据规模大,但是我们要注意一点,这是一个动态的序列,而前面也说到了二分查找针对的是有序集合,那么维护这样的一个有序的集合,每次修改数据,都需要重新排序,添加、删除的平均时间复杂度是O(n),对于这种动态的数据集合,二分查找的优势并不明显。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。

那么针对这个需求,有没有更好的方案?能将添加、删除、搜索的最坏时间复杂度均可优化至:O(logn),主角登场,二叉搜索树可以办到。

概念

定义:是二叉树的一种,是应用非常广泛的一种二叉树,英文简称为BST又被称为:二叉查找树、二叉排序树

图解

在这里插入图片描述

性质

  • 任意一个节点的值都大于其左子树所有节点的值
  • 任意一个节点的值都小于其右子树所有节点的值
  • 它的左右子树也是一棵二叉搜索树

使用二叉搜索树可以大大提高搜索数据的效率,同时也需要注意一点,二叉搜索树存储的元素必须具备可比较性,同时不能为null, 比如intdouble等,如果是自定义类型,需要指定比较方式,这一点在后面会仔细讲到

设计

提示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官方提供的intdouble这种基本的数值类型,或者是IntegerDoubleString这些实现了比较接口的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、保留ComparableComparator接口,同时提供无参构造,与带参构造

/**
 * 无参构造
 */
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地址

posted @ 2020-09-19 12:23  衍方  阅读(561)  评论(0编辑  收藏  举报