圖解紅/黑樹狀結構及Java進行紅黑二叉樹遍曆的方法_java

來源:互聯網
上載者:User

紅/黑樹狀結構
紅/黑樹狀結構是一種資料結構與演算法課堂上常常提到但又不會細講的樹,也是技術面試中經常被問到的樹,然而無論是書上還是網上的資料,通常都比較刻板難以理解,能不能一種比較直觀的方式來理解紅/黑樹狀結構呢?本文將以圖形的方式來解釋紅/黑樹狀結構的插入與刪除操作。
對樹結構的學習是一個遞進的過程,我們通常所接觸的樹都是二叉樹,二叉樹簡單來說就是每個非葉子節點都有且只有兩個孩子,分別叫做左孩子和右孩子。二叉樹中有一類特殊的樹叫二叉尋找樹,二叉尋找樹是一種有序的樹,對於每個非葉子節點,其左子樹的值都小於它,其右子樹的值都大於它。比二叉尋找樹更進一步的是二叉平衡樹,二叉平衡樹除了保證有序外,還能夠保持每個節點左右子樹的高度相差不超過1。常見的平衡樹有AVL樹,Treap,紅/黑樹狀結構,伸展樹,等等。
紅/黑樹狀結構是一種二叉尋找樹,但在每個節點上增加一個儲存位表示節點的顏色,可以是RED或BLACK。通過對任何一條從根到葉子的路徑上各個節點著色方式的限制,紅/黑樹狀結構確保沒有一條路徑會比其他路徑長出兩倍,因而是接近平衡的。
紅/黑樹狀結構滿足一下5個性質:

  • 每個節點是紅色或者黑色;
  • 根節點是黑色;
  • 每個葉子節點NIL是黑色;
  • 如果一個節點是紅色,則它的兩個孩子都是黑色;(每條路徑上不能有兩個連續的紅色節點)
  • 任一節點到其所有子孫葉子節點NIL的路徑上包含相同數目的黑色節點。

注意,在紅/黑樹狀結構中,把傳統二叉樹的葉子節點的孩子指向NIL,稱NIL為紅/黑樹狀結構中的葉子節點。NIL節點中含有指向父節點的指標,這可能是需要把null改為NIL的原因。

一、插入操作
首先以二叉尋找樹的插入方式(插入的新節點都在葉子節點處)插入新的節點,並將其繪為紅色。然後再重繪其顏色或旋轉以保持紅/黑樹狀結構的性質,調整分為以下三種情況:
1 新節點N沒有父節點(即位於根上)
將新節點N繪為黑色。

2 新節點N的父節點P為黑色
不用調整。

3 新節點N的父節點P為紅色
因為紅/黑樹狀結構不允許有兩個連續的紅色節點(性質4),所以需要調整,根據N的叔父節點顏色分為兩種情況:(我們以 N的父節點P為左孩子為例,P為右孩子的情況類似,不再詳述)
3.1 新節點N的叔父節點U為紅色
將新節點N的父節點P和叔父節點U都繪為黑色,將其祖父節點G繪為紅色,這樣保證從G到每個null節點的路徑上所包含的黑色節點個數與原來保持一致。但由於我們把G變成了紅色,如果G的父親也是紅色,就可能導致連續兩個紅色節點(違反性質4),所以,需要重新檢查G是否違反了紅/黑樹狀結構性質。

3.2 新節點N的叔父節點U為黑色
若新節點N是其父節點P的左孩子:將其父節點P繪為黑色,祖父節點G繪為紅色,然後對G進行一次右旋轉。

若新節點N是其父節點P的右孩子:對其父節點進行一次左旋轉,問題轉化為左孩子的情況。

二、刪除操作
《演算法導論》和維基百科上的做法都是,當刪除一個黑色節點D時,把D的黑色“下推”至其子節點C,也就是說C除了本身的顏色外多了一重額外的黑色,然後不斷把這重額外的黑色沿樹上移,直到碰到一個紅色節點,把其變為黑色以保證路徑上黑色節點數目不變,或者移到樹的根部,這樣所有路徑上的黑色節點數目都減一,保持相等。上移過程中可能需要旋轉和修改一些節點的顏色,以保證路徑上黑色節點數目不變。
這種做法可能有利於代碼的實現(可用迭代的方式),但卻不便於理解(個人認為)。本著理解優先的目的,我根據被刪除節點D的孩子是否為NIL做如下分類:
1 被刪除節點D的兩個孩子都是NIL
1.1 被刪除節點D是紅色
用NIL替換D即可。

