[数据结构] --- 树

1 树的基本概念

1.1 树的定义

树是n(n>=0)个结点的有限集。当 n = 0 时,称为空树。在任意一棵树非空树中应满足:

(1) 有且仅有一个特定的称为根 (root) 的结点;

(2) 当 n > 1 时,其余结点可分为m(m>0)个互不相交的有限集T1, T2, ... Tm,,其中每一个集合本身又是一颗树,并且称为根的子树(SubTree)。

1.2 树的基本术语


祖先结点与子孙结点:

以结点K为例,根A到结点K的唯一路径上的任意结点,称为结点K的祖先结点。

如结点B、A都是结点K的祖先结点,而结点K是结点B的子孙结点。


双亲结点、孩子结点与兄弟结点:

以结点K为例,路径上最接近结点K的结点E称为结点K的双亲结点,而结点K为结点E的孩子结点。

根A是树中唯一没有双亲的结点。

有相同双亲的结点称为兄弟结点。如结点K和结点L有相同的双亲结点E,即K和L为兄弟结点。


结点的度与树的度:

树中一个结点的子结点的个数称为该结点的度。

树中结点的最大度数称为树的度。

如结点B的度为2,结点D的度为3,树的度为3。


分支结点与叶子结点:

度大于0的结点称为分支结点(又称非终端结点),度为0(没有孩子结点)的结点称为叶子结点(又称终端结点)。

在分支结点中,每个结点的分支数就是该结点的度。

结点的深度、高度和层次:

结点的层次从根开始定义,根结点为第1层(有些地方将根结点定义为第0层),它的子结点为第2层,以此类推。

结点的深度是从根结点开始自顶向下逐层累加的。

结点的高度是从叶结点开始自底向上逐层累加的。

树的高度(又称深度)是树中结点的最大层数。如上图树的高度为4。


有序树和无序树:

树中结点的子树从左到右是有次序的,不能交换,这样的树称为有序树,有序树中,一个结点的子结点按照从左到右的顺序出现是有关联的,反之则称为无序树。上图所示的树就是一棵有序树,若将子结点的位置互换,则变成一棵不同的树。


路径和路径长度:

树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。

在上图中,结点A和结点K的路径长度为3,中间经过结点B和结点E。

注意:由于树中的分支是有向的,即从双亲结点指向孩子结点,所以树中的路径是从上向下的,同一双亲结点的两个孩子结点之间不存在路径。


森林:

森林是m(m≥0)棵互不相交的树的集合。

森林的概念和树的概念十分相近,只要把树的根结点删去就成了森林。反之,只要给n棵独立的树加上一个结点,并把这n棵树作为该结点的子树,则森林就变成了树。


1.3 树的性质

树具有如下基本的性质:

1)树中的结点数 = 所有结点的度数 + 1

这个很容易理解:每个结点的度数 = 孩子结点的个数,最后再加上根结点,就是树中的结点数了。

2)度为m的树中,第i层上至多有mi-1个结点

求最多的结点数,那么每个结点的度均为m。

第 i 层上的结点数 = 第 i-1 层结点的度数,也就是m*(第 i-1 层的结点个数) = mm(第 i-2 层的结点个数) = ... = mi-1*(第1层的结点数)= mi-1(共乘了i次)。

3)高度为h的m叉树至多有(mh-1)/(m-1)个结点

由树的上个性质可知,m叉树第 i 层至多有mi-1个结点。那么高度为h的m叉树的结点数至多:

S = mh-1 + mh-2 + mh-3 + ... + m + 1 = (mh-1)/(m-1)

该树现在是个满m叉树。

4)具有n个结点的m叉树的最小高度为⌈logm(n(m-1)+1)⌉

求最小高度也就是每层结点数最多时的高度,即该树是一棵完全m叉树,设其高度为h。

由树的第4个性质,有 n ≤ (mh-1)/(m-1),解得 h ≥ logm(n(m-1)+1) 。

故h为⌈logm(n(m-1)+1)⌉。

另外,实际上有 (mh-1-1)/(m-1) +1≤ n ≤ (mh-1)/(m-1),故最小高度h也可以为 ⌊logm((n-1)(m-1)+1)⌋ + 1。

2 二叉树

2.1 二叉树的定义

**二叉树是每个节点最多有两个子树的树结构。**它有五种基本形态:二叉树可以是空集;根可以有空的左子树或右子树;或者左、右子树皆为空。

2.2 二叉树的分类

二叉树又分为斜树,满二叉树,完全二叉树。

**斜树:**所有节点都只有左子树的二叉树叫做左斜树,所有节点都只有右子树的二叉树叫做右斜树。(本质就是链表)

