详解二叉搜索树(Binary Search Tree,BST)

同线性结构的类似,树型结构也是一种组织数据元素的数据结构。这种结构中的数据元素存在一对多的关系,在逻辑上像一颗倒立(一对多)的树。若树中节点的分支个数的最大值为m,则该树被称为m叉树,因此二叉树指树中所有节点的的分叉最多只能有2个。特别指出的是,树型结构没有一叉树,最小的就是二叉树,即分支个数的最大值为1时,该树也可称为二叉树,甚至只有一个节点也是二叉树,null也是二叉树。

二分搜索树又称为二分查找树、二分排序树,是一种添加了限定条件的二叉树。其定义为

  • 若树的左子树不为空,则左子树上的所有节点的值都小于它的根节点;
  • 若树的右子树不为空,则右子树的所有节点的值都大于它的根节点;
  • 所有子树也都是二分搜索树;
  • 默认情况下,二分搜索树中不包括重复元素;

数据结构实质是指数据之间的组织关系,主要有四种结构:1)集合 元素之间无关系,除了同属于一个集合;2)线性结构 元素之间存在一对一的关系 3)树型结构 元素之间存在一对多的关系; 4)图状或网状结构 元素之间存在多对多的关系;

特点

  • 有序性
    定义中明确指出了二分搜索树的元素之间的大小关系。在实现层面,这隐式要求数据元素必须可比较。在java中,数据元素需要实现Comparable接口或有外部比较器Comparator

  • 动态性
    由于节点维护了父子节点间的关系信息,因此二分搜索树在编译节点不必声明树的存储空间,可以在程序运行过程中动态的增删节点,即具备动态性。

    数组不能动态扩容,进而产生链表。但链表不能随机查询且查询效率低,进而出现树结构。

  • 高效查找
    从定义可知,二分搜索树是一颗有序树,且具备高效的查找效率。这是因为相对于线性结构每次只能排除一个元素,树型结构每次同某一节点对比后,下一步只能选择搜索左子树或右子树中的一种,即每一次对比都排除了一个子树的无效数据。

基本框架

二叉搜索树需要维护三项信息

  • 根节点root。用于表示整棵树
  • 内部节点类Node。同链表类似,二叉搜索树内部需要维护一个节点类,用于保存真正的数据信息和节点关系,其中节点关系是一条单向射线,由父节点指向子节点。
  • 树大小size。表示树中节点的个数。

除以上三项信息外,其他均为向外部用户提供功能方法及类内部辅助方法。其基本框架如下

//二分搜索树(不包含重复元素)
//泛型E需要继承Comparable接口
public class BinarySearchTree<E extends Comparable<E>> {
//内部节点类。
//只在二叉搜索树内部使用,因此是私有的
private class Node{
    	//注意泛型E需要具备可比性
        public E e;
    	//表示节点间的逻辑关系
        public Node left,right;
        public Node(E e){
            this.e = e;
            left = null;
            right = null;
        }
    }
    
    //树的根。
    //因为节点中保存的有节点间的父子关系,因此拿到树的根就可以找到其他所有的节点;
    //根是树的操作入口,它代表了整棵树。
    private Node root;
    //树的节点数量
    private int size;
    //构造函数 初始是一棵空树
    public BinarySearchTree(){
        root = null;
        size = 0;
    }
    
    //功能方法
    public int  getSize(){
        return size;
    }
    //功能方法
    public boolean isEmpty(){
        return  size == 0;
    }
    
    //其他增删改查功能方法
}

添加元素

从定义可知,二叉搜索树是一颗有序树,这为高效查询提供了基础,但同时也限制内部方法的实现。它要求在对二叉搜索树执行各类操作后,仍需保持树的有序性,而有序性问题的关键是定位元素的目标位置,随后执行相应操作。

对于添加元素操作,目标位置指元素最终插入位置。又因为是插入操作,那在插入完成之前,目标位置上一定没有元素,即目标位置为null,插入完成之后,该节点是一个叶子节点。根据定位的“目标位置”的不同,有两种实现方式

