b+树时间复杂度_二分搜索树——从线性结构到树形结构的转变

本文介绍了二分搜索树的基本概念、特性、时间复杂度,并详细解析了插入、删除、查找等操作的递归实现。二分搜索树是一种非自平衡的树形数据结构,具有快速查找、插入和删除的特性,但在最坏情况下可能退化为链表。此外,文章还探讨了二分搜索树的遍历方法,包括前序、中序、后序和层序遍历。
摘要由CSDN通过智能技术生成

从这篇文章开始,我们正式进入到树这种数据结构的学习,前面我们讲到的数组、链表、散列表、集合与映射都是线性的数据结构。我们回顾一下数组和链表,数组的随机访问非常高效,时间复杂度是O(1),但是写操作由于涉及到数据的搬移,时间复杂度为O(n)。而链表正好相反,由于随机访问最坏的情况需要遍历所有节点,所以链表随机访问的时间复杂度是O(n),链表插入数据是直接将新的节点挂到队头或者队尾,时间复杂度为O(1)。我们再回忆一下散列表,散列表是非常高效的数据结构,写和读都可以做到O(1)的时间复杂度,但是散列表也有它的问题,因为要使用额外的空间来保存散列表的键值,并且无法保证数据的有序性。

在实际工程中,对于读写都很频繁的场景,如果使用我们前面介绍的几种线性数据结构,没办法在读写上做很好的平衡。那有没有一种读写性能都比较好的数据结构呢?答案就是使用这种数据结构,树形数据结构有非常多的种类,我们这个系列文章会介绍二分搜索树、堆、线段树、字典树、平衡二叉树、红黑树。我们今天要讲的是树形数据结构里面最基础的一种,叫二分搜索树。

什么是二分搜索树?

一棵二叉搜索树是以一棵二叉树来组织的。这样一棵树可以使用一个链表数据结构来表示,其中每个节点就是一个对象。除了key和卫星数据之外,每个节点还包含属性left、right和p,它们分别指向结点的左孩子、右孩子和双亲。如果某个孩子结点和父结点不存在,则相应属性的值为NIL。根结点是树中唯一父指针为NIL的结点。 《算法导论》二叉搜索树

上面是《算法导论》中对二叉搜索树的定义,是不是有点蒙?为了方便你理解,我将上面的定义提取出了下面3条规则:

  •  一个节点有两个孩子节点,右子节点和左子节点

  • 左子节点的值小于右子节点

  • 如果某个节点的孩子节点不存在则为null

通过以上3条规则就可以描述一颗二分搜索树了。

文字描述可能还是不够直观,下面我画了几颗二分搜索树,你可以对照上面给出的几个特征来看。

e7ad4a4deb8029ba097630ed633ad1f9.png

图(1)

以上都是合法的二分搜索树,第三颗树有点特殊,所有的节点都没有左子节点,这种情况我们说树退化成了链表,这也是二分搜索树的缺点,由于没有自平衡的机制,在一些极端情况下会退化成链表。

上面我们解释了什么是二分搜索树,接下来我们就来分析一下二分搜索树的时间复杂度。对于一颗二分搜索树,由于左子节点比右子节点的值要小,在插入的时候我们只需要从根节点一层一层往下找,直到找到合适的位置插入,如下图:

8b798387756650c6e5fb29dc47dfcb5a.png

图(2)

从上图我们可以看出,插入一个节点每次选择都将范围缩小了一半,所以,对于插入元素来讲,其时间复杂度为O(logN)。

对于随机读取一个元素也是同样的过程,时间复杂度为O(logN),如下图:

8a555d39c425055af56ff248212312ed.png

图(3)

要注意的是,我们在上面说二分搜索树在一些极端情况下可能会退化成链表,这时候随机读的时间复杂度相应的就变成了O(n)了。

通过上面对二分搜索树的读写过程的简单分析,我们发现二分搜索树天然的就具备递归的特性,关于递归我在如何使用堆栈实现浏览器的前进后退功能?那一节有讲,你可以点击链接回顾一下。实际上对于二分搜索树的读写最经典的实现方式也是使用递归的方式,我们在下面讲如何实现一颗二分搜索树的时候会详细说明。

如何实现一颗二分搜索树?

我们上面解释了什么是二分搜索树以及二分搜索树的时间复杂度,下面我们通过代码来剖析一下二分搜索树的增删查操作,我们依然使用Java来实现。

首先,我们定义一个节点类Node

private class Node {    public E e;    public Node left,right;    public Node(E e) {        this.e = e;        this.left = null;        this.right = null;    }}

在这个节点类里面我们定义三个成员变量和一个构造方法,用于保存数据的成员变量e我们使用了泛型,方便我们保存不同类型元素节点。left和right是一个Node对象,分别代表左、右子节点。构造方法接收一个元素值参数,会在我们New一个Node对象的时候将数据赋值给节点对象的成员e,同时将左、右子节点初始化为null。

对于一个节点来讲,至少会包含3个属性,分别是左、右子节点指针和存放数据的变量。如下图:

51e63f7d5991549217f494e71648c9ba.png

