二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?

上一篇专栏,我们介绍了二叉树的基本知识,接下来,就来学习一种特殊的二叉树,名为二叉查找树

二叉查找树最大的特点就是:支持动态数据集合的快速插入、删除、查找操作。

我们知道,散列表也都是支持这些操作的,而且散列表kv查找必然更快,时间复杂度还是O(1),那我们为何还用二叉查找树呢?

带着这个问题就来学习今天的内容,二叉查找树!

二叉查找树

二叉查找树,也叫二叉搜索树。二叉查找树是为了实现快速查找而生的。不过,这不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。它是怎么做到这些的呢?

这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值

以下是几个二叉查找树的例子:

在这里插入图片描述
以下主要针对二叉查找树针对快速查找、插入、删除操作进行讲解。

  1. 二叉查找树的查找操作

首先,我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,就直接返回。

如果查找的数据比根小,就去左子树递归,比根大,就去右子树递归。

在这里插入图片描述

上面的图代码思想如下:

public class BinarySearchTree {
    private Node tree;

    public Node find(int data) {
        Node p = tree;
        while (p != null) {
            if (data < p.data) {
                p = p.left;
            } else if (data > p.data) {
                p = p.right;
            } else {
                return p;
            }
            
        }
        return null;
    }

    public static class Node{

        private int data;
        private Node left;
        private Node right;

        public Node(int data){
            this.data = data;
        }

    }

}
  1. 二叉查找树的插入操作

二叉查找树的插入过程类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

如果要插入的数据比节点的大,并且右子树为空,就将新数据直接插到右子节点上,如果不为空,就递归遍历右子树,查找插入位置。同理,若比当前结点小也是如此的思路。

在这里插入图片描述

同样,插入的代码也实现了一下:

public void insert(int data) {
        if (tree == null) {
            tree = new Node(data);
            return;
        }
        Node p = tree;
        while (p != null) {
            if (data > p.data) {
                if (p.right == null) {
                    p.right = new Node(data);
                    return;
                }
                p = p.right;
            } else {  //data<p.data
                if (p.left == null) {
                    p.left = new Node(data);
                    return;
                }
                p = p.left;
            }
        }
    }

  1. 二叉查找树的删除操作

二叉查找树的查找、插入操作都比较简单易懂,但是它的删除操作就比较复杂了。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

<1> 如果要删除的结点没有子结点,我们只需要直接将父节点中,指向要删除结点的指针的指针置换为null。比如图中的结点55。

<2>如果要删除的结点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除结点的指针,让它指向要删除结点的子结点就可以了,比如图中的删除结点13.

<3>如果要删除的结点有两个子结点,这就比较复杂了。我们需要找到这个结点的右子树中的最小结点,把它替换到要删除的结点上。然后要删除掉这个最小结点,因为最小结点肯定没有左节点(如果有,肯定不是最小),所以骂我们可以用上面两条规则删除这个最小结点。比如图中的删除结点18.

在这里插入图片描述
代码如下:

    //删除结点

    public void delete(int data) {
        Node p = tree;//p指向要删除的结点,初始化指向根节点
        Node pp = null;//pp记录的是p的父节点

        while (p != null && p.data != data) {
            pp = p;
            if (data > p.data) {
                p = p.right;
            } else {
                p = p.left;
            }
            if (p == null) {
                return;//没有找到
            }

            //要删除的结点有两个子节点
            if (p.left != null && p.right != null) {//查找右子树中最小结点
                Node minP = p.right;
                Node minPP = p;//minPP表示minP的父节点
                while (minP.left != null) {
                    minPP = minP;
                    minP = minP.left;
                }
                p.data = minP.data;//将minP的数据替换到p中
                p = minP; //下面就变成了删除minP了
                pp = minPP;
            }

            //删除结点是叶子结点或者仅有一个子节点
            Node child; //p的子节点
            if (p.left != null) {
                child = p.left;
            } else if (p.right != null) {
                child = p.right;
            } else {
                child = null;
            }

            if (pp == null) {
                tree = child;//删除的是根节点
            } else if (pp.left == p) {
                pp.left = child;
            } else {
                pp.right = child;
            } 
        }
    }