定位目标位置的父节点

对于某一节点N,比较待插入元素E和节点N中的元素值:

  1. 若两者相等,表示树中已有该元素,不需再次执行插入操作,方法返回。
  2. 若待插入元素E小于节点N中元素值,则在节点N的左子树中重复执行插入逻辑,直到节点N满足1)节点值小于E;2)节点的左子树为空。此时N的左子树位置即为目标位置,构造元素E的节点,插入该位置即可。
  3. 若待插入元素E大于节点N中元素值,则在节点N的右子树中重复执行插入逻辑,直到节点N满足1)节点值大于E;2)节点的右子树为空。此时N的右子树位置即为目标位置,构造元素E的节点,插入该位置即可。

代码如下:

	/**
     * 向binarysearchtree添加元素
     * 公有方法,提供给外部用户
     * @param e
     */
    public void addFirst(E e){
       //空树。新建一个节点直接返回
        if (root == null) {
            root = new Node(e);
            size++;
        }else{
            //直接把根节点root传递进去,相当于给了操作入口
            //使用root代表整棵树,从整棵树出发,是一种整体的思想。
            //此时把整棵树都交给你操作,操作的结果直接写入树中,不必返回值。
            addFirst(root,e);
        }
    }

    /**
     * 向根为node的binarysearchtree中插入元素
     * 私有方法,内部调用
     * @param node
     * @param e
     */
    private void addFirst(Node node,E e){
        //终止条件分支1
        //因为比较的是目标位置的父节点,因此需要判断值是否相等。
        //add方法中判断的是目标树自身,因此判断条件是 树是否为null
        if(e.equals(node.e)){
            //节点值相等,无操作
            return;
        }else
            //终止条件分支2
            if(e.compareTo(node.e) < 0 && node.left == null ){
            node.left = new Node(e);
            size++;
            return;
        }else
            //终止条件分支3
            if(e.compareTo(node.e) > 0 && node.right == null){
            node.right = new Node(e);
            size++;
            return;
        }

        //递归判断及调用
        if(e.compareTo(node.e) < 0){
            addFirst(node.left,e);
        }
        //递归判断及调用
        if(e.compareTo(node.e) > 0){
            addFirst(node.right,e);
        }
    }

直接定位目标位置

上面的插入方法使用了递归的思想,但由于定位的位置是最终插入位置的父节点,因此需要讨论根节点为空和不为空两种情况,没有统一处理逻辑。实际上,null也是一棵树,若把定位目标位置的父节点改为直接定位目标位置,即找到那棵最终要插入元素E的null树,就可把根为空的情况包括进去,统一处理逻辑。

特别指出的是,该方法的内部执行返回了每次插入元素后的子树的根节点,因此右赋值操作。

 /**
     * 为什么外部方法要包裹一层?
     * @param e
     * @return
     */
    public void add(E e){
        //返回插入节点,并挂接
        root = add(root,e);
    }

    /**
     * 返回插入新节点后bst的根
     * 前提:1)元素最终插入位置上没有元素,即目标位置为null,插入之后该节点是一个叶子节点。
     * 2)null也可认为是一棵树,只是一颗空树。
     * 方法思路:插入元素实质上找到插入位置,因为前提1,2,只需按照规则【左小右大】找到null位置,
     * 在该位置上创建新节点,并把新节点挂载到树上即可。
     * @param node
     * @param e
     * @return
     */
    private Node add(Node node ,E e){
        //递归终止条件
        //因为比较的是目标位置的父节点,因此需要判断值是否相等。
        //add方法中判断的是目标树自身,因此判断条件是 树是否为null
        //注意循环终止条件处的附加操作,处是size + 1
        if(node == null){
            size++;
            return new Node(e);
        }

        //递归调用 隐含元素值跟当前节点值相等时,不操作。
        //元素小,插入左子树
        if(e.compareTo(node.e) < 0){
            //等号右边得add返回值是一棵树的根节点,即代表了一棵树
            //将这棵树挂到当前节点上
            node.left = add(node.left,e);
        }else
            //元素大,插入右子树
            if(e.compareTo(node.e) > 0){
                //返回插入节点,并挂接
            node.right = add(node.right,e);
        }
        return node;
    }