图(4)

然后我们定义一个二分搜索树的类,代码如下:

public class BinarySearchTree<E extends Comparable<E>> {    // 许多其它代码......    private Node root;    private int size;    public BinarySearchTree() {        this.root = null;        this.size = 0;    }    // 许多其它代码......}

大概解释一下这段代码的意思,我们声明了一个BinarySearchTree的类,有两个成员变量root和size,root表示这颗树的根节点,size表示这颗树有多少个数据元素。同时,我们让E继承了Comparable,使得泛型数据可以进行比较大小,这是Java里的语法,有兴趣可以下去看看,这里你只要知道,E继承了Comparable之后,我们就可以使用e.compareTo()方法来比较两个数据的大小了。

接下来我们看一下插入元素,代码如下:

 private Node add(Node node, E e) {      if (node == null) {          size++;          return new Node(e);      }      if (e.compareTo(node.e) < 0)          node.left = this.add(node.left, e);      else if (e.compareTo(node.e) > 0)          node.right = this.add(node.right, e);      return node; }

我们说二叉树天生就是有递归的特性,这里插入元素我们使用了一个递归函数,简单整理一下插入元素的过程。这个递归函数有两个参数node和e,node表示要将数据插入到哪个节点,e表示要插入的数据。首先我们要找到递归的终止条件node==null,当node==null的时候表示找到了最终要插入的位置,将要插入的数据e做成一个节点返回。然后我们看第代码第6行到第9行,当e小于当前节点里的元素的时候去当前节点的左子节点找要插入的位置,当e大于当前节点的时候去当前节点的右子节点找要插入的位置。第10行表示数据插入到这颗之后将新的这颗树返回,这样就完成了数据的插入操作,整个过程可如图(2)。

接下来我们看数据的删除,如果要删除的数据刚好在叶子节点,那我们只需要将这个节点从这颗树里面移除就可以了,这里说的叶子节点是指没有子节点的节点。如下图:

1ff89b34434b1f9f82afc526d78158c6.png

图(5)

情况稍复杂一点的是如果被删除的数据不在叶子节点,那我们需要将要删除节点的子节点按照规则重新关联到要删除节点的父节点上。如下图:

58eb6ebcb035397f312fc2421dd822c6.png

图(6)

删除一个元素的代码如下:

private Node del(Node node, E e) {    if (node == null)         return null;    if (e.compareTo(node.e) < 0) {        node.left = del(node.left, e);        return node;    } else if (e.compareTo(node.e) > 0) {        node.right = del(node.right, e);        return node;    } else { // e.compareTo(node.e) == 0        // 左子树为空的情况        if (node.left == null) {            Node tmpNode = node.right;            node.right = null;            size--;            return tmpNode;        }        // 右子树为空的情况        if (node.right == null) {            Node tmpNode = node.left;            node.left = null;            size--;            return tmpNode;        }        // 左右子树都不为空的情况        // 找到最右子树最小的节点来补充到要删除的位置        Node successor = min(node.right);        successor.right = delMin(node.right);        successor.left = node.left;        node.right = node.left = null;        return successor;    }}

下面我们来看一下二分搜索树的遍历,常用的遍历分为以下4种:前序遍历、中序遍历、后序遍历、层序遍历

前序遍历,前序遍历其实就是两个“先”,先遍历父节点和先遍历左子树。如下图:

8b05b69e8ec089b7ee673ef1af831545.png

图(7)

上图中,先遍历根节点20,然后再遍历左子节点10,接着遍历10的子节点5,然后是10的右子节点15,最后是根节点20的右子节点30。

前序遍历的代码如下:

private void preOrder(Node node) {    if (node == null)        return;    System.out.println(node.e);    preOrder(node.left);    preOrder(node.right);}

前序遍历的非递归实现,前序遍历非递归的实现,我们需要借助一个额外的数据结构:,利用栈先进后出的特性,我们每遍历一层就将右子节点和左子节点依次入栈,注意这个顺序,是右子节点先入栈,这样每次先出栈的就一定是左节点,过程如下图:

21c2a2a5da522d8130e3b11a3c1d7288.png

图(8)

前序的百递归实现代码如下:

public void preOrderNR() {    if (root == null)        return;        Stackstack = new Stack<>();    stack.push(root);    while(!stack.isEmpty()) {        Node cur = stack.pop();        System.out.println(cur.e);        if (cur.right != null)            stack.push(cur.right);        if (cur.left != null)            stack.push(cur.left);    }}

结合代码和图(8)我们解释一下这个过程:

  1. 将根节点20入栈,进入第一次循环

  2. 第一次循环将根节点出栈,拿到根节点20,并将右子节点和左子节点依次入栈

  3. 进入第二层循环,出栈拿到右子节点10,并将节点10的右子节点和左子节点依次入栈

  4. 进入第三层循环,出栈拿到子节点5,左、右子节点为null,所以没有入栈操作

  5. 进入第四层循环,出栈拿到子节点15,左、右子节点为null,所以没有入栈操作

  6. 进入第五层循环,出栈拿到节点30,左、右子节点为null,所以没有入栈操作

  7. 进入第六层循环,此时栈里已经没有元素了,程序执行结束

最后遍历的结果为:20、10、5、15、30

中序遍历,中序遍历从根节点开始,先遍历左子树,然后遍历右子树,过程如下图:

27cf74097ec644aa4fea3a818068c3ab.png

图(9)

中序遍历代码如下:

private void inOrder(Node node) {    if (node == null)         return;    inOrder(node.left);    System.out.println(node.e);    inOrder(node.right);}

观察发现,中序遍历出的结果刚好是从小到大排序好的,这也是中序遍历很常用的一种应用场景。

后序遍历,后序遍历其实就是从最后一层开始从下往上一层层遍历,过程如下图:

8bd09d420da36db28c724c9986b1a20b.png

图(11)

后序遍历代码如下:

private void postOrder(Node node) {    if (node == null)        return;    postOrder(node.left);    postOrder(node.right);    System.out.println(node.e);}

层序遍历,层序遍历就是从树的最上面一层开始一层层遍历到最后一层,我们需要借助额外的数据结构队列来辅助层序的遍历,利用队列先进先出的特点,我们将左子节点和右子节点依次入队,注意顺序,是左子节点先入队。这样每一次最先出队的一定是最先入队的左子节点。过程如下图:

94e6ccd2dddff36fd36713e2727b3f7d.png

图(12)

层序遍历代码如下:

public void levelOrder() {    if (root == null)        return;    Queue 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);    }}

我们来总结一下层序遍历的过程:

  1. 将根节点入队,进入第一层循环

  2. 在第一次循环中,出队拿到根节点20,然后将左子节点和右子节点依次入队

  3. 进入第二次循环,出队拿到节点10,然后将节点10的左、右子节点依次入队

  4. 进入第三次循环,出队拿到节点30,节点30没有左、右子节点,不做入队操作

  5. 进入第四次循环,出队拿到节点5,节点5没左、右子节点,不做入队操作

  6. 进入第五次循环,出队拿的到节点15,节点15没有左、右子节点,不做入队操作

  7. 进入第六次循环,队列为空,退出循环,程序结束

最终遍历的结果:20、10、30、5、15

获取最小值,我们回忆一下前面我们说二分搜索树的左子节点小于右子节点,那么获取小值就是找到最左边最后一层的那个节点,过程如下图:

76f26fcf291e585a638737061a381066.png

图(13)

获取最小值代码如下:

private Node min(Node node) {    if (node.left == null)         return node;    return min(node.left);}

获取最大值,获取最大值和获取最小值原理是一样的,如下图:

6233be5b70416df17863fe4c076fc840.png

图(14)

获取最大值代码如下:

private Node max(Node node) {    if (node.left == null)         return node;    return max(node.right);}

删除最小值,如果被删除的节点没有子节点我们只需要找到最小的那个元素,然后删除就可以了。如下图:

7ab45c7e6b148e2fc9cbb1dafdb67366.png

图(15)

稍微复杂一些的情况是如果被删除的节点有子节点,这里所指的子节点肯定是右节点,到于为什么你可以考虑一下,那除了要把这个节点删除,还要将被删除节点的右子节点和被删除的父亲节点做关联。如下图:

9eb0ebfb6fe98c9cc4ce716883c02c77.png

图(16)

删除最小节点的代码如下:

public E delMin() {    E e = min();    root = delMin(root);    return e;}private Node delMin(Node node) {    if (node.left == null) {        Node tmpNode = node.right;        node.right = null;        size--;        return tmpNode;    }    node.left = delMin(node.left);    return node;}

删除最大值,删除最大值原理和删除最小值是一样的,这里就不赘述了。如下图:

94afd6bc4e2f7839293b7ddedb0b6990.png

图(17)

同样的,如果被删除的节点有子节点,这里指的子节点指的就是左子节点,和删除最小值原理也是一样的,过程如下图:

843bb2c321eb05fd8b690fd986b0e702.png

图(18)

删除最大值代码如下:

public E delMax() {    E e = max();    root = delMax(root);    return e;}private Node delMax(Node node) {    if (node.right == null) {        Node tmpNode = node.left;        node.left = null;        size--;        return tmpNode;    }    node.right = delMax(node.right);    return node;}

上面是对整个二分搜索树的实现,完整代码可以去我的github上查看:

https://github.com/seepre/data-structure

总结:

今天分享了二分搜索树,我们说二分搜索树在最坏的情况下有可能退化成一个链表。所以说,二分搜索树不是一颗自平衡的树。实际上在工程中也很少会使用到二分搜索树,因为其不涉及自平衡机制所以也不会有左旋转右旋转这类复杂的操作是比较理想的入门树形数据结构的例子。虽然二分搜索树相对较简单,但如果你想学好树形数据结构,那一定要把二分搜索树理解透,特别是其中的新增和删除的两个递归函数,只有在理解透了二分搜索树的基础上再去学习高阶的树形数据结构才会水到渠成。

下一节我们来讲堆这种特殊的树形数据结构

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值