**满二叉树:**如果二叉树的高度为h,树的节点数为2^h - 1 ,h >= 0,我们就称此树为"满二叉树"

**完全二叉树:**二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边。

Note: 一颗满二叉树必定是一颗完全二叉树,而完全而二叉树不一定是满二叉树。

3 二叉树的存储方式

3.1 链式存储

通过指针把分布在散落在各个地址的节点串联在一起,链式存储如图所示:

二叉树每个节点创建一个Node对象:

c 复制代码
class Node{
		int data;
		Node left,right;
}

每个节点都是一个Node对象,它包含我们所需要存储的数据,指向左子节点的引用,指向右子节点的引用,就像链表一样将整个树串起来。如果该节点没有左子节点,则Node.leftnull或者Node.rightnull.

3.2 顺序存储

我们把根节点储存在下标为i=1的位置,那么左子节点储存在2i=2的位置,右子节点储存在下标为2i+1=2的位置。依此类推,完成树的存储。借助下标运算,我们可以很轻松的从父节点跳转到左子节点和右子节点或者从任意一个子节点找到它的父节点。如果X的位置为i,则它的两个子节点的下标分别为2i和2i+1,它的父节点的位置为i/2(这里结果向下取整)。

具体如下图所示:可以发现,只有完全二叉树存储的效率才最高,最省内存

4 二叉树的遍历方式

4.1 深度优先遍历

  • 前序遍历(迭代法,递归法)中左右
  • 中序遍历(迭代法,递归法)左中右
  • 后序遍历(迭代法,递归法)左右中

前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。

中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。

后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

注意,这其中有点递归的味道

以下图为例:

前序遍历:A->B->D->E->C->F

中序遍历:D->B->E->A->F->C

后序遍历:D->E->B->F->C->A

4.2 广度优先遍历

层次遍历(迭代法)

5 二叉查找树

5.1 二叉查找树规则

二叉查找树只是在二叉树的定义上做了一个小小的限制:
一棵二叉查找树是一棵二叉树,其中每个节点的键都大于它的左子树上的任意节点的键,并且小于右子树上任意节点的键。

二叉查找树规则:

  • 非空左子树的所有键值小于其根结点的键值。
  • 非空右子树的所有键值大于其根结点的键值。
  • 左、右子树都是二叉搜索树。

如果我们对二叉查找树进行中序遍历的话,得到的序列是有序的,这是二叉查找树天生的灵活性。具体也可以看一下下面这幅图:

5.2 二叉查找树实现

数据表示

完全等同于二叉树的链式存储法,我们定义一个节点类Node来表示二叉查找树上的一个节点,每个节点含有一个键,一个值,一个左链接,一个有链接。其中键和值是为了储存和查找,一般来说,给定键,我们能够快速的找到它所对应的值。

c 复制代码
private class Node{
    private int key;//键
    private String value;//值,我这里把数据设为String,为了和key区分开
    private Node left,right;//指向子树的链接
    public Node(int key,String value);//Java中的构造函数
}

查找数据

查找操作接受一个键值(key),返回该键值对应的值(value),如果找不到就返回 null。

大致思路是:如果树是空的,则查找未命中,返回null;如果被查找的键和根节点的键相等,查找命中,返回根节点对应的值;如果被查找的键较小,则在左子树中继续查找;如果被查找的键较大,则在右子树中继续查找。我们用递归来实现这个操作,具体的代码如下:

c 复制代码
public String find(int key){
    return find(root,key);
}
private String find(Node x,int key){
    //在以x为根结点的子树中查找并返回键key所对应的值
    //如果找不到,就返回null
    if(x==null) return null;
    if(key<x.key) return find(x.left,key);
    else if(key>x.left) return find(x.right,key);
    else return x.value;
}
// 注意这里用了两个方法,一个私有一个公开,私有的用来递归,公开的对外开放

递归代码的实现是很简洁的,比较容易理解,我们来看你一下动图:
比如我们想查找32,首先,32小于41,所以对41的左子树进行查找,32大于20,再对20的右子树进行查找,紧接着对29的右子树查找,正好命中32,如果查找不到的话就返回null。

插入数据

我们首先判断根节点是不是空节点,如果是空节点,就直接创建一个新的Node对象作为根节点即可;

如果根节点非空,就通过while循环以及p.key和key的大小比较不断向下寻找。循环结束时肯定会找到 一个空位置,这时我们就创建一个新的Node对象并把它接在这里。当然还有一种情况,如果p.key==key,就更新这个键键对应的值,结束。

来一起看下面这个例子,向树中插入31,可以结合着实现方法一(非递归)理解一下:

实现方法一(非递归实现):