其他操作

对比两种插入思路,发现

  • addFirst方法是找到目标位置的父节点,处理逻辑不统一;按照遍历树的节点的角度递归;每次内部插入操作无返回值,无赋值操作。
  • add方法是直接找到目标位置【null树】,处理逻辑统一;按照整棵树的角度递归,把一个大树看成很多小树构成;每次内部插入操作有返回值,有赋值操作。

为统一操作逻辑,推荐使用add方法。同插入操作类似,其他操作具备同样的特点。

将整棵树看成一个“具有两个叶子节点,一个根节点的树”,在处理时,分两大部分

  • 处理根节点
    对根节点的处理一般两种1)根节点元素为空时 怎么操作 2)根节点元素为目标元素时,执行各种真实的业务操作。
  • 处理两个叶子节点
    此时要注意元素E隐含的可比性。将其同左右子节点的元素对比,符合条件执行操作。
    这个操作需在逻辑把左右子树分别和根节点断开,形成两个分别以左右子节点为根的新树(因此外层有赋值操作),进而递归执行各种真实的业务操作。
    特别注意,null也是一颗树,统一处理逻辑。

上述过程可进一步抽象为一个节点,对一个节点有三处访问位置,访问左子树,访问元素、访问右子树

因业务的原因,需把所有节点都访问一遍,称为遍历。遍历实质是将树型结构中节点排列成一个线性序列。

递归的过程实质上是查找目标位置的过程。上述可以add或preOrder为例描述。

全部源码

package com.company.binarysearchtree;

import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;

/**
 * @description: 二分搜索树
 * @Date: 2021/8/31 15:00
 */
//二分搜索树要求元素可比,因此E需要继承Comparable<E>结构
    //不包含重复元素
public class BinarySearchTree<E extends Comparable<E>> {
    private class Node{
        public E e;
        public Node left,right;
        public Node(E e){
            this.e = e;
            left = null;
            right = null;
        }
    }

    private Node root;
    private int size;
    public BinarySearchTree(){
        root = null;
        size = 0;
    }

    public int  getSize(){
        return size;
    }
    public boolean isEmpty(){
        return  size == 0;
    }

    /**
     * 向binarysearchtree添加元素
     * 公有方法,提供给外部用户
     * @param e
     */
    public void addFirst(E e){
        if (root == null) {
            root = new Node(e);
            size++;
        }else{
            //直接把根节点root传递进去,相当于给了操作入口
            //使用root代表整棵树,从整棵树出发,是一种整体的思想。
            //此时把整棵树都交给你操作,操作的结果直接写入树中,不必返回值。
            addFirst(root,e);
        }
    }

    /**
     * 向根为node的binarysearchtree中插入元素
     * 私有方法,内部调用
     * 前提:元素最终插入位置上没有元素,即目标位置为null,插入之后该节点是一个叶子节点。
     * 方法思路:通过判断叶子节点是否为空(node.left == null),找到插入的目标位置的父节点
     * 然后判断插入左侧还是右侧(e.compareTo(node.e) > 0)
     * @param node
     * @param e
     */
    private void addFirst(Node node,E e){
        //终止条件分支1
        //因为比较的是目标位置的父节点,因此需要判断值是否相等。
        //add方法中判断的是目标树自身,因此判断条件是 树是否为null
        if(e.equals(node.e)){
            //节点值相等,无操作
            return;
        }else
            //终止条件分支2
            if(e.compareTo(node.e) < 0 && node.left == null ){
            node.left = new Node(e);
            size++;
            return;
        }else
            //终止条件分支3
            if(e.compareTo(node.e) > 0 && node.right == null){
            node.right = new Node(e);
            size++;
            return;
        }

        //递归判断及调用
        if(e.compareTo(node.e) < 0){
            addFirst(node.left,e);
        }
        //递归判断及调用
        if(e.compareTo(node.e) > 0){
            addFirst(node.right,e);
        }
    }

