紅黑樹的設計與實踐

本文主要介紹以下五個方面,通過本文可以大致掌握紅黑樹的基礎知識,並且有助於你在現實場景中根據需要選擇合適的數據結構。

  1. 什麼是紅黑樹?

  2. 紅黑樹與哈希表的區別

  3. 如何構造紅黑樹?

  4. 紅黑樹在DNS數據存儲中的應用

  5. 其他使用場景

在這裏插入圖片描述

1. 定義

紅黑樹本身也是一個二叉樹結構,對於普通二叉樹結構,其本身的樹形結構依賴於插入數據的順序,比如我們插入一個數組(1,2,3,4,5,6)到二叉樹中, 普通二叉樹的結構如下面所示:

這種情況下,二叉樹和鏈表等價,執行效率也相同,我們使用二叉樹的目的就是獲得O(log(N))的性能提升,這種情況下只能達到O(N),顯然不是我們所期望的。爲了實現更加平衡(左右基本高度差不多)的二叉樹結構,當前主要的兩種平衡樹結構是紅黑樹和AVL樹,其中AVL樹因其對平衡性要求更高,實現上會更復雜一些,因此在插入效率上相對紅黑樹更慢一些。這裏我們先來看一下一個紅黑樹的五大特性來了解一下什麼樣子的二叉樹是紅黑樹。

紅黑樹五大特性分別指的是:

  • 每個節點只能是紅色或者黑色
  • 根節點是黑色
  • 葉子節點是黑色
  • 每一個紅色節點的子節點是黑色
  • 從根節點到任意的葉子節點包含相同的黑色節點數量(簡稱爲:黑高)。

下面就是一個典型的紅黑樹,爲了節省空間佔用,由於葉子節點不會實際存儲數據,所有的葉子節點設置爲爲一個即可。而且根節點的父節點也指向這個節點。 大家可以逐一比對上面的條件,可以看到所有條件均滿足要求。

在這裏插入圖片描述

在滿足上面的五個條件情況下,可以保證在n個節點的紅黑樹中高度最多爲2lg(n+1)! ,在設計節點數據結構的時候一般均包含有五個基本屬性,分別是: 代表顏色的Color, 代表左右節點的Left和Right 代表父節點的Parent 以及代表 鍵的Key, 假如需要按照KV形式存儲數據,還可以包含對應的數據Data。

節點的顏色與實際存儲的數據沒有關係,只是用於平衡使用。 當一個節點增加或者刪除的時候, 需要根據實際的情況進行重新平衡來滿足上面的兩個條件,其中顏色用於確定執行平衡時候需要的操作情況。

我們可以定義每個節點的數據結構如下面的代碼所示, 由於顏色只有紅黑兩種,我們可以使用一個Bool類型來存儲即可,當然如果爲了達到最佳存儲效果,可以使用一個比特位即可,而不是一個字節。

type Node struct{
	Left *Node
	Right *Node
	Parent *Node
	Color bool

	Key string 
	Value interface{}
}

2. 與哈希表的區別

紅黑樹相對於哈希表的優點在於,儘管都可以做到存儲鍵值,但是紅黑樹具有一些自己的特性:

  • 具有順序性,可以按照鍵值進行元素的順序遍歷,如果一個數據結構需要排序元素,那麼哈希表基本上會被直接淘汰掉。另外這裏的順序性實際包含很多功能實現,比如範圍選擇,最大最小值,迭代元素等。
  • 存儲數據方面,紅黑樹可以做到穩定的增長,但是鑑於哈希表本身的實現,往往需要定期的複製元素到一個更大或者更小的容器,才能實現容量動態的變化。
  • 哈希表存儲數據需要計算哈希值,儘管O(1)操作,但是性能完全取決於哈希函數實現。在紅黑樹中這些存儲數據,需要的是鍵與鍵之間實現比較即可, 另外哈希表還需要處理好可能出現的碰撞問題。

3. 設計紅黑樹

我們使用下面的初始化函數來創建一個紅黑樹,RBTree結構本身包含有一個根節點,一個總的葉子節點以及一個計數器統計當前元素數量。


type RBTree struct {
	root *Node
	leaf *Node
	count uint
}

