STL map / set 底层机制: 红黑树完全剖析



前言:

在 STL 的容器中两大阵营 (vector deque list forward_list) 顺序容器 与 关联容器 (map set unorder_map unorder_set) 。

关联容器指的是以关键字匹配指定数据的一种关联数据结构的集合, 在关联容器中通过关键字去查找对应的数据是非常块的, 下面我们会彻底地去剖析其底层实现机制 : 红黑树

在这里插入图片描述


第一部分:由基础树 到 二叉搜索树

介绍红黑树之前,容许我介绍树的概念。

1. 树(tree), 在计算机科学中,是十分基础的数据结构:

  • 几乎所有 OS 都会将文件存在 树状结构中(Linux 的 VFS 也是如此)。
  • 编译器的实现需要一个表达式树(用于词法分析)
  • 文件压缩的 哈夫曼算法 也是使用的 树状结构。
  • 数据库所使用的 B-tree 则是一种相当复杂的树状结构。

在这里插入图片描述

树的结构特点:

  • 树由节点和边构成
  • 相连节点中,在上者称为父节点,下者为子节点, 无子的节点称为叶节点
  • 子节点可以有很多,如果最多只允许有两个子节点, 即二叉树
  • 从根到任一节点的路径成度即 节点的深度,根的深度是 0 , 某节点到最深子节点的路径长度,称为该节点的高度

2. 二叉搜索树

我们提到二叉树的特点是: 任意节点仅允许有两个孩子。

二叉搜索树

  • 任何节点的键值一定大于它的左子树中的每一个节点的键值,并小于其右子树中的每一个节点的键值。
  • 因此从根节点一直往左走直到无路可走,可得到最小元素, 从根节点一直往右走,直至无右路可走, 可得最大元素。
  • 提供 O(logn) 的元素插入与访问。

如图所示:我们找一个值,只需要从根节点出发,与根节点去比较,若大于根节点去右子树找,若小于去左子树中找(每次淘汰一半的子树),这样只需要 O(logn)就能找到你要找的元素。

在这里插入图片描述

二叉搜索树的插入和删除


插入: 从根节点出发,遇到键值大的就向左, 遇到键值小的就向右,一直到尾端,即为插入点。

在这里插入图片描述

在这里插入图片描述

删除

  • 如果删除的节点只有一个子节点,我们就将 A 的子节点连接到 A 的父节点,并将 A 删除

删除 12 节点:

在这里插入图片描述
在这里插入图片描述

  • 如果 A 有两个子节点, 那么我们就以右子树的最小节点取代 A,(从右子节点开始,一直走到左直到底就是 其 右子树的最小节点),将 A 赋予其值,并将其删除。

删除 10 节点:

在这里插入图片描述
在这里插入图片描述




第二部分:平衡二叉搜索树的引入 AVL_tree

二叉搜索树的不平衡问题

也许因为输入值不够随机,或者因为经过某些插入或删除操作,二叉搜索树可能失去平衡,造成搜索效率低落的情况(退化成链表),如图:

在这里插入图片描述



平衡二叉搜索树的维护平衡的策略

”平衡“ 的大致意义是:没有任何一个节点过深(深度过大)。
为了保证树形的平衡,衍生出了各种解决该问题的特殊结构: AVL_tree, RB_tree 等等。

AVL tree 是一个”加上了额外平衡条件“ 的二叉搜索树,其平衡条件的建立是为了保证整棵树的深度为 O(logN)

接下来让我们了解一下额外平衡条件到底是怎么操作的:

如图所示 AVL tree

插入了节点 11 后,灰色节点违反了 AVL tree 的平衡条件,由于只有 “插入点至根节点”路径上的各节点可能改变平衡状态,因此,只需要调整其中最深的那个节点,便可以使得整个树重新获取平衡。

在这里插入图片描述

此时我们看到 18 节点的两颗子树因为 11 的插入平衡被破坏,而 18 这个节点就是从插入点到根路径上的最深节点X, 因此我们可以轻而易举地将情况分为以下四种:

  • 插入点位于 X 的 左子节点的左子树 ------左左
  • 插入点位于 X 的 左子节点的右子树 -------左右
  • 插入点位于 X 的 右子节点的左子树--------右左
  • 插入点位于 X 的 右子节点的右子树--------右右

