云计算百科
云计算领域专业知识百科平台

C++语言原理与实践(二十):红黑树的实现

本篇目标:
1.掌握红黑树的概念与染色过程

2.学会红黑树的实现

注意:学习本片前必须要学会AVL树的旋转。

一.红黑树的概念

1.概念

红黑树是一棵二叉搜索树,他的每个结点增加⼀个存储位来表示结点的颜色,可以是红色或者黑色。 通过对任何一条从根到叶子的路径上各个结点的颜色进行约束,红黑树确保没有⼀条路径会比其他路径长出2倍,因而是接近平衡的。

如图:

2.规则

一棵红黑树必须要满足以下的规则:

<1>.每个结点不是红色就是黑色

<2>. 根结点是黑色的

<3>. 如果⼀个结点是红色的,则它的两个孩子结点必须是黑色的,也就是说任意⼀条路径不会有连续的红色结点。

<4>. 对于任意⼀个结点,从该结点到其所有NULL结点的简单路径上,均包含相同数量的黑色结点

首先呢,我们先看一张图片,看看这张图片从根节点到NULL节点上有几条路径?

如图:

很多人的第一反应可能是 4 条,因为图中有 15、25、35、50 四个叶子节点。但这数的是从根节点到普通叶子节点的路径,还没有走到 NULL。

每个叶子节点都有两个空孩子,因此,这四个叶子节点对应:8条到 NULL 的路径。

此外,节点 10 的左孩子也是 NULL,还需要再算一条。

所以,这棵树从根节点到 NULL 节点,一共有:9条

红黑树性质中所说的叶子节点,指的是这些黑色的 NULL(也称 NIL)节点。 为了让图更简洁,它

们通常没有画出来,但分析路径时不能忽略。

所以完整的图如下:

注意:通常每个NULL节点都是黑色的,如果是红色容易与规则三冲突。

然后我们接着看些其他的红黑树:

3.效率

首先,我们思考⼀下,红黑树如何确保最长路径不超过最短路径的2倍的?

关键在于两条性质:

  • 从根节点到任意 NULL 节点的路径,包含的黑色节点数量相同。

  • 红色节点不能连续出现,并且根节点为黑色。

设每条路径上都有 bh 个黑色节点。这里统一不计 NULL,用路径上的非空节点数量表示路径长度。

对于任意一条路径:

  • 长度下限:即使没有红色节点,也有 bh 个黑色节点,因此路径长度至少为 bh。

  • 长度上限:由于根为黑色,且红色节点不能连续出现,红色节点数量不会超过黑色节点数量,因此路径长度至多为 2bh。

所以,任意路径长度 h 都满足:bh ≤ h ≤ 2 × bh

设最短路径长度为 h_min,最长路径长度为 h_max,则:h_min ≥ bh,h_max ≤ 2 × bh

因此:h_max ≤ 2 × bh ≤ 2 × h_min

所以,最长路径不超过最短路径的两倍。

需要注意:全黑路径和红黑交替路径只是分析上下限时考虑的极端情况,并不要求每棵红黑树中都存在这样的路径。

接下来,我们谈下红黑树的效率:

假设红黑树中有 N 个非空节点,最短路径长度为 h,最长路径长度为 H。这里路径长度统一按从根到 NULL 经过的非空节点数计算。

由于最短路径长度为 h,前 h 层一定是满的,因此:N ≥ 2^h − 1

由此得到:h ≤ log₂(N + 1)

又因为红黑树的最长路径不超过最短路径的两倍,所以:H ≤ 2h ≤ 2 × log₂(N + 1)

因此,红黑树的高度为 O(log N),查找、插入和删除的最坏时间复杂度也都是 O(log N)。如果需要修改 value,先按 key 查找即可;key 不能直接修改,否则可能破坏搜索树的性质。

与 AVL 树相比,红黑树对平衡的控制更加间接:AVL 树通过左右子树的高度差维持平衡,红黑树则通过颜色约束,保证最长路径不超过最短路径的两倍。

两者的操作复杂度都属于 O(log N),但红黑树对平衡的要求更宽松,调整时可以通过变色和旋转恢复性质。需要注意,不能简单认为插入相同数量的节点,红黑树的旋转次数就一定比 AVL 树少,具体次数还与插入顺序有关。

二.红黑树的实现

1.结构