// NewRBTree create a new rbtree
func NewRBTree()*RBTree{
	node := &Node{
		Left:   nil,
		Right:  nil,
		Parent: nil,
		Color:  ColorBlack,
		Key:    "",
		Value:  nil,
	}
	return &RBTree{
		root:  node,
		leaf:  node,
		count: 0,
	}
}

3.1 旋轉操作

爲了維持節點在插入和刪除的時候保持紅黑樹的四大規則,在節點插入後需要執行一定的左旋和右旋來完成對於樹的修改,使其能夠繼續滿足作爲一個紅黑樹的要求。左旋和右旋節點的方式如下面所示,其實就是要保證作爲一個二叉樹需要滿足的條件,對於旋轉後的abc位置進行調整。

在這裏插入圖片描述

如果用程序代碼來表示的話,對於左旋的方式如下:

func (rbtree *RBTree) leftRotate(x *Node){
	// 對於x執行左旋轉的時候必須保證x存在右節點,參考實例圖
	if x.Right == rbtree.NIL{
		return
	}
	y := x.Right
	
	x.Right = y.Left  // y 的左子樹現在將成爲 x的右子樹
	if y.Left != rbtree.NIL{
		y.Left.Parent = x
	}

	y.Parent = x.Parent 	// x 的父元素現在成爲y的父元素
  
	// 替換x到y需要處理其父元素中的位置
	if x.Parent == rbtree.NIL{
		rbtree.root = y
	}else if x == x.Parent.Left{
		x.Parent.Left = y
	}else{
		x.Parent.Right = y
	}
 
	y.Left = x  // x現在成爲y的左子節點
	x.Parent = y
}

右旋操作同上,對稱進行處理即可,這裏不再給出相關代碼。

3.2 插入操作

瞭解了旋轉後我們可以直接進行紅黑樹的插入操作了,相對於普通的二叉樹的插入,其實多了着色和重新調整的步驟。對於新插入的節點,我們找到其位置後,對其進行着色,設置顏色爲紅色,然後進行重新調整操作。

func (rbtree *RBTree) Insert(z *Node) *Node{
	x := rbtree.root
	y := rbtree.NIL

	for x != rbtree.NIL{
		y = x
		if z.Key < x.Key{
			x = x.Left
		}else if z.Key > x.Key{
			x = x.Right
		}else{
			return  x
		}
	}

	z.Parent = y
	if y == rbtree.NIL{
		rbtree.root = z
	}else if z.Key < y.Key{
		y.Left = z
	}else {
		y.Right = z
	}
	
	z.Left =  rbtree.NIL
	z.Right = rbtree.NIL
	z.Color = ColorRed
	rbtree.count ++
	rbtree.insertFixUp(z)
	return z
}

前面主要是通過循環來獲取對應的二叉樹的節點,定位最終獲得數據位置比如下面的示例圖,我們在一個紅黑樹中插入數據(4),插入後通過上述代碼找到插入的位置並着色爲紅色。 這樣可能破壞的規則只可能是:

  • 根節點必須是黑色(如果插入的節點爲根節點則違背該規則)
  • 紅色節點的子節點必須是黑色(如果插入的節點父節點爲紅色則違背該規則)

在這裏插入圖片描述

在上面的實例中,插入節點完成後由於節點5作爲其父節點爲紅色,而節點4也是紅色,違背紅黑規則因此需要做調整。調整的方式。這裏存在三種情況 分別對應了上面這個節點插入後調整的三個步驟:

  • 情況一: 節點的叔叔節點爲紅色(上述例子中的8節點)
  • 情況二: 節點的叔叔節點爲黑色,且節點本身是一個右子節點
  • 情況三: 節點的叔叔節點爲黑色, 且節點本身是一個左子節點

首先對於上面的實例按照情況一的處理方式,先將元素進行重新着色,叔叔節點和父節點重新設置爲黑色,爺爺節點設置爲紅色也就是圖中z’節點,執行完成後發現仍舊不滿足條件存在兩個紅色節點相鄰的情況,但處於情況二,因此我們可以繼續按照情況二的方式進行處理。

在這裏插入圖片描述