c 复制代码
public void insert(int key,String value) {
    //如果根节点为空节点
    if (root == null) {
        root = new Node(key,value);
        return;
    }

    //根节点不是空节点
    Node p = root;
    while (p != null) {
      if (key > p.key) { //向右走
        if (p.right == null) {
          p.right = new Node(key,value);
          return;
        }
        p = p.right;
       } 
       else if { // key < p.key,向左走
         if (p.left == null) {
           p.left = new Node(key,value);
           return;
         }
        p = p.left;
      }
      else p.value=value;//如果原来树中就含有value键,则更新它的值
    }
  }

实现方法二(递归实现):

c 复制代码
public void insert(int key,String value){
    root=insert(root,key,value);
}
private Node insert(Node x,int key,String value){
    //如果key存在于以x为根节点的子树中则更新它的值;
    //如果不在就创建新节点插入到合适的位置;
    if(x==null) return new Node(key,value);
    if(key<x.key) x.left=insert(x.left,key,value);
    else if(key>x.key) x.right=insert(x.right,key,value);
    else x.value=value;
    return x;
}

这个递归的代码尽管很简洁,但却不是那么容易理解。

我先说一下写递归算法需要注意的问题:

1.一个问题的解可以分解为几个子问题的解何为子问题
2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
3.存在递归终止条件

PS:关键在于写出递推公式,找到终止条件

在这里,递推公式就是根据条件判断。然后将根节点对应的树转化为规模小一点的左子树或右子树,终止条件就是遇到空链接。如果实在绕脑子,你只需要理解第一种非递归的方法就行了:)。

查找最大值与最小值

这个操作应该是最简单的了。根据二叉查找树的定义,最小值就是最左边的元素,直接从根节点一直向左查找即可。它也有两种实现方式,具体的代码如下:

实现一(递归实现)

c 复制代码
public int min(){
    return min(root).key;
}
private Node min(Node x){
    // 返回以x为根节点的树的最小节点
    if(x.left==null) return x;
    return min(x.left);
}

实现二(非递归实现)

c 复制代码
public int min()
    if(root==null) return -1; //表示不存在最小值
    Node x=root;
    //沿着左子树一直深入搜索下去,直到遇到左子树为空的节点,此时当前节 点为最小值
    while(x.left !=null)
        x = x.left
    return x.key;
}

以下是动图演示:

查找前驱节点和后继节点

前驱节点指的是小于该键的最大键,后继节点指的是大于该键的最小键。你可以结合中序遍历理解,通过中序遍历,在得到的序列中位于该点左侧的就是前驱节点,右侧的就是后驱节点。

举个例子,如图所示:

我们首先介绍以下前驱节点的性质:

1.若一个节点有左子树,那么该节点的前驱节点是其左子树中最大的节点(也就是左子树中最右边的那个节点),示例如下:
2.若一个节点没有左子树,那么判断该节点和其父节点的关系

2.1 若该节点是其父节点的右子节点,那么该节点的前驱结点即为其父节点。 示例如下:

2.2 若该节点是其父节点的左子节点,那么需要沿着其父亲节点一直向树的顶端寻找,直到找到一个节点P,P节点是其父节点Q的右子节点,那么Q就是该节点的后继节点,示例如下:
以上就是寻找的思路,不过实际上我们还有一步操作,就是找到这个给定的节点,在这个过程中,我们同时记录最近的一个向右走的节点first_parent。具体的代码如下(已测试):

c 复制代码
    public int get_prenode(int key)
    {
        if (root==null)
            return -1;//-1表示找不到给定的节点
        if (root.key==key)
            return -1;
        Node p = root;
        Node pp = null;
        Node  first_parent=null;
        while (p != null) {
            if (key>p.key) {
                pp = p;
                first_parent=p;
                p = p.right;

            } else if (key<p.key) {
                pp = p; 
                p = p.left;
            } else {

                break;
            }
        }
        if(p==null) return -1;
        else if(p.left!=null) return max(p.left).key;//对应了第1种情况,如果左子树不为空,则前驱一定是左子树的最大值,即小于p的最大值(左子树的值都小于p节点)
        //以下是左子树为空的情况2.1
        else if(pp.right==p) return pp.key;
        //以下是左子树为空的情况2.2
        else if(pp.left==p) return first_parent.key;
        return -1;
    }

查找向下取整和向上取整

向上取整是指大于等于该键的最小键,向下取整是指小于等于该键的最小值。

向下取整与前驱后继节点的区别在于查找前驱后继节点对应的参数是树中的某一个节点键,而向下取整则允许接受任意的键作为参数,另一方面,向下取整可以包含等于自己的键,是小于等于

