二分搜索树的原理和实现

1、文章简介

  本文将从二叉搜索树的定义和性质入手,带领你们实现一个二分搜索树,经过代码实现让你们深度认识二分搜索树。html

 

  后面会持续更新数据结构相关的博文。node

  数据结构专栏:https://www.cnblogs.com/hello-shf/category/1519192.htmlgit

  git传送门:https://github.com/hello-shf/data-structure.gitgithub

2、二叉树

  说树这种结构以前,咱们要先说一下树这种结构存在的意义。在咱们的现实场景中,好比图书馆,咱们能够根据分类快速找到咱们想要找到的书籍。好比咱们要找一本叫作《Java编程思想》这本书,咱们只须要根据,理工科 ==> 计算机 ==>Java语言分区就能够快速找到咱们想要的这本书。这样咱们就不须要像数组或者链表这种结构,咱们须要遍历一遍才能找到咱们想要的东西。再好比,咱们所使用的电脑的文件夹目录自己也是一种树的结构。算法

  从上面的描述咱们可知,树这种结构具有自然的高效性能够巧妙的避开咱们不关心的东西,只须要根据咱们的线索快速去定位咱们的目标。因此说树表明着一种高效。编程

  在了解二分搜索树以前,咱们不得不了解一下二叉树,由于二叉树是实现二分搜索树的基础。就像咱们后面会详细讲解和实现AVL(平衡二叉树),红黑树等树结构,你不得不在此以前学习二分搜索树同样,他们都是互为基础的。数组

2.一、二叉树的定义:

  二叉树也是一种动态的数据结构。每一个节点只有两个叉,也就是两个孩子节点,分别叫作左孩子,右孩子,而没有一个孩子的节点叫作叶子节点。每一个节点最多有一个父亲节点,最多有两个孩子节点(也能够没有孩子节点或者只有一个孩子节点)。对于二叉树的定义咱们不经过复杂的数学表达式来叙述,而是经过简单的描述,让你们了解一个二叉树长什么样子。缓存

1 只有一个根节点。
2 每一个节点至多有两个孩子节点,分别叫左孩子或者右孩子。(左右孩子节点没有大小之分哦)
3 每一个子树也都是一个二叉树

 

  知足以上三条定义的就是一个二叉树。以下图所示,就是一颗二叉树数据结构

2.二、二叉树的类型

  根据二叉树的节点分布大概能够分为如下三种二叉树:彻底二叉树,满二叉树,平衡二叉树。对于如下树的描述不使用数学表达式或者专业术语,由于那样很难让人想象到一棵树到底长什么样子。post

  满二叉树:从根节点到每个叶子节点所通过的节点数都是相同的。

  以下图所示就是一颗满二叉树。

  彻底二叉树:除去最后一层叶子节点,就是一颗彻底二叉树,而且最后一层的节点只能集中在左侧。

  对于上面的性质,咱们从另外一个角度来讲就是将满二叉树的叶子节点从右往左删除若干个后就变成了一棵彻底二叉树,也就是说,满二叉树必定是一棵彻底二叉树,反之不成立。以下图所示:除了图3都是一棵彻底二叉树。

  

  平衡二叉树:平衡二叉树又被称为AVL树(区别于AVL算法),它是一棵二叉树,又是一棵二分搜索树,平衡二叉树的任意一个节点的左右两个子树的高度差的绝对值不超过1,即左右两个子树都是一棵平衡二叉树。

  

3、二分搜索树

3.一、二分搜索树的定义

 

1 二分搜索树是一颗二叉树
2 二分搜索树每一个节点的左子树的值都小于该节点的值,每一个节点右子树的值都大于该节点的值
3 任意一个节点的每棵子树都知足二分搜索树的定义

   上面咱们给出了二分搜索树的定义,根据定义咱们可知,二分搜索树是一种具有可比较性的树,左孩子 < 当前节点 < 右孩子。这种可比较性为咱们提供了一种高效的查找数据的能力。好比,对于下图所示的二分搜索树,若是咱们想要查询数据14,经过比较,14 < 20 找到 10,14 > 10。只通过上面的两步,咱们就找到了14这个元素,以下面gif所示。可见二分搜索树的查询是多么的高效。

 