// 枚举值表示颜色
enum Colour
{
RED,
BLACK
};
// 默认按 key/value 结构实现
template<class K, class V>
struct RBTreeNode
{
pair<K, V> _kv;
RBTreeNode<K, V>* _left;
RBTreeNode<K, V>* _right;

// 调整树的平衡时,需要通过 parent 指针找到父节点
RBTreeNode<K, V>* _parent;
Colour _col;

RBTreeNode(const pair<K, V>& kv)
: _kv(kv)
, _left(nullptr)
, _right(nullptr)
, _parent(nullptr)
{}
};
template<class K, class V>
class RBTree
{
typedef RBTreeNode<K, V> Node;
public:
private:
Node* _root = nullptr;
};

结构与之前的AVL树大致相同,但是红黑树中我们需要一个用来表示颜色的Colour结构。

2.插入

2.1.大致过程

红黑树的插入分为两步:先按二叉搜索树的规则插入节点,再检查并恢复红黑树的性质。

<1>.树为空:创建新节点作为根节点,并将其设为黑色,插入结束。

<2>.树不为空:新增节点设为红色。因为插入红色节点不会改变各条路径上的黑色节点数量;如果插入黑色节点,则会破坏黑色节点数量相同的性质,调整起来更加复杂。

<3>.父节点为黑色:新增的红色节点没有违反任何性质,插入结束。

<4>.父节点为红色:出现连续的红色节点,需要进行调整。此时,当前节点和父节点都是红色,祖父节点必然为黑色。接下来需要根据叔叔节点是否存在及其颜色,结合当前节点与父节点的位置关系,分别处理。

后续图中统一使用以下标记:

  • c(cur):当前需要处理的节点,初始为新增节点。

  • p(parent):c 的父节点。

  • g(grandfather):p 的父节点,即 c 的祖父节点。

  • u(uncle):p 的兄弟节点,即 c 的叔叔节点。

调整过程中可能需要继续向上处理,因此 c 不一定始终指向最初新增的节点。

2.2.变色

情况一:c 为红色,p 为红色,g 为黑色,且叔叔节点 u 存在并为红色。

此时,c 与 p 连续为红色,违反了红黑树的性质。处理方式是:

将父节点 p 和叔叔节点 u 变为黑色。

将祖父节点 g 变为红色。

把 g 当作新的 c,继续向上检查。

为什么这样变色?

将 p 和 u 变黑后,经过它们的路径都增加了一个黑色节点;再将 g 变红,每条路径又减少一个黑色节点。因此,从 g 所在子树通往各个 NULL 节点的路径,黑色节点数量保持不变,同时消除了 c 与 p 连续为红色的问题。

为什么还要向上检查?

因为 g 变成了红色,可能与它的父节点形成新的连续红色:

  • g 的父节点为红色:继续向上调整。

  • g 的父节点为黑色:调整结束。

  • g 已经是根节点:将 g 重新设为黑色,调整结束。

这种情况只需要变色,不需要旋转。 无论 c 是 p 的左孩子还是右孩子,p 是 g 的左孩子还是右孩子,处理方式都相同。

变色如图:

这个图片向我们展示了⼀种具体情况,但是实际中需要这样处理的有很多种情况。

抽象图如下:

该图将以上类似的处理进行了抽象表达,d/e/f代表每条路径拥有hb个黑色结点的子树,a/b代表每

条路径拥有hb-1个黑色结点的根为红的子树,hb>=0。

代码如下:

bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = Black;
return true;
}

//寻找插入的地方
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
return false;
}
}
//插入节点
cur = new Node(kv);
cur->_col = Red;
if (parent->_kv.first < kv.first)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
cur->_parent = parent;

//检查parent是否为Red
//注意此处是先检查parent是否存在,在检查parent的颜色是否为红色
while (parent&&parent->_col == Red)
{
Node* grandfather = parent->_parent;
if (grandfather->_left == parent)
{
Node* uncle = grandfather->_right;
//染色不旋转
if (uncle && uncle->_col == Red)
{
parent->_col = Black;
uncle->_col = Black;
grandfather->_col = Red;
// 继续往上处理
cur = grandfather;
parent = cur->_parent;
}
//uncle->_col == Black||uncle==nullptr
else
{
}
}
//grandfather->_right == parent
else
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == Red)
{
parent->_col = Black;
uncle->_col = Black;
grandfather->_col = Red;

cur = grandfather;
parent = cur->_parent;
}