    /**
     * 为什么外部方法要包裹一层?
     * @param e
     * @return
     */
    public void add(E e){
        //返回插入节点,并挂接
        root = add(root,e);
    }

    /**
     * 返回插入新节点后bst的根
     * 前提:1)元素最终插入位置上没有元素,即目标位置为null,插入之后该节点是一个叶子节点。
     * 2)null也可认为是一棵树,只是一颗空树。
     * 方法思路:插入元素实质上找到插入位置,因为前提1,2,只需按照规则【左小右大】找到null位置,
     * 在该位置上创建新节点,并把新节点挂载到树上即可。
     * 区别:addFirst方法是找到目标位置的父节点,处理逻辑不统一;按照遍历树的节点的角度递归
     *add方法是直接找到目标位置【null树】,处理逻辑统一;按照整棵树的角度递归,把一个大树看成很多小树构成。
     * @param node
     * @param e
     * @return
     */
    private Node add(Node node ,E e){
        //递归终止条件
        //因为比较的是目标位置的父节点,因此需要判断值是否相等。
        //add方法中判断的是目标树自身,因此判断条件是 树是否为null
        //注意循环终止条件处的附加操作,处是size + 1
        if(node == null){
            size++;
            return new Node(e);
        }

        //递归调用 隐含元素值跟当前节点值相等时,不操作。
        //元素小,插入左子树
        if(e.compareTo(node.e) < 0){
            //等号右边得add返回值是一棵树的根节点,即代表了一棵树
            //将这棵树挂到当前节点上
            node.left = add(node.left,e);
        }else
            //元素大,插入右子树
            if(e.compareTo(node.e) > 0){
                //返回插入节点,并挂接
            node.right = add(node.right,e);
        }
        return node;
    }

    /**
     * 是否包含元素
     * @param e
     * @return
     */
    public boolean contains(E e){
        return contains(root,e);
    }

    /**
     * 查询以node为根节点的bst中,是否包含元素e
     * @param node
     * @param e
     * @return
     */
    private boolean contains(Node node,E e){
       /* if(e.equals(node.e )){
            return true;
        }
        return  contains(node.left,e) ||contains(node.right,e);*/

        //递归终止条件1
        //只需处理根节点1 判空处理
        if(node == null){
            return false;
        }

        //递归终止条件2
        //只需处理根节点2 判等处理
        if(e.equals(node.e)){
            return true;
        }else if(e.compareTo(node.e) > 0){
            return contains(node.right,e);
        }else{
            return contains(node.left,e);
        }
    }

    /**
     * 前序遍历
     * 外部用户使用,不需要传递参数
     * 因为根节点root是类的成员变量,可以直接使用
     * 且root是用户同bst类通信的唯一入口
     * 因此用户不需要显式的传递参数
     */
    public void preOrder(){
        preOrder(root);
    }

    /**
     * 功能方法,以某个节点为根节点,遍历树
     * 此时根节点可以是root节点,也可以是其他节点,
     * 因此方法需要提供形参
     * @param node
     */
    private void preOrder(Node node){
        //1 节点判空
        //一般null为递归终止条件
        if(node == null){
            //2 空条件下的操作
            return;
        }
        //3 业务操作
        System.out.println(node.e);

        //4 左子树递归
        preOrder(node.left);
        //5 右子树递归
        preOrder(node.right);
    }

    /**
     * 中序遍历
     */
    public void inOrder(){
        inOrder(root);
    }