3.二、二分搜索树的实现

  本章咱们的重点是实现一个二分搜索树,那咱们规定该二分搜索树应该具有如下功能:

1 以Node做为链表的基础存储结构
2 使用泛型,并要求该泛型必须实现Comparable接口
3 基本操做:增删改查

 

3.2.一、基础结构实现

  经过上面的分析,咱们可知,若是咱们要实现一个二分搜索树,咱们须要咱们的节点有左右两个孩子节点。

  根据要求和定义,构建咱们的基础代码以下:

/**
 * 描述:二叉树的实现
 * 须要泛型是可比较的,也就是泛型必须实现Comparable接口
 *
 * @Author shf
 * @Date 2019/7/22 9:53
 * @Version V1.0
 **/
public class BST<E extends Comparable> {
    /**
     * 节点内部类
     */
    private class Node{
        private E e;
        private Node left, right;//左右孩子节点
        public Node(E e){
            this.e = e;
            this.left = right;
        }
    }

    /**
     * BST的根节点
     */
    private Node root;
    /**
     * 记录BST的 size
     */
    private int size;
    public BST(){
        root = null;
        size = 0;
    }

    /**
     * 对外提供的获取 size 的方法 
     * @return
     */
    public int size(){
        return size;
    }

    /**
     * 二分搜索树是否为空
     * @return
     */
    public boolean isEmpty(){
        return size == 0;
    }
}

   对于二分搜索树这种结构咱们要明确的是,树是一种自然的可递归的结构,为何这么说呢,你们想一想二分搜索树的每一棵子树也是一棵二分搜索树,恰好迎合了递归的思想就是将大任务无限拆分为一个个小任务,直到求出问题的解,而后再向上叠加。因此在后面的操做中,咱们都经过递归实现。相信你们看了如下实现后会对递归有一个深层次的理解。

3.2.二、增

  为了让你们对二分搜索树有一个直观的认识,咱们向二分搜索树依次添加[20,10,6,14,29,25,33]7个元素。咱们来看一下这个添加的过程。

  

  增长操做和上面的搜索操做基本是同样的,首先咱们要先找到咱们要添加的元素须要放到什么位置,这个过程其实就是搜索的过程,好比咱们要在上图中的基础上继续添加一个元素15。以下图所示,咱们通过一路寻找,最终找到节点14,咱们15>14因此须要将15节点放到14节点的右孩子处。

   有了以上的基本认识,咱们经过代码实现一下这个过程。

 1     /**
 2      * 添加元素
 3      * @param e
 4      */
 5     public void add(E e){
 6         root = add(root, e);
 7     }
 8 
 9     /**
10      * 添加元素 - 递归实现
11      * 时间复杂度 O(log n)
12      * @param node
13      * @param e
14      * @return 返回根节点
15      */
16     public Node add(Node node, E e){
17         if(node == null){// 若是当前节点为空,则将要添加的节点放到当前节点处
18             size ++;
19             return new Node(e);
20         }
21         if(e.compareTo(node.e) < 0){// 若是小于当前节点,递归左孩子
22             node.left = add(node.left, e);
23         } else if(e.compareTo(node.e) > 0){// 若是大于当前节点,递归右孩子
24             node.right = add(node.right, e);
25         }
26         return node;
27     }

 

 

  若是你还不是很理解上面的递归过程,咱们从宏观角度分析一下,首先明确  add(Node node, E e) 这个方法是干什么的,这个方法接收两个参数 node和e,若是node为null,则咱们将实例化node。咱们的递归过程正是这样,若是node不为空并按照大小关系去找到左孩子节点仍是右孩子,而后对该孩子节点继续执行  add(Node node, E e)  操做,经过按照大小规则一路查找直到找到一个符合条件的节点而且该节点为null,执行node的实例化便可。

  若是看了上面的解释你仍是有点懵,没问题,继续往下看。刘慈欣的《三体》不只让中国的硬科幻登上了世界的舞台,更是给广大读者普及了诸如“降维打击”之类的热门概念。“降维打击”之因此给人如此之震撼,在于它以极简的方式,从更高的、全新的技术视角有效解决了当前困局。那么在算法的世界中,“递归”就是这种牛叉哄哄的“降维打击”技术。递归思想及:当前问题的求解是否能够由规模小一点的问题求解叠加而来,后者是否能够再由更小一点的问题求解叠加而来……依此类推,直到收敛为一个极简的出口问题的求解。若是你能从这段话概括出递归就是一种将大的问题不断的进行拆分为更小的问题,直到拆分到找到问题的解,而后再向大的问题逐层叠加而最终求得递归的解。

  看了以上解释相信你们应该对以上递归过程有了一个深层次的理解。若是你们还有疑问建议画一画递归树,经过压栈和出栈以及堆内存变化的方式详细分析每个步骤便可。在我以前写的文章,在分析链表反转的时候对递归的微观过程进行了详细的分析,但愿对你们有所帮助。

 

