2-3 树——学习红黑树的捷径
如果从先易后难的顺序介绍各种树,那么红黑树必然放在 AVL 树后面。但在红黑树之前,还有一种名为 2-3 树的平衡树(Balanced-Tree,B-树)。2-3 树理解起来比红黑树容易很多,并且在理解它的基础上增加一个变更,就成了红黑树(尽管不是通常使用的那种红黑树)。因此学习红黑树的时候,最好先学习 2-3 树。
2-3 树与 AVL 树在树高的增长上有所不同:AVL 树是从上到下增加树高,根节点只会因为旋转而改变;而 2-3 树是从下到上增加树高,节点值是从下往上挤,如果挤到根节点且容纳不下的时候,会再往上挤出一个新的根节点。
2-3 树
2-3 树与二叉树不同的是,它的节点除了可以有 2 个叉之外,还可以有 3 个叉。如下图:
根节点的 4_7 表示该节点有两个值,分别是 4 和 7。为了方便讨论,把较小的值 4 称为低值,把较大的值 7 称为高值。
该节点有三颗子树,子树与节点的关系是:左子树所有节点的值都小于低值,右子树所有节点的值都大于高值,中间子树所有节点的值都介于高值和低值之间。
把这种有两个值且可以有 3 个叉的节点称为 3节点(例如值为 1_2 的节点),把只有一个值且最多只能有 2 个叉的节点(例如值为 6 的节点)称为 2节点。
新数据插入示例
由于有两种不同的节点,因此应该分情况讨论。
首先是最简单的 2 节点:
当插入值为 2 时,由于该节点还有一个空缺的位置,又没有子树,因此把值放入到节点内部。如下图:
此时该节点成为一个 3节点。
接着,当继续插入值 3 的时候,发现这个节点无法容纳 3 这个值,只好把三个数的中间值 2 挤上去,两边的值作为子节点。
为了方便理解,有些资料会把值强行插进原先节点,变成一个 4节点,再把 4节点转换到上图的双层解构。下图是一个 4节点。
除了这种简单的根节点转换,还需要考虑到原先 3 节点有父节点的情况。例如最开始的图:
8_9 这个节点是一个 3节点,如果往树中插入 10 会发生什么?
8_9 节点无法容纳 10 这个值,需要把三个数 8_9_10 的中间数 9 往上挤。但 4_7 节点也无法容纳 9,因此需要再把 4_7_9 的中间数 7 往上挤。最终成为下图的样子:
这样,2-3 树不需要有旋转的操作,只需不断地把中间数往上挤,就能保持平衡。
2-3 树的实现
根据上述过程的需要,可以写出以下结构:
const (
NODE_TYPE_2 = 2 // 2 节点
NODE_TYPE_3 = 3 // 3 节点
)
type TreeNode struct {
Type int // 表示节点类型:2节点、3节点
LowValue int
HighValue int
Parent *TreeNode
Left *TreeNode
Middle *TreeNode
Right *TreeNode
}
// NewTreeNode 创建一个节点并设置为 2 节点
func NewTreeNode(value int) *TreeNode {
return &TreeNode{Type: NODE_TYPE_2, LowValue: value}
}
根据合并算法的具体实现,结构体还会有不同的变化。例如有的实现中,不使用单独的 LowValue 和 HighValue,而是一个存储三个元素的数组 Values [3]int
。先把子节点拆分时的中间数挤到父节点,再让父节点去调整。因为存储的时候节点变成了一个 4节点,所以结构体中还需要加入一个 Tmp *TreeNode
来存储子节点分离后多出来的一颗子树。
现在这个结构体,是我在 GitHub 上找到的一份 C 语言实现的 2-3 树代码翻译过来的。我认为这种实现容易理解,因此接下来会使用完整翻译后的代码做解析。
C 实现的源代码在:
https://github.com/Hazeman28/self-balancing-trees/blob/master/2_3tree/2_3tree.c
首先关注 Insert 时的行为。根据 2-3 树的特性,具体处理的时候应该考虑两点:
- 是否是叶子节点?
- 节点类型是 2节点还是 3节点?
最外层根据是否为叶子节点做不同的处理。
func Insert(node *TreeNode, value int) *TreeNode {
if node == nil {
return NewTreeNode(value)
}
if node.IsLeaf() {
return AddToLeaf(node, value)
}
// ... 非叶子节点的情况
}
// IsLeaf 判断是否为叶子节点。如果不是叶子节点,则 Left 必不为空。
func (node *TreeNode) IsLeaf() bool {
return node.Left == nil
}
插入叶子节点
确定是否为叶子节点后,要考虑节点类型。2节点的情况比较容易,优先考虑 2 节点。
// AddToLeaf 把新 value 添加到叶子节点
func AddToLeaf(node *TreeNode, newValue int) *TreeNode {
if node.Type == NODE_TYPE_2 {
if newValue > node.LowValue {
node.HighValue = newValue
} else {
node.HighValue = node.LowValue
node.LowValue = newValue
}
node.Type = NODE_TYPE_3
return GetRoot(node)
}
// ... 3节点的情况
}
// GetRoot 获取 node 所在树的根节点
func GetRoot(node *TreeNode) *TreeNode {
if node.Parent == nil {
return node
}
return GetRoot(node.Parent)
}
2节点类型的叶子节点只需操作 Value。需要注意的点是,操作完必须把节点类型改为 3节点。
接着考虑插入时叶子节点为 3节点的情况。
3节点必然要拆成几个部分:
- 三数最小值单独为一个节点
- 三数中间值提升到父节点
- 三数最大值单独为一个节点
由于是叶子节点,不需要让单独出来的节点继承原先 3节点的 Left、Middle、Right,因此比较简单。
func AddToLeaf(node *TreeNode, newValue int) *TreeNode {
// ... 2节点的情况
// 3节点的情况
var left, right *TreeNode
// 要提升到父节点的 value
var promotedValue int
if newValue < node.LowValue { // new_value < low_value < high_value
left = NewTreeNode(newValue)
promotedValue = node.LowValue
right = NewTreeNode(node.HighValue)
} else if newValue > node.HighValue { // low_value < high_value < new_value
left = NewTreeNode(node.LowValue)
promotedValue = node.HighValue
right = NewTreeNode(newValue)
} else { // low_value < new_value < high_value
left = NewTreeNode(node.LowValue)
promotedValue = newValue
right = NewTreeNode(node.HighValue)
}
return MergeWithParent(node.Parent, left, right, promotedValue)
}
最后是将这三个部分合并到父节点。注意这里已经不再使用 node 这个节点了,它已经被拆成 3 个部分。
由于三个部分作为参数传入,它们也就不需要放到 node 里面。node 不需要额外添加属性去存储这些信息。
合并到父节点
当最初的拆分出现时,拆分的节点必然是根节点。因此先考虑根节点的情况。
func MergeWithParent(parent, left, right *TreeNode, promotedValue int) *TreeNode {
// 拆分的节点是根节点,因此再往上创建新的根节点。
if parent == nil {
parent = NewTreeNode(promotedValue)
parent.Left = left
parent.Right = right
parent.Left.Parent = parent
parent.Right.Parent = parent
return parent
}
// ... 非根节点的情况
}
在这种情况下,promotedValue 必然是作为一个新的节点类型为2节点的根节点出现。
由于使用同样的解构存储2节点和3节点,因此需要确定2节点时的使用规则。3节点可以使用 Left\Middle\Right 三个属性,2节点只能使用两个。C 的实现中,2节点使用 Left 和 Middle 属性,不使用 Right。我认为使用 Right 会更贴近于 AVL 树,容易理解,因此 2节点不使用 Middle,而是使用 Right。
要特别注意的点是,必须让 left 和 right 的 Parent 指向新节点。由于 2-3 树是向上生长的,Parent 如果没设置好,会在升高的时候没法正确地把 Value 合并到父节点。
接着考虑非根节点的情况。由于节点特性,还需要分 2节点和 3节点的情况。
首先考虑比较简单的非根 2节点。与叶子节点不同的地方在于需要处理子树,其他没有区别。处理子树时,根据子节点提升的值和2节点已有值的大小比较,决定子树存放的位置。下图分别是左右两种情况。
func MergeWithParent(parent, left, right *TreeNode, promotedValue int) *TreeNode {
// ... 根节点的情况
// 非根节点的情况
if parent.Type == NODE_TYPE_2 {
if promotedValue > parent.LowValue {
parent.HighValue = promotedValue
parent.Middle = left
parent.Right = right
} else {
parent.HighValue = parent.LowValue
parent.LowValue = promotedValue
parent.Left = left
parent.Middle = right
}
parent.Left.Parent = parent
parent.Middle.Parent = parent
parent.Right.Parent = parent
parent.Type = NODE_TYPE_3
return GetRoot(parent)
}
// ... parent.Type == NODE_TYPE_3
}
当然,仍然需要注意设置子节点的父节点。
最后考虑父节点是 3节点的情况。把提升的值的位置分为左中右三种情况讨论就行了。
- promoted_value < low_value < high_value
- low_value < promoted_value < high_value
- low_value < high_value < promoted_value
代码和插入到叶子节点不同的地方在于处理子节点。
func MergeWithParent(parent, left, right *TreeNode, promotedValue int) *TreeNode {
// ... 根节点的情况
// 非根节点的情况
// ... parent.Type == NODE_TYPE_2
// parent.Type == NODE_TYPE_3
var newLeft, newRight *TreeNode
var newPromotedValue int
if promotedValue < parent.LowValue { // promoted_value < low_value < high_value
newLeft = NewTreeNode(promotedValue)
newLeft.Left = left
newLeft.Right = right
newPromotedValue = parent.LowValue
newRight = NewTreeNode(parent.HighValue)
newRight.Left = parent.Middle
newRight.Right = parent.Right
} else if promotedValue > parent.HighValue { // low_value < high_value < promoted_value
newLeft = NewTreeNode(parent.LowValue)
newLeft.Left = parent.Left
newLeft.Right = parent.Middle
newPromotedValue = parent.HighValue
newRight = NewTreeNode(promotedValue)
newRight.Left = left
newRight.Right = right
} else { // low_value < promoted_value < high_value
newLeft = NewTreeNode(parent.LowValue)
newLeft.Left = parent.Left
newLeft.Right = left
newPromotedValue = promotedValue
newRight = NewTreeNode(parent.HighValue)
newRight.Left = right
newRight.Right = parent.Right
}
newLeft.Left.Parent = newLeft
newLeft.Right.Parent = newLeft
newRight.Left.Parent = newRight
newRight.Right.Parent = newRight
return MergeWithParent(parent.Parent, newLeft, newRight, newPromotedValue)
}
需要再次提醒的是要给子节点设置父节点信息。
新值的插入就到这里了。由于使用了 MergeWithParent 的这种处理方式,使得代码无论是写起来还是理解起来都比较简单。
删除
如果没有做好总结,那么会发现删除时的情况特别多。不仅要考虑从删除的节点往下的节点,还要考虑其往上的节点。
由于 C 版本代码不包含删除操作,因此这里会先把删除操作总结后的内容写出来,然后再转换成代码实现。删除操作参考以下链接的说明:
https://www.geeksforgeeks.org/2-3-trees-search-and-insert/
https://www.cs.princeton.edu/~dpw/courses/cos326-12/ass/2-3-trees.pdf
删除某个已存在节点时的操作有三条:
- 如果删除的值不在叶子节点,则交换待删除值和它在树的中序遍历结果中的下一个节点值,然后删除;
需要注意的是,中序遍历结果中的下一个值必然在一个叶子节点上。 - 如果一个值被删除后,所在节点值的个数为 0,就要从父母节点中取出一个值与兄弟合并
- 如果一个值被取出后,所在节点值的个数为 0,就要从父母节点中取出一个值与兄弟合并,直到根节点为空时删除根节点。
以这张图为例。
删除 13 时,由于它在叶子节点,直接删除就完成了:
删除 9 时,由于它不在叶子节点,交换它和中序遍历下一个值 10 的位置:
然后删除 9:
删除 11 时,由于是叶子节点,直接删除:
11 之前所在的节点成为空节点。由于空节点在父节点的左子树,因此从父节点中取出低值,和空节点最近的兄弟 14 合并。
删除 16 时,由于是叶子节点,直接删除:
删除 17 时,由于是叶子节点,直接删除:
现在出现空节点了。空节点在父节点的右子树,如果父节点是3节点就要从父节点中取出高值,但这里父节点是2节点,因此取仅剩的一个值与 empty 节点的兄弟节点合并。
但兄弟节点 12_14 是一个3节点,所以合并的时候要把 12_14_15 的中间值挤上去:
删除 12 时,由于是叶子节点,直接删除:
原来的节点变成空节点。则让父节点的值合并到空节点的兄弟节点:
空节点仍然存在。由于空节点在中间子树,因此可以选择取父节点的低值然后与左兄弟合并,也可以取高值和右兄弟合并。这里取前者。
由于左兄弟 3_6 是3节点,合并时需要把 3_6_10 的中间值挤上父节点,接着由于这个节点变成了2节点,右子树 7_8 要转移到新节点上:
由于时间有限,代码留着以后再实现吧。
完整代码
package main
import "fmt"
const (
NODE_TYPE_2 = 2 // 2 节点
NODE_TYPE_3 = 3 // 3 节点
)
type TreeNode struct {
Type int // 表示节点类型:2节点、3节点。2节点时 Middle 必为空
LowValue int
HighValue int
Parent *TreeNode
Left *TreeNode
Middle *TreeNode
Right *TreeNode
}
// NewTreeNode 创建一个节点并设置为 2 节点
func NewTreeNode(value int) *TreeNode {
return &TreeNode{Type: NODE_TYPE_2, LowValue: value}
}
// IsLeaf 判断是否为叶子节点。如果不是叶子节点,则 Left 必不为空。
func (node *TreeNode) IsLeaf() bool {
return node.Left == nil
}
// Text 获取节点 value 的字符串表示
func (node *TreeNode) Text() string {
if node.Type == NODE_TYPE_2 {
return fmt.Sprintf("%d", node.LowValue)
}
return fmt.Sprintf("%d_%d", node.LowValue, node.HighValue)
}
// GetRoot 获取 node 所在树的根节点
func GetRoot(node *TreeNode) *TreeNode {
if node.Parent == nil {
return node
}
return GetRoot(node.Parent)
}
// Insert 往树中添加一个 value。如果 value 已存在,则不做任何操作
func Insert(node *TreeNode, value int) *TreeNode {
if node == nil {
return NewTreeNode(value)
}
if node.IsLeaf() {
return AddToLeaf(node, value)
}
if value < node.LowValue {
return Insert(node.Left, value)
}
if node.Type == NODE_TYPE_2 && value > node.LowValue ||
node.Type == NODE_TYPE_3 && value > node.HighValue {
return Insert(node.Right, value)
}
if value == node.LowValue || value == node.HighValue {
return GetRoot(node)
}
return Insert(node.Middle, value)
}
// AddToLeaf 把新 value 添加到叶子节点。如果添加前叶子节点已是 3 节点,则拆分并合并到父节点
func AddToLeaf(node *TreeNode, newValue int) *TreeNode {
if node.Type == NODE_TYPE_2 {
if newValue > node.LowValue {
node.HighValue = newValue
} else {
node.HighValue = node.LowValue
node.LowValue = newValue
}
node.Type = NODE_TYPE_3
return GetRoot(node)
}
// node.Type == NODE_TYPE_3
var left, right *TreeNode
var promotedValue int
if newValue < node.LowValue {
left = NewTreeNode(newValue)
promotedValue = node.LowValue
right = NewTreeNode(node.HighValue)
} else if newValue > node.HighValue {
left = NewTreeNode(node.LowValue)
promotedValue = node.HighValue
right = NewTreeNode(newValue)
} else {
left = NewTreeNode(node.LowValue)
promotedValue = newValue
right = NewTreeNode(node.HighValue)
}
return MergeWithParent(node.Parent, left, right, promotedValue)
}
// MergeWithParent 把拆分后的左右子树和提升的 value 合并到父母节点。如果父母节点已经是 3 节点,则继续拆分往上合并。
func MergeWithParent(parent, left, right *TreeNode, promotedValue int) *TreeNode {
// 拆分的节点是根节点,因此再往上创建新的根节点。
if parent == nil {
parent = NewTreeNode(promotedValue)
parent.Left = left
parent.Right = right
parent.Left.Parent = parent
parent.Right.Parent = parent
return parent
}
if parent.Type == NODE_TYPE_2 {
if promotedValue > parent.LowValue {
parent.HighValue = promotedValue
parent.Middle = left
parent.Right = right
} else {
parent.HighValue = parent.LowValue
parent.LowValue = promotedValue
parent.Left = left
parent.Middle = right
}
parent.Left.Parent = parent
parent.Middle.Parent = parent
parent.Right.Parent = parent
parent.Type = NODE_TYPE_3
return GetRoot(parent)
}
// parent.Type == NODE_TYPE_3
var newLeft, newRight *TreeNode
var newPromotedValue int
if promotedValue < parent.LowValue { // promoted_value < low_value < high_value
newLeft = NewTreeNode(promotedValue)
newLeft.Left = left
newLeft.Right = right
newPromotedValue = parent.LowValue
newRight = NewTreeNode(parent.HighValue)
newRight.Left = parent.Middle
newRight.Right = parent.Right
} else if promotedValue > parent.HighValue { // low_value < high_value < promoted_value
newLeft = NewTreeNode(parent.LowValue)
newLeft.Left = parent.Left
newLeft.Right = parent.Middle
newPromotedValue = parent.HighValue
newRight = NewTreeNode(promotedValue)
newRight.Left = left
newRight.Right = right
} else { // low_value < promoted_value < high_value
newLeft = NewTreeNode(parent.LowValue)
newLeft.Left = parent.Left
newLeft.Right = left
newPromotedValue = promotedValue
newRight = NewTreeNode(parent.HighValue)
newRight.Left = right
newRight.Right = parent.Right
}
newLeft.Left.Parent = newLeft
newLeft.Right.Parent = newLeft
newRight.Left.Parent = newRight
newRight.Right.Parent = newRight
return MergeWithParent(parent.Parent, newLeft, newRight, newPromotedValue)
}
// Search 搜索 value
func Search(node *TreeNode, value int) *TreeNode {
if node == nil {
return nil
}
if node.Type == NODE_TYPE_2 {
if value < node.LowValue {
return Search(node.Left, value)
}
if value > node.LowValue {
return Search(node.Right, value)
}
if value == node.LowValue {
return node
}
return nil
}
if value < node.LowValue {
return Search(node.Left, value)
}
if value > node.HighValue {
return Search(node.Right, value)
}
if value == node.LowValue || value == node.HighValue {
return node
}
return Search(node.Middle, value)
}
2-3 树与红黑树的关系
2-3 树理解起来并不复杂,使用这种方式实现也比较简单。从 2-3 树可以看出一个规律:任意节点到叶子节点的所有路径的长度相同。
有没有一点红黑树的味道?如果把 3节点的 LowValue 和 Left、Middle 下放,并且把 LowValue 标记为红色,原先节点标记为黑色,是不是就得到满足红黑树性质要求的树了?
区别是,红黑树所说的是“从一个节点到一个 NULL 指针的每一条路径必须包含相同数目的黑色节点”。对于 2-3 树来说也是如此。
但是如果对比 2-3 树转换的红黑树与我们通常看到的红黑树,会发现两者不一样。例如在如下红黑树展示网站插入 1、2、3 这三个节点的时候:
https://www.cs.usfca.edu/~galles/visualization/RedBlack.html
得到的是两个红色子节点。如果是使用 2-3 树的转换,应该三个节点都是黑色的:
实际使用的红黑树是 2-3-4 树所对应的红黑树。
前面 2-3 树规定 3 节点只能用低值作为红色节点,对应的红黑树是一颗左倾红黑树(LLRB,Left-leaning red-black trees)。左倾指的是连接到红色子节点的线是往左的。
- 为什么不直接用 2-3 树或者 2-3-4-树?
- 为什么不使用 2-3 树转换的红黑树,而是使用 2-3-4 树的?
数据解构专题的下一篇会继续展开。