如何构建一棵二叉搜索树?

604 阅读17分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

二叉搜索树的分析与实现

写在前面

本文摘要

  1. 二叉搜索树的分析与实现【添加、删除】
  2. 如何查找二叉树的前驱、后继节点
  3. 树、二叉树的基本概念【可以略过】

一、树形结构

image-20221023151744080

  • 使用树形结构,可以大大的提高效率
  • 下面我们一起来了解一下树形结构吧,看看它如何提高效率的
  • 生活中也有很多树形结构

image-20221023152952835

二、二叉搜索树(Binary Search Tree)

  • 有一些关于树的基本概念,在文章的下一小节,如果有什么概念不清楚,可以看看下面的介绍
  • 下面,我们一起来学习一下二叉搜索树吧~

(1)问题引入

  • 在 n 个动态的整数中查找某一个整数,如何能更高效的搜索
    • 假设使用动态数组存放元素,那就是从头开始遍历搜索,平均时间复杂度:O(n)
  • 如果这 n 个数是一个有序的
    • 那么使用二分法查找动态数组中的元素,最坏的时间复杂度:O(logn)
    • 但是添加、删除的平均复杂度是:O(n)
  • 对于这样的需求,有没有更好的方案呢?
    • 这时候,我们就引入了高效的二叉搜索树
    • 可以将添加、删除、搜索的最坏时间复杂度都优化至:O(logn)

(2)概念&特点&基本构造&接口设计

概念

  • 二叉搜索树是一种应用非常广泛的一种二叉树
  • 英文缩写为 BST,又被称为二叉查找树、二叉排序树

image-20221024100512480

特点

  • 任意一个节点的值都大于其左子树所有节点的值
  • 任意一个节点的值都小于其右子树所有节点的值
  • 任何一个节点的左右子树也都是二叉搜索树
  • 存储的元素必须具有可比较性
    • 存储的元素不能为 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,也就是构建出了根节点

image-20221024112456363

② 不是根节点

  • 那如果不是根节点,比如说我们想要继续添加5和20,该如何添加进去呢?
  • 要想添加节点,肯定得先利用元素构建节点吧
  • 而想要构建节点,肯定得先找到他的父节点吧
  • 构建好节点之后,我们来复习一下二叉搜索树的性质:每一个节点的左子节点都比自己小,右子节点都比自己大
  • ❓那既然牵扯到大小关系,是不是应该要有节点元素的大小比较呢?
  • 比较完大小之后,是不是就知道,将刚刚构建好的节点,放在左边、还是右边了

image-20221025200435201

  • 如上图所示,我们先添加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的将比较逻辑写到BinarySearchTreecompare()中
  • 可是,放入二叉搜索树中的元素,不是数字类型(比如说:我们想要比较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这个节点了,直接返回,仿佛也没任何不妥,那如果是下面这种情况呢?

image-20221026100228422

  • 上图是按照Person的age大小构建的一棵二叉搜索树
  • 这时候,P3:ZY的年龄为 20,这时候想添加一个年龄也为20的 P4:John
  • 按之前的逻辑,直接返回的话,那么树上就还是 P3 对象,和新添加的P4都不是同一个人,是不是有些不合理呢?

image-20221026100809144

  • 所以,我们最终选择了覆盖再返回,即:
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
  • 那么,也就是说,待删除的节点就是这③种情况,我们试着来分析一下

image-20221028184246955

① 删除度为 0 的节点

  • 度为 0 的情况,咱们直接找到这个节点,让其父节点取消对它的引用即可
  • 但是这棵二叉树也可能仅有一个节点root,直接让root = null即可

image-20221028185252641

  • 将其思路转化为代码:
        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指向它的子节点即可

image-20221028191659834

        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):二叉树中序遍历时,节点的一个节点

  • :这里提及的中序遍历,下篇文章统一说明,可以先自行研究一下

  • 如下图所示:

image-20221027200208506

  • ❓了解了什么是前驱节点,如果让我们求某个节点的前驱节点,思路又该如何呢?
  • 先看一看我这灵魂画手秒的边,关于二叉树的中序遍历顺序

image-20221027194832344

  • 我们可以发现,任意节点的前驱节点,有如下几种情况:

image-20221027201503848

  • 那我们分别来看看这几种情况吧
  • 情况①:当某一个节点拥有左子树时,那么它的前驱节点一定在左子树上面,而且是在左子树的最右边
  • 情况②:没有左子树时,只能一直找父节点,当此节点在父节点的右子树中时,才有前驱节点