3.2.三、查

  有了上面的基础咱们实现一个查询的方式,应该也不存在很大的难度了。咱们设计一个方法叫 contains 即判断是否存在某个元素。

 1     /**
 2      * 搜索二分搜索树中是否包含元素 e
 3      * @param e
 4      * @return
 5      */
 6     public boolean contains(E e){
 7         return contains(root, e);
 8     }
 9 
10     /**
11      * 搜索二分搜索树中是否包含元素 e
12      * 时间复杂度 O(log n)
13      * @param node
14      * @param e
15      * @return
16      */
17     public boolean contains(Node node, E e){
18         if(node == null){
19             return false;
20         } else if(e.compareTo(node.e) == 0){
21             return true;
22         } else if(e.compareTo(node.e) < 0){
23             return contains(node.left, e);
24         } else {
25             return contains(node.right, e);
26         }
27     }

 

  从上面代码咱们不难发现其实和add方法的递归思想是同样的。那在此咱们就不作详细解释了。

  为了后面代码的实现,咱们再设计两个方法,即查找树中的最大和最小元素。

  经过二分搜索树的定义咱们不难发现,左孩子 < 当前节点 < 右孩子。按照这个顺序,对于一棵二分搜索树中最小的那个元素就是左边的那个元素,最大的元素就是最右边的那个元素。

  经过下图咱们不难发现,最大的和最小的节点都符合咱们上面的分析,最小的在最左边,最大的在最右边,但不必定都是叶子节点。好比图1中的6和33元素都不是叶子节点。


  经过上面的分析,咱们应该能很容易的想到,查询最小元素,就是使用递归从根节点开始,一直递归左孩子,直到一个节点的左孩子为null。咱们就找到了该最小节点。查询最大值同理。

 1     /**
 2      * 搜索二分搜索树中以 node 为根节点的最小值所在的节点
 3      * @param node
 4      * @return
 5      */
 6     private Node minimum(Node node){
 7         if(node.left == null){
 8             return node;
 9         }
10         return minimum(node.left);
11     }
12 
13     /**
14      * 搜索二分搜索树中的最大值
15      * @return
16      */
17     public E maximum(){
18         if (size == 0){
19             throw new IllegalArgumentException("BST is empty");
20         }
21         return maximum(root).e;
22     }
23 
24     /**
25      * 搜索二分搜索树中以 node 为根节点的最大值所在的节点
26      * @param node
27      * @return
28      */
29     private Node maximum(Node node){
30         if(node.right == null){
31             return node;
32         }
33         return maximum(node.right);
34     }

 

