【爬树合集】难啃的骨头——红黑树

【爬树合集】难啃的骨头——红黑树

写在前面

红黑树应用:
1.红黑树在Linux非实时任务调度中的应用

2.红黑树在Linux虚拟内存中的应用
3.红黑树在检测树的平衡性上的应用
4.epoll在内核中的实现,用红黑树管理事件块
5.nginx中,用红黑树管理timer等
6.Java的TreeMap实现

7.广泛用在C++的STL中。map和set都是用红黑树实现的。

​ 在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。在jdk1.8版本后,java对HashMap做了改进,在链表长度大于8的时候,将后面的数据存在红黑树中,以加快检索速度。

HashMap的扩容因子为什么是0.75:

​ 假设hash函数是理想的,数据会通过hash函数均匀的映射到数组上。一个数据映射到每一个桶(bucket)的概率是相等的。**那么在任意的数组容量下,put一个数据发生碰撞的概率=**数组中元素的个数数组容量数组容量数组中元素的个数。而数组的扩容门槛threshold = capacity * loadFactorloadFactor。也就是说扩容因子就是HashMap在扩容门槛的状态下,put操作发生碰撞的概率。

​ 那么,扩容因子等于0.75还是0.95的区别就很明显了。扩容因子=0.75。当使用量接近数组容量的75%的时候,数组中还有25%的剩余空间。平均来看,就是每4个桶(bucket)中还有一个是空的,当我们向map中put数据的时候,发生碰撞的概率是75%。因为这25%的空闲空间的存在,发生hash碰撞的概率还处在一个可以接受的范围内。
而当扩容因子=0.95的时候,平均来看,就是每20个桶(bucket)中才有一个是空的,此时数组中几乎没有空闲的桶(bucket),当我们put数据的时候,碰撞的概率是95%,几乎可以认为会发生碰撞。
除此之外,碰撞的概率越大,put的元素就越多,平均到每个桶中的元素的数量也越多。一旦发生碰撞,需要付出更大的代价。所以,如果扩容因子越大,碰撞的概率也就越大,发生碰撞后的代价也更大,结果导致效率大打折扣。
因此扩容因子=0.75也是一个空间换时间的考虑,0.75这个数值应该是经过充分的考虑决定的。

红黑树可以保证 最好 最坏 情况的所有操作(插入/删除/查找等)时间复杂度都是对数级别 O(logN)

B+树插入操作的平均时间复杂度为O(logn),最坏时间复杂度为O(logn)

哈希表插入的时间复杂度与冲突次数有关,O(冲突次数/n),最好的情况冲突次数为0,直接插入,时间复杂度为O(1)。最坏情况是所有值对应同一个键值,这是冲突次数最多,为0+1+2+3+4+…+(n-1)=n*(n-1)/2,平均比较次数为(n-1)/2**,时间复杂度为O(n)**

排序链表插入操作的平均时间复杂度为O(n),最坏时间复杂度为O(n)


深入解读:

红黑树是一种自平衡二叉查找树

红黑树的特性:

  1. 每个结点是黑色或者红色
  2. 根结点是黑色。
  3. 每个叶子结点(NIL)是黑色。
  4. 如果一个结点是红色的,则它的子结点必须是黑色的。
  5. 每个当代结点到叶子结点NIL所经过的黑色结点的个数一样的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Jx82js9P-1587804300664)(D:\CSDN\pic\Yolo\插入1.png)]上图介绍了一颗正常的红黑树

通过旋转与变色保证自平衡

左旋

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CX2yqyll-1587804300667)(D:\CSDN\pic\红黑树\左旋.png)]
可以很简单的发现他们位置的变换,通俗的说是:
V做P的父节点,V的左结点R做P的右结点

伪代码;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9QdryK0c-1587804300669)(D:\CSDN\pic\红黑树\左旋伪代码.png)]