image-20221027203642068

  • 看图说话,没有左子树,向上找父节点时必须要拐弯,才有前驱节点
  • 一直向上找父节点,当父节点为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):二叉树中序遍历时,节点的一个节点
  • 看看下面的代码,就知道它和查询前驱节点,有多么对称了

image-20221028192918027

  • 好了,如果你了解了什么是前驱、后继节点,那我们就方便探讨,如何删除二叉搜索树度为 2 的节点了

  • 我们直接看代码

image-20221028194225191

  • 核心思路:去删除度为 2 的节点,转换成删除度为 0 或 1 的节点【前驱节点 或者 后继节点】

image-20221028195234972

④ 最终实现

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]

image-20221031141409696

  • 按这样的添加顺序,构建好的一棵树:高度: h = 3,元素数量:n = 7
  • 那我们增删改查的操作,都需要去比较节点的大小。再决定从左子树查找?添加到右子树?还是从右子树删除?
  • 一般情况下,最多只需要比较 h(树的高度)次即可。时间复杂度也就为:O(h) = O(logn)
  • 如果有 1000000 条数据,树的高度最低仅仅只有 20 ,是不是大大提高了效率?
  • 那如果按这样的顺序添加呢?[2, 5, 8, 10, 14, 20, 24]

image-20221031142703181

  • 哎呀,怎么这么眼熟呢?这不是上节课学习的链表吗,是的,它退化成了一个链表
  • 按这样的添加顺序,构建好的一棵树:高度: h = 7,元素数量:n = 7
  • 那么他的复杂度就和链表的复杂度一样了。删除也可能使二叉搜索树退化成链表
  • 那么,我们有没有什么办法,防止二叉搜索树退化成链表呢?也就是让其添加、删除、搜索的复杂度维持在 O(logn)

三、树的基本概念【可以略过】

(1)最基础概念

  • 都是一些基本概念,很容易理解,就统一过一下
  • 如下有一颗树

image-20221023154051274

节点

  • 每一个元素都是放在一个节点中的

  • 有这样几种节点:根节点、父节点、子节点、兄弟节点

  • 一颗树可以没有任何节点,这样的树被称为空树

  • 叶子节点:度为0的节点

  • 非叶子节点:度不为0的节点

子树

  • 树的每一个节点下面,可以看做是有很多颗子树组成的
  • 有左子树、右子树

  • 节点的度:子树的个数
  • 树的度:所有节点度中的最大值

层数:根节点在第一层,依次往下数到最远的叶子节点所在的层

深度:从根节点到当前节点的唯一路径上的节点总数

高度:从当前节点到最远叶子节点的路径上的节点总数

  • 一整颗树的高度等于深度

有序树:树中任意节点的子节点之间有顺序关系

无序树:树中任意节点的子节点之间没有顺序关系,也叫自由树

(2)二叉树(Binary Tree)

  • 这里也是说一些概念性的东西

image-20221023160234756

二叉树的特点

  • 每个节点的度,最大为2(每个节点最多拥有2棵子树)
  • 左子树和右子树是有顺序的【有序树】
  • 即使某一节点只有一棵子树,也要区分左右子树

image-20221023160819002

  • 上面这些,都是二叉树
  • 空树也属于二叉树

二叉树的性质

image-20221023162933256

  • 不好写上标,只能借助外界截图

image-20221023164358822

特殊的二叉树

1、真二叉树

  • 所有节点的度,不能为 1,只能是0 or 2

2、满二叉树

  • 必须是一棵真二叉树,并且所有的叶子节点都在最后一层

image-20221023164924422

  • 在同样高度的二叉树中,满二叉树的叶子节点的数量最多,总节点数量最多
  • 满二叉树一定是真二叉树,真二叉树不一定是满二叉树

image-20221023165730155

3、完全二叉树

  • 叶子节点只会出现在最后2层,且最后一层的叶子节点都靠左对齐
  • 完全二叉树从根节点至倒数第二层,是一棵满二叉树
  • 满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树

image-20221023185202484

  • 度为 1 的节点只有左子树
  • 度为 1 的节点最多有1个,要么没有

image-20221023190641472

写在后面

本篇收获🦄🦄🦄

  1. 了解了树形结构与二叉搜索树
  2. 通过逐步分析,能够实现二叉搜索树的增删查
  3. 学会了查找二叉树的前驱、后继节点
  4. 复习了Java的Comparable、Compartor接口【策略模式】

读后思考✨♨️🤚

  • 如何优化二叉搜索树,防止它退化成链表?
  • 在删除节点时,度不同的节点,删除的操作都不一样。节点内部维护了父节点,很方便的去改变引用。如果节点内部不维护父节点。二叉搜索树又该如何去删除元素?
  • 给定一棵二叉树,该如何遍历?