3.2.四、删

  删除操做咱们设计三个方法,即:删除最小,删除最大,删除任意一个元素。

 

  3.2.4.一、删除最大最小元素

  经过对上面3.2.3中的查最大和最小元素咱们不难想到首先咱们要找到最大或者最小元素。

  如3.2.3中的图2所示,若是待删除的最大最小节点若是没有叶子节点直接删除。可是如图1所示,若是待删除的最大最小元素还有孩子节点,咱们该如何处理呢?对于删除最小元素,咱们须要将该节点的右孩子节点提到被删除元素的呃位置,删除最大元素同理。而后咱们再看看图2所示的状况,使用图1的删除方式,也就是对于删除最小元素,将该节点的右孩子节点提到该元素位置便可,只不过对于图2的状况,右孩子节点为null而已。

  

 1     /**
 2      * 删除二分搜索树中的最小值
 3      * @return
 4      */
 5     public E removeMin(){
 6         if (size == 0){
 7             throw new IllegalArgumentException("BST is empty");
 8         }
 9         E e = minimum();
10         root = removeMin(root);
11         return e;
12     }
13 
14     /**
15      * 删除二分搜索树中以 node 为根节点的最小节点
16      * @param node
17      * @return 删除后新的二分搜索树的跟
18      */
19     //////////////////////////////////////////////////
20     //             12                     12        //
21     //           /     \                 /   \      //
22     //          8       18   ----->     10    18    //
23     //           \      /                     /     //
24     //           10    15                    15     //
25     //////////////////////////////////////////////////
26     private Node removeMin(Node node){
27         if(node.left == null){
28             Node rightNode = node.right;// 将node.right(10) 赋值给 rightNode 保存
29             node.right = null;// 将node的right与树断开链接 
30             size --;
31             return rightNode; // rightNode(10)返回给递归的上一层,赋值给 12 元素的左节点。
32         }
33         node.left = removeMin(node.left);
34         return node;
35     }
36 
37     public E removeMax(){
38         E e = maximum();
39         root = removeMax(root);
40         return e;
41     }
42 
43     /**
44      * 删除二分搜索树中以 node 为根节点的最小节点
45      * @param node
46      * @return
47      */
48     //////////////////////////////////////////////////
49     //             12                      12       //
50     //           /     \                 /    \     //
51     //          8       18   ----->     8     15    //
52     //           \      /                \          //
53     //           10    15                 10        //
54     //////////////////////////////////////////////////
55     private Node removeMax(Node node){
56         if(node.right == null){
57             Node leftNode = node.left; // 将node.right(15) 赋值给 leftNode 保存
58             node.left = null;// 将 node 的 left 与树断开链接 
59             size --;
60             return leftNode; // leftNode (10)返回给递归的上一层,赋值给 12 元素的右节点。
61         }
62         node.right = removeMax(node.right);
63         return node;
64     }

 

3.2.4.二、删除指定元素

  待删除元素可能存在的状况以下:

1 第一种,只有左孩子;
2 第二种,只有右孩子;
3 第三种,左右孩子都有;
4 第四种,待删除元素为叶子节点;

 

  第一种状况和第二种状况的树形状相似3.2.3中的图1,其实他们的处理方式和删除最大最小元素的处理方式是同样的。这个就不过多解释了,你们能够本身手动画出来一棵树试试。那对于第四种状况就是第一种或者第二种的特殊状况了,也不须要特殊处理。和3.2.3中的图1和图2的处理方式都是同样的。

  那咱们重点说一下第三种状况,这个状况有点复杂。如上图所示,若是咱们想删除元素10,咱们该怎么作呢?咱们经过二分搜索树的定义分析一下,其实很简单。首先10这个元素必定是大于他的左子树的任意一个节点,并小于右子树的任意一个节点。那咱们删除了10这个元素,仍然不能打破平衡二叉树的性质。通常思路,咱们得想办法找个元素顶替下10这个元素。找谁呢?这个元素放到10元素的位置之后,仍然还能保证大于左子树的任意元素,小于右子树的任意元素。因此咱们很容易想到找左子树中的最大元素,或者找右子树中的最小元素来顶替10的位置,以下图1所示。

  以下图所示,首先咱们用7顶替10的位置,以下图2所示。咱们删除了10这个元素后,用左子树的最大元素替代10,依然能知足二分搜索树的定义。同理咱们用右孩子最小的节点替换被删除的元素也是彻底能够的。在咱们后面的代码实现中,咱们使用右孩子最小的节点替换被删除的元素。

 

 1     /**
 2      * 从二分搜索树中删除元素为e的节点
 3      * @param e
 4      */
 5     public void remove(E e){
 6         root = remove(root, e);
 7     }
 8 
 9     /**
10      * 删除掉以node为根的二分搜索树中值为e的节点, 递归算法
11      * @param node
12      * @param e
13      * @return 返回删除节点后新的二分搜索树的根
14      */
15     private Node remove(Node node, E e){
16 
17         if( node == null )
18             return null;
19 
20         if( e.compareTo(node.e) < 0 ){
21             node.left = remove(node.left , e);
22             return node;
23         } else if(e.compareTo(node.e) > 0 ){
24             node.right = remove(node.right, e);
25             return node;
26         } else{   // e.compareTo(node.e) == 0 找到待删除的节点 node
27 
28             // 待删除节点左子树为空,直接将右孩子替代当前节点
29             if(node.left == null){
30                 Node rightNode = node.right;
31                 node.right = null;
32                 size --;
33                 return rightNode;
34             }
35 
36             // 待删除节点右子树为空,直接将左孩子替代当前节点
37             if(node.right == null){
38                 Node leftNode = node.left;
39                 node.left = null;
40                 size --;
41                 return leftNode;
42             }
43 
44             // 待删除节点左右子树均不为空
45             // 找到右子树最小的元素,替代待删除节点
46             Node successor = minimum(node.right);
47             successor.right = removeMin(node.right);
48             successor.left = node.left;
49 
50             node.left = node.right = null;
51 
52             return successor;
53         }
54     }

   