    /**
     *遍历的最后一次非空节点是叶子节点,
     * 但是null也被当作一个树执行了一次,只是null树符合递归终止条件,返回
     * @param node
     */
    private void inOrder(Node node){
        //1 节点判空
        //一般null为递归终止条件
        if(node == null){
            //2 空条件下的操作
            return;
        }
        //3 左子树递归
        inOrder(node.left); //倒数第二次执行叶子节点的左子树(null树)返回,不执行任何操作
        //4 业务操作
        System.out.println(node.e);
        //5 右子树递归
        inOrder(node.right); //倒数第二次执行叶子节点的右子树(null)返回,不执行任何操作
    }

    /**
     * 后续遍历
     */
    public void postOrder(){
        postOrder(root);
    }
    private void postOrder(Node node){
        //1 节点判空
        //一般null为递归终止条件
        if(node == null){
            //2 空条件下的操作
            return;
        }
        //3 左子树递归
        postOrder(node.left); //倒数第二次执行叶子节点的左子树(null树)返回,不执行任何操作

        //4 右子树递归
        postOrder(node.right); //倒数第二次执行叶子节点的右子树(null)返回,不执行任何操作
        //5 业务操作
        System.out.println(node.e);
    }

    private void preOrder1(Node node){
        if(node == null) return;
        Node node1 = node;
        while(node1.left != null){
            System.out.println(node1.e);
            node1 = node1.left;
        }
        System.out.println(node1.e);
        Node node2 = node;
        while(node.right != null){
            System.out.println();
        }
    }

    /**
     * 序遍历的非递归写法
     * no recursion
     */
    public void preOrderNR(){
        preOrderNR(root);
    }

    /**
     * 前序遍历的非递归写法
     * 思路:使用辅助数据结构 -栈,构建节点栈
     * 先将根节点压栈,随后循环出栈,每次出栈需把出栈节点的右节点、左节点压栈,
     * 注意:左右节点入栈顺序不能变化,因为栈的后进先出特点,左节点后入栈,
     * 可先于右子树出栈,进而满足前序要求。
     * @param node
     */
    private void preOrderNR(Node node){
        Stack<Node> es = new Stack<>();
        es.push(node);
        //栈为空,说明没有入栈操作,说明所有节点都已经遍历完了
        while(!es.empty()){
            Node cur = es.pop();
            System.out.println(cur.e);
            if(cur.right != null){
                es.push(cur.right);
            }
            if(cur.left != null){
                es.push(cur.left);
            }
        }
    }


    /**
     * 借助队列实现层序遍历
     */
    public void levelOrder(){
        levelOrder(root);
    }

    /**
     * 非递归方式
     * 借助队列实现层序遍历
     * @param node
     */
    private void levelOrder(Node node){
        //队列的使用
        Queue<Node> q = new LinkedList<>();
        q.add(root);
        while(!q.isEmpty()){
            Node cur = q.remove();
            System.out.println(cur.e);
            if(cur.left != null){
                q.add(cur.left);
            }
            if(cur.right != null){
                q.add(cur.right);
            }
        }
    }


    /**
     * 查找bst的最小值
     * @return
     */
    public E minimum(){
        if(size == 0){
            throw new IllegalArgumentException("bst为空");
        }
        return  minimum(root).e;
    }

    /**
     * 从根节点出发,一直左子树,直到找到没有左子树的节点,该节点即为目标节点。
     * @param node
     * @return
     */
    private Node minimum(Node node){
        if(node.left == null){
            return node;
        }
        return minimum(node.left);
    }


    /**
     * 查找bst的最小值
     * @return
     */
    public E maxmum(){
        if(size == 0){
            throw new IllegalArgumentException("bst为空");
        }
        return  maxmum(root).e;
    }

    /**
     * 从根节点出发,一直右子树,直到找到没有右子树的节点,该节点即为目标节点。
     * @param node
     * @return
     */
    private Node maxmum(Node node){
        if(node.right == null){
            return node;
        }
        return minimum(node.right);
    }

    /**
     * 删除最小值
     * 常识:当删除的目标不是用户指定时,一般会把删除元素值返回给用户。
     * @return
     */
    public E removeMin(){
        //找到这个最小值
        E e = minimum();
        root = removeMin(root);
        return e;
    }