情况 左左,和右右,可以看作是 外侧插入,使用单旋转即可调整,

在这里插入图片描述

而 右左,左右 则称为内侧插入, 可以采用双旋转操作调整解决。

在这里插入图片描述



单旋转双旋转

旋转: 将旋转点(x)以某个方向进行偏移一单位,若左旋,则其右子节点将变为其 x 的父节点, 由于 右子节点的左子树必须挂在其左侧,所以旋转后原右子节点的左子树必须挂在旋转点(x) 的右侧,才能继续维持二叉搜索树的性质。
在这里插入图片描述

单旋转的精华是:

左旋:将旋转的根节点的右孩子的左孩子变成根节点的左孩子的右子树,再将根节点向左旋转,令根节点的右孩子为新根,原根节点成为现根节点的左孩子。
右旋:将旋转的根节点的左孩子的右子树变成根节点的右孩子的左子树,再将根节点向右旋转,令根节点的左孩子为新根,原根节点成为现根节点的右孩子。

双旋转的精华是:

两次单旋转



第三部分:RB-Tree (红黑树)

颇具历史并且被广泛应用的自平衡二叉搜索树是 RB-tree (红黑树),平衡的概念指叶子节点间的最大深度不能超过1,但红黑树不是这样, 它以牺牲部分的平衡性换取了操作上/旋转次数的降低, 插入操作旋转次数不超过2,删除操作不超过3,它不仅仅是一个二叉搜索树,它必须满足以下规则:

  1. 节点是红色或黑色
  2. 根是黑色
  3. 所有叶子都是黑色(叶子是 NULL 节点)
  4. 每一个红色节点必须有两个黑色的子节点(不能连续红)
  5. 从任一节点到每个叶子的所有简单路径都包含相同数目的黑色节点。(维持一种黑色平衡),这点证明了它并非是平衡二叉搜索树。

1*该路径上的黑色节点数量 <= 任意路径长度 <= 2 * 该路径上的黑色节点数目(红色节点和黑色节点一样多)

从上述特点得知:

  • 新增节点必须为红
  • 新增节点之父节点必须为黑。

在这里插入图片描述
上图:白节点默认是红色节点

操作

  • 因为每一个红黑树也是一个特化的二叉查找树,因此红黑树上的查找和修改与普通二叉搜索树的操作一样
  • 插入和删除操作会导致不再匹配红黑树的性质, 恢复红黑树的性质需要少量 O(logn)的颜色变更和不超过三次的树旋转。


红黑树的节点插入

在插入新节点时, 以二叉查找树的方法增加节点并标记其为红色(设置黑色违反性质 5)

下面是否要进行调整和如何调整取决于其临近节点的颜色,同人类的家族树一样,我们将使用术语叔父节点来指一个节点的父节点的兄弟节点。

注意

  • 性质 1 和性质总是保持着
  • 性质 4 只在增加红色节点, 重绘黑色节点为红色,或做旋转时受到威胁。
  • 性质 5 只在增加黑色节点,重绘红色节点为黑色, 或做旋转时受到威胁。

我们设定将要插入的节点标为 N, N 的父节点标为 P, N 的祖父节点为 G, N 的叔父节点为 U

通过下面操作,能找到一个节点的叔父节点和祖父节点

node *grandparent(node *n) {
   
   return n->parent->parent;
}
node *uncle(node *n) {
   
    if(n->parent == grandparent(n)->left)
       return grandparent(n)->right;
    else
        return grandparent(n)->left;
}

情形 1: 新节点 N 位于树的根上,没有父节点,在这种情况下,我们将其绘制成黑色节点以满足性质2, 因为它在每个路径上对黑节点数目增加 1, 性质 5 也是符合的。

void insert_case1(node *n) {
   
  if(n->parent == NULL) 
     n->color = BLACK;
   else 
      insert_case2(n)
}

情形2: 新节点的父节点 P 是黑色, 所以性质 4 没有失效(新节点的确是红色的),这种情况下红黑树是有效的,性质 5 也没有受到威胁,尽管新节点 N 有两个黑色叶子子节点, 但由于新节点 N 是红色, 通过它的每个子节点的路径就都有通过它所取代的黑色的叶子的路径同样数目的黑色节点,所有依然满足这个性质。

