图解红黑树

红黑树的定义

之所以这么久才新开红黑树, 是因为我觉得红黑树还是挺复杂的, 要说清楚与实现都不是容易的事情, 我主要参考了一些博客, 传送门.
实际上我的大部分理解都是来自这一篇博客, 我添加了一些自己的理解以及实现方式.
红黑树是在二叉搜索树的基础上, 添加了对二叉搜索的限制, 每次新增或者删除节点时需要维护树的结构, 红黑树有以下性质:

  1. 节点为红色或黑色
  2. NIL 节点(空叶子节点)为黑色, 所有的叶子节点均为空叶子节点.
  3. 红色节点的子节点为黑色
  4. 从根节点到 NIL 节点的每条路径上的黑色节点数量相同
    下图是一颗合法的红黑树:

红黑树时间复杂度的计算

我们假设一颗节点个数为 \(n\) 的红黑树, 从根节点到叶子节点的路径上, 黑色节点的个数为 \(k\), 由于红黑树的性质3, 两个黑色节点之间最多有一个红色节点, 那么红黑树的实际高度小于等于 \(2 \times k\).

假设节点个数为 \(n\) 的红黑树, 从根节点到叶子节点的最短路径为 \(h_{min}\), 最长路径为 \(h_{max}\). 根据上面的描述, \(h_{min}\) 就是全部都是黑色节点的路径, 那么:

\[h_{max} \leq 2h_{min} \]

我们知道一颗高度为 \(h\) 的完全二叉树的节点的个数为 \(2^{h+1}-1\). 那么我们可以得到:

\[2^{h_{\min }+1}-1 \leq n \leq 2^{h_{\max }+1}-1 \Longrightarrow h_{\min } \leq \lg (n+1)-1 \leq 2 h_{\min } \]

这是因为我们的节点个数 \(n\) 一定大于高度为 \(h_{min}\) 的满二叉树的节点个数, 一定小于高度为 \(h_{max}\) 的满二叉树的节点个数. 因此我们可以得到下面的式子:

\[(1 / 2)(\lg (n+1)-1) \leq h_{\min } \leq \lg (n+1)-1 \Longrightarrow h_{\min }=\Theta(\lg (n+1)-1)=\Theta(\lg n) \]

然后因为: \(h_{min} \leq h_{max} \leq 2h_{min}\), 所以实际上 \(h_{max} = \Theta(\lg(n))\).

红黑树的操作

旋转操作

红黑树的旋转操作是维护平衡的关键操作之一, 另一个操作是颜色的改变. 实际上红黑树的旋转操作与平衡树的旋转操作一致, 左旋与右旋在节点的变换的时候, 是一样的. 这里就不再过多叙述了. 可以参考 平衡二叉树的旋转.

插入操作

红黑树的插入与普通的二叉搜索树相同, 插入节点都是在叶子节点, 红黑树设置新插入的节点为红色节点, 完成插入之后, 树的结构发生变化, 可能需要调整树的结构. 可能调整的情况如下:

Case 1

空树直接插入, 第一个节点无需调整.

Case 2

当前的节点的父节点为黑色且为根节点, 这时性质已经满足, 不需要进行修正.

Case 3

当前节点 \(N\) 的父节点 \(P\) 是为根节点且为红色, 将父节点 \(P\) 染为黑色即可, 此时性质也已满足, 不需要进一步修正.

Case 4

当前节点 \(N\) 的父节点 \(P\) 和叔节点 \(U\) 均为红色, 此时 \(P\) 包含了一个红色子节点, 违反了红黑树的性质3和4, 需要进行重新染色. 由于在当前节点 \(N\) 之前该树是一棵合法的红黑树, 根据性质3 可以确定 \(N\) 的祖父节点 \(G\) 一定是黑色, 这时只要后续操作可以保证以 \(G\) 为根节点的子树在不违反性质4 的情况下再递归维护祖父节点G 以保证性质 3 即可.

因此, 这种情况的维护需要:

  1. \(P\), \(U\) 节点染黑, 将 \(G\) 节点染红(可以保证每条路径上黑色节点个数不发生改变)
  2. 递归维护 \(G\) 节点(因为不确定 \(G\) 的父节点的状态, 递归维护可以确保性质 3 成立).
    如下图所示:
    img

Case 5

当前节点 \(N\) 与父节点 \(P\) 的方向相反(即 \(N\) 节点为右子节点且父节点为左子节点, 或 \(N\) 节点为左子节点且父节点为右子节点. 类似 AVL 树中 LR 和 RL 的情况). 根据性质4, 若 \(N\) 为新插入节点, \(U\) 则为 NIL 黑色节点, 否则为普通黑色节点.
如下图所示:
img
该种情况无法直接进行维护, 需要通过旋转操作将子树结构调整为 Case 6 的初始状态并进入 Case 6 进行后续维护.