情況二的調整直接進行左旋轉即可,旋轉後符合情況三的說明,我們統一按照情況三來處理。情況三下面需要對於其中的z’節點也就是7節點進行右旋操作完成後檢查是否滿足條件。

在這裏插入圖片描述

插入操作總計的時間複雜度爲O(lgN),其中查詢定位元素的操作時間最多爲O(lgN), 而執行調整的時間最多爲O(lgN),總計的時間也是O(lgN).

整個修復的代碼如下:

func (rbtree *RBTree) insertFixUp(z *Node) {
   for z.Parent.Color == ColorRed {
      if z.Parent == z.Parent.Parent.Left {

         y := z.Parent.Parent.Right
         if y.Color == ColorRed {

            z.Parent.Color = ColorBlack
            y.Color = ColorBlack
            z.Parent.Parent.Color = ColorRed
            z = z.Parent.Parent
         } else {
            if z == z.Parent.Right {

               z = z.Parent
               rbtree.leftRotate(z)
            }

            z.Parent.Color = ColorBlack
            z.Parent.Parent.Color = ColorRed
            rbtree.rightRotate(z.Parent.Parent)
         }
      } else { 
         y := z.Parent.Parent.Left
         if y.Color == ColorRed {
            z.Parent.Color = ColorBlack
            y.Color = ColorBlack
            z.Parent.Parent.Color = ColorRed
            z = z.Parent.Parent
         } else {
            if z == z.Parent.Left {
               z = z.Parent
               rbtree.rightRotate(z)
            }
            z.Parent.Color = ColorBlack
            z.Parent.Parent.Color = ColorRed
            rbtree.leftRotate(z.Parent.Parent)
         }
      }
   }
   rbtree.root.Color = ColorBlack
}

3.3 刪除節點

紅黑樹中節點的刪除分爲兩個階段,第一個階段基本和二叉樹刪除的相似,第二個階段則進行顏色和位置的調整。針對第一個階段二叉樹的節點刪除又分爲三類情況:

  • 第一種情況:節點不包含孩子節點
  • 第二種情況:節點包含一個孩子節點
  • 第三種情況:節點包含兩個孩子節點

節點不包含孩子節點的時候,我們可以直接將該節點刪除掉即可,節點包含一個的子節點的時候我們將其作爲與父節點直接相連,節點包含兩個子節點的時候,則需要在其右子樹中找到其繼任者重新完成父子節點元素的調整。不同於二叉樹的刪除,我們在紅黑樹刪除中需要根據顏色來判斷是否需要進行處理,如果待刪除的元素顏色爲黑色或者待移動的顏色爲黑色,可能會破壞原有的規則,需要額外的處理。

第一種情況最簡單,直接刪除即可,我們看下第二種情況,這裏不管左還是右節點爲空,都可以直接將另一方提升到父節點即可。示意圖如下面所示:

在這裏插入圖片描述

對於第三種情況又可以分爲兩類,第一類爲右子樹中的左節點爲空,則可以直接將其右字數提升到父節點,而不需要其他的操作.

在這裏插入圖片描述

而如果不是空節點的話,則需要考慮多個節點的狀態,如下面的操作,z的後繼節點肯定是右子樹的最小節點,至少包含一個左節點爲nil(否則該節點的左節點纔是最小節點), 這時候需要將其提升爲當前節點,假如包含右節點,則右節點上移即可。

在這裏插入圖片描述

該步驟執行的代碼如下面所示:

func (rbtree *RBTree) delete(key string) *Node {
   z := rbtree.Search(key)
   if z == nil{
      return rbtree.NIL
   }
   ret := &Node{rbtree.NIL, rbtree.NIL, rbtree.NIL, z.Color, z.Key, z.Value}
   var y *Node
   var x *Node
   if z.Left == rbtree.NIL || z.Right == rbtree.NIL {
      y = z
   } else {
      y = rbtree.successor(z)
   }

   if y.Left != rbtree.NIL {
      x = y.Left
   } else {
      x = y.Right
   }

   x.Parent = y.Parent

   if y.Parent == rbtree.NIL {
      rbtree.root = x
   } else if y == y.Parent.Left {
      y.Parent.Left = x
   } else {
      y.Parent.Right = x
   }

   if y != z {
      z.Key = y.Key
   }

   if y.Color == ColorBlack {
      rbtree.deleteFixUp(x)
   }

   rbtree.count--
   return ret
}