1.2 被刪除節點D是黑色(我們以D是左孩子為例)
1.2.1 被刪除節點D的兄弟節點B的兩個孩子都為NIL
將D的兄弟節點B繪為紅色,父節點P繪為黑色。

圖中半紅半黑表示該節點可能為紅色,也可能為黑色。如果P原來是紅色,這樣修改後路徑上的黑色節點數目和刪除D之前一樣;如果P原來是黑色,那麼刪除D後會導致路徑上黑色節點的數目比刪除前少了一個,所以還需繼續檢查經過P的路徑上黑色節點數目的改變是否影響了紅/黑樹狀結構的性質。
1.2.2 被刪除節點D的兄弟節點B有一個孩子不為NIL
這個孩子一定是紅色的,否則從D的父節點到各個葉子節點的路徑上黑色節點的數目就會不等(違反性質5)。
若這個孩子為右孩子,將B的這個右孩子繪為黑色,B繪為其父節點P原來的顏色,P繪為黑色,然後對P進行一次左旋轉。

若這個孩子為左孩子,將B的這個左孩子繪為黑色,B繪為紅色,然後對B進行一次右旋轉,問題轉化為右孩子的情況。

1.2.3 被刪除節點D的兄弟節點B的兩個孩子都不為NIL
若B為紅色,則B的兩個孩子一定為黑色。將B繪為黑色,B的左孩子繪為紅色,然後對P進行一次左旋轉。

若B為黑色,則B的兩個孩子一定為紅色。將B的父節點P繪為黑色,B的右孩子繪為黑色,B繪為其父節點P原來的顏色,然後對P進行一次左旋轉。

2 被刪除節點D的兩個孩子都不是NIL
按照二叉尋找樹刪除節點的方法找到D的後繼節點S,交換D和S的內容(顏色保持不變),被刪除節點變為S,如果S有不為NIL的節點,那麼繼續用S的後繼節點替換S,直到被刪除節點的兩個孩子都為NIL,問題轉化為被刪除節點D的兩個孩子都為NIL的情況。
3 被刪除節點D有一個孩子不是NIL
這個孩子C一定是紅色節點,否則從D到各個NIL節點的路徑上的黑色節點數目就會不同(違反性質5)。
交換D和C的內容(顏色保持不變),被刪除節點變為C,問題轉化為被刪除節點D的兩個孩子都為NIL的情況。

二叉樹的遍曆
二叉樹的遍曆有三種:前序走訪、中序遍曆和後序遍曆。每種遍曆的實現又有遞迴和迭代兩種,這篇文章我們來討論如何用比較優雅的代碼來實現二叉樹的遍曆。
首先我來定義一個二叉樹的節點:

public class TreeNode {  int val;  TreeNode left;  TreeNode right;    public TreeNode(int x) {    val = x;  }}

 
一、前序走訪(Preorder Traversal)
簡單來講,前序走訪就是先訪問父節點,再訪問左孩子,最後訪問右孩子,即以父、左、右的順序來遍曆。
遞迴實現非常簡單,代碼如下:

public class Solution {  List<Integer> result = new ArrayList<Integer>();    public List<Integer> preorderTraversal(TreeNode root) {    dfs(root);    return result;  }    private void dfs(TreeNode root) {    if (root == null) {      return;    }     result.add(root.val);    dfs(root.left);    dfs(root.right);  }}

迭代實現需要藉助一個棧,儲存沒被訪問的右節點,代碼如下:

public class Solution {   public List<Integer> preorderTraversal(TreeNode root) {    List<Integer> result = new ArrayList<Integer>();        if (root == null) {      return result;    }        Stack<TreeNode> stack = new Stack<TreeNode>();    stack.push(root);        while (!stack.isEmpty()) {      TreeNode curr = stack.pop();      result.add(curr.val);            if (curr.right != null) {        stack.push(curr.right);      }      if (curr.left != null) {        stack.push(curr.left);      }    }        return result;  }}

 
二、中序遍曆(Inorder Traversal)
簡單來講,中序遍曆就是先訪問左孩子,再訪問父節點,最後訪問右孩子,即以左、父、右的順序遍曆。
遞迴代碼也比較容易,如下所示:

public class Solution {   public List<Integer> inorderTraversal(TreeNode root) {    List<Integer> result = new ArrayList<Integer>();    recurse(root, result);    return result;  }    private void recurse(TreeNode root, List<Integer> result) {    if (root == null) return;    recurse(root.left, result);    result.add(root.val);    recurse(root.right, result);  }}