LEFT-ROTATE(T, x)  
 y ← right[x]            // 前提:这里假设x的右孩子为y。下面开始正式操作
 right[x] ← left[y]      // 将 “y的左孩子” 设为 “x的右孩子”
 p[left[y]] ← x          // 将 “x” 设为 “y的左孩子的父亲”
 p[y] ← p[x]             // 将 “x的父亲” 设为 “y的父亲”
 if p[x] = nil[T]       
 then root[T] ← y                 // 情况1:如果 “x的父亲” 是空节点,则将y设为根节点
 else if x = left[p[x]]  
           then left[p[x]] ← y    // 情况2:如果 x是它父节点的左孩子,则将y设为“x的父节点的左孩子”
           else right[p[x]] ← y   // 情况3:(x是它父节点的右孩子) 将y设为“x的父节点的右孩子”
 left[y] ← x             // 将 “x” 设为 “y的左孩子”
 p[x] ← y                // 将 “x的父节点” 设为 “y”

右旋

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G6BE8oLb-1587804300673)(D:\CSDN\pic\红黑树\右旋.png)]

RIGHT-ROTATE(T, y)  
 x ← left[y]             // 前提:这里假设y的左孩子为x。下面开始正式操作
 left[y] ← right[x]      // 将 “x的右孩子” 设为 “y的左孩子”
 p[right[x]] ← y         // 将 “y” 设为 “x的右孩子的父亲”
 p[x] ← p[y]             // 将 “y的父亲” 设为 “x的父亲”
 if p[y] = nil[T]       
 then root[T] ← x                 // 情况1:如果 “y的父亲” 是空结点,则将x设为根结点
 else if y = right[p[y]]  
           then right[p[y]] ← x   // 情况2:如果 y是它父结点的右孩子,则将x设为“y的父结点的左孩子”
           else left[p[y]] ← x    // 情况3:(y是它父结点的左孩子) 将x设为“y的父结点的左孩子”
 right[x] ← y            // 将 “y” 设为 “x的右孩子”
 p[y] ← x                // 将 “y的父结点” 设为 “x”

插入:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TGwVkz7f-1587804300679)(C:\Users\acer\AppData\Roaming\Typora\typora-user-images\1587640850319.png)]

插入算法伪代码:

寻找并插入

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Aq29Gjsk-1587804300681)(D:\CSDN\pic\红黑树\1587704954330.png)]网络上的伪代码

RB-INSERT(T, z)  
 y ← nil[T]                        // 新建结点“y”,将y设为空结点。
 x ← root[T]                       // 设“红黑树T”的根结点为“x”
 while x ≠ nil[T]                  // 找出要插入的结点“z”在二叉树T中的位置(父结点),即“y”结点要存放的位置
     do y ← x                      
        if key[z] < key[x]  
           then x ← left[x]  
           else x ← right[x]  
 p[z] ← y                          // 设置 “z的父亲” 为 “y”
 if y = nil[T]                     
    then root[T] ← z               // 情景1:若y是空结点,则将z设为根结点
    else if key[z] < key[y]        
            then left[y] ← z       // 情景2:若“z的key值” < “y的key值”,则将z设为“y的左孩子”
            else right[y] ← z      // 情景2:若“z的key值” >= “y的key值”,则将z设为“y的右孩子” 
 left[z] ← nil[T]                  // z的左孩子设为空
 right[z] ← nil[T]                 // z的右孩子设为空。至此,已经完成将“结点z插入到二叉树”中了。
 color[z] ← RED                    // 将z着色为“红色”
 RB-INSERT-FIXUP(T, z)             // 通过RB-INSERT-FIXUP对红黑树的结点进行颜色修改以及旋转,让树T仍然是一颗红黑树

变色处理,多情景

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vlXQYge7-1587804300684)(D:\CSDN\pic\红黑树\1587705054356.png)]