关于向上取整与向下取整这两个操作,我只在算法(第四版)上面见到过,在其他的相关文章中没有遇到,不过我感觉咱们可以体会一下它的思想,毕竟我感觉这个操作也蛮重要的。
我们拿上图中查找19前驱节点为例说明一下流程:首先,在以41为根节点的树中查询,由于19<41,在41的左子树查询,即在以20为根节点的树中查询。接着因为19<20,继续向左,在以11为根结点的树中查询。集中注意力,因为19>11,所以11有可能是19的前驱节点,但是前提是11的右子树中没有比19小的元素。也就是说我们应该先在11的右子树中寻找,然后判断寻找的情况(命中或未命中),如果命中,那就自动返回结果了,如果没有命中,则说明11就是19的前驱节点!,这其中查找的过程是一个递归的过程!希望你仔细体会:)

我只能说到这里了,不好理解,具体实现如下:

c 复制代码
public int floor(int key){
    Node x=floor(root,key);
    if(x==null) return -1;//未查找到
    return x.key;
}
private Node floor(Node x,int key){
    if(x==null) return null;//表示在以x为根节点的树中没有查找到
    if(key=x.key) return x;//命中,且恰好在根节点x
    if(key<x.key) return floor(x.left,key);//在x的左子树中查询,根节点有x变为x的子节点,数据规模减小
    //往下走说明key>x.key,这个时候要去x的右子树去寻找
    Node  t=floor(x.right,key);//在右子树中寻找
    if(t!=null) return t;//在右子树中找到了
    else return x;//在右子树中没有找到,那就说明x节点就是要求的前驱节点
}

向上取整的代码类似,我这里就不详细说了,你可以自己实现一下。

删除操作

二叉树的删除操作就相对比较复杂了。希望你打起十二分的精神!删除一个结点只会对一颗二叉查找树的局部产生一定的影响,所以,我们的任务就是恢复删除这个结点所带来的影响。

删除操作也有递归算法,不过我很迷,而且我见很多地方也不是用递归实现的,所以这里就不再介绍了,
感兴趣的话可以看一下算法(第四版),上面有详细的介绍。好了,不啰嗦了,咱们继续~

针对待删除结点的子节点个数的不同,我们将它分为三种情况加以处理。

1.如果要删除的结点没有子节点,此时的操作时十分容易的,我们只需要将父节点中指向该节点的链接设置为null就可以了。请看下图,我们的任务是删除结点27,找到这个节点后直接抹去就 OK 了。
2.如果要删除的节点只有一个子节点(只有左子节点或只有右子节点),这种情况也不复杂。我们只需要更新父节点中的指向待删除结点的链接即可,让它指向待删除结点的子节点即可。请看下图,我们的目标是删除节点50:
3.如果要删除的节点有两个子节点,这时就变得复杂了。你听我仔细描述以下这个过程:我们需要找到这个节点的右子树上的最小结点【记为H】(因为它没有左子节点),把【H】替换到我们计划删除的节点上;然后,再删除这个最小的节点【H】(因为它没有左子节点,所以可以转化成之前的两种情况之一),而且,你会发现,二叉查找树的性质被完美的保留了下来,惊不惊喜!

接下来请看下面这三个例子,它们分别能够转化为情况一和情况二:

第一幅图,想要删除节点20,它的右子树的最小节点【H】没有子节点

第二幅图,想要删除节点20,它的右子树的最小节点【H】存在右节点

注意:【H】不可能有左节点,因为它是最小的


具体的代码如下:

c 复制代码
    public void delete(int key){
        //如果找到键为key的结点,就将它删除,找不到不做处理
        Node p=root;//p指向需要删除的结点,这里初始化为根节点
        Node pp=null;//pp记录的是p的父节点
        
        //通过while循环查找Key结点
        while(p!=null&&p.key!=Key){
            pp=p;
            if(Key>p.Key) p=p.right;
            else p=p.left;
        }
        if(p==null) return;//没有找到

        //情况一:要删除的结点有两个子结点
        if(p.left!=null&&p.right!=null){
            //查找右子树的最小结点
            Node minP=p.right;//minP是右子树的最小结点
            Node minPP=p;//minPP表示minP的父结点
            while(minP.left!=null){
                minPP=minP;
                minP=minP.left;
            }
            p.Key=minP.Key;p.val=minP.val;//替换p(将被删除的结点)的键和值
            
            //转化,以下问题只需要将minP删除即可
            //因为minP作为右子树最小的结点,肯定没有左子结点,可以转化为情况二处理
            p=minP;//使p指向右子树的最小结点
            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) root=child;//删除的是根结点
        else if(pp.left==p) pp.left=child;
        else pp.right=child;
    }