4、二分搜索树的遍历

  二分搜索树的遍历大概能够分为一下几种:

1,深度优先遍历:
    (1)前序遍历:父节点,左孩子,右孩子
    (2)中序遍历:左孩子,父节点,右孩子
    (3)后序遍历:左孩子,右孩子,父节点
2,广度优先遍历:按树的高度从左至右进行遍历

  如上所示,大类分为深度优先和广度优先,深度有点的三种方式,你们不难发现,其实就是遍历父节点的时机。广度优先呢就是按照树的层级,一层一层的进行遍历。

 

4.一、深度优先遍历

 

4.1.一、前序遍历

  前序遍历是按照:父节点,左孩子,右孩子的顺序对节点进行遍历,因此按照这个顺序对于以下图所示的一棵树,前序遍历,应该是按照编号所示的顺序进行遍历的。

  

  递归实现:虽然看着很复杂,其实递归代码实现是十分简单的。看代码吧,请别惊掉下巴。

    /**
     * 前序遍历
     */
    public void preOrder(){
        preOrder(root);
    }

    /**
     * 前序遍历 - 递归算法
     * @param node 开始遍历的根节点
     */
    private void preOrder(Node node){
        if(node == null){
            return;
        }
        // 不作复杂的操做,仅仅将遍历到的元素进行打印
        System.out.println(node.e);
        preOrder(node.left);
        preOrder(node.right);
    }
-------------前序遍历------------
20
10
6
14
29
25
33

  非递归实现:若是咱们不使用递归如何实现呢?但是使用栈来实现,这是一个技巧,当咱们须要按照代码执行的顺序记录(缓存)变量的时候,栈是一种再好不过的数据结构了。这也是栈的自然优点,由于JVM的栈内存正是栈这种数据结构。

  从根节点开始,每次迭代弹出当前栈顶元素,并将其孩子节点压入栈中,先压右孩子再压左孩子。为何是先右孩子再左孩子?由于栈是后进先出的数据结构

 1     /**
 2      * 前序遍历 - 非递归
 3      */
 4     public void preOrderNR(){
 5         preOrderNR(root);
 6     }
 7 
 8     /**
 9      * 前序遍历 - 非递归实现
10      */
11     private void preOrderNR(Node node){
12         Stack<Node> stack = new Stack<>();
13         stack.push(node);
14         while (!stack.isEmpty()){
15             Node cur = stack.pop();
16             System.out.println(cur.e);
17             if(cur.right != null){
18                 stack.push(cur.right);
19             }
20             if(cur.left != null){
21                 stack.push(cur.left);
22             }
23         }
24     }

 

4.1.二、中序遍历

  中序遍历:左孩子,父节点,右孩子。按照这个顺序,咱们不难画出下图。红色数字表示遍历的顺序。

  递归实现:

 1     /**
 2      * 二分搜索树的中序遍历
 3      */
 4     public void inOrder(){
 5         inOrder(root);
 6     }
 7 
 8     /**
 9      * 中序遍历 - 递归
10      * @param node
11      */
12     private void inOrder(Node node){
13         if(node == null){
14             return;
15         }
16         inOrder(node.left);
17         System.out.println(node.e);
18         inOrder(node.right);
19     }