RB-INSERT-FIXUP(T, z)
 while color[p[z]] = RED                                            // 若“当前结点(z)的父结点是红色”,则进行以下处理。
    do if p[z] = left[p[p[z]]]                                      // 若“z的父结点”是“z的祖父结点的左孩子”,则进行以下处理。
          then y ← right[p[p[z]]]                                   // 将y设置为“z的叔叔结点(z的祖父结点的右孩子)”
            if color[y] = RED                                       // 4.1情景:叔叔是红色
              then color[p[z]] ← BLACK                              //  (01) 将“父结点”设为黑色。
                   color[y] ← BLACK                                 //  (02) 将“叔叔结点”设为黑色。
                   color[p[p[z]]] ← RED                             //  (03) 将“祖父结点”设为“红色”。
                   z ← p[p[z]]                                      //  (04) 将“祖父结点”设为“当前结点”(红色结点)
              else if z = right[p[z]]                               // 4.3.1情景:叔叔是黑色,且当前结点是右孩子
                    then z ← p[z]                                   //  (01) 将“父结点”作为“新的当前结点”。
                        LEFT-ROTATE(T, z)                           //  (02) 以“新的当前结点”为支点进行左旋。
                    color[p[z]] ← BLACK                             // 4.2.1情景:叔叔是黑色,且当前结点是左孩子。(01) 将“父结点”设为“黑色”。
                    color[p[p[z]]] ← RED                            //  (02) 将“祖父结点”设为“红色”。
                    RIGHT-ROTATE(T, p[p[z]])                        //  (03) 以“祖父结点”为支点进行右旋。
       else (same as then clause with "right" and "left" exchanged) // 若“z的父结点”是“z的祖父结点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
 color[root[T]] ← BLACK                                             // 情景1:若y是空结点,则将z设为根结点

情景1:
红黑树为空树
最简单的一种情景,直接把插入结点作为根结点就行,但注意,根据红黑树性质2:根结点是黑色。所以还需要把插入结点设为黑色。
处理: 把插入结点作为根结点,并把结点设置为黑色。

情景2:
插入结点的Key已存在
插入结点的Key已存在,因为红黑树总保持平衡,在插入前红黑树已经是平衡的,那么把插入结点设置为将要替代的结点颜色,再把结点的值更新就完成插入了。
处理:
把z设为当前结点的颜色。
更新当前结点的值为插入结点的值。

情景3:
插入结点的父结点为黑结点
由于插入的结点是红色的,并不会影响红黑树的平衡,直接插入即可,无需做自平衡。
处理:

直接插入。

情景4:
插入结点的父结点为红结点
再次回想下红黑树的性质2:根结点是黑色。如果插入的父结点为红结点,那么该父结点不可能为根结点,所以插入结点总是存在祖父结点。这点很重要,因为后续的旋转操作需要祖父结点的参与。
情景4.1:

叔叔结点存在并且为红结点
从红黑树性质4可以确定,祖父结点为黑结点,因为不可以同时存在两个相连的红结点。那么此时该插入子树的红黑层数的情况是:黑红红。显然最简单的处理方式是把其改为:红黑红。
将P和S设置为黑色(当前插入结点I)
将PP设置为红色
把PP设置为当前插入结点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MvYO9vZx-1587805862280)(D:%5CCSDN%5Cpic%5C%E7%BA%A2%E9%BB%91%E6%A0%91%5C%E6%83%85%E6%99%AF4.1.png)]

情景4.2:
叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的左子结点
单纯从插入前来看,也即不算情景4.1自底向上处理时的情况,叔叔结点非红即为叶子结点(Nil)。因为如果叔叔结点为黑结点,而父结点为红结点,那么叔叔结点所在的子树的黑色结点就比父结点所在子树的多了,这不满足红黑树的性质5。后续情景同样如此,不再多做说明了。

情景4.2.1:
插入结点是其父结点的左子结点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fLgmqGJU-1587804300697)(D:\CSDN\pic\红黑树\4.2.1.png)]

同样的根据《算法》书中讲解的就是把PP设为红色,I和P设为黑色。但把PP设为红色,显然又会出现情景4.1的情况,需要自底向上处理,做多了无谓的操作~

情景4.2.2:
插入结点是其父结点的右子结点
这种情景显然可以转换为情景4.2.1,如图12所示,不做过多说明了。