上面這種寫法有別於前序走訪的遞迴代碼,前序走訪的遞迴我們使用了成員變數來儲存遍曆的結果,這裡我們使用方法參數來儲存遍曆的結果。兩種寫法都可以,喜歡哪種就使用哪種。
中序遍曆的迭代實現沒有前序走訪那麼簡單,雖然也需要藉助一個棧,但迭代終止的條件卻有所不同。想象一下,對於一棵二叉樹,我們最先訪問的節點是其最左邊的節點,我們當然可以通過一個 while 迴圈到達其最左邊,可是當我們回退時,我們如何知道某個節點的左孩子是否已經訪問過了?我們使用一個 curr 變數記錄當前訪問的節點,當我們把一棵子樹的右節點都訪問完畢時,我們就該回退該子樹的父節點了,而此時 curr 為 null,所以我們可以以此來區分一個節點的左子樹是否已被訪問過。代碼如下:

public class Solution {    public List<Integer> inorderTraversal(TreeNode root) {    List<Integer> result = new ArrayList<Integer>();        Stack<TreeNode> stack = new Stack<TreeNode>();    TreeNode curr = root;        while (curr != null || !stack.isEmpty()) {      while (curr != null) {        stack.push(curr);        curr = curr.left;      }      curr = stack.pop();      result.add(curr.val);            curr = curr.right;    }        return result;  }}

 
三、後序遍曆(Postorder Traversal)
簡單來講,後序遍曆就是先訪問左孩子,在訪問右孩子,最後訪問父節點, 即以左、右、父的順序遍曆。
仿照中序遍曆,可以很容易地寫出後序遍曆的遞迴實現:

public class Solution {   public List<Integer> postorderTraversal(TreeNode root) {    List<Integer> result = new ArrayList<Integer>();    recurse(root, result);    return result;  }    private void recurse(TreeNode root, List<Integer> result) {    if (root == null) return;    recurse(root.left, result);    recurse(root.right, result);    result.add(root.val);  }}

後序遍曆的迭代,也需要一個標識要區分一個節點的左右孩子是否已經訪問過了,如果沒有,則依次訪問其左右孩子,如果訪問過了,則訪問該節點。為此,我們用一個 pre 變數來表示上一個訪問的節點,如果上一個訪問的節點是當前節點的左孩子或右孩子,那麼說明當前節點的左右孩子已經訪問過了,那麼就可以訪問該節點了,否則,則需要進入左右孩子依次訪問。代碼如下:

public class Solution {   public List<Integer> postorderTraversal(TreeNode root) {    List<Integer> result = new LinkedList<Integer>();        Stack<TreeNode> stack = new Stack<TreeNode>();    if (root != null) stack.push(root);        TreeNode pre = root;        while (!stack.isEmpty()) {      TreeNode curr = stack.peek();      if (curr.left == pre || curr.right == pre || (curr.left == null && curr.right == null)) {        result.add(curr.val);        stack.pop();        pre = curr;      } else {        if (curr.right != null) stack.push(curr.right);        if (curr.left != null) stack.push(curr.left);      }    }        return result;  }}

後序遍曆的迭代還有另外一種比較簡單的實現,我們知道先序遍曆的順序是父、左、右,而後序遍曆的順序是左、右、父,那麼如果我們把先序遍曆稍作修改,改成父、右、左的順序,那麼就剛好與後序遍曆的順序相反了,以如此順序訪問完,最後我們對訪問結果做個反轉就可以了。而先序遍曆的迭代實現相對來說比較容易,仿照上面寫法我們可以如下實現:

public class Solution {   public List<Integer> postorderTraversal(TreeNode root) {    List<Integer> result = new LinkedList<Integer>();        Stack<TreeNode> stack = new Stack<TreeNode>();    if (root != null) stack.push(root);        while (!stack.isEmpty()) {      TreeNode curr = stack.pop();      result.add(curr.val);      if (curr.left != null) stack.push(curr.left);      if (curr.right != null) stack.push(curr.right);    }     Collections.reverse(result);        return result;  }}

 
四、總結
三種遍曆的遞迴實現都很容易。前序走訪的迭代實現最好寫,只需要一個棧就好;中序遍曆最難,迴圈條件除了判斷棧是否為空白,還要判斷當前節點是否為空白,以表示是否左子樹已經遍曆完畢;後續遍曆的迭代如果轉化為前序走訪的迭代,就容易很多,否則,也需要記錄上一個訪問的節點,以表示當前節點的左右子樹是否已經訪問完畢。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.