实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的结点标记为“已删除”,但是并不真正从树中将这个结点去掉。这样原本删除的结点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

  1. 二叉查找树的其他操作

除了插入、删除、查找,二叉查找树还可以支持快速查找到最大节点和最小结点、前驱节点和后继节点

二叉查找树除了支持上满几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树们可以输出有序的数据序列,时间复杂度是O(n),非常高效,因此,二叉查找树也叫作二叉排序树。

支持重复数据的二叉查找树

前面讲二叉查找树的时候,我们默认树中节点存储的都是数字。很多时候,在实际的软件开发中,我们在二 叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值(key)来构建二叉查 找树。我们把对象中的其他字段叫作卫星数据。

前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理呢?我这里有两种解决方法。

第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的 数组等数据结构,把值相同的数据都存储在同一个节点上。

第二种方法是每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相 同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点 的值来处理。

在这里插入图片描述

当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶 子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

在这里插入图片描述
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。在这里插入图片描述

二叉查找树的时间复杂度分析

实际上,二叉查找树的形态各式各样。比如这个图中,对于同一组数据,我们构造了三种二叉查找树。它们 的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树,根节点的左右子树极度不平 衡,已经退化成了链表,所以查找的时间复杂度就变成了O(n)。
在这里插入图片描述

刚刚其实分析了一种最糟糕的情况,我们现在来分析一个最理想的情况,二叉查找树是一棵完全二叉树 (或满二叉树)。这个时候,插入、删除、查找的时间复杂度是多少呢?

从我前面的例子、图,以及还有代码来看,不管操作是插入、删除还是查找,时间复杂度 时间复杂度其实 其实都跟树的高度 都跟树的高度 成正比,也就是O(height) 成正比,也就是O(height)。既然这样,现在问题就转变成另外一个了,也就是,如何求一棵包含n个节点的 完全二叉树的高度?

树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从图中可以看出,包含n个节点的完 全二叉树中,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,依次类推,下面一层节点个 数是上一层的2倍,第K层包含的节点个数就是2^(K-1)。

不过,对于完全二叉树来说,最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在1个到 2^(L-1)个之间(我们假设最大层数是L)。如果我们把每一层的节点个数加起来就是总的节点个数n。也就 是说,如果节点的个数是n,那么n满足这样一个关系:

n>=	1+2+4+8+...+2^(L-2)+1 
n<=	1+2+4+8+...+2^(L-2)+2^(L-1)

借助等比数列的求和公式,我们可以计算出,L的范围是[log (n+1), log n +1]。完全二叉树的层数小于等于 log n +1,也就是说,完全二叉树的高度小于等于log n。

显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删 除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是我们下一节课要 详细讲的,一种特殊的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、 查找操作的时间复杂度也比较稳定,是O(logn)。

解答开篇

我们在散列表那节中讲过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1),非常高 效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn),相对散列表,好 像并没有什么优势,那我们为什么还要用二叉查找树呢?

我认为有下面几个原因:

  • 第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说, 我们只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列。

  • 第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在 工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。

  • 第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量 不一定比logn小,所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡 二叉查找树的效率高。

  • 第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩 容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

  • 最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的 开发过程中,需要结合具体的需求来选择使用哪一个。

总结

二叉查找树中,每个节点的值都大于左子树节点的值,小于右子树节点的值。不过,这只是针对没有重复数 据的情况。对于存在重复数据的二叉查找树,我介绍了两种构建方法,一种是让每个节点存储多个值相同的 数据;另一种是,每个节点中存储一个数据。针对这种情况,我们只需要稍加改造原来的插入、删除、查找 操作即可。

在二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复 杂度分别是O(n)和O(logn),分别对应二叉树退化成链表的情况和完全二叉树。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值