• 探究 — 二叉搜索树


    二叉搜索树(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地址

    CSDN:https://blog.csdn.net/baidu_40188909 掘金:https://juejin.im/user/1151943919304840
  • 相关阅读:
    一个小程序的经验总结
    my favorite computer publishers
    关于在天涯看小说
    书店
    Server 2003&Windows 7&Server 2008 R2&Visual Studio&MSDN: my personal best practice
    Google搜索:基本语法
    【我喜欢的一篇文章】Fire And Motion
    Windbg学习笔记(1)
    如何清除Help Viewer 2.0之Filter Contents中的列表内容
    5年了,难道我真的不适合做一个程序员吗,请告诉我我该怎么做?
  • 原文地址:https://www.cnblogs.com/kalton/p/13695713.html
Copyright © 2020-2023  润新知