可以再根据下面这幅图理解一下:)

理论分析

我们前面用了那么大的力气来讲解二叉查找树,那么它的性能怎么样呢?

其实,对于二叉查找树来说,不管是插入、删除还是查找操作,时间复杂度都和树的高度成正比,也就是O(H),因为每次操作都对应了一条从根节点向下的一条路径。而对于树的高度,却很可能因树的形状的不同而不同。

理想情况下,二叉查找树是一颗完全二叉树,每层的节点依次为 1、2、4、8、16............,不难发现,树的高度为log(N),所以时间复杂度为 O(logN),这是一个相当高效的算法了。下面是一张表格,对常见的符号表的耗时成本做了一个简单的对比。
据此可见二叉查找树的性能,它能够在O(logN)的时间复杂度内完成查找和插入操作,我们花这么大力气学习它是值得的!

但是,你有没有注意到,它的最坏情况依旧是O(logN)。二叉查找树在一定条件下可能会退化成链表,就像下图所示,这明明就是一个弯曲的链表!
我们希望找到一种数据结构,它能保证无论键的插入顺序如何,树的高度将总是总键数的对数,这就是平衡二叉查找树。

6 平衡二叉树

由于二叉树的根节点是确定不变的,所以当调整数据的插入或删除顺序,会造成二叉树朝着单项链表的方向发展(长歪了,变成歪脖子树了),大大降低了数据的查询效率。

平衡二叉树是基于二叉查找树优化而来的。

6.1 平衡二叉树规则

平衡二叉树规则:

规则1:每个节点最多只有两个子节点(二叉)

规则2:每个节点的值比它的左子树所有的节点大,比它的右子树所有节点小(有序)

规则3:每个节点左子树的高度与右子树高度之差的绝对值不超过1

6.2 平衡二叉树的构建

平衡二叉树是如何构建呢,如果简单的按照排序的规则插入,那么很可能会使得二叉树不平衡,所以为了维持树的平衡,我们在插入和删除平衡二叉树的结点时会进行一定的操作来保持平衡,其中包括以下几种情况,以及解决方法:

  • 左左:右旋解决
  • 左右:先左旋再右旋
  • 右右:左旋解决
  • 右左:先右旋再左旋

这里右右和右左是对应这左左和左右的,我们只讲左左和左右两种情况,另外两种依次类推:

左左

如图,如果1号是新插入的节点,在未插入之前,二叉树是平衡的,插入之后3号节点左右不平衡了,这种不平衡是3号的左孩子节点,新增了一个左孩子,我们的方法是右旋。

右旋之后,节点有平衡了,结果如下,

左右:

如图,如果3号是新插入的节点,在未插入之前,二叉树是平衡的,插入之后4号节点左右不平衡了,这种不平衡是34号的左孩子节点,新增了一个右孩子,我们的方法是先左旋再右旋。

结果如下:

二叉查找树和平衡二叉树的比较:

  • 二叉查找树的根节点是不可变的,左右两边结点层级差没有限制;
  • 平衡二叉树左右两边结点层级相差不大于1,通过旋转实现根节点可变,达到自平衡;

平衡二叉树的缺点:

相对于二叉查找树,平衡二叉树的查询效率高,但由于增加和删除节点时,为了保证自平衡会做连续的旋转操作,平衡二叉树的额外开销比较大,耗时相对较长。

7 红黑树

7.0 为什么要有红黑树这种数据结构

我们知道ALV树是一种严格按照定义来实现的平衡二叉查找树,所以它查找的效率非常稳定,为O(log n),由于其严格按照左右子树高度差不大于1的规则,插入和删除操作中需要大量且复杂的操作来保持ALV树的平衡(左旋和右旋),因此ALV树适用于大量查询,少量插入和删除的场景中。

那么假设现在假设有这样一种场景:大量查询,大量插入和删除,现在使用ALV树就不太合适了,因为ALV树大量的插入和删除会非常耗时间,那么我们是否可以降低ALV树对平衡性的要求从而达到快速的插入和删除呢?

答案肯定是有的,红黑树这种数据结构就应运而生了(因为ALV树是高度平衡的,所以查找起来肯定比红黑树快,但是红黑树在插入和删除方面的性能就远远不是ALV树所能比的了)。

7.1 红黑树规则

(颜色属性)规则1:节点非黑即红

(根属性) 规则2:根节点一定是黑色

(叶子属性)规则3:叶子节点(NIL)一定是黑色

(红色属性)规则4:每个红色节点的两个子节点,都为黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)

(黑色属性)规则5:从任一节点到其每个叶子的所有路径,都包含相同数目的黑色节点。

