从二叉查找树到平衡树:avl, 2-3树,左倾红黑树(含实现代码),传统红黑树...
参考:自平衡二叉查找树 ,红黑树, 算法:理解红黑树 (英文pdf:红黑树)
目录
- 自平衡二叉树介绍
- avl树
- 2-3树
- LLRBT(Left-leaning red-black tree左倾红黑树 (代码见git)
- 2-3-4树和红黑树
- avl和红黑树的比较
自平衡二叉查找树
诞生的目的:
它是为了解决二叉查找树的查找时间复杂度最差是O(n)的问题而发明的数据结构。
完全二叉树的公式: n = 2h - 1
BST的查找运行时间和BST的高度有关。一个树的高度指的是从树的根开始所能到达的最长的路径长度。
如果按照从小到大的顺序输入一组key值,得到的将是一棵只有右子树的树。见下图。
如这个例子:
有6个节点,它的时间复杂度是O(n)。无论是新增,变更还是查找,删除,都需要诸葛对比key值。
假如要查找节点200,那么节点会比较5次,相当于遍历所有节点了。
这太浪费时间了,因此降低树的高度,就可以减少时间复杂度。
我们知道二叉搜索树的搜索节点的最小时间复杂度是 O(log2n)。因此找到一个高度和节点数量的最佳比例。让它的时间复杂度维持在O(log2n)。
期望是:
如果树中节点的数量为 n,则一棵满足O(log2n) 渐进运行时间的 BST 树的高度应接近于比 log2n 小的最大整数。
但实际问题是:
如何保证 BST 的拓扑结构始终保持树高度与节点数量的最佳比例?
因为 BST 的拓扑结构与节点的插入顺序息息相关,一种方式是通过数据的乱序来保证。所以必须在插入节点前就得到数据。
但是如果无法掌控数据的来源,怎么做?一种方案是新的节点插入不会打乱BST树的平衡。这种始终维持树的平衡状态的数据结构称为:自平衡二叉查找树。self-balancing binary search tree.
一棵平衡树
指的是树能够保持其高度与广度能够保持预先定义的比例。有许多种不同的自平衡 BST 数据结构,例如 AVL 树、红黑树(Red-Black Tree)、2-3 树、2-3-4 树、伸展树(Splay Tree)、B 树等等。
AVL树
1962年, 数学家发明的第一种自平衡二叉查找树,以其名字命名。它的平衡条件是,对每个节点n:
节点n的左子树的高度与右子树的高度差最多是1。即可以没有高度差,也可以高度差距1.
树的高度可递归性定义为:
- 如果节点n没有子节点,则它的高度为 0;
- 如果节点n只有一个子节点,则n的高度为该子节点的高度加 1;
- 如果节点n有两个子节点,则n的高度为两个子节点中高度较高的加 1;
- 如果节点没有子节点,无子节点侧的高度是-1。
例子:
下面有4个BST树,节点中的数代表节点的值,左右两侧的数代表左右子树的高度。a, b是AVL树,c,d不是,因为c/d不满足AVL的平衡条件。
当创建一棵 AVL 树时,难点在于如何保证 AVL 的平衡性质要求,而不用关注对树的具体操作。也就是说,无论是向树添加节点还是删除节点,最重要的事情就是保持树的平衡。
AVL 树通过 "旋转操作(rotations)" 来保持树的平衡。旋转操作可以重塑树的拓扑结构来恢复树的平衡,更重要的是,重塑后的树依然符合二叉查找树的性质要求。
当向一棵 AVL 树中插入一个新的节点时,需要经过两阶段的过程。
- 插入新节点的操作将使用与向 BST 树中插入新节点时使用的相同的查找算法。新的节点将做为一个叶子节点被添加到树中合适的位置,以满足 BST 的性质要求。在添加完节点后,将导致树的结构可能已经违背 AVL 树的性质要求。
- 在第二个阶段中,将遍历访问路径,来检查每个节点左右子树高度。如果存在某节点的左右子树的高度差大于 1 时,则需要使用旋转操作来处理。
例子:
有时除了像上图中描述的简单的旋转操作之外,可能还需要进行多次旋转操作。最重要的就是要意识到插入操作和删除操作都会破坏 AVL 树的平衡,而旋转操作就是解决这些问题的法宝。
通过确保所有节点的左右子树的差小于等于 1,AVL 树保证了插入、删除和查找操作将始终保持 O(log2n) 的渐进运行时间,而与插入或删除节点的顺序无关。
2-3树
2-3树是多叉树,它同样是一个平衡查找树。
二叉树中,每个节点最多只储存一个数据项的同时,最多也只有左右两条链接。而2-3树则不同:
定义
2-3树是一个多叉树。一个节点可以保存1个或2个数据项。可以有0-3个子节点。
- 有一个数据项的节点必须有2个子节点。
- 有二个数据项的节点必须有3个子节点。
- 每个节点的数据项按照,数据项的key,从左到右保持从小到大的顺序。
- 两个key之间的子树的key的值,大于父节点左key,小于父节点的右key.
- 作为平衡树,所有从leaf到root的path的高度相同,因此所有的叶子节点都是位于同一层。
- ⚠️2-3树的节点分裂是:自底向上的(不能预分裂),而且2-3树节点分裂必须用到新数据项。
- 由1,2可知,除了叶节点不允许出现空节点。
时间复杂度:
- 在最坏的情况下,也就是所有的节点都是2-node节点,查找效率为lgN
- 在最好的情况下,所有的节点都是3-node节点,查找效率为log3N约等于0.631lgN
原理:
2-3树在插入key值的过程中会不断构建并分解3-key节点来保持树的平衡,因此在2-3树就可以避免二叉树的不平衡导致的效率低下的问题。
2-3树是自平衡的树,例如插入一组从小到大的key值的元素:
- 首先2-3树已经构建一个根节点
- 然后插入值为1的key。(⚠️2-3树允许一个节点储存两个key值)
- 插入2。成为了临时的3key节点,这是2-3树不允许的,需要分裂,并把1上传,形成二叉树结构。
- 插入3。⚠️这时的2-3树是左右平衡的。
- 再插入4。形成了3-key节点,需要分解它,并导致树的不平衡。(⚠️定义所有叶节点都在同一层)
- 重构,把节点3和节点1合并。成为一个2-3树。
- 再插入5。然后插入6,形成一个3key节点。分解它,上传5节点。根节点形成3-key节点,树虽然平衡,但不符合2-3树的定义:每个节点最多有2个数据key。
- 分解root节点:最后的树是左右平衡的。
由此可知,2-3树可以避免二叉搜索树的不平衡导致的效率低下的问题。
总结-插入方法:
- 如果2-3树已存在当前插入的key,则插入失败,正确的插入一定是在叶子节点内插入。
- 如果等待插入的节点内只有一个节点,则直接插入。
- 如果等待插入的节点内有2个节点,插入后,需要对节点分裂。形成一个二叉树结构,然后将父节点再向上传递。
- 重复2和3的步骤,直到满足2-3树的定义。
删除方法:
比较复杂,未细看。参考:https://blog.****.net/u012152619/article/details/84332165
首先找到所在要删除的关键字(假设是K)所在的节点。
如果这个节点不是叶节点,就要找到中序排列时K后面的关键字所在的节点,这个节点一定是叶节点(因为它在右子树中是最小的)。然后交换这两个节点,那么所删除的关键字最终还是在一个叶节点中。
如果这个节点是叶节点,分叶节点和非叶节点,再根据key的数量分为4种:
- 1key的叶节点
- 2key的叶节点
- 1key的非叶节点
- 2key的非叶节点
下面从简单到复杂情况分析:
1. 如果删除的节点是2key的叶节点,只需删除目标key即可。2key叶节点变为1key叶节点,仍是2-3树。
10 10
/ \ => / \
4,6 18 4,x 18
2. 如果删除的节点属于2key非叶节点,则中序遍历找到待删除节点的后继节点,然后将后继节点和待删除节点位置交换。 此时问题转化为删除节点为叶子节点了。这时有2种情况:
- 待删除节点位于一个2-key叶节点内。删除方法见方法1。
10, 20 12,20 / | \ => / | \ 4 12, 15 25 4 x ,15 25 #直接删除10即可。
- 待删除节点是一个独立的叶子节点。 删除方法见????。
10,20 12,20 / | \ => / | \ 4,6 12 25 4, 6 X 25 #需要再平衡,删除方法见????。
3. 如果删除节点是1key的非叶节点, 删除方法和2一样。
10 18 / \ => / \ 4,6 18 4,6 x # 需要再平衡,删除方法见????。
10 12 / \ => / \ 4,6 12, 18 4,6 x,18 #直接删除10后,仍是2-3树。
4.如果删除节点是1key叶节点,将它删除后,需再平衡。分几种情况:
看待删除节点的兄弟节点和父节点是,1key还算2key节点:
A: 当父节是1key,兄弟节点是两key节点。3个节点可以拆分成一个二叉树,满足2-3树结构。
10 18 6
/ \ => / \ => / \
4,6 18 4,6 x 4 18
B: 当父节是1-key,兄弟节点是1-key节点。合并这2个节点,并将合并后的节点看成当前节点x。然后重复4的判断,即x的兄弟节点和父节点的情况,然后做A,B,C之一的处理,直到满足2-3树结构。例子:
C: 当父节点是2-key节点,兄弟有2个,兄弟有1-keyor2-key的可能,总共有4种组合,再加上删除节点的位置有左,中,右三种。穷举起来:总共3*4=12种。
虽然有12种,但处理方式只有2种:
- 父节点下移和子节点合并
- 父节点下移形成单独的子节点,然后2key的子节点上移一个key到父节点合并。
具体参考https://blog.****.net/u012152619/article/details/84332165
从2-3树到左倾红黑树
先放上定义:
1. No node has two red links connected to it。没有任何一个节点同时和两条红链接相连(类似传统红黑树的定义4:红性质)
2. Every path from root to nil(null) link has the same number of black links。(相当于传统红黑树的定义5黑性质)
3. Red links lean left。红链接为左链接。
⚠️定义1的反例:
同时也符合传统红黑树的定义:
- 节点的颜色是红色或者黑色;
- 根节点是黑色的;(根性质)
- NIL(null) 节点的颜色是黑色;
- 如果节点的颜色是红色,则其子节点均为黑色;(红性质)
- 从任一节点到其后代任一叶子节点的nil节点的路径上的黑色节点的数量相同;(黑性质)
如何将2-3树这种保持完美平衡的特性移植到二叉搜索树上呢?
要知道,二叉树不允许一个节点储存2个key值的。解决的办法就是,如果我们将2-3中的链接表示为二叉树中的黑色链接的话,那么我们就将拥有2个key值的节点表示为用红色链接连接起来的两个节点。如下图:
变为:
用红线和左右2个节点表示一个2-key节点。
红线链接的左节点,链接左子节点和中间子节点。红线链接的右节点,链接一个右子节点。
上图就是一棵红黑树
把2-3树的2-key节点用两个节点和一个红色的左链接表示了出来。
为了表示在原本的2-3树中的关系,我们将红色的左链接画成了平级关系,而不是父子关系。
这种用红线的左链接来表示2-key节点的红黑树,我们称之为左倾红黑树。
⚠️左倾红黑树是2008年发明的一种红黑树,比传统的红黑树更简单,代码更少。
因为无法用代码来区分链接的颜色,所以将链接的颜色转换为被指向的节点的颜色。所以上面的红黑树可以这样画:
如果改为父子关系就是:
红黑树实际就是一种特殊的二叉搜索树,所以二叉搜索树的很多方法,例如max、min、search、keys,遍历等方法,基本可以搬过来在红黑树中使用。但是,为了在插入和删除的同时维持红黑树的平衡,我们在插入和删除操作上需要做一定的修改。
回想2-3树的插入。有两种情况:叶节点包含1个数据和包含2个数据。对应红黑树。看本例子:
左倾红黑树就是一种特别的2-3树;而二叉搜索树只要能转化为2-3树,就代表它是一个平衡树。
红黑树的插入方法
红黑树的插入是通过旋转来降低/升高树的高度。本质上它的插入的方式是按照2-3树(或者2-3-4树)插入节点的方式来完成再平衡的。
LLRBT之所以使用左倾(left-leaning)则是为了将2-key节点(3树)限制为一种,以减少实现上的复杂程度。
本质
左倾红黑树本质上就是2-3树新插入节点后,形成了3key节点,通过分裂并向上合并的操作,调整树的高度,以保持树的平衡。只不过红黑树使用了左倾(左旋,右旋,上传红色)的方法来达目的。
插入方法
红黑树在最初插入时使用的方法和二叉树一样,递归查找到树的底部,然后将新节点插入到树底部。在递归的回归时对整棵树进行旋转调整。
规则:
- 每个节点默认插入是红色的。
- 判断是否有红色节点在右边,如果有,则将该红色节点的父节点左旋(以符合定义3)
- 判断左侧是否有连续的红节点,如果有,则将连续红色节点起始位置的节点的父节点右旋转。(定义的第一条:一个节点不能同时有2条红链接。)
- 判断是否存在左右子节点均是红色的情况,如果有,则将左右子节点的红色上传。(以符合定义3)
开始插入:
- 开始只有一个根节点key值为0。
- 插入红节点1。此时对应2-3树的一个节点包含2key值的情况。
- 然后进行左旋转,然后改变颜色,以符合左倾的规则。
- 接下来插入红节点2。此时对应2-3树的3key节点,所以中间节点1是上传为父节点。
- 而在红黑树中,因为红节点只能在左侧,所以此时要进行变色处理:
- 把红色上传,父节点变为红色,左右儿子变为黑色。
- 因为父节点也是root,所以节点1变为黑色。
- 整个插入完成后,得到的红黑树和2-3树的结构一致。
- 插入红节点3.
- 根据左倾规则,红节点只能在左边,所以执行左旋。完成后和2-3树插入的结果是一致的。(可见上面2-3树的图)
- 接着插入4.
- 形成了左右儿子都是红色的状况,不符合左倾规则。
- 变色,然后上传。此时3节在右侧,仍然不符合左倾规则,还要左旋转处理。
- 左旋转:节点1及其左子树成为节点3的左子树。把原先3的左子树,放到节点1的右子树上。
- 变色,让节点1为红色,3为黑色。
- 此时的形态和2-3树是一致的。(即把红黑树的红节点和其父节点看做一个节点)
所以,红黑树可以和2-3树一样有效的保存树的平衡,同时又是一个二叉树的结构。
- 接下来插入-1。
- 然后插入-2。此时出现左侧连续2红色的情况。要处理它。(以符合定义1)
- 首先,使用右旋转。对-1的父节点0右旋转。
- 然后,改变节点-1的颜色,变为黑色;节点0的颜色变为红色。(以符合定义2)
- 之后,因为节点-2和0是红色的,所以上传颜色到-1。(符合定义3)
- 然后,又发现1和-1是链接的,对1的父节点右旋转。(以符合定义1)
- 此时注意还要改变1和3的颜色。被右旋的节点3变为红色。1变为黑色。(以符合定义2)
- 同样上传红色给1,但此时1是根节点,所以1还是黑色。(以符合定义3)
代码参考:https://github.com/leyafo/practice-algorithm/blob/master/DataStruct/rb_tree.c
改使用ruby代码并重构:
首先建立一个Node类,用于储存节点实例。
然后建立一个RedBlackTree类,添加插入/删除等方法,对树的实例对象进行操作。
class Node attr_accessor :item, :color, :left, :right attr_reader :key def initialize(key, item = "any data") @key = key @item = item # 用于在节点内储存需要的数据,可以是任意格式 @color = :red # 默认插入的节点的颜色为红色 @left = nil @right = nil end end
定义一个红黑树类,以生成左倾红黑树实例:
- 红黑树实例包括@root, 用于储存根节点(Node实例),默认为nil
- @lenght用于储存树的节点总数。
- 实例方法insert(key, item),用于树的插入节点。
- 因为根据左倾rdt性质,@root由于调整操作会发生改变,所以需要给@root变量重新赋值。
- 并注意始终保持根节点的color为black。
- private私有方法,为insert()方法服务。
class RedBlackTree def initialize @root = nil #初始化一个树,默认根节点nil。 @length = 0 #记录树的节点总数 end def insert(key, item) # 插入操作 @root = _insert(@root, key, item) #左倾红黑树,新插入节点后,经过调整,根节点可能发生改变。 @root.color = :black # 根据定义,根节点为黑色。如果调整时把根节点的颜色变为红色,需要改回来 end private #私有方法。。。 end
私有方法
_insert(), fix_up(),is_red?(), rotate_left(), rotate_right(), shift_color()
(明确这不是tree实例能够直接调用的方法)
1. _insert(node, key, item)的参数:
- node节点: 用来和新插入节点的key进行比较。
- 新插入节点:key和item是其属性。
下面是插入节点3的整个递归过程:
解释:_insert()是一个递归函数。
# 因为每个节点和其左右子树都是一个二叉搜索树,所以可以使用递归的方法。
# 首先对插入的节点的key和root节点的key进行比较,然后向下传递参数(儿子node和新插入节点的key,item)。
# 传递参数并逐层的对节点key值进行比较,直到找到要插入的位置。 # 完成插入节点Node.new(),会对树进行平衡(即对左倾红黑树调整,以符合最佳时间复杂度)。
# 然后回归到上一个节点,继续再fix_up平衡,直到回归到最初tree.insert()方法内。 def _insert(node, key, item) if node == nil @length += 1 return Node.new(key, item) end if key < node.key node.left = _insert(node.left, key, item) elsif key> node.key node.right = _insert(node.right, key, item) elsif key == node.key puts "key can't same。" return end return fix_up(node) end
2. fix_up()用来确定左倾红黑树的三种调整方式:左旋,右旋,上传颜色。并返回调整后的父节点。
def fix_up(n) n = rotate_left(n) if is_red?(n.right) && (n.left == nil || n.left.color == :black) n = rotate_right(n) if is_red?(n.left) && is_red?(n.left.left) n = flip_color(n) if is_red?(n.left) && is_red?(n.right) return n end
3. 用于判断是否是红节点来选择不同调整方式
def is_red?(n) n != nil && n.color == :red end
4. rotate_right()左旋转调整:
def rotate_left(n) #左旋: #1. 得到新插入节点new_node #2. new_node的原左儿子放到node的右儿子位置。(回归中产生的情况。) #3. 最后,把node放到new_node的左儿子位置。 new_node = n.right n.right = new_node.left new_node.left = n #改色 new_node.color = n.color n.color = :red return new_code end #node的父节点的right需要改成new_node。通过回归就做到了。node.right = _insert()
问题:为什么在左旋时,方法rotate_left(n)不改变节点1的右儿子,应该从2改为3啊?
答:递归函数的回归对象是调整后的节点,它会被上层父节点所指向。代码中node.right指向了_insert()返回的对象。
node.right = _insert(node.right, key, item)
#node.right 指向 _insert()方法返回的node节点3。
_insert方法有2个return。一个是返回Node.new创造的新节点,另一个是返回fix_up()后的节点。同样以上面_insert()过程图为例子,在rotate_left左旋后,fix_up返回的新插入节点3被根节点1的right所指(right指向节点3),因此节点1的右儿子也就改变了。这是递归算法中的回归。(递与归)
4. rotate_right(n)右旋转调整:
def rotate_right(n) # 右旋一个节点 n_l = n.left # 如果n_l有右儿子的话,需要重新调整位置。 n.left = n_l.right n_l.right = n #调整后,改颜色 n_l.color = n_l.right.color n_l.right.color = :red return n_l end
以下图举例分析代码:
第一回归的是新插入的节点2。
第二次回归使用fix_up(-1),经过判断无需调整,所以返回的还是节点-1
第三次回归使用fix_up(0), 经过判断需要调整,
此时右旋后n是节点-1,但还有进行判断发现节点-1的两个儿子都是红,所以上调颜色。调用pop_color(-1)
def pop_color(n) n.color = :red n.left.color = :black n.right.color = :black return n end
,然后返回的是调整位置和颜色为红的节点-1。
第4次回归使用fix_up(1), 经过判断无需调整,返回1。
第5次回归使用fix_up(3), 经过判断需调整(节点3的左儿子1和节点1的左儿子-1都是红色的),进行右旋转rotate_right(),后 n是1,又经过判断节点1有双红儿子,需要pop_color()。
最后,返回到tree.insert()函数中, @root根节点指向红色的节点1,改变颜色@root.color = :black。
总结:
以上就是左倾红黑树插入的原理和代码及其讲解。
完整代码:https://github.com/chentianwei411/left_lean_red_black_tree/blob/master/llrbt.rb
2-3树改成2-3-4树:
只需要改变一下上色的代码的位置。⚠️新插入的节点是黑色的。
def _insert(node, key, item) if node == nil @length += 1 return Node.new(key, item) end node = flip_color(node) if is_red?(node.left) && is_red?(node.right) if key < node.key node.left = _insert(node.left, key, item) elsif key > node.key node.right = _insert(node.right, key, item) end node = rotate_left(node) if is_red?(node.right) node = rotate_right(node) if is_red?(node.left) && is_red?(node.left.left) return node end
遍历方法:
遍历方法就是二叉搜索树的原代码无需改动:(中序遍历)
def inorder_tree_walk(node) if node.left != nil inorder_tree_walk(node.left) end p node.key if node.right != nil inorder_tree_walk(node.right) end end
查找一个节点:就是二叉搜索树的方法,传入一个key值后从root开始比较,直到底层nil。
class RedBlackTree #。。。略 def include?(node = self.root, number) if node.key > number && node.left != nil include?(node.left, number) elsif node.key < number && node.right != nil include?(node.right, number) elsif node.key == number return true else return false end end
左倾红黑树的删除方法 :
我的思路:
删除一个节点,可以理解为删除的这个节点是刚刚新增的,所以回复到刚刚添加这个节点的状态然后删除即可。
因为左倾红黑树本质上可以是2-3树,先思考2-3树的删除是怎么回事。见上面 2-3树的删除。
参考:https://www.cs.princeton.edu/~rs/talks/LLRB/RedBlack.pdf
删除策略:
从2-3树的删除方式可知道:
- 如果要删除的节点不是2-node节点。即这个节点是2-key或3-key。直接删除。
- 如果需要,引进4-nodes节点(3-key节点)
- 从底部删除key节点。
- 在上升途中,排除掉3-key节点。
从简单的删除最大值和最小值开始:
删除最大值:
文章参考:https://zhuanlan.zhihu.com/p/55711442
原理:
2-3树从root开始沿着右子树搜索最大值。
- 如果搜索结果是是2-key,3-key节点,2-3树即可直接删除最大值,而为保持左倾红黑树结够,L需要右旋后,再删除最大值。
- 如果搜索结果是单key节点,直接移除会破坏????的平衡,因此需要从父亲节点或者兄弟节点借用节点。
常规思路是,搜索到结果,然后判断,如果是单key就向上借节点。但如果父节点也是单key节点还要向上借。
因此,从效率上考虑:
- 可以从root开始就沿着搜索路径转化tree,把右子树的节点都转化为可以借出节点的2-key节点。
- 找到最大值并删除,
- 再从下往上恢复左倾红黑树的结构。
例子:下面两个图都是2-3树删除最大值的方式:最大值是单key所以向父节点借节点再删除,依然保持了2-3树结构。
左倾红黑树的删除最大值方法,思路就是2-3树的方法。但需要为保持红黑树结构,做右旋,变色等操作。
具体方法:
从根节点开始向下寻找最大key所在节点位置,在这个过程中如果发现当前节点的右节点是单节点,就需要借节点给这个单节点。
首先,判断当前节点n的left是否是red(即当前节点是否是2-3树中的2-key节点)。如果是的话,就右旋,将左子节点的红色转移到右边。为将key借给右子节点做作准备。
然后,判断n.right和n.right.left是否都是黑色,如果是的话,从2-3树来看,n的right不是2-key节点。是单节点。那么就要从n那里借出key给它了。目的是为了下面别的节点向它借的时候,没有多余的节点可借。
此时有2种处理办法:
- 简单的: n的right节点h的left的left是也黑色的。那么直接改色下color即可。返回h。
- 复杂的:n的right节点h的left的left是红色的。那么先改色⬇️color,然后右旋,然后再⬆️color。返回h。
ruby分析代码:(全部代码见git)
def delete_max # 只有root节点的情况: if @root.right == nil && @root.left == nil @root = nil return puts "delete root node, then tree is nil" end @root = _delete_max(@root) @root.color = :black end private def _delete_max(node) # 相当于把双key节点的较大值准备借出。 if is_red?(node.left) node = rotate_right(node) end # 节点的right是nil,则判断为最大节点,返回nil if node.right == nil # 树节点总数减1. if @length > 0 @length -= 1 end return nil end # 从2-3树来看,当前节点的右节点不是双key节点的话,就需要借用了。 if !is_red?(node.right) && !is_red?(node.right.left) node = move_red_right(node) end #继续移动到下一层: node.right = _delete_max(node.right) #删除完后需要,从下向上修复左倾红黑树结构。 return fix_up(node) end def move_red_right(node) #借用有2种处理:即node.left.left是否是红的。 flip_color(node) if is_red?(node.left.left) node = rotate_right(node) flip_color(node) end return node end
删除最小值
类似删除最大值,但有些许不同。因为左倾红黑树,左儿子,一般是红色,无需再左旋转了。
直接删除的情况:
- 从2-3树角度看:最小值n是双key节点就可以直接删除:
- 从红黑树角度看:n是red即可直接删除。
从兄弟借节点(即最小值不是双key节点的情况下):
- 从红黑树角度看:如果n.left和n.left.left都是black, 即都是单key节点。就需要借用。
- 又分两种情况:看n.right.left是不是红色(从2-3树看,n的右节点是不是2-key节点)
方法和删除最大值类似:
首先,从root开始递归,判断当前节点的left和left.left的情况,看是否需要借用。
- 是的话:调用move_red_left。
- 不是的话, 继续递归:node.left = _delete_min(node.left)
具体代码见git
小结:不论是删除最大值还是删除最小值,删除的都是叶节点。思路是把左倾红黑树想象成2-3树,为了保持树的平衡,只能删除即是2-key叶节点中的数据项。如果不是双key节点就要从父亲兄弟节点去借用节点,形成双key节点,删除后,再恢复成左倾红黑树的结构。
删除任意节点
最后终于来到删除任意节点的方法:delete()
参考:https://www.cs.princeton.edu/~rs/talks/LLRB/RedBlack.pdf
delete方法,将适用于delete_min和delete_max。
当前节点node或者它的一个儿子是:red.
- 从左子树开始搜索:使用move_red_left
- 从右子树开始搜索:使用move_red_right
- 删除的节点必须位于底部,即是叶子节点。(为了树的平衡)
- 在递归的回归中,fix_up, 重新恢复成左倾红黑树。
⚠️第三点。回想2-3树是如何删除非叶节点的。左倾红黑树本质是2-3树,因此方法同样适用。
❌????了测试删除不能通过?
错误1:
llrbt.rb:103:in `delete': undefined method `key=' for #<Node:0x00007f89ed063168> (NoMethodError) Did you mean? key
使用byebug xx.rb跟踪:
102: if node.key == k 103: next_node = min(node.right) => 104: node.key = next_node.key 105: # node.item = next_node.item 106: node.right = delete_min(node.right) 107: else 108: #没有找到要删除节点,则移动到下一层: (byebug) step undefined method `key=' for #<Node:0x00007f8a04953720> Did you mean? key
Node对象为什么不能使用key=方法,看class Node类的代码发现attr_reader :key
所以报告这个❌。
错误2:
删除0/1/2的话,遍历树,发现5以后的节点都被删除了。
但是删除最小值是正确的。
然后byebug xx.rb一下,看看过程。哪里出现了问题?
答案:发现遍历正常结束后,返回的是节点3,但返回值并没有赋值给tree2的root节点。所以出现❌。
(我的笔记)
tree2 = RedBlackTree.new() tree2.insert(0, 'a') [1,2,3,4,5,6,7,8,9].map { |e| tree2.insert(e, 'aa') } # 3.times do |x| # puts "第#{x + 1} 次:" # tree2.delete_min # tree2.inorder_tree_walk(tree2.root) # end tree2.inorder_tree_walk(tree2.root) tree2.delete(0) puts "..." # tree2.delete_min tree2.inorder_tree_walk(tree2.root)
下面是代码有❌,改正方法见git:
- 增加一个私有方法_delete方法,把下面代码放入_delete, ⚠️代码内的delete改成_delete
- delete(k)方法改为: @root = _delete(@root, k),接收回归的对象给根节点。
def delete(node = self.root, k) #删除节点在左子树。left if node.key > k if !is_red?(node.left) && !is_red?(node.left.left) node = move_red_left(node) end node.left = delete(node.left, k) # 删除节点在右子树。 right else # 类似删除最大值: if is_red?(node.left) node = rotate_right(node) end # 在树底部,找到要删除的节点。删除它,返回nil。 if node.right == nil && node.key == k return nil end # 当前节点的右节点不是双key节点的话,就需要借用了。 if !is_red?(node.right) && !is_red?(node.right.left) node = move_red_right(node) end # 在树中间层找到要删除的节点。需要转化: # 思考? # 结论:2-3树删除中间层的节点的方法同样适用于这里: # 找到要删除节点x的中序列遍历的后续节点next_node,它一定在底层,交换两者。然后删除x。 # 考虑红黑树的代码: # 1.node右子树的最小值即它的后续节点next_node。 # 2.把next_node的key和item(即储存的内容)复制给节点x。那么节点x就被替换掉了。相当于x被删除了。 # 3.最后删除在底部的重复的后续节点next_node。 if node.key == k next_node = min(node.right) node.key = next_node.key # node.item = next_node.item node.right = delete_min(node.right) else #没有找到要删除节点,则移动到下一层: node.right = delete(node.right, k) end end return fix_up(node) end def min(node) if node.left != nil min(node.left) else return node end end
红黑树
简介:
红黑树是2-3-4树的一种抽象表示,在1978年 Guibas 和 Sedgewick 发明最初的红黑树。2008年Sedgewick 对其进行了改进,并将此命名为 LLRBT(Left-leaning red–black tree 左倾红黑树)。
LLRBT 相比1978年的红黑树要简单很多,实现的代码量也少很多。Sedgewick的一篇 PPT 对此有非常详细的介绍。
现在大部分工程中的红黑树都是基于1978发明的算法。
1,2-3-4树就是红黑树的抽象表示。
2, 红黑树使用internal红链接来代表3节点(2key节点)和4节点(3key节点)
算法导论对R-B Tree的介绍:
红黑树,一种二叉查找树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。
通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。
因此最大的时间复杂度是2倍的log2(N+1)。
红黑树还添加了一种特殊类型的节点,称为 NIL 节点。NIL 节点将做为红黑树的伪叶子节点出现。也就是说,所有带有关键数据的节点称为内节点,而所有其他的外节点则均指向 NIL 节点。
红黑树(R-B Tree)需要满足如下性质:
- 节点的颜色只能是红色或者黑色;
- 根节点是黑色的;(根性质)
- NIL 节点的颜色是黑色;
- 如果节点的颜色是红色,则其子节点均为黑色;(红性质)
- 从任一节点到其后代任一叶子节点的nil节点的路径上的黑色节点的数量相同;(黑性质)
只有最后一条最难理解。简单的说,从树中任意一个节点开始,从该节点到其后代的任意一个 NIL 节点的路径上的黑色节点的数量必须相同。
比如上图中,以根节点为例,从节点 41 到任意一个 NIL 节点的路径上,黑色节点的数量都是相同的,也就是 3 个。如从节点 41 到左下角的 NIL 节点的路径上,黑色节点包括 41, 2, NIL,所以黑色节点数量是 3 个。
类似于 AVL 树,红黑树也是一种自平衡二叉查找树。AVL 树的平衡性质是通过限制节点的左右子树的高度来达成,而红黑树则是通过更形象化的方式来保证树的平衡。如果一棵树满足红黑树的性质,其节点的总数量为 n,则它的高度将始终小于 2 * log2(n+1) 。鉴于这个原因,致使红黑树保证了对树的所有操作都能在 O(log2n) 渐进运行时间范围内。
AVL 树通过使用旋转操作(rotations)来恢复树的平衡。而红黑树则是通过重新着色(recoloring)和旋转两种操作共同来完成。这不仅需要判断节点的父节点的颜色,还需要对比叔父节点的颜色,使得红黑树的恢复过程变得更加复杂。
如何插入:
假设已存在红黑树 T,即将被插入的新节点为 K。
如果树 T 为空,则可直接将节点 K 设置为根节点,并且将颜色标为黑色,这样即可满足 R-B 树的所有要求。
如果树T不为空,则需要:
- 使用 BST 插入算法将节点 K 插入到树 T 中;
- 将节点 K 着色为红色;
- 如果需要,则重塑 R-B 树的性质
⚠️BST的性质,任意节点c,其左子树中的节点必小于c, 右子树的节点必大于c。
针对第3步, 添加一个红色的叶子节点仅有可能影响树 T 的红性质,所以我们仅需检查树的红性质,如果红性质被违背,则需要重塑树结构以重新满足红黑树性质。
我们将节点 K 的父节点称为节点 P(parent node),将节点 P 的父节点称为节点 G(grandparent node),将节点 P 的兄弟节点称为节点 S(sibling node)。
G
P S
k
当向非空树 T 中插入节点 K 时,将直接受到父节点 P 的颜色的影响,可能会遇到如下多种情况。
情况1:节点 P 是黑色。
如果 P 为黑色,而节点 K 为红色,所以实际上不会违背黑性质,则树 T 已经满足所有红黑树性质条件。
情况2:节点 P 是红色。
如果节点 P 为红色,那么 P 现在有了新的子节点 K,并且 K 也为红色,所以已经违背了红性质。为了处理这种两个红节点的情况,我们需要考虑节点 G 的其他子节点,也就是节点 P 的兄弟节点 S。此时,会有两种情况发生:
情况 2a:节点 S 是黑色或者为空。
如果节点 S 是黑色或者为空,则需要对节点 K、P、G 进行旋转。根据 K、P、G 顺序的不同,旋转操作可能存在四种可能性。
前两种可能性为当 P 为 G 的左子节点时。
情况 2a:节点 S 是黑色或者为空。
如果节点 S 是黑色或者为空,则需要对节点 K、P、G 进行旋转。根据 K、P、G 顺序的不同,旋转操作可能存在四种可能性。
前两种可能性为当 P 为 G 的左子节点时。
如果 S 为空,则上图中直接将 S 删除即可。
另两种可能性为当 P 为 G 的右子节点时,正好与上面图中的过程相反。
情况 2b:节点 S 是红色。
如果 P 的兄弟节点 S 是红色,则需要对 P、S、G 进行重新着色:将 P 和 S 着色为黑色,将 G 着色为红色。
重新着色操作不会影响树 T 的黑性质,因为当 P、G 的颜色更改时,所有路径上的黑色节点数量并没有改变。但是,重新着色可能会使 G 和 G 的父节点产生双红情况。这种情况下,则需要从 G 和 G 的父节点开始继续遵循处理 K 和 K 的父节点的方式递归式地解决双红问题。
对于红黑树深入的讨论不在本文的范畴,这里不再赘述。
红黑树和AVL树的比较
红黑树在AVL树之后出现,目的在于提高效率。
自平衡二叉树的平衡条件是:
如果树中节点的数量为 n,则一棵满足O(log2n) 渐进运行时间的 BST 树的高度应接近于比 log2n 小的最大整数。
但是红黑树的查找效率时间复杂度为2*O(log2n)比AVL高一些,但是由于它不是严格的自平衡,所以在删除和插入节点时,旋转的次数比AVL树少很多。总体效率就比AVL树高!
这里引用一下知乎上的回答:
Answer 1:
1. 如果插入一个node引起了树的不平衡,AVL和RB-Tree都是最多只需要2次旋转操作,即两者都是O(1);但是在删除node引起树的不平衡时,最坏情况下,AVL需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级O(logN),而RB-Tree最多只需3次旋转,只需要O(1)的复杂度。
2. 其次,AVL的结构相较RB-Tree来说更为平衡,在插入和删除node更容易引起Tree的unbalance,因此在大量数据需要插入或者删除时,AVL需要rebalance的频率会更高。因此,RB-Tree在需要大量插入和删除node的场景下,效率更高。自然,由于AVL高度平衡,因此AVL的search效率更高。
3. map的实现只是折衷了两者在search、insert以及delete下的效率。总体来说,RB-tree的统计性能是高于AVL的。
作者:Acjx
链接:http://www.zhihu.com/question/20545708/answer/58717264
Answer 2 这个总结比较好:
红黑树的查询性能略微逊色于AVL树,因为他比avl树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多多一次比较,但是,红黑树在插入和删除上完爆avl树,avl树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于avl树为了维持平衡的开销要小得多
作者:陈智超
链接:http://www.zhihu.com/question/43744788/answer/98258881
Answer 3 :
功能、性能、空间开销的折中结果。
AVL更平衡,结构上更加直观,时间效能针对读取而言更高;维护稍慢,空间开销较大。
红黑树,读取略逊于AVL,维护强于AVL,空间开销与AVL类似,内容极多时略优于AVL,维护优于AVL。
基本上主要的几种平衡树看来,红黑树有着良好的稳定性和完整的功能,性能表现也很不错,综合实力强,在诸如STL的场景中需要稳定表现。
作者:Coldwings
链接:http://www.zhihu.com/question/20545708/answer/44370878
下面的文章来源:http://blog.****.net/klarclm/article/details/7780319
1 好处 及 用途
红黑树并不追求“完全平衡”——它只要求部分地达到平衡要求,降低了对旋转的要求,从而提高了性能。红黑树能够以O(log2 n) 的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。当然,还有一些更好的,但实现起来更复杂的数据结构 能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高。
当然,红黑树并不适应所有应用树的领域。如果数据基本上是静态的,那么让他们待在他们能够插入,并且不影响平衡的地方会具有更好的性能。如果数据完全是静态的,例如,做一个哈希表,性能可能会更好一些。
在实际的系统中,例如,需要使用动态规则的防火墙系统,使用红黑树而不是散列表被实践证明具有更好的伸缩性。
2 AVL树是最先发明的自平衡二叉查找树。
在AVL树中任何节点的两个儿子子树的高度最大差别为一,所以它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下都是O(log n)。增加和删除可能需要通过一次或多次树旋转来重新平衡这个树。AVL树得名于它的发明者 G.M. Adelson-Velsky 和 E.M. Landis,他们在 1962 年的论文 "An algorithm for the organization of information" 中发表了它。
引入二叉树的目的是为了提高二叉树的搜索的效率,减少树的平均搜索长度.为此,就必须每向二叉树插入一个结点时调整树的结构,使得二叉树搜索保持平衡,从而可能降低树的高度,减少的平均树的搜索长度.
AVL树的定义:
一棵AVL树满足以下的条件:
1>它的左子树和右子树都是AVL树
2>左子树和右子树的高度差不能超过1
从条件1可能看出是个递归定义,如GNU一样.
性质:
1>一棵n个结点的AVL树的其高度保持在0(log2(n)),不会超过3/2log2(n+1)
2>一棵n个结点的AVL树的平均搜索长度保持在0(log2(n)).
3>一棵n个结点的AVL树删除一个结点做平衡化旋转所需要的时间为0(log2(n)).
从1这点来看红黑树是牺牲了严格的高度平衡的优越条件为 代价红黑树能够以O(log2 n)的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。当然,还有一些更好的,但实现起来更复杂的数据结构 能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。红黑树的算法时间复杂度和AVL相同,但统计性能比AVL树更高.