else
{
}
}
}
return true;
}

但是,我们还有个问题:会不会改着改着将根节点给改成红色的了?当然是有这种可能的,所以我们要在最后加上:

_root->_col = Black;

而且我们这个代码还要区分grandfather的左节点和右节点哪一个指向parent?这个将会影响后面

的旋转,但是这篇文章我们优先考虑grandfather->_left==parent,当我们真正的了解思想后就可以独立的写grandfather->_right==parent的代码了。

2.3.单旋+变色

情况二:c 为红色,p 为红色,g 为黑色,叔叔节点 u 不存在或为黑色。

这里可以进一步区分:

  • u 不存在:c 是本次新增的红色节点。

  • u 存在且为黑色:c 不是新增节点,而是在下层按情况一调整时,由黑色变为红色,再作为新的 c 向上传递的节点。

此时,c 与 p 连续为红色,需要调整。但由于叔叔一侧没有红色节点可以一起变黑,单纯变色无法同时保证黑色节点数量相同,因此需要通过旋转配合变色恢复红黑树的性质。

<1>. p 是 g 的左孩子,c 是 p 的左孩子:右单旋+变色

g
p u(可能不存在)
c

此时,g、p、c 形成左左结构,处理步骤如下:

  • 以 g 为旋转点进行右单旋,让 p 成为这棵子树的新根。

  • 将 p 变为黑色,g 变为红色。

  • c 保持红色。

  • 调整后,p 为黑色,c 和 g 为红色,消除了连续红色节点,同时保持这棵子树各条路径上的黑色节点数量不变。

    <2>. p 是 g 的右孩子,c 是 p 的右孩子:左单旋+变色

    g
    u p
    c

    此时,g、p、c 形成右右结构,与左左情况对称:

  • 以 g 为旋转点进行左单旋,让 p 成为这棵子树的新根。

  • 将 p 变为黑色,g 变为红色。

  • c 保持红色。

  • 调整后,同样消除了连续红色节点,并保持这棵子树各条路径上的黑色节点数量不变。

    这两种情况调整完成后,都不需要继续向上处理。

    因为新的子树根节点 p 为黑色,无论它上面连接的是红色节点、黑色节点,还是它直接成为整棵树的根,都不会产生新的颜色冲突;同时,子树对外的黑色高度保持不变,不会影响其他路径。

    这里我拿上面的右单旋来举例子:

    示例一:

    插入红色节点 3 后,3 与父节点 6 连续为红,形成左左结构。以 10 为旋转点右单旋,再将 6 变黑、10 变红,调整结束。

    示例二:

    节点 3 原本为黑色。在它的子树中插入节点并进行变色后,3 变红,作为新的 c 向上检查,与红色父节点 6 产生冲突。

    此时叔叔 15 为黑色,需要以 10 为旋转点右单旋:

    • 6 成为这棵子树的新根。
    • 子树 d 从 6 的右子树,转接为 10 的左子树。
    • 10 成为 6 的右孩子。
    • 将 6 变黑、10 变红,3 保持红色。

    示例三:

    最初 a、b 的根为红色,d 的根为黑色。下层插入并变色后,a、b 的根变黑,节点 3 变红,因此经过 3 的路径上,黑色节点数量保持不变。

    随后进行右单旋和变色,消除 3 与 6 连续为红的问题。调整后的子树根 6 为黑色,子树对外的黑色高度保持不变,不需要继续向上调整。

    代码:

    //存在且为⿊或不存在-》旋转+变⾊
    if (parent->_left == cur)
    {
    Rotate_Right(grandfather);
    parent->_col = Black;
    grandfather->_col = Red;
    }

    2.4.双旋+变色

    情况三:c 为红色,p 为红色,g 为黑色,叔叔节点 u 不存在或为黑色,且 c 与 p 的方向不同。

    其中:

    • u 不存在:c 是本次新增的红色节点。

    • u 存在且为黑色:c 是下层按情况一变色后,由黑色变为红色,再向上传递的节点。

    此时,c 与 p 连续为红色,单纯变色无法同时维持各条路径上的黑色节点数量,需要通过双旋配合变色恢复红黑树的性质。

    <1>. p 是 g 的左孩子,c 是 p 的右孩子:左右双旋+变色

    g
    p u(可能不存在)
    c

    处理步骤如下:

  • 以 p 为旋转点进行左单旋,将左右结构转化为左左结构。

  • 以 g 为旋转点进行右单旋,让原来的 c 成为这棵子树的新根。

  • 将 c 变为黑色,g 变为红色,p 保持红色。

  • 调整后,c 为黑色,左右孩子 p、g 为红色,消除了连续红色节点,并保持子树各条路径上的黑色节点数量不变。

    <2>. p 是 g 的右孩子,c 是 p 的左孩子:右左双旋+变色

    g
    u p
    c

    处理步骤与左右情况对称:

  • 以 p 为旋转点进行右单旋,将右左结构转化为右右结构。

  • 以 g 为旋转点进行左单旋,让原来的 c 成为这棵子树的新根。

  • 将 c 变为黑色,g 变为红色,p 保持红色。

  • 双旋完成后,变黑的是新的子树根 c,而不是 p。

    新的子树根 c 为黑色,无论它上面连接的是红色节点、黑色节点,还是它直接成为整棵树的根,都不会产生新的颜色冲突。同时,子树对外的黑色高度保持不变,因此不需要继续向上调整。

    那么我拿上面的左右单旋来举例子:

    示例一:

    先以 p(6) 左旋,使 8 成为 10 的左孩子,6 成为 8 的左孩子;再以 g(10) 右旋,使 8 成为子树的新根。最后将 8 变黑、10 变红,6 保持红色。

    示例二:

    第一次左旋时,子树 a 转接为 6 的右子树,b 仍为 8 的右子树。第二次右旋时,子树 b 转接为 10 的左子树。最终 6、10 分别成为 8 的左右孩子。

    示例三:

    下层插入并变色后,8 由黑变红,与父节点 6 产生冲突。随后依次左旋、右旋并变色,在消除连续红色节点的同时,保持子树对外的黑色高度不变,调整结束。

    代码:

    //染色+双旋转,parent->_right == cur
    else
    {
    Rotate_Left(parent);
    Rotate_Right(grandfather);
    cur->_col = Black;
    grandfather->_col = Red;
    }
    break;

    所以一个半完整的代码如下:

    if (grandfather->_left == parent)
    {
    Node* uncle = grandfather->_right;
    //染色不旋转
    if (uncle && uncle->_col == Red)
    {
    parent->_col = Black;
    uncle->_col = Black;
    grandfather->_col = Red;
    // 继续往上处理
    cur = grandfather;
    parent = cur->_parent;
    }
    //uncle->_col == Black||uncle==nullptr
    else
    {
    //存在且为⿊或不存在-》旋转+变⾊
    if (parent->_left == cur)
    {
    Rotate_Right(grandfather);
    parent->_col = Black;
    grandfather->_col = Red;
    }
    //染色+双旋转,parent->_right == cur
    else
    {
    Rotate_Left(parent);
    Rotate_Right(grandfather);
    cur->_col = Black;
    grandfather->_col = Red;
    }
    break;
    }
    }

    那么这个grandfather->_right == parent的代码我就不再详细的解释了,如下:

    // grandfather->_right == parent
    else
    {
    Node* uncle = grandfather->_left;

    // 染色,不旋转
    if (uncle && uncle->_col == Red)
    {
    parent->_col = Black;
    uncle->_col = Black;
    grandfather->_col = Red;

    cur = grandfather;
    parent = cur->_parent;
    }
    else
    {
    // 染色 + 单旋转
    if (parent->_right == cur)
    {
    Rotate_Left(grandfather);

    parent->_col = Black;
    grandfather->_col = Red;
    }
    // 染色 + 双旋转,parent->_left == cur
    else
    {
    Rotate_Right(parent);
    Rotate_Left(grandfather);

    cur->_col = Black;
    grandfather->_col = Red;
    }

    break;
    }
    }

    3.验证

    仅通过获取最长路径和最短路径,检查最长路径是否不超过最短路径的 2 倍,无法判断一棵树是否满足红黑树的规则。

    因为即使满足这个条件,结点的颜色仍可能违反规则。当前暂时没有出现问题,后续继续插入时也可能出现问题。

    因此,我们还是需要检查以下 4 条规则。满足这些规则,就能保证最长路径不超过最短路径的 2 倍。

    <1>.结点的颜色不是黑色,就是红色。

    使用枚举类型定义颜色,在正常使用的情况下即可保证这一点。

    <2>.根结点必须是黑色。

    直接检查根结点的颜色即可。

    <3>.不能出现连续的红色结点。

    使用前序遍历检查。遇到红色结点时,可以检查其孩子是否为红色,但孩子有两个,而且不一定存在。

    因此,反过来检查其父亲的颜色更方便:如果当前结点和父亲都是红色,就违反了规则。

    <4>.从根结点到每个空结点的路径上,黑色结点的数量必须相同。

    使用前序遍历,通过形参 blackNum 记录从根结点到当前结点的黑色结点数量。每遇到一个黑色结点,就执行 ++blackNum;走到空结点时,就得到了一条完整路径上的黑色结点数量。

    任选一条路径的黑色结点数量作为参考值,再依次比较其他路径。如果存在不同的数量,就违反了规则。

    代码如下:

    bool Check(Node* root, int blackNum, const int refNum)
    {
    if (root == nullptr)
    {
    // 遍历到空结点,意味着一条路径走完了
    if (refNum != blackNum)
    {
    cout << "存在黑色结点数量不相等的路径" << endl;
    return false;
    }
    return true;
    }
    // 检查当前结点和父亲是否都是红色
    if (root->_col == RED && root->_parent->_col == RED)
    {
    cout << root->_kv.first << " 存在连续的红色结点" << endl;
    return false;
    }
    // 记录当前路径上的黑色结点数量
    if (root->_col == BLACK)
    {
    blackNum++;
    }
    return Check(root->_left, blackNum, refNum)
    && Check(root->_right, blackNum, refNum);
    }

    bool IsBalance()
    {
    // 空树符合红黑树规则
    if (_root == nullptr)
    {
    return true;
    }
    // 根结点必须是黑色
    if (_root->_col == RED)
    {
    return false;
    }
    // 统计最左侧路径上的黑色结点数量,作为参考值
    int refNum = 0;
    Node* cur = _root;
    while (cur)
    {
    if (cur->_col == BLACK)
    {
    ++refNum;
    }
    cur = cur->_left;
    }
    return Check(_root, 0, refNum);
    }

    这里讲一下思路:

    先检查根结点,再选择一条路径作为参考,最后递归检查整棵树。

  • IsBalance():检查根结点,确定参考值。

    空树直接返回 true;根结点为红色,直接返回 false。

    接着从根结点开始,一直往左走,统计这条路径上的黑色结点数量,记为 refNum。选最左侧路径只是因为方便,任意一条从根到空结点的路径都可以作为参考。

  • Check():统计每条路径上的黑色结点数量。

    blackNum 表示当前路径上已经遇到的黑色结点数量。遇到黑色结点,就执行 blackNum++。

    当 root == nullptr 时,说明一条路径走完了,此时比较:

    refNum == blackNum

    相等,说明这条路径符合要求;不相等,说明不同路径上的黑色结点数量不一致。

    这里没有把空结点计入黑色结点数量,但每条路径都采用相同的统计方式,因此不影响比较结果。

  • 遇到红色结点,检查其父亲的颜色。

    if (root->_col == RED && root->_parent->_col == RED)

    当前结点和父亲都是红色,就出现了连续的红色结点,返回 false。

    这里依赖 IsBalance() 已经检查根结点为黑色。 检查到根结点时,&& 左侧为假,右侧不会执行,因此不会访问根结点的空父指针。

  • 左右子树都通过,当前子树才通过。

    return Check(root->_left, blackNum, refNum)
    && Check(root->_right, blackNum, refNum);

    任意一侧返回 false,整体就返回 false。

    blackNum 按值传递,左右递归拿到的是各自的副本,所以左子树对计数的修改不会影响右子树,也不需要在递归返回后执行 –blackNum。

  • 最后,删除这个部分依旧不讲,如果后期我学会了相关的操作再考虑要不要补充。

    总结:

    红黑树是在二叉搜索树的基础上,通过颜色规则维持近似平衡的数据结构,其查找、插入和删除的最坏时间复杂度均为 O(log N)。插入时,若出现连续的红色结点,就根据叔叔结点的颜色及结点的位置,通过变色、单旋或双旋恢复红黑树的性质。验证时,应检查根结点为黑色、不存在连续红色结点,以及各条从根到 NULL 的路径上黑色结点数量相同,不能仅凭最长路径不超过最短路径的两倍来判断。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C++语言原理与实践(二十):红黑树的实现
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!