    /**
     * 删除以node为根的最小节点
     * 返回删除节点后新的bst的根
     * 由于bst的特性,最小值一定是在最左侧的、没有左子树的节点。
     * 有可能是1)一个叶子节点,此时直接删除该节点,bst结构不发生变化;
     * 也有可能是2)一个没有左子树,有右子树的节点。
     * 此时需要把 目标节点的右子树 挂接到 目标节点父节点 的左子树上。
     *
     * @param node
     * @return
     */
    private Node removeMin(Node node){
        //若节点的左子树为空,说明当前节点是最小节点
        //已经到底部
        if(node.left == null){
            //不管有没有右子树【叶子节点可以看作左节点为空或右节点为空的节点】,
            // 都先拿到将目标节点的右子树的根节点
            //拿到右子树的根即代表拿到了右子树
            Node rightNode = node.right;
            //目标节点右子树置空,即断开连接
            node.right = null;
            //整个bst大小减一
            size--;
            //返回目标节点右子树的根节点,即返回右子树
            return rightNode;
        }
        //节点的左子树不为空,递归调用该方法
        //返回值为删除以左子树为根的bst后,新生成的bst的根
        node.left =  removeMin(node.left);
        return node;
    }

    /**
     * 删除最大值
     * 常识:当删除的目标不是用户指定时,即用户不知道删除的具体值,一般会把删除元素值返回给用户。
     * @return
     */
    public E removeMax(){
        //找到这个最小值
        E e = maxmum();
        root = removeMax(root);
        return e;
    }

    private Node removeMax(Node node){
        if(node.right == null){
            Node leftNode = node.left;
            node.left = null;
            size--;
            return leftNode;
        }
        node.right = removeMax(node.right);
        return node;
    }

    /**
     * 返回删除节点后的 新二叉树的根
     * @param e
     * @return
     */
    public Node remove(E e){
         return remove(root,e);
    }

    /**
     * 返回删除节点后的 新二叉树的根
     * @param node
     * @param e
     * @return
     */
    private Node remove(Node node,E e){
        if(node == null){
            return null;
        }
        //不能这么比,e可能是一个对象,
        //if(e < node.e)
        //元素e比node的值大,说明e在node的右子树上
        if(e.compareTo(node.e) > 0){
            return remove(node.right,e);
        }else
            //元素e比node的值小,说明e在node的左子树上
        if(e.compareTo(node.e) < 0){
            return remove(node.left,e);
        }else{
            //元素e和node.e相等 分三种情况讨论
            //node 左右子树都为空的情况 已经包含在情况1中

            //情况1)node无左子树 只需用右子树的根节点替换node的位置
            if(node.left == null){
                Node rightNode = node.right;
                node.right = null;
                size--;
                return rightNode;
            }else
            //情况2)node无右子树  只需用左子树的根节点替换node的位置
            if(node.right == null){
                Node leftNode = node.left;
                node.left = null;
                size--;
                return leftNode;
            }else
            //情况3) node的左右子树都不为空 此时需要拿到node右子树的最小值,使用该节点替换node的位置
            {
                //找到node节点右子树上得最小值
                Node minimum = minimum(node.right);

                //使用最小值替换node节点位置 分两步
                // step1 将node的左子树直接赋值给最小值。因为左子树没有发生变化
                minimum.left = node.left;
                //step2 删除node的右节点的最小值 并将删除后的根节点赋值给minimum的右节点。
                minimum.right = removeMin(node.right);

                //将node置空,以便回收
                node.left = node.right = null;
                return minimum;
            }
        }
    }
}

延申

  • Comparable是排序接口;若一个类实现了Comparable接口,就意味着“该类支持排序”。
    而Comparator是比较器;我们若需要控制某个类的次序,可以建立一个“该类的比较器”来进行排序。我们不难发现:Comparable相当于“内部比较器”,而Comparator相当于“外部比较器”。

参考资料

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值