1. 程式人生 > >簡單聊聊紅黑樹(Red Black Tree)

簡單聊聊紅黑樹(Red Black Tree)

​​​

 

前言

眾所周知,紅黑樹是非常經典,也很非常重要的資料結構,自從1972年被髮明以來,因為其穩定高效的特性,40多年的時間裡,紅黑樹一直應用在許多系統元件和基礎類庫中,默默無聞的為我們提供服務,身邊有很多同學經常問紅黑樹是怎麼實現的,所以在這裡想寫一篇文章簡單和大家聊聊下紅黑樹

 

小編看過很多講紅黑樹的文章,都不是很容易懂,主要也是因為完整的紅黑樹很複雜,想通過一篇文章來說清楚實在很難,所以在這篇文章中我想盡量用通俗口語化的語言,再結合 Robert Sedgewick 在《演算法》中的改進的版本(2-3樹版本,容易理解也方便實現),可以保證讓大家對紅黑樹的原理有大概的理解

 

其實對於大部分同學來說,大概瞭解紅黑樹的工作原理就基本夠用了,因為通常不會有面試官讓你去手寫紅黑樹,你也幾乎不需要去自己實現一個紅黑樹,看完這裡,如果感覺意猶未盡,還有興趣的同學可以去看看《演算法導論》的紅黑樹實現,那是完整的4階B樹(2-3-4樹)版本的實現

 

關於紅黑樹的主題,我們的文章有以下的靈魂三問:

  • 為什麼會有紅黑樹?

  • 紅黑樹的應用場景和定義?

  • 紅黑樹的高效和穩定是怎麼實現?

 

為什麼會有紅黑樹

要了解紅黑樹,先它的前輩:二叉樹,平衡二叉樹(我們的讀者應該都具備這些前置知識,所以我們只做大概的講解)

前置知識:

二叉樹:傳統的陣列和連結串列等線性結構表效率低下,線性表在處理大規模資料的時間複雜度都是線性級別 O(n),所以這種低效的資料結構,幾乎不可能用來處理千萬級別或者以上的資料量,於是基於二分思想的二叉樹就誕生了,在最好情況下,二叉樹查詢的時間複雜度可以達到恐怖的對數級別 O(logN),什麼概念呢?就是在十億級別的資料量裡面,二叉樹只需要15~30次的訪問就可以找到目標,當然我們的前提是最好情況,那麼最壞情況呢?可以參考下圖

 

二叉樹的最好/最壞情況:

​​

 

上圖可以看到,二叉樹的效能的好壞,依賴資料的插入順序,最壞情況下二叉樹會退化為連結串列,所有操作的時間複雜度回到的線性級別 O(n),那麼怎麼解決這個問題呢?

 

想要讓樹的查詢效率最大化,那麼就要保持樹的平衡,所以平衡二叉樹出現了,平衡二叉樹的思想是在操作的時候對樹進行平衡調整,來防止二叉樹退化為連結串列,從而保證二叉樹的最優查詢效能,完美的平衡二叉樹對高度的定義是相差不會大於1,這就相當於每次都插入/刪除操作,都會對樹進行平衡操作,這是代價非常高的操作,你可以理解為,類似陣列為了保證有序性,陣列中間插入資料,所有元素都要向後移動的代價,雖然名字叫 平衡二叉樹,其實它的效能非常不平衡,因為它是最大化 插入/刪除 操作的時間來換取 查詢 操作的時間最小化

 

看到這裡,就有好奇的同學問,那麼有沒有既可以保證樹的完美平衡,又可以保證所有操作效能的資料結構呢?可以很負責任的告訴你,有的,就是紅黑樹,我們先看看紅黑樹能為我們帶來什麼?

  • 紅黑樹可以保證 所有操作時間複雜度都是對數級別 O(logN)

  • 和二叉樹不同,無論插入順序如何,紅黑樹都是接近完美平衡的

  • 無數實驗的應用證明,紅黑樹的操作成本比二叉樹降低40%左右

 

常見樹形結構的操作複雜度對比,可以看到紅黑樹是最均衡的:

 

紅黑樹的應用場景和定義

 