对P进行左旋
把P设置为插入结点,得到情景4.2.1
进行情景4.2.1的处理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vEqtxrG3-1587804300701)(D:\CSDN\pic\红黑树\4.2.2.png)]

情景4.3:
叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的右子结点
该情景对应情景4.2,只是方向反转,不做过多说明了

情景4.3.1:
插入结点是其父结点的右子结点
处理:

将P设为黑色
将PP设为红色
对PP进行左旋

情景4.3.2:
插入结点是其父结点的右子结点

对P进行右旋
把P设置为插入结点,得到情景4.3.1
进行情景4.3.1的处理

实际插入演示
前文参考《知音小助手》还有不明白的地方,可以去搜索一下。接下来我找了个实践的插入操作演示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BXf35Zrc-1587804300708)(D:\CSDN\pic\红黑树\1587705212022.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-51JeAYYq-1587804300712)(D:\CSDN\pic\红黑树\1587705169987.png)]
相信在仔细阅读了前文以后,这一套插入操作应该能够看明白了,当你仔细阅读看到了这里,肯定已经明白了,不然早就草草划走了,上面两张图是我找到很明了的图了,大家有不明白的可以下方评论,顺带关注一下哟!

删除

查找目标结点显然可以复用查找操作,当不存在目标结点时,忽略本次操作;当存在目标结点时,删除后需要做自平衡处理。删除结点后我们需要找结点来替代删除结点的位置,不然子树跟父辈结点断开了,除非删除结点刚好没子结点,那么就不需要替代。

查找目标结点。
删除结点后自平衡。

情景1:若删除结点无子结点,直接删除。

情景2:若删除结点只有一个子结点,用子结点替换删除结点。

情景3:若删除结点有两个子结点,用后继结点(大于删除结点的最小结点)替换删除结点。

在这里插入图片描述

前继和后继
这里的前继和后继,根据数据结构中学的是指:离该节点大小最近的结点,比如上图P的前继和后继是指M,Q这点很基础也很重要,而不是K,R

删除算法

RB-DELETE(T, z)
if left[z] = nil[T] or right[z] = nil[T]         
   then y ← z                                  // 若“z的左孩子” 或 “z的右孩子”为空,则将“z”赋值给 “y”;
   else y ← TREE-SUCCESSOR(z)                  // 否则,将“z的后继节点”赋值给 “y”。
if left[y] ≠ nil[T]
   then x ← left[y]                            // 若“y的左孩子” 不为空,则将“y的左孩子” 赋值给 “x”;
   else x ← right[y]                           // 否则,“y的右孩子” 赋值给 “x”。
p[x] ← p[y]                                    // 将“y的父节点” 设置为 “x的父节点”
if p[y] = nil[T]                               
   then root[T] ← x                            // 若“y的父节点” 为空,则设置“x” 为 “根节点”。
   else if y = left[p[y]]                    
           then left[p[y]] ← x                 // 若“y是它父节点的左孩子”,则设置“x” 为 “y的父节点的左孩子”
           else right[p[y]] ← x                // 若“y是它父节点的右孩子”,则设置“x” 为 “y的父节点的右孩子”
if y ≠ z                                    
   then key[z] ← key[y]                        // 若“y的值” 赋值给 “z”。注意:这里只拷贝z的值给y,而没有拷贝z的颜色!!!
        copy y's satellite data into z         
if color[y] = BLACK                            
   then RB-DELETE-FIXUP(T, x)                  // 若“y为黑节点”,即替换结点是黑色则调用删除修正算法
return y

删除修复算法

