前言
在一些涉及树的知识中,我们总会听到B树、红黑树这些树的相关知识,想必你一定知道 java中TreeMap,Hashmap的实现使用到了红黑树,其实在IO多路复用epoll的实现,Ngnix中timer 的管理,以及前端setTimeout都使用到了红黑树的概念,那么红黑树相比于其他树结构又有什么优点呢?接下来,将会从二叉树一步一步引出红黑树。
准备知识
在学习红黑树之前,我们先来复习一些树的相关概念。
- 前驱节点:某个节点的前驱节点指所有值小于该节点的节点中值最大的节点
- 后继节点:某个节点的前驱节点指所有值大于该节点的节点中值最小的节点
- 度:节点的子树数目就是节点的度
- 层:从根节点开始,根节点为第一层,根的子节点为第二层,以此类推
- 高度:从下往上
- 深度:从上往下
- 平衡因子:某结点的左子树与右子树的高度(深度)差即为该结点的平衡因子
二叉树
二叉树(Binary tree)是每个节点最多只有两个分支(即不存在分支度大于2的节点)的树结构。
遍历
每种数据结构都有遍历操作,当然二叉树也不例外,二叉树的遍历常见有以下5种:
- 广度优先
- 深度优先
- 前序遍历
- 中序遍历
- 后序遍历
广度优先(层次遍历)
广度优先搜索算法(Breadth-First Search,BFS),又译作宽度优先搜索,或横向优先搜索,是一种图形搜索算法。简单的说,BFS是从根节点开始,沿着树的宽度遍历树的节点。如果所有节点均被访问,则算法中止。
实现方法(动图演示):
-
首先将根节点放入队列中。
-
从队列中取出第一个节点,并检验它是否为目标。
- 如果找到目标,则结束搜索并回传结果。
- 否则将它所有尚未检验过的直接子节点加入队列中。
-
若队列为空,表示整张图都检查过了——亦即图中没有欲搜索的目标。结束搜索并回传“找不到目标”。
-
重复步骤2。
深度优先
深度优先搜索算法(Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会尽可能深的搜索树的分支。当节点v的所在边都己被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。
实现方法:
- 首先将根节点放入stack中。
- 从stack中取出第一个节点,并检验它是否为目标。如果找到目标,则结束搜寻并回传结果。否则将它某一个尚未检验过的直接子节点加入stack中。
- 重复步骤2。
- 如果不存在未检测过的直接子节点。将上一级节点加入stack中。重复步骤2。(回溯)
- 重复步骤4。
- 若stack为空,表示整张图都检查过了——亦即图中没有欲搜寻的目标。结束搜寻并回传“找不到目标”。
BFS 与 DFS 区别:可视化
BFS广度优先搜索:使用队列来实现 (FIFO)。
DFS深度优先搜索:使用栈(递归)来实现(FILO)。
深度优先可进一步按照根节点相对于左右子节点的访问先后来划分。如果把左节点和右节点的位置固定不动,那么根节点放在左节点的左边,称为前序(pre-order)、根节点放在左节点和右节点的中间,称为中序(in-order)、根节点放在右节点的右边,称为后序(post-order)。
前序遍历
void pre_order_traversal(TreeNode *root) {
// Do Something with root
if (root->lchild != NULL)// 若其中一侧的子树非空则会读取其子树
pre_order_traversal(root->lchild);
if (root->rchild != NULL)// 另一侧的子树也做相同事
pre_order_traversal(root->rchild);
}
中序遍历
void in_order_traversal(TreeNode *root) {
if (root->lchild != NULL)// 若其中一侧的子树非空则会读取其子树
in_order_traversal(root->lchild);
// Do Something with root
if (root->rchild != NULL)// 另一侧的子树也做相同事
in_order_traversal(root->rchild);
}
后序遍历
void post_order_traversal(TreeNode *root) {
if (root->lchild != NULL)// 若其中一侧的子树非空则会读取其子树
post_order_traversal(root->lchild);
if (root->rchild != NULL)// 另一侧的子树也做相同事
post_order_traversal(root->rchild);
// Do Something with root
}
二叉查找树
二叉查找树(英语:Binary Search Tree),也称为二叉查找树、有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree),是指一棵空树或者具有下列性质的二叉树:
- 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 任意节点的左、右子树也分别为二叉查找树;
二叉查找树相比于其他数据结构的优势在于查找、插入的时间复杂度较低。为O(log n)。二叉查找树是基础性数据结构,用于构建更为抽象的数据结构,如集合、多重集、关联数组等。
查找
在二叉查找树b中查找x的过程为:
- 若b是空树,则搜索失败,否则:
- 若x等于b的根节点的数据域之值,则查找成功;否则:
- 若x小于b的根节点的数据域之值,则搜索左子树;否则:
- 查找右子树。
插入
向一个二叉查找树b中插入一个节点s的算法,过程为:
- 若b是空树,则将s所指节点作为根节点插入,否则:
- 若s->data等于b的根节点的数据域之值,则返回,否则:
- 若s->data小于b的根节点的数据域之值,则把s所指节点插入到左子树中,否则:
- 把s所指节点插入到右子树中。(新插入节点总是叶子节点)
删除
在二叉查找树删去一个结点,分三种情况讨论:
-
若*p结点为叶子结点,即PL(左子树)和PR(右子树)均为空树。由于删去叶子结点不破坏整棵树的结构,则只需修改其双亲结点的指针即可(无子节点) 。
-
若p结点只有左子树PL或右子树PR,此时只要令PL或PR直接成为其双亲结点f的左子树(当p是左子树)或右子树(当p是右子树)即可,作此修改也不破坏二叉查找树的特性 (只有一个子节点) 。
-
若p结点的左子树和右子树均不空。在删去p之后,为保持其它元素之间的相对位置不变,可按中序遍历保持有序进行调整,可以有两种做法:
- 其一是令p的左子树为f的左/右(依p是f的左子树还是右子树而定)子树,s为p左子树的最右下的结点,而p的右子树为s的右子树;
- 其二是令p的直接前驱(in-order predecessor)或直接后继(in-order successor)替代p,然后再从二叉查找树中删去它的直接前驱(或直接后继) (有两个子节点) 。
以下图片为删除 节点 8 的两种示例图:
上图我们可以看到使用a 方法删除后,节点8 的右子树挂载到了8 的前驱节点上。当然,你也可以将8 的父节点的右子树指针指向8 的右子树,然后将8 的左子树挂载节点8的后继节点上。
使用B 方法,寻找前驱(后继)替补的形式也可以完成删除操作,而这两种操作的主要区别在于,使用a 方法会增加数的高度,不利于后续节点的查找。
我们知道,删除操作会改变树的结构,如果频繁的删除节点,最坏情况下可能会变成每个节点只有一个子节点的情况,那么,这个时候,树状结构就退化成了链表结构,而查询的时间复杂度就成 O(log n) 变成了 O (n)。那么,如何保证频繁操作下,树的查询效率仍保持在 O(log n) 呢? 那么就要引出平衡树的概念了。
平衡树
平衡树(Balance Tree,BT) 指的是,任意节点的子树的高度差(平衡因子)都小于等于1。平衡树更多的是一种状态的概念,我们可以说这个树在某一状态下是平衡的,某一状态下是不平衡的,而如何从失衡状态转化成平衡状态就是我们算法实现的过程了。常见的自平衡树有 AVL、堆树、2-3树、红黑树等。
AVL 树
AVL树(Adelson-Velsky and Landis Tree)是计算机科学中最早被发明的自平衡二叉查找树。在AVL树中,任一节点对应的两棵子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是O(log n)。增加和删除元素的操作则可能需要借由一次或多次树旋转,以实现树的重新平衡。
旋转分为左旋和右旋:演示动画
左旋
设 Q 为 P 的右子树。 将 P 的右子树设为 Q 的左子树 [将 Q 的左子树的父节点设为 P] ;将 Q 的左子树设为 P [将 P 的父节点设为 Q]。
右旋
设 P 为 Q 的左子树。 将 Q 的左子树设为 P 的右子树, [将 P 的右子树的父节点设为 Q] ;将 P 的右子树设为 Q [将 Q 的父节点设为 P]。
我们看以下例子:
左图中,节点3 的左子树高度为2,右子树高度为 0 ,此时平衡因子为 2,大于 1,处于失衡状态,为了达到平衡需要对节点3 进行右旋操作,就可以转化为右图的平衡状态。
其实,以上的旋转只是比较简单的情况,需要旋转的情况大致有以下4种:
LL
RR
LR
RL
可以看到,LL 与 RR 的情况只需要一次旋转就可以达到平衡状态,而 LR 与 RL 需要先转变成LL 或 RR 的情况,再进行左旋或右旋操作。
AVL 树的缺点:增加和删除操作可能需要一次或多次的旋转操作。
2-3-4树
2-3-4 树是阶为 4 的B树。这里只对 2-3-4 树的性质做简单介绍:
根据B 树的两个重要性质:
- 每一个节点最多有 m 个子节点
- 每一个非叶子节点(除根节点)最少有 ⌈m/2⌉ 个子节点
作为一个 4 阶B 树,那么,我们可以列举出所有的节点情况:2 节点 (1 个关键字)、3 节点 (2 个关键字)、4 节点 (3 个关键字)。以下为一个2-3-4 树。
当然,对于2-3-4 树的增删改查操作与B 树一致:
查询
从根节点开始,从上到下递归的遍历树。在每一层上,搜索的范围被减小到包含了搜索值的子树中。子树值的范围被它的父节点的键确定。
插入
所有的插入都从根节点开始。要插入一个新的元素,首先搜索这棵树找到新元素应该被添加到的对应节点。将新元素插入到这一节点中的步骤如下:
-
如果节点拥有的元素数量小于最大值,那么有空间容纳新的元素。将新元素插入到这一节点,且保持节点中元素有序。
-
否则的话这一节点已经满了,将它平均地分裂成两个节点:
- 从该节点的原有元素和新的元素中选择出中位数
- 小于这一中位数的元素放入左边节点,大于这一中位数的元素放入右边节点,中位数作为分隔值。
-
分隔值被插入到父节点中,这可能会造成父节点分裂,分裂父节点时可能又会使它的父节点分裂,以此类推。如果没有父节点(这一节点是根节点),就创建一个新的根节点(增加了树的高度)。
如果分裂一直上升到根节点,那么一个新的根节点会被创建,它有一个分隔值和两个子节点。这就是根节点并不像内部节点一样有最少子节点数量限制的原因。每个节点中元素的最大数量是 U-1。当一个节点分裂时,一个元素被移动到它的父节点,但是一个新的元素增加了进来。所以最大的元素数量 U-1 必须能够被分成两个合法的节点。如果 U-1 是奇数,那么 U=2L ,总共有 2L-1 个元素,一个新的节点有 L-1 个元素,另外一个有 L 个元素,都是合法的节点。如果 U-1 是偶数,那么 U=2L-1,总共有 2L-2 个元素。 一半是 L-1,正好是节点允许的最小元素数量。
删除
删除主要分以下两种情况:
-
非叶子节点的删除
对于非叶子节点的删除,可以通过前驱或者后继节点的替换实现,比如上面的一张4阶B 树中,我们如果要删除节点 9,9 作为一个内部节点,如果直接删除9 ,那么9 所在的节点就只有一个关键字[7] ,三个子节点[(6),(8),(10,11,12)],显然,这是不满足B 树的要求的,此时只需找到 9 的前驱(或者后继),将 9 替换为前驱节点8 ,并删除前驱节点即可,这样就实现了删除非叶子节点到叶子节点的转化。
-
叶子节点的删除
对于叶子节点的删除,又有以下两种情况:
-
节点关键字数量大于最小值要求
可直接删除
-
节点关键字数量小于最小值要求
需要调整
-
调整:
重新平衡从叶子节点开始向根节点进行,直到树重新平衡。如果删除节点中的一个元素使该节点的元素数量低于最小值,那么一些元素必须被重新分配。通常,移动一个元素数量大于最小值的兄弟节点中的元素。如果兄弟节点都没有多余的元素,那么缺少元素的节点就必须要和他的兄弟节点 合并。合并可能导致父节点失去了分隔值,所以父节点可能缺少元素并需要重新平衡。合并和重新平衡可能一直进行到根节点,根节点变成惟一缺少元素的节点。重新平衡树的算法如下:
-
如果缺少元素节点的右兄弟存在且拥有多余的元素,那么向左旋转
- 将父节点的分隔值复制到缺少元素节点的最后(分隔值被移下来;缺少元素的节点现在有最小数量的元素)
- 将父节点的分隔值替换为右兄弟的第一个元素(右兄弟失去了一个节点但仍然拥有最小数量的元素)
- 树又重新平衡
-
否则,如果缺少元素节点的左兄弟存在且拥有多余的元素,那么向右旋转
-
将父节点的分隔值复制到缺少元素节点的第一个节点(分隔值被移下来;缺少元素的节点现在有最小数量的元素)
-
将父节点的分隔值替换为左兄弟的最后一个元素(左兄弟失去了一个节点但仍然拥有最小数量的元素)
-
树又重新平衡
-
-
否则,如果它的两个直接兄弟节点都只有最小数量的元素,那么将它与一个直接兄弟节点以及父节点中它们的分隔值合并
-
将分隔值复制到左边的节点(左边的节点可以是缺少元素的节点或者拥有最小数量元素的兄弟节点)
-
将右边节点中所有的元素移动到左边节点(左边节点现在拥有最大数量的元素,右边节点为空)
-
将父节点中的分隔值和空的右子树移除(父节点失去了一个元素)
- 如果父节点是根节点并且没有元素了,那么释放它并且让合并之后的节点成为新的根节点(树的深度减小)
- 否则,如果父节点的元素数量小于最小值,重新平衡父节点
-
由于2-3-4 树在多数编程语言中实现起来相对困难,因为在树上的操作涉及大量特殊情况。2-3-4 树是红黑树(Red–black tree,RBT)的一种等同,相比来说红黑树实现起来更简单一些,所以可以用它来替代。
红黑树
- 节点是红色或黑色。
- 根是黑色。
- 所有叶子都是黑色(叶子是NIL节点)。
- 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点(黑色平衡)。
2-3-4树与RBT的等价关系
Demo Tree
以下是一个包含十二个节点的2-3-4树与对应的二叉树
由于红黑树是2-3-4树的一种等同,那么红黑树的操作就从B树抽象成了旋转与着色。
插入
分情况讨论,主要是要找到插入位置,然后自平衡(左旋或者右旋)且插入节点是红色(如果是
黑色的话,那么当前分支上就会多出一个黑色节点出来,从而破坏了黑色平衡),以下分析全部以左子
树为例子,右子树的情况则相反。
-
插入的是根节点
如果插入的是第一个节点(根节点),红色变黑色。
-
往二节点插入
如果父节点为黑色,则直接插入,不需要变色。
-
往三节点插入
如果父节点是红色,没有叔叔节点或者叔叔节点是黑色(此时只能是NIL节点),则以爷爷节点为支点右旋,旋转之后原来的爷爷节点变红色,原来的父节点变黑色(这是LL 的情况,注意LR时)。
-
往四节点插入(分裂)
如果父节点为红色,叔叔节点也是红色(此种情况爷爷节点一定是黑色),则父节点和叔叔节点变黑色,爷爷节点变红色(如果爷爷节点是根节点,则再变成黑色),爷爷节点此时需要递归(把爷爷节点当做新插入的节点再次进行比较)。
删除
同样,红黑树的删除操作都发生在叶子节点(非叶子节点使用前驱/后继节点代替)。
对于红黑树的删除,我们对应2-3-4 的删除可以归为3种情况:可直接删除(3节点与4节点)、向兄弟( 这里的兄弟节点指的是对于2-3-4 树中的兄弟节点,而不是字面意义的兄弟节点 )节点借、合并
-
直接删除
如果删除的节点对应于2-3-4树的3节点或者4节点,则直接删除,不用跟兄弟和父亲借
如果删除的是红色节点,则直接删;如果删除的是黑色节点,则红色节点上来替代,变黑即
可。
-
向“兄弟”节点借
前提是找到真正“兄弟”的节点。
兄弟节点有的借(此时兄弟节点一定是黑色,如果是红色那说明这个节点不是真正的兄弟节
点,需要回到上一步找真正的兄弟节点)。
兄弟节点有两个子节点的情况(2个子节点肯定是红色,如果是黑色的话相当于此时兄
弟节点对应2-3-4树是2节点,不可能有多余的元素可以借),此时需要旋转变色。
兄弟节点只有一个子节点的情况,此时需要旋转变色。
-
合并
前提是找到“真正”的兄弟节点。
兄弟节点没有多余的元素可借(此时兄弟节点一定为黑色2节点),此时兄弟节点所在分支也
要自损一个黑色节点以此达到黑色平衡,最快的方式就是兄弟节点直接变红(相当于就是减
少一个黑色节点),此时一父节点为root的子树又达到了平衡(两边都比之前少一个黑
色)。但是以祖父节点为root的树依然是不平衡的,此时需要递归处理。
结语
以上,我们知道,二叉查找树虽然能够将查询的复杂度从O(n)降到O(log n),但是二叉查找树在频繁操作后可能会退化为类似链表结构,于是出现了AVL 树,但是AVL 树的自平衡可能需要多次的节点旋转,由于2-3-4 树的插入与删除大多情况下不会出现失衡,又因为在多数编程语言中实现起来相对困难,需要在树上的操作涉及大量特殊情况,一般使用他的等同红黑树来替代实现。