如果從先易後難的順序介紹各種樹,那麼紅黑樹必然放在 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 樹的?
數據解構專題的下一篇會繼續展開。