當我們執行的過程中,只有發現被刪除的節點或者被移動的節點(y)爲黑色的時候纔會執行修復操作,只有這種情況纔會導致紅黑樹規則出現問題。第二階段進入顏色調整和旋轉的步驟,也就是上面deleteFixUp函數所表示的過程,主要爲了修復已經遭受破壞的紅黑樹規則。

這裏又分爲四種情況,循環依次檢測屬於下面的哪種情況,一旦檢測發現x不在是黑色或者x爲根節點,則退出循環即可:

第一種情況,X的兄弟w節點爲紅色如下面的圖例所示,x代表已經完成刪除後補充到原有位置的節點(可以是子節點或者後繼節點兩種),由於刪除了一個黑色的節點,導致B節點左右不再保持平衡,因此我們需要執行一次顏色的轉換,之後再進行一次左旋。從而變爲下面的第二,三或四中情況

在這裏插入圖片描述

第二種情況, X的兄弟w節點爲黑色且其兩個子節點也是黑色: 如下面圖例所示, 其中綠色代表可以爲紅色或者黑色節點。這是唯一一個需要重複不斷執行的情況,執行後x上移一層。假如從情況一轉入,則由於x本身的父元素爲紅色,因此直接滿足退出循環的條件,退出即可。否則繼續循環執行檢查。

在這裏插入圖片描述

第三種情況, X的兄弟w節點爲黑色且其兩個子節點中左子節點爲紅色,右子節點爲黑色:這裏需要交換一次C和D的顏色,並進行右旋轉。此時轉換爲情況四處理

在這裏插入圖片描述

第四種情況, X的兄弟w節點爲黑色且w兩個子節點中右子節點爲紅色: 此時將通過修改顏色以及對於B執行左旋轉。

在這裏插入圖片描述

基本上通過每次循環檢測是否屬於上述的幾種情況,進行對應的處理,從而保證最終到達根節點或者最終x節點變爲了紅色節點。修復操作需要通過循環進行不斷上移指針,但是最多上移logN次,最多執行3次循環即可終止。加上最開始查詢的時間O(logN), 因此總的時間複雜度爲O(logN). 整個的代碼較多,此處不再給出。

3.4 輔助操作

對於紅黑樹我們經常遇到的一些操作包括查詢最大值,查詢最小值,搜索節點,獲取下一個節點,獲取上一個節點等操作,這些與常規的二叉樹相同,所以比較簡單這裏給出查詢以及尋找下一個節點操作的代碼:

func (rbtree *RBTree)Search(key string) *Node{
   p := rbtree.root
   
   for p!=rbtree.NIL{
      if p.Key < key{
         p = p.Right
      }else if p.Key > key{
         p = p.Left
      }else {
         return p
      }
   }
   return  nil
}

查詢下一個節點的操作函數Successor, 判斷是否存在右節點,如果存在返回右節點的最小節點,如果不存在的話,則查找父節點的右節點並進行循環查找直到找到該節點返回。

func (rbtree *RBTree) successor(x *Node) *Node {
   if x == rbtree.NIL {
      return rbtree.NIL
   }

   if x.Right != rbtree.NIL {
      return rbtree.min(x.Right)
   }

   y := x.Parent
   for y != rbtree.NIL && x == y.Right {
      x = y
      y = y.Parent
   }
   return y
}

4. 使用紅黑樹存儲DNS數據

在DNS軟件中,可以使用DNS的名稱作爲存儲的鍵,對應存儲的數據可以是任何的類型。紅黑樹算法與DNS本身的樹形結構可以很容易的結合在一起。對於具有相同後綴的名稱,存儲的時候進行獨立的存儲,可以使用單獨的紅黑樹完成,並通過指針進行串聯在一起。

au 
com 
de 
org 
ftp.example.net 
www.example.net  
bbs.vip.example.net 
admin.vip.example.net 
login.vip.example.net

我們對於具有相同後綴的域名存儲在一個獨立的紅黑樹結構中,這裏我們使用了三個獨立的紅黑樹,分別如下面所示:

  • 第一個紅黑樹用來存儲au, com, de, org和example.net。因爲這裏並沒有單獨的net所以這裏將example,net合併到一起。
  • 第二個存儲具有相同後綴的example.net的相關信息。vip作爲一個獨立的子域切分出來,使用指針進行關聯。
  • 第三層存儲vip.example.net的相關信息,包含對應的三個子域名

在這裏插入圖片描述

這裏可以看到,假如一個節點只有一個特定的標籤的時候,多個標籤可以合併在一起,減少存儲空間和提升查詢索引路徑。比如這裏的example.net, 這裏使用紅黑樹的另外一個原因是,有序性。當我們對於一個區域的域名進行排序的時候(比如NSEC中使用), 可以直接進行二叉樹遍歷操作。

值得注意的是,我們這裏儘管具有example.net但是由於本身並沒有存儲任何數據,所以實際認爲該域名不存在,當我們插入example.net的數據的時候,可以成功的插入節點數據到該節點。

假如我們插入一個新的節點net,則插入後的節點將會存儲在第一層,example.net作爲其一個子域只保留example的標籤,由於不存在其他任何的同級別的net子域名,example作爲第二層存儲,並通過指針與上面和下面建立關聯關係。實際存儲的結果如下面:

在這裏插入圖片描述

對於刪除節點,也需要考慮其中的重組關係,比如對於最後的三個域名如果刪除掉兩個(admin, login),這時候導致vip域名僅僅包含一個子域名,因此它在存儲的時候會按照聚合規則上移到第三層中,也就是變成了[ftp, www, bbs.vip]三個節點。

搜索域名的時候,一般會返回三個結果,(1)找到對應的Key,(2) 找到了父級域名,(3)找不到任何與之關聯的數據。對於第二種結果一般是查詢了一個子域名不存在的時候返回,比如查詢abc.exmaple.net,元素不存在,此時返回example.net的信息

對於實際的存儲DB設計可以參考如下的結構定義, 相對於傳統的紅黑樹,增加一個down指針,以及一些額外的控制屬性,數據存儲的Data指針指向DNS數據集合 用於存儲多種類型的RRSet數據,比如A類型,NS類型等等,這裏設計細節不再給出。

type Node struct{
  Key string
    
	Left *Node
	Right *Node
	Parent *Node
    
	Down *RBTree
  Data *RRData 
	Color bool
}

當然數據結構設計與實際可用仍舊存在一定的差距,比如如何實現併發的讀寫,假如DNS數據進行更新處理,那如何保證數據的併發一致性。當前多核心處理的情況下, 使用讀寫鎖可以保證併發的讀,以及串行的序列化的寫入數據。但是這個方式最大的問題是,當寫入的時候,讀取會被鎖死。考慮到執行一些IXFR操作的時候,可能會有很長的寫操作窗口,導致讀取不了因此是不可接受的。

爲了解決這個問題,一種方式是控制數據的版本,一個數據可以可以具有多個讀版本,Bind軟件在實現上就是採用多版本控制來完成對於數據的併發讀寫訪問情況。

5. 紅黑樹的其他應用

Linux的完全公平調度器(英語:Completely Fair Scheduler,縮寫爲CFS) ,使用紅黑樹來完成進程的調度執行,之前使用的是一個類似於優先隊列的數據結構完成調度(O(1)調度器)。這種調度器執行方式每次選擇最左邊的節點來執行任務,任務執行完成則刪除節點,如果任務執行超出一定時間,則停止任務,重新插入節點(基於其執行的時間)。整體的調度複雜度爲O(logN)。

C++標準庫STL中的Set和Map以及Java程序中的HashMap實現均爲紅黑樹,作爲底層數據結構實現,其實很多人都在不知不覺的使用紅黑樹去存儲數據。

大家如果對於算法感興趣,可以關注我的微信公衆號: 銀河系算法指南,我會定期發佈一些算法相關的技術文章,感謝大家的閱讀。

在這裏插入圖片描述

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