黑色属性,可以理解为平衡特征, 如果满足不了平衡特征,就要进行平衡操作。

RBT 有点属于一种空间换时间类型的优化,在avl的节点上,增加了颜色属性的数据,相当于增加了空间的消耗。通过颜色属性的增加, 换取后面平衡操作的次数 减少。

7.2 平衡二叉树和红黑树的区别

  • 平衡二叉树的左右子树的高度差绝对值不超过1,但是红黑树在某些时刻可能会超过1,只要符合红黑树的五个条件即可。
  • 二叉树只要不平衡就会进行旋转,而红黑树不符合规则时,有些情况只用改变颜色不用旋转,就能达到平衡。

7.3 红黑树的基本操作之旋转

红黑树的基本操作是添加和删除,在对红黑树进行添加和删除之后,都会用到旋转方法,为什么呢?

因为添加或者删除红黑树中的结点之后,红黑树就发生了变化,可能不满足上面的5条性质了,这个时候就需要通过旋转操作来保证它依旧是一棵红黑树,旋转分为左旋和右旋

(旋转操作仅仅只是用来调节结点的位置的,就是为了满足红黑树的性质5)

左旋

左旋是将X的右子树绕X逆时针旋转,使得X的右子树成为X的父亲,同时修改相关结点的引用,旋转之后,要求二叉查找树的属性依然满足

右旋

右旋是将X的左子树绕X顺时针旋转,使得X的左子树成为X的父亲,同时注意修改相关结点的引用,旋转之后要求仍然满足搜索树的属性

7.4 红黑树插入节点

插入节点宏观过程:首先将红黑树当作一颗查找树一样将结点插入,然后将结点着为红色,最后通过旋转和重新着色的方法使之重新成为红黑树。

将新加入的结点涂成红色的原因:

插入结点涂成红色就是为了不违背第5条性质,既:

(黑色属性)规则5:从任一节点到其每个叶子的所有路径,都包含相同数目的黑色节点。

下面分不同的场景说明如何插入新的节点:

场景1:插入的结点为根结点

将新插入的红色结点变成黑色结点,满足根结点为黑色结点的要求!

场景2:父亲结点为黑色结点

这个时候不需要进行任何调整操作,此时的树仍然是一颗标准的红黑树

场景3:父亲结点为红色结点的情况下,叔叔结点为红色结点(不用考虑左右)

解决方案:将叔叔和父亲结点改为黑色,爷爷结点改为红色,然后又将爷爷结点当作插入结点看待,一直进行上面的操作,直到当前结点为根结点,然后将根结点变成黑色。

原红黑树如下:

插入一个125的结点:

步骤一:

步骤二:

现在125结点和130结点都是红色的,显然违背了规则4,所以将新插入结点的父亲130结点和插入结点的叔叔结点150变成黑色,并将新插入结点的爷爷结点140变成红色,图如下:

步骤三:

然后又将140结点当作新插入结点处理(因为140结点和新插入结点面临的情况都是一样的:和父亲结点都是红色),也就是做如下处理:将140结点的父亲结点120和140的叔叔结点60变成黑色结点,将140结点的爷爷结点变成红色,因为遍历到了根结点,要满足根结点是黑色的性质要求,所以又将140的爷爷结点也就是根结点变成黑色,图如下:

场景四:新插入的结点的父亲结点为红色,其叔叔结点为黑色

1)父亲结点为爷爷结点的左孩子,新插入结点为父节点的左孩子(左左情况)

2)父亲结点为爷爷结点的右孩子,新插入结点为父亲结点的右孩子(右右情况)

上述两种情况都是同一个处理办法

比如下图,新插入结点为25,其父亲结点30为红色,其叔叔结点为空黑色叶子结点,且新插入结点和其父节点都是左孩子:

步骤一:

我们将其父亲结点和爷爷结点颜色互换,然后针对爷爷结点进行一次左旋,图如下:

步骤二:

现在这颗树完全满足红黑树的5个性质了(最好自己对照5个性质看一下)

现在又一个问题,我们为什么要进行旋转?

假设我们只将新增结点的父亲结点和其爷爷结点的颜色互换了,图如下:

我们发现上述两条到叶子结点的路径经过的黑色结点数量不一样!!!,所以它不满足红黑树的第5条性质,所以这就是我们旋转的意义所在!!!(因为无论你这么旋转都没有改变结点颜色,改变的是结点的位置,而这位置改变刚好能使得树满足红黑树的第5条性质!)

场景五:新插入的结点的父亲结点是红色,其叔叔结点是黑色

1)插入结点是右结点,父节点是左结点

2)插入结点是左结点,父亲结点是右结点

上述两种情况都是同一个处理办法