删除修正算法:
RB-DELETE-FIXUP(T, x)
while x ≠ root[T] and color[x] = BLACK  
    do if x = left[p[x]]      
          then w ← right[p[x]]                                             // 若 “x”是“它父节点的左孩子”,则设置 “w”为“x的兄弟”(即x为它父节点的右孩子)                                          
               if color[w] = RED                                           // 情景2.1.1: x是“黑+黑”节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。
                  then color[w] ← BLACK                                    //   (01) 将x的兄弟节点设为“黑色”。
                       color[p[x]] ← RED                                   //   (02) 将x的父节点设为“红色”。
                       LEFT-ROTATE(T, p[x])                                //   (03) 对x的父节点进行左旋。
                       w ← right[p[x]]                                     //   (04) 左旋后,重新设置x的兄弟节点。
               if color[left[w]] = BLACK and color[right[w]] = BLACK       // 情景2.1.2.3: x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。
                  then color[w] ← RED                                      //   (01) 将x的兄弟节点设为“红色”。
                       x ←  p[x]                                           //   (02) 设置“x的父节点”为“新的x节点”。
                  else if color[right[w]] = BLACK                          // 情景2.1.2.2: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。
                          then color[left[w]] ← BLACK                      //   (01) 将x兄弟节点的左孩子设为“黑色”。
                               color[w] ← RED                              //   (02) 将x兄弟节点设为“红色”。
                               RIGHT-ROTATE(T, w)                          //   (03) 对x的兄弟节点进行右旋。
                               w ← right[p[x]]                             //   (04) 右旋后,重新设置x的兄弟节点。
                        color[w] ← color[p[x]]                             // 情景2.1.2.1: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的。(01) 将x父节点颜色 赋值给 x的兄弟节点。
                        color[p[x]] ← BLACK                                //   (02) 将x父节点设为“黑色”。
                        color[right[w]] ← BLACK                            //   (03) 将x兄弟节点的右子节设为“黑色”。
                        LEFT-ROTATE(T, p[x])                               //   (04) 对x的父节点进行左旋。
                        x ← root[T]                                        //   (05) 设置“x”为“根节点”。
       else (same as then clause with "right" and "left" exchanged)        // 若 “x”是“它父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
color[x] ← BLACK

接下来我们使用找个例子:删除某个值时,用R结点代替的情况
在这里插入图片描述
图中字母并不代表结点Key的大小。R表示替代结点,P表示替代结点的父结点,S表示替代结点的兄弟结点,SL表示兄弟结点的左子结点,SR表示兄弟结点的右子结点。灰色结点表示它可以是红色也可以是黑色。
值得特别提醒的是,R是即将被替换到删除结点的位置的替代结点,在删除前,它还在原来所在位置参与树的子平衡,平衡后再替换到删除结点的位置,才算删除完成。

删除情况可以总结为下面这些:

情景1:
替换结点是红色结点

我们把替换结点换到了删除结点的位置时,由于替换结点是红色,删除也了不会影响红黑树的平衡,只要把替换结点的颜色设为删除的结点的颜色即可重新平衡。
处理:

颜色变为删除结点的颜色

情景2:
替换结点是黑结点

当替换结点是黑色时,我们就不得不进行自平衡处理了。我们必须还得考虑替换结点是其父结点的左子结点还是右子结点,来做不同的旋转操作,使树重新平衡。

情景2.1:
替换结点是其父结点的左子结点

情景2.1.1:
替换结点的兄弟结点是红结点

若兄弟结点是红结点,那么根据性质4,兄弟结点的父结点和子结点肯定为黑色,不会有其他子情景,我们按图15处理,得到情景2.1.2.3(后续讲解,这里先记住,此时R仍然是替代结点,它的新的兄弟结点SL和兄弟结点的子结点都是黑色)
处理:

将S设为黑色
将P设为红色
对P进行左旋,得到情景2.1.2.3
进行情景2.1.2.3的处理

在这里插入图片描述

情景2.1.2:
替换结点的兄弟结点是黑结点

当兄弟结点为黑时,其父结点和子结点的具体颜色也无法确定(如果也不考虑自底向上的情况,子结点非红即为叶子结点Nil,Nil结点为黑结点),此时又得考虑多种子情景。

情景2.1.2.1:
替换结点的兄弟结点的右子结点是红结点,左子结点任意颜色

即将删除的左子树的一个黑色结点,显然左子树的黑色结点少1了,然而右子树又有红色结点,那么我们直接向右子树“借”个红结点来补充黑结点就好啦,此时肯定需要用旋转处理了。
处理:

将S的颜色设为P的颜色
将P设为黑色
将SR设为黑色
对P进行左旋

图16

平衡后的图怎么不满足红黑树的性质?前文提醒过,R是即将替换的,它还参与树的自平衡,平衡后再替换到删除结点的位置,所以R最终可以看作是删除的。另外图15是考虑到第一次替换和自底向上处理的情况,如果只考虑第一次替换的情况,根据红黑树性质,SL肯定是红色或为Nil,所以最终结果树是平衡的。如果是自底向上处理的情况,同样,每棵子树都保持平衡状态,最终整棵树肯定是平衡的。后续的情景同理,不做过多说明了。

情景2.1.2.2:
替换结点的兄弟结点的右子结点为黑结点,左子结点为红结点

兄弟结点所在的子树有红结点,我们总是可以向兄弟子树借个红结点过来,显然该情景可以转换为情景2.1.2.1。
在这里插入图片描述

将S设为红色
将SL设为黑色
对S进行右旋,得到情景2.1.2.1
进行情景2.1.2.1的处理

图17

删除情景2.1.2.3:专业啃老!
替换结点的兄弟结点的子结点都为黑结点

好了,此次兄弟子树都没红结点“借”了,兄弟帮忙不了,找父母呗,这种情景我们把兄弟结点设为红色,再把父结点当作替代结点,自底向上处理,去找父结点的兄弟结点去“借”。但为什么需要把兄弟结点设为红色呢?显然是为了在P所在的子树中保证平衡(R即将删除,少了一个黑色结点,子树也需要少一个),后续的平衡工作交给父辈们考虑了,还是那句,当每棵子树都保持平衡时,最终整棵总是平衡的。
处理:
在这里插入图片描述

将S设为红色
把P作为新的替换结点
重新进行删除结点情景处理

删除情景2.2:
替换结点是其父结点的右子结点

好啦,右边的操作也是方向相反,不做过多说明了,相信理解了删除情景2.1后,肯定可以理解2.2。

删除情景2.2.1:
替换结点的兄弟结点是红结点

将S设为黑色
将P设为红色
对P进行右旋,得到情景2.2.2.3
进行情景2.2.2.3的处理

图19

删除情景2.2.2:
替换结点的兄弟结点是黑结点

删除情景2.2.2.1:
替换结点的兄弟结点的左子结点是红结点,右子结点任意颜色

处理:

将S的颜色设为P的颜色
将P设为黑色
将SL设为黑色
对P进行右旋

图20

删除情景2.2.2.2:
替换结点的兄弟结点的左子结点为黑结点,右子结点为红结点

处理:

将S设为红色
将SR设为黑色
对S进行左旋,得到情景2.2.2.1
进行情景2.2.2.1的处理

图21

删除情景2.2.2.3:
替换结点的兄弟结点的子结点都为黑结点

将S设为红色
把P作为新的替换结点
重新进行删除结点情景处理

综上,红黑树删除后自平衡的处理可以总结为:

自己能搞定的自消化(情景1)
自己不能搞定的叫兄弟帮忙(除了情景1、情景2.1.2.3和情景2.2.2.3)
兄弟都帮忙不了的,通过父母,找远方亲戚(情景2.1.2.3和情景2.2.2.3)

上文来源

最后再附上一个实践:

现在我们有一颗红黑树:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述最后,我相信能看完这篇文章的朋友不多,虽然自己写的不是很好,但还是会一直努力,朋友点个关注,谢谢啦!看到这里的朋友,可以回头去看看最前面的一些问题了~

全文结束,除去自己复制的文章,自己也写了几千字,花了很多时间熟悉红黑树,谢谢老哥看到最后,感谢,码字不易,插图不易,望一个关注,认真学习,认真对待每一件事!纵使被虐千百遍,也不负自己!

学不死的小强!
上海第二工业大学智能科学与技术大二 周小夏(CV调包侠)

发布了8 篇原创文章 · 获赞 90 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_46098574/article/details/105753799