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

刪除某個已存在節點時的操作有三條:

  1. 如果刪除的值不在葉子節點,則交換待刪除值和它在樹的中序遍歷結果中的下一個節點值,然後刪除;
    需要注意的是,中序遍歷結果中的下一個值必然在一個葉子節點上。
  2. 如果一個值被刪除後,所在節點值的個數爲 0,就要從父母節點中取出一個值與兄弟合併
  3. 如果一個值被取出後,所在節點值的個數爲 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 樹的?

數據解構專題的下一篇會繼續展開。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章