比如下图,新插入结点是126,其父结点125为红色,其叔叔结点为空的黑色结点,而且插入结点是右结点,父结点是左结点

步骤一:

我们将父亲结点125看作当前结点进行左旋,旋转结果如下:

步骤二:

现在我们的当前结点是125,现在125的处境和上面的情况4是一样的(父节点为红,叔叔结点为黑,插入结点为左结点,父亲结点也为左孩子)现在我们继续按照情况4的处理办法处理上述情况(措施和情况4一样,父亲结点和爷爷结点互换颜色,然后针对爷爷结点进行左旋),处理后情况如下:

现在树就是一颗标准的红黑树了!

我们现在总结一下插入结点面临的几种情况以及采取的措施:

1.树为空,插入的结点为根结点

直接将插入的结点变成黑色

2.父亲结点为黑色结点

不需要任何操作

3.父亲结点为红色结点的情况下:

    3.1 叔叔结点也为红色结点

    将叔叔和父亲结点改为黑色,爷爷结点改为红色,未完,然后又将爷爷结点当作插入结点看待,一直进行上

    面的操作,直到当前结点为根结点,然后将根结点变成黑色

    3.2 叔叔结点为黑色结点的情况下:

        3.2.1 (父亲结点为左孩子,插入结点也为左孩子)||(父亲结点为右孩子,插入结点也为右孩子)

        将父亲结点和爷爷结点的颜色互换,然后针对爷爷结点进行一次左旋

        3.2.2 (父亲结点为左孩子,插入结点为右孩子)||(父亲结点为右孩子,插入结点为左孩子)

        针对父结点进行左旋,此时左旋后的情况必定是3.2.1的情况,然后按照3.2.1的情况处理

现在我们来讨论一下,为什么插入的情况只有上面这些:

1.爷爷结点为红色结点的情况下,父亲结点只能为黑色(红黑树的性质4),处理操作:上面情况2

2.爷爷结点为黑色的情况下,父亲结点和叔叔结点:可以为红色,也可以为黑色

2.1 父亲结点为黑,叔叔结点为黑:处理操作:上面情况2

2.2 父亲结点为黑,叔叔结点为红:处理操作:上面情况2

2.3 父亲结点为红,叔叔结点为红:处理操作:上面情况3.1

     (上面3种情况都是不用考虑左右的)

2.4 父亲结点为红,叔叔结点为黑:

     2.4.1 父亲结点为左孩子,叔叔结点为左孩子:处理操作:上面情况3.2.1

     2.4.2 父亲结点为右孩子,叔叔结点为右孩子:处理操作:上面情况3.2.1

     2.4.3 父亲结点为左孩子,插入结点为右孩子:处理操作:上面情况3.2.2

     2.4.4 父亲结点为右孩子,插入结点为左孩子:处理操作:上面情况3.2.2

7.5 RBT 面试题

Q:有了二叉搜索树,为什么还需要平衡二叉树?

A:二叉搜索树容易退化成一条链,这时,查找的时间复杂度从O ( log n)也将退化成O ( N )

引入对左右子树高度差有限制的平衡二叉树 AVL,保证查找操作的最坏时间复杂度也为O ( log n)

Q:有了平衡二叉树,为什么还需要红黑树?

A:AVL的左右子树高度差不能超过1,每次进行插入/删除操作时,几乎都需要通过旋转操作保持平衡

在频繁进行插入/删除的场景中,频繁的旋转操作使得AVL的性能大打折扣

红黑树通过牺牲严格的平衡,换取插入/删除时少量的旋转操作

整体性能优于AVL

  • 红黑树插入时的不平衡,不超过两次旋转就可以解决;删除时的不平衡,不超过三次旋转就能解决
  • 红黑树的红黑规则,保证最坏的情况下,也能在O ( log n)时间内完成查找操作。

Q:红黑树那几个原则,你还记得么?

可以按照括号里边的分类,记住 红黑树的几个原则:

(颜色属性)节点非黑即红
(根属性)根节点一定是黑色
(叶子属性)叶子节点(NIL)一定是黑色
(红色属性)每个红色节点的两个子节点,都为黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
(黑色属性)从任一节点到其每个叶子的所有路径,都包含相同数目的黑色节点。

Q:红黑树的有那些内部操作

A:

变色,把一个红色的节点变成黑色,或者把一个黑色的节点变成红色,就是对这个节点的变色。

旋转,与平衡二叉树的旋转操作类似。

Q:红黑树与AVL树区别

A:

1、调整平衡的实现机制不同

红黑树根据路径上黑色节点数目一致,来确定是否失衡,如果失衡,就通过变色和旋转来恢复