void insert_case2(node *n) {
   
  if(n->parent->color == BLACK)
    return;
   else
     insert_case3(n);
}

注意: 在下列情形中,我们假定新节点的父节点为红色,所以其有祖父节点,如果父节点是根节点,那父节点就应当是黑色,所以新节点总有一个叔父节点,尽管在情形 4 和 5 它可能是叶子节点。


情形3: 父节点 P 和 叔父节点 U 都是红色(此时新插入节点N 作为P 的左子节点或右子节点都属于情形三这里我们演示 N 作为 P 左子节点的情形)则我们可以将它们两个重新绘制为黑色并重绘祖父节点 G 为红色(用来保持性质 5).现在我们的新节点 N 有了一个黑色的父节点P, 因为通过父节点P或叔父节点 U 的任何路径都必定通过祖父节点 G,在这些路径上的黑节点数目没有改变,

注意: 红色的祖父节点 G 的父节点也有可能是红色的, 这就违反了性质 4, 为了解决这个问题,我们在祖父节点 G 上递归地进行情形 1 的整个过程。(把 G 当成是新加入的节点进行各自情况的检查。)

在这里插入图片描述

void  insert_case3(node *n) {
   
   if(uncle(n) != NULL && uncle(n)->color == RED) {
   
       n->parent->color = BLACK;
       uncle(n)->color = BLACK;
       grandparent(n)->color = RED;
       insert_case1(grandparent(n));
    }
    else 
       insert_case4(n);   
}

注意:在余下的情况下,我们假定父节点 P 是其父亲 G 的左子节点,如果是右子节点,情形 4 和情形 5的左和右应当对调。


情形4父节点 P 是红色而叔父U是黑色或没有, 并且新节点 N 是其父节点 P 的右子节点而父节点 P 又是其父节点的左子节点,在这种情形下,我们进行一次左旋转调换新节点, 在这种情况下我们进行一次左旋转调换新节点和其父节点的角色,
接着, 我们按情形 5 处理以前的 父节点 P 以解决性质 4 失效的问题, 注意这个改变会导致某些路径通过它们以前不通过的新节点 N ,或不通过节点 P,但由于这两个节点都是红色,所以性质 5 仍然有效。

在这里插入图片描述

void insert_case4(node *n
  • 12
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
C++ STL中的map和multimap是关联容器,用于存储键值对(key-value pairs),其中每个键(key)唯一对应一个值(value)。 map是一个有序容器,根据键的大小进行自动排序,默认按照键的升序进行排序。每个键只能在map中出现一次,如果尝试插入具有相同键的元素,新元素将替代旧元素。 multimap也是一个有序容器,与map不同的是,它允许多个具有相同键的元素存在。多个具有相同键的元素将按照插入的顺序进行存储,而不会自动排序。 这两个容器都提供了一系列的操作函数,如insert、erase、find等,用于插入、删除和查找元素。 以下是一个使用map的简单示例: ```cpp #include <iostream> #include <map> int main() { std::map<std::string, int> scores; scores.insert(std::make_pair("Alice", 90)); scores.insert(std::make_pair("Bob", 80)); scores.insert(std::make_pair("Charlie", 70)); // 查找并输出Bob的分数 std::cout << "Bob's score: " << scores["Bob"] << std::endl; // 遍历并输出所有键值对 for (const auto& pair : scores) { std::cout << pair.first << ": " << pair.second << std::endl; } return 0; } ``` 上述示例中,我们创建了一个存储string类型键和int类型值的map容器scores。通过insert函数依次插入了三个键值对。然后我们通过scores["Bob"]来获取Bob的分数,并输出结果为80。 接着我们使用范围-based for循环遍历map中的所有键值对,并输出每个键值对的键和值。 multimap的用法与map类似,只是它允许多个具有相同键的元素存在。 这些关联容器在查找和插入操作上具有较高的效率,特别适用于需要根据键进行快速查找的场景。在实际应用中,你可以根据自己的需求选择适合的容器类型。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值