Case 6

当前节点 \(N\) 与父节点 \(P\) 的方向相同(即 \(N\) 节点为右子节点且父节点为右子节点, 或 \(N\) 节点为左子节点且父节点为左子节点. 类似 AVL 树中 LL 和 RR 的情况). 根据性质 4, 若 \(N\) 为新插入节点, \(U\) 则为 NIL 黑色节点, 否则为普通黑色节点.
在Case5 的情况下, 进行一次旋转之后, 还需要进行Case6 的判断. 这里有点类似与AVL二叉树的 RL与LR的调整.

在这种情况下, 若想在不改变结构的情况下使得子树满足性质 3, 则需将 \(G\) 染成红色, 将 \(P\) 染成黑色. 但若这样维护的话则性质 4 被打破, 且无法保证在 \(G\) 节点的父节点上性质 3 是否成立. 而选择通过旋转改变子树结构后再进行重新染色即可同时满足性质 3 和 4.
这时操作如下图所示:
img
这里需要说明的是, 从上图中直接把节点 \(N\) 作为新插入的节点, 但是如果是从Case 5转到 Case 6, \(P\)\(N\) 的方向应该是相反的, 但是旋转操作是一样的.
因此, 这种情况的维护需要:

  1. \(N\) 为左子节点则右旋祖父节点 \(G\), 否则左旋祖父节点 \(G\).(该操作使得旋转过后 \(P\) - \(N\) 这条路径上的黑色节点个数比 \(P\) - \(G\) - \(U\) 这条路径上少 1, 暂时打破性质 4).
  2. 重新染色, 将 \(P\) 染黑, 将 \(G\) 染红, 同时满足了性质 3 和 4.

Case5 和 Case6 无需向上递归, 这是因为可以看到Case5 和 Case6 进行节点的调整之后, 子树中黑色节点的深度仍然为 2, 与调整之前保持一致, 并且, 我们发现, 调整之后, 根节点依然是黑色, 不会导致性质3不符合的情况. 而在Case4 中, 因为将根节点颜色变成了红色, 所以需要递归的向上调制, 防止不符合性质3.

插入操作_另一个视角