AVL根据树的平衡因子(所有节点的左右子树高度差的绝对值不超过1),来确定是否失衡,如果失衡,就通过旋转来恢复

2、红黑树的插入效率更高

红黑树是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,

红黑树并不追求"完全平衡",它只要求部分地达到平衡要求,降低了对旋转的要求,从而提高了性能

而AVL是严格平衡树(高度平衡的二叉搜索树),因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。

所以红黑树的插入效率更高

3、红黑树统计性能比AVL树更高

红黑树能够以O(log n) 的时间复杂度进行查询、插入、删除操作。

AVL树查找、插入和删除在平均和最坏情况下都是O(log n)。

红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高,

4、适用性:AVL查找效率高

如果你的应用中,查询的次数远远大于插入和删除,那么选择AVL树,如果查询和插入删除次数几乎差不多,应选择红黑树。

即,有时仅为了排序(建立-遍历-删除),不查找或查找次数很少,R-B树合算一些。

总结

我们知道,实际应用当中,我们经常使用的是查找和排序操作,这在我们的各种管理系统、数据库系统、操作系统等当中,十分常用。下面是一些常见数据结构的优缺点及适用场景:

数组的下标寻址十分迅速,但计算机的内存是有限的,故数组的长度也是有限的,实际应用当中的数据往往十分庞大;而且无序数组的查找最坏情况需要遍历整个数组;后来人们提出了二分查找,二分查找要求数组的构造一定有序,二分法查找解决了普通数组查找复杂度过高的问题。任何一种数组无法解决的问题就是插入、删除操作比较复杂,因此,在一个增删查改比较频繁的数据结构中,数组不会被优先考虑。

普通链表由于它的结构特点被证明根本不适合进行查找。

哈希表是数组和链表的折中,同时它的设计依赖散列函数的设计,数组不能无限长、链表也不适合查找,所以也适合大规模的查找。

二叉查找树因为可能退化成链表,同样不适合进行查找。

AVL 树是为了解决可能退化成链表问题,但是AVL树的旋转过程非常麻烦,因此插入和删除很慢,也就是构建AVL树比较麻烦。

红黑树是平衡二叉树和AVL树的折中,因此是比较合适的。集合类中的Map、关联数组具有较高的查询效率,它们的底层实现就是红黑树。

多路查找树 是大规模数据存储中,实现索引查询这样一个实际背景下,树节点存储的元素数量是有限的(如果元素数量非常多的话,查找就退化成节点内部的线性查找了),这样导致二叉查找树结构由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下。

B树与自平衡二叉查找树不同,B树适用于读写相对大的数据块的存储系统,例如磁盘。它的应用是文件系统及部分非关系型数据库索引。

B+树在B树基础上,为叶子结点增加链表指针(B树+叶子有序链表),所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中。通常用于关系型数据库(如Mysql)和操作系统的文件系统中。

B*树是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针, 在B+树基础上,为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3。

R树是用来做空间数据存储的树状数据结构。例如给地理位置,矩形和多边形这类多维数据建立索引。

Trie树是自然语言处理中最常用的数据结构,很多字符串处理任务都会用到。Trie树本身是一种有限状态自动机,还有很多变体。什么模式匹配、正则表达式,都与这有关。

Note: 针对大量数据,如果在内存中作业优先考虑红黑树(map,set之类多为RB-tree实现),如果在硬盘中作业优先考虑B系列树(B+, B, B*)。

参看链接:

https://blog.csdn.net/weixin_44162361/article/details/116568205

https://blog.csdn.net/qq_45966440/article/details/119617131

https://www.cnblogs.com/yinbiao/p/10732600.html

https://www.cnblogs.com/chaotalk/p/13253104.html

相关推荐
Starry_hello world3 小时前
二叉树实现
数据结构·笔记·有问必答
嵌入式AI的盲5 小时前
数组指针和指针数组
数据结构·算法
reyas7 小时前
B树系列解析
数据结构·b树
Indigo_code7 小时前
【数据结构】【顺序表算法】 删除特定值
数据结构·算法
阿史大杯茶8 小时前
Codeforces Round 976 (Div. 2 ABCDE题)视频讲解
数据结构·c++·算法
不穿格子衬衫9 小时前
常用排序算法(下)
c语言·开发语言·数据结构·算法·排序算法·八大排序
aqua35357423589 小时前
蓝桥杯-财务管理
java·c语言·数据结构·算法
韬. .10 小时前
树和二叉树知识点大全及相关题目练习【数据结构】
数据结构·学习·算法
野草y10 小时前
数据结构(7.4_1)——B树
数据结构·b树
Word码10 小时前
数据结构:栈和队列
c语言·开发语言·数据结构·经验分享·笔记·算法