-------------中序遍历------------
6
10
14
20
25
29
33

 

   咱们观察上面的遍历结果,不难发现一个现象,打印结果正是按照从小到大的顺序。其实这也是二分搜索树的一个性质,由于咱们是按照:左孩子,父节点,右孩子。咱们二分搜索树的其中一个定义:二分搜索树每一个节点的左子树的值都小于该节点的值,每一个节点右子树的值都大于该节点的值。

  非递归实现:依然是用栈保存。

 1     /**
 2      * 中序遍历 - 非递归
 3      */
 4     public void inOrderNR(){
 5         inOrderNR(root);
 6     }
 7 
 8     /**
 9      * 中序遍历 - 非递归实现
10      * 时间复杂度 O(n)
11      * @param node
12      */
13     private void inOrderNR(Node node){
14         Stack<Node> stack = new Stack<>();
15         while(node != null || !stack.isEmpty()){
16             while(node != null){
17                 stack.push(node);
18                 node = node.left;
19             }
20             node = stack.pop();
21             System.out.println(node.e);
22             node = node.right;
23         }
24     }

 

 

 4.1.三、后序遍历

  后序遍历:左孩子,右孩子,父节点。遍历顺序以下图所示。

 

 1     /**
 2      * 后序遍历
 3      */
 4     public void postOrder(){
 5         postOrder(root);
 6     }
 7 
 8     /**
 9      * 后续遍历 - 递归
10      * 时间复杂度 O(n)
11      * @param node
12      */
13     public void postOrder(Node  node){
14         if(node == null){
15             return;
16         }
17         postOrder(node.left);
18         postOrder(node.right);
19         System.out.println(node.e);
20     }
21 -------------后序遍历------------
22 6
23 14
24 10
25 25
26 33
27 29
28 20

 

   非递归实现:

 1     /**
 2      * 后序遍历 - 非递归
 3      */
 4     public void postOrderNR(){
 5         postOrderNR(root);
 6     }
 7 
 8     /**
 9      * 后序遍历 - 非递归实现
10      * 时间复杂度 O(n)
11      * @param node
12      */
13     private void postOrderNR(Node node){
14         Stack<Node> stack = new Stack<>();
15         Stack<Node> out = new Stack<>();
16         stack.push(node);
17         while(!stack.isEmpty()){
18             Node cur = stack.pop();
19             out.push(cur);
20 
21             if(cur.left != null){
22                 stack.push(cur.left);
23             }
24             if(cur.right != null){
25                 stack.push(cur.right);
26             }
27         }
28         while(!out.isEmpty()){
29             System.out.println(out.pop().e);
30         }
31     }

  

 4.二、广度优先遍历

  广度优先遍历:又称为,层序遍历,按照高度顺序一层一层的访问整棵树,高层次的节点将会比低层次的节点先被访问到。这种遍历方式显然是不适合递归求解的。至于为何,相信通过咱们前面对递归的分析,你们已经很清楚了。

  对于层序优先遍历,咱们使用队列来实现,利用队列的先进先出(FIFO)的的特性。

 1     /**
 2      * 层序优先遍历
 3      * 时间复杂度 O(n)
 4      */
 5     public void levelOrder(){
 6         Queue<Node> queue = new LinkedList<>();
 7         queue.add(root);
 8         while(!queue.isEmpty()){
 9             Node node = queue.remove();
10             System.out.println(node.e);
11             if(node.left != null){
12                 queue.add(node.left);
13             }
14             if(node.right != null){
15                 queue.add(node.right);
16             }
17         }
18     }

 

5、二分搜索树存在的问题

  前面咱们讲,二分搜索树是一种高效的数据结构,其实这也不是绝对的,在极端状况下,二分搜索树会退化成链表,各类操做的时间复杂度大打折扣。好比咱们向咱们上面实现的二分搜索树中按顺序添加以下元素[1,2,3,4,5],以下图所示,咱们发现咱们的二分搜索树其实已经退化成了一个链表。关于这个问题,咱们在后面介绍平衡二叉树(AVL)的时候会讨论如何能让二分搜索树保持平衡,并避免这种极端状况的发生。

 

   

    《祖国》

    小时候

    觉得你就是远在北京的天安门
    
    长大了

    才发现原来你就在个人内心

 

 

  参考文献:

  《玩转数据结构-从入门到进阶-刘宇波》

  《数据结构与算法分析-Java语言描述》

 

 

  若有错误的地方还请留言指正。

  原创不易,转载请注明原文地址:https://www.cnblogs.com/hello-shf/p/11342907.html 

相关文章
相关标签/搜索