我在一篇英文博客上看到了对红黑树的解释, 感觉比上面解释的要更加清晰以及通俗易懂, 下面大部分是翻译而来的,
红黑树中的插入最初与二叉搜索树中的插入相同, 我们可以回想一下, 新元素是插入到在树的适当位置创建的叶子节点中的. 问题是: 我们应该给这个新节点什么颜色?
如果它的颜色是红色, 并且它有一个红色的父节点, 那么就违反了 红黑树的性质3, 如果它的颜色是黑色, 那么通往这个节点的路径就会多出一个黑色节点, 违反了红黑树的性质4.
因此, 新节点在颜色上是一个有问题的节点. 事实证明, 我们总是可以通过一些局部的重新排列和重新着色来解决局部问题, 或者将问题推到树的上层(每次两层. 因此, 问题会在树中的某个地方得到解决, 或者在最坏的情况下, 问题会一直递归处理到树根, 在那里我们可以对它进行细微的处理. 由于树的高度是有界的, 而且事实上, 它与元素的数量成对数关系, 因此重新分色和重新平衡的过程不会超过 \(O\lg(n)\) 步. 因此, 整个插入过程耗时为 \(O\lg(n)\).

为了说明重新着色和重新平衡的过程, 我们把有问题的节点涂成绿色, 表示该节点尚未着色. 后续图中均使用该方法表示, 因为插入节点为叶子节点, 所以我们知道无论在哪里出现的问题节点(插入节点)都只有黑色(或没有)子节点. 回想一下, \(NIL\) 指针被视为黑色, 因此在插入新节点后, 情况确实如此. 我们会发现, 每当有问题的节点在树中被向上递归的时候, 都会出现同样的情况. 因此, 只需解决这种情况即可. 我们将根据绿色节点的父节点颜色(如果存在父节点)为其指定一种颜色. 我们将三种情况区分开来:

父节点是黑色

这种情况实际上很简单, 如下图所示, 我们知道 \(T_1\)\(T_2\) 都是 \(NIL\) 节点或者黑色节点, 所以直接将 \(X\) 节点置为红色即可.
img

父节点是红色

这种情况我们需要考虑很多的场景, 因为我们需要知道 \(C\) (\(X\) 的叔叔节点)或 \(B\) (\(X\)的父节点)的颜色情况. \(B\) 不可能是树根, 因为它是红色的, 所以 \(C\) 存在. 如果 \(C\) 不存在 , 那么\(NIL\) 指针被视为黑色, 同样可以视为叔叔节点. 我们按照这些节点的颜色分为下面几种情况:

叔叔节点为红色

在这种情况下, 我们将 \(X\) 着色为红色. 不过, 在此之前, 我们必须确保它的父节点已变成黑色. 为此, 我们可以将 \(A\) 的黑色节点向下推一层, 将其一分为二. 拆分是为了确保两条路径获得相同数量的黑色节点. 做完这一切后, 我们就只剩下给 \(A\) 染色的问题了. 此时问题节点 \(A\) 已经在树中向上移动了两级, 我们又遇到了同样的问题(带有黑色子节点的未着色节点). 同样的过程可以递归地应用于 \(A\), 因此递归处理即可. 处理过程如下图所示:
img

在进行上述调整之后, 我们可以观察到, \(T_1\), \(T_2\), \(T_3\), \(T_4\), \(T_5\) 到节点 \(A\) 的黑色节点的个数不变, 仍然是 1, 且性质都满足.

叔叔节点为黑色

因为父节点是红色, 叔叔节点是黑色节点, 因为当前需要保证红黑树的性质, 所以当前状态下当前子树下黑色节点个数一定比以叔叔节点为根节点的子树的黑色节点个数多, 在下图中也就是:

假设 \(T_1\)\(T_3\) 为在 \(X\) 插入之前, \(B\)的孩子节点, 根据红黑树的性质4, \(B\) 为黑色节点, \(C\) 为红色节点, 那么 \(T_1\)\(T_3\) 中黑色节点的个数一定比 \(T_4\)\(T_5\) 中多一个.

因此我们插入 \(X\) 之后需要进行旋转.
img

最后, 如果新插入的节点与父节点的方向不同, 如下图所示, 需要将该问题转换解决:
img

没有父节点

因为在前面所述, 父节点为黑色, 叔叔节点为红色的情况下, 我们会向上递归处理, 此时递归到最开始的位置, 也就是问题节点(颜色不确定的节点到达根节点), 此时将根节点染为黑色即可.

删除操作

红黑树的删除操作, 不知道为什么 传送门 这里突然看不懂了, 因此我找了一篇英文博客, 感觉解释的要更加清楚.
在下图中, 我们使用 \(T\) 表示 \(NIL\) 节点或者根节点为黑色节点的子树.
删除操作的初始步骤和普通的二叉搜索树是一样的, 通常情况都是将当前节点的直接后续节点替换当前节点, 然后问题就变成了删除当前节点的直接后续节点. 假设这个直接后续节点为 \(X\), 那么 \(X\) 只有一个右子树, 或者没有子树( \(NIL\) 节点不视为子树), 我们先总结一些简单的情况:

  1. 如果 \(X\) 是红色节点, 直接删除即可, 因为不影响前面总结的红黑树的性质.
  2. 如果 \(X\) 是黑色节点, 然后它有且有唯一的子树的根节点是红色节点, 那么直接删除这个黑色节点, 然后将红色的孩子节点变成黑色节点即可.
    这两种简单的情况如下如所示:

img

现在剩下的情况就是, 如果 \(X\) 节点是叶子节点, 或者 \(X\) 节点只有一个黑色根节点的子树的情况了. 这些情况我们需要通过一些旋转或者向根节点的调整操作来维持红黑树的平衡. 前面我们已经证明过了, 这种情况下最坏的时间复杂度为 \(\lg(n)\). 为了解决这一类问题, 我们需要分几类情况考虑待删除节点 \(X\) 的父节点(parent of \(X\)), 兄弟节点(sibling of \(X\)), 以及侄子节点(its siblings’ children).

父节点是红色

因为 \(X\) 节点是黑色, 如果父节点是红色, 那么根据红黑树的性质3 和4, 节点 \(X\) 一定有一个兄弟节点, 且为黑色.
我们定义邻近侄子节点如下:

节点 \(X\) 的邻近侄子节点为当前节点的兄弟节点的孩子节点, 并且在二叉搜索树的顺序中, 位于当前节点 \(X\) 与其兄弟节点之间. 从图上来看, 就是靠近当前节点 \(X\) 的侄子节点. 在代码中, 则是, 如果当前节点 \(X\) 是其父节点的左孩子节点, 那么邻近侄子节点就是当前节点兄弟节点的左孩子节点.

根据当前节点 \(X\) 的邻近节点的颜色, 我们又分为下面两种情况:

邻近侄子节点为黑色

如下图所示, 当前节点 \(X\) 的邻近侄子节点 \(C\) 为黑色, 并且当前以 \(A\) 为根节点的子树已经满足红黑树的性质, 如果删除节点 \(X\), 那么将不满足红黑树的性质4. 在删除节点 \(X\) 之前, 节点满足红黑树的性质, 需要保证删除节点 \(X\) 之后, 调整之后仍然满足红黑树的性质.

进行调整的操作为向 \(X\) 节点侧进行一次旋转, 例如 \(X\) 为左孩子节点, 那么以其父节点 \(A\) 为轴节点进行左旋. 如果 \(X\) 为右孩子节点, 那么进行右旋. 旋转之后从根节点到 \(NIL\) 节点 \(T_1\), \(T_2\), \(T_3\), \(T_4\) 三个节点路径上的黑色节点的数目不变, 因此能够保证红黑树的性质不变.
img

邻近侄子节点为红色

删除节点 \(X\) 后将不满足红黑树的性质4, 需要做出的调整为:

  1. 首先以该节点的兄弟节点为轴节点进行旋转.
  2. 以节点 \(X\) 的父节点为轴节点进行旋转.
  3. 将节点 \(X\) 原来的父节点 \(A\) 染成黑色.
    我们发现, 调整前后, 从根节点到 \(NIL\) 节点 \(T_1\), \(T_2\), \(T_3\), \(T_4\) 三个节点路径上的黑色节点的数目不变, 因此能够保证红黑树的性质不变.
    img

父节点是黑色

我们知道当前节点一定是黑色, 如果父节点是黑色, 该节点一定也有一个兄弟节点, 否则不符合性质4, 节点 \(X\) 路径上黑色节点的数目要多余另一条路径.
我们需要考虑下面几种情况:

兄弟节点为红色

兄弟节点为红色, 那么兄弟节点的孩子节点 \(\mathbf{T_2}\)\(\mathbf{T_3}\) 一定是根节点为黑色节点的子树, 而不是 \(NIL\) 节点, 否则无法满足性质 4.
这种情况下我们进行的调整为:

  1. \(X\) 的父节点为轴进行一次旋转.
  2. 将父节点染成红色
  3. 删除节点 \(X\), 使用前面叙述的, 父节点为红色节点情况下, 调整红黑树的结构.
    img
兄弟节点为黑色

我们定义远侄子节点如下:

我们之前定义了邻近侄子节点, 而远侄子节点就是当前节点 \(X\) 的兄弟节点的另一个孩子节点, 在顺序上是在当前节点与兄弟节点之后的.

根据远侄子节点, 我们将兄弟节点为黑色分为下面几种情况:

远侄子节点为红色节点

我们先描述一种比较简单的情况, 当前节点 \(X\) 的父节点与兄弟节点均为黑色, 远侄子节点为红色, 如下图所示:
删除节点 \(X\) 之后的调整也很简单:

  1. \(X\) 节点的父节点为轴进行旋转
  2. 将远侄子节点染成黑色即可.
    img
远侄子节点为黑色节点

这种情况下, 除了要考虑远侄子节点, 还需要考虑邻近侄子节点, 此时根据邻近侄子节点, 分成下面两种情况:
####### 邻近侄子节点为红色节点

  1. 先以当前节点 \(X\) 的兄弟节点为轴节点进行一次旋转, 如下图所示
  2. 然后再以父节点为轴节点, 进行一次旋转.
  3. 旋转之后我们可以注意到, 根节点依然为黑色, 且 \(T_1\), \(T_2\), \(T_3\), \(T_4\), \(T_5\) 到根节点路径上黑色节点的个数不变.
    img

####### 邻近侄子节点为黑色节点
这种情况比较复杂, 因为所有相关的节点都是黑色节点, 导致删除当前节点 \(X\) 这个黑色节点, 一定会导致性质4的变化, 不符合性质4, 因此我们将这个问题递归的向上解决, 解决的方式如下图所示:

img
我们可以观察到:

  1. 节点 \(T_1\), \(T_2\), \(T_3\), \(T_4\), \(T_5\) 到节点 \(A\) 的路径长度仍然相同, 只是长度比调整之前少一.
  2. 调整后节点 \(X\) 的孩子节点是黑色, 且只有一个孩子节点, 另一个节点仍然是 \(NIL\) 节点, 因此我们就递归到原来的问题了, 就是处理删除操作最开始的判断情况.
posted @ 2024-08-14 16:53  虾野百鹤  阅读(19)  评论(0编辑  收藏  举报