簡單羅列下我們常用的哪些工具是通過紅黑樹實現的

  • Java 的 HashMap (8 以後)的連結串列樹化是通過 紅黑樹實現

  • Java 的 TreeMap 是通過紅黑樹實現

  • Nginx 用紅黑樹管理 timer 等

  • Linux 程序排程用紅黑樹管理程序控制塊

  • 等等……

     

紅黑樹的定義,標準的紅黑樹示意圖:

 

紅黑樹本身是二叉樹,其背後的思想是使用二叉樹的結構再載入額外的顏色資訊,來表示2-3樹,所以紅黑樹是包含了二叉樹的高效查詢和2-3樹的高效插入平衡優點的演算法

 

在我們討論的版本中對紅黑樹的定義如下:

  • 紅連結必須為左連結

  • 不能出現兩條相連的紅連結

  • 該樹是完美黑色平衡的

 

只看這些定義你可能會覺得描述非常的學院派,不好理解,我們先看看標準的紅黑樹,後面再用畫圖的方式來逐漸講解

 

紅黑樹插入維護規則的核心程式碼

    private Node put(Node h, Key key, Value val) {
        // 二分插入
        if(h == null) return new Node(key, val, RED, 1);
        int cmp = key.compareTo(h.key);
        if(cmp < 0) h.left = put(h.left, key, val);
        else if(cmp > 0) h.right = put(h.right, key, val);
        else h.val = val;
 
        // 修復 右傾連線
        if(isRed(h.right) && !isRed(h.left)) h = rotateLeft(h);         // 違反規則 不允許出現右紅連線
        if(isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);     // 違反規則 不允許出現連續的左紅連線
        if(isRed(h.left) && isRed(h.right)) flipColors(h);              // 當左右子節點為紅色, 則變色
        h.size = size(h.left) + size(h.right) + 1;
        return h;
    }

 

 

紅黑樹的高效和穩定是怎麼實現?

 

在插入資料的過程中紅黑樹會出現很多違反上面定義的情況,如果出現違反紅黑樹定義的情況,那麼就依靠紅黑樹的三個核心操作來保證樹的平衡,這三個操作也對應了紅黑樹定義的三條規則,分別如下:

  • 左旋轉(當出現右紅子節點時,進行左旋轉)

  • 右旋轉(當出現兩條相連的左子紅連結時,進行右旋轉)

  • 變色(當左右節點都是紅連結時,進行變色)

 

左旋轉

將紅色的右節點,調整到樹的左邊,假如我要在樹的底部插入元素S,但是元素被分配到的元素E的右邊,具體如下:

​​

左旋轉是針對明顯的紅右連結,紅色的右連結違反了紅黑樹定義的第一條規則,所以我們需要將它進行左旋轉操作,被操作了左旋轉後,元素E的位置會被元素S取代,E元素成為了S的左子節點,符合了二叉樹的定義,左旋轉的具體程式碼:

private Node rotateLeft(Node h) {
        Node x = h.right;
        h.right = x.left;
        x.left = h;
        x.color = x.left.color;
        x.left.color = RED;
        x.size = h.size;
        h.size = size(h.left) + size(h.right) + 1;
        return x;
    }

 

右旋轉

當左邊出現連續的左紅連結時,把左連結放到右邊

​​

 

右旋轉的程式碼(右旋轉的程式碼和左旋轉幾乎相同把 x.left 換成 x.right 即可)

private Node rotateRight(Node h) {
        Node x = h.left;
        h.left = x.right;
        x.right = h;
        x.color = x.right.color;
        x.right.color = RED;
        x.size = h.size;
        h.size = size(h.left) + size(h.right) + 1;
        return x;
    }

變色

當左右子節點都是紅色的時候,把顏色進行轉換,具體如圖:

​​

顏色轉換的程式碼也非常簡單:

private void flipColors(Node h) {
        h.color = !h.color;
        h.left.color = !h.left.color;
        h.right.color = !h.right.color;
}

理解了以上三種操作的原理,基本也就理解了紅黑樹的原理,有了這三種操作的基本知識,最後我們開始結合案例來分析紅黑樹插入平衡的全過程

 

為了便於理解,我們看一個簡單的例子,下面羅列的三種情況:

  • 插入最大鍵

  • 插入最小鍵

  • 插入中間鍵

我們可以發現,無論插入的資料如何不同,通過旋轉,變色操作後最終得到的結果都是相同的,樹永遠保持平衡,具體可以看下方的示意圖:

有了上面的理解,我們可以分析一組有序資料插入的過程,再結合文字逐步分析紅黑樹是怎麼把它構造為一顆接近完美平衡的樹

解析:

  1. A首先成為根節點

  2. C首先插入在A的右邊,A違反了不能出現紅右子節點的規則,進行左旋轉,A成了C的左紅子節點

  3. E首先插入在C的右邊,C違反左右子節點均為紅色的規則,進行變色,C,A,E變黑(根節點永遠為黑)

  4. H首先插入在E的右邊,E違反了不能出現紅右子節點的規則,進行左旋轉,E成了H的左紅子節點

  5. L首先插入在H的右邊,H違反左右子節點均為紅色的規則,進行變色,E,L變黑,H變紅,導致C違反了不能出現紅右子節點的規則,進行左旋轉,C成為H的左紅子節點(這裡違反2個規則)

  6. M首先插入在L的右邊,L違反了不能出現紅右子節點的規則,進行左旋轉,L成為M的左紅子節點

  7. P首先插入在M的右邊,M違反左右子節點均為紅色的規則,進行變色,L,P變黑,M變紅,導致H違反左右子節點均為紅色的規則,進行變色,H,C,M變黑(這裡違反2個規則)

  8. R首先插入到P的右邊,P違反了不能出現紅右子節點的規則,進行左旋轉,P成為R的左紅子節點

  9. S首先插入到R的右邊,R違反左右子節點均為紅色的規則,進行變色,S,P變黑,R變紅,導致M違反了不能出現紅右子節點的規則,進行左旋轉,M成為R的左紅子節點(這裡違反2個規則)

  10. X首先插入到S的右邊,S違反了不能出現紅右子節點的規則,進行左旋轉,S成為X的左紅子節點

通過以上證明,就可以得出結論,和二叉樹不同,無論資料的插入順序如何,紅黑樹都可以保證完美平衡

 

理解紅黑樹的背後思想,就能明白只要謹慎的使用簡單的,左旋,右旋,變色這三個操作,就可以保證紅黑樹的兩種重要的特性 有序性和完美平衡性,因為旋轉和變色都是區域性操作,所以無需為整棵樹的平衡性擔心,另外紅黑樹的查詢完全和二叉樹相同,不需要額外的平衡,這裡並不打算講紅黑樹的刪除操作,因為紅黑樹的刪除實現複雜,比插入平衡還要複雜的多,要在文章裡講清楚很困難,推薦大家去看看我開篇推薦的經典書籍

 

 

總結

到這裡對於為什麼要使用紅黑樹的結論已經非常簡單了,紅黑樹最吸引人的是它的所有操作在 最好 最壞 情況下都可以保證對數級別的時間複雜度 O(logN),是什麼概念呢,可以簡單說明對比下:

 

例如要在十億級別的資料量找到一條資料,十億的對數是30,線性表要找到資料需要訪問十億次,而使用紅黑樹的書只需要訪問30次元素就能找到,10億次/30次,差不多是3千萬倍的效能提升,在現代上千億資料的資訊海洋裡,只要通過幾十次的比較就能隨意的插入和查詢資料,這是多麼了不起的成就呀

 

而且對於二叉樹,無數的實驗和應用都能證明,紅黑樹的操作成本比二叉樹要低 40% 左右(包含旋轉和變色),紅黑樹自從被發現這40年來,一直高效穩定的通過各種應用的考驗,包含需要系統基礎元件和類庫都是用紅黑樹,所以非常值得我們去學習和掌握它,最後留給大家一個問題,紅黑樹和散列表有什麼區別,散列表查詢的時間複雜度是常數級別 O(1),那為什麼很多場景我們不用散列表而用紅黑樹呢?歡迎留言拍磚

 

參考資料

https://algs4.cs.princeton.edu/33balanced/

https://algs4.cs.princeton.edu/33balanced/RedBlackBST.java.html

https://zh.wikipedia.org/wiki/%E7%BA%A2%E9%BB%91%E6%A0%91

https://book.douban.com/subject/10432347/