資料結構與演算法(九):AVL樹詳細講解
資料結構與演算法(二):基於陣列的實現ArrayList原始碼徹底分析
資料結構與演算法(三):基於連結串列的實現LinkedList原始碼徹底分析
資料結構與演算法(四):基於雜湊表實現HashMap核心原始碼徹底分析
資料結構與演算法(五):LinkedHashMap核心原始碼徹底分析
本文目錄
一、二叉排序樹效能問題
在上一篇中我們提到過二叉排序樹構造可能出現的效能問題,比如我們將資料:2,4,6,8構造一顆二叉排序樹,構造出來如下:
這肯定不是我們所希望構造出來的,因為這樣一棵樹查詢的時候效率是及其低下的,說白了就相當於陣列一樣挨個遍歷比較。
那我們該怎麼解決這個問題呢?這時候就需要我們學習一下二叉平衡樹的概念了,本系列設計的二叉平衡樹主要包含AVL樹以及紅黑樹,本篇主要講解AVL樹。
下面我們瞭解一下AVL書。
二、AVL樹定義以及相關概念
AVL樹定義
一棵AVL樹是其每個結點的平衡因子絕對值最多相差1的二叉查詢樹。
平衡因子?這是什麼鳥,別急,繼續向下看。
平衡因子
平衡因子就是二叉排序樹中每個結點的左子樹和右子樹的高度差。
這裡需要注意有的部落格或者書籍會將平衡因子定義為右子樹與左子樹的高度差,本篇我們定義為左子樹與右子樹的高度差,不要搞混。
比如下圖中:
根結點45的平衡因子為-1 (左子樹高度2,右子樹高度3)
50結點的平衡因子為-2 (左子樹高度0,右子樹高度2)
40結點的平衡因子為0 (左子樹高度1,右子樹高度1)
根據定義這顆二叉排序樹中有結點的平衡因子超過1,所以不是一顆AVL樹。
所以AVL樹可以表述如下:一棵AVL樹是其每個結點的左右子樹高度差絕對值最多相差1的二叉查詢樹。
最小不平衡二叉排序樹
最小不平衡二叉樹定義為:距離插入結點最近,且平衡因子絕對值大於2的結點為根結點的子樹,稱為最小不平衡二叉排序樹。
比如下圖:
在插入80結點之前是一顆標準的AVL樹,在插入80結點之後就不是了,我們查詢一下最小不平衡二叉排序樹,從距離80結點最近的結點開始,67結點平衡因子為-1,50結點平衡因子為-2,到這裡就找到了,所以以50為根結點的子樹就是最小不平衡二叉排序樹。
明白了以上概念後我們就需要再瞭解一下左旋與右旋的概念了,這裡左旋右旋對於剛接觸的同學來說有點難度,但是對於理解AVL樹,紅黑樹是必須掌握的概念,十分重要,不要怕,跟著我的思路我就不信講不明白。
三、左旋與右旋的概念
左旋與右旋就是為了解決不平衡問題而產生的,我們構建一顆AVL樹的過程會出現結點平衡因子絕對值大於1的情況,這時就可以通過左旋或者右旋操作來達到平衡的目的。
接下來我們瞭解一下左旋右旋的具體操作。
左旋操作
上圖就是一個標準的X結點的左旋流程。
在第一步圖示僅僅將X結點進行左旋,成為Y結點的一個子節點。
但是此時出現一個問題,就是Y結點有了三個子節點,這連最基礎的二叉樹都不是了,所以需要進行第二部操作。
在第二部操作的時候,我們將B結點設定為X結點的右孩子,這裡可以仔細想一下,B結點一開始為X結點的右子樹Y的左孩子,那麼其肯定比X結點大,比Y結點小,所以這裡設定為X結點的右孩子是沒有問題的。
上圖中Y結點有左子樹B,如果沒有左子樹B,那麼第二部也就不需要操作了,這裡很容易理解,都沒有還操作什麼鬼。
到這裡一個標準的左旋流程就完成了。
左旋操作具體應用
在構建AVL樹的過程中我們到底怎麼使用左旋操作呢?這裡我們先舉一個例子,如下圖:
在上圖中我們插入結點5的時候就出現不平衡了,3結點的平衡因子為-2,這時候我們可以將結點3進行左旋,如右圖,這樣就重新達到平衡狀態了。
左旋操作程式碼實現
1 /** 2 * 左旋操作 3 * @param t 4 */ 5 private void left_rotate(AVL<E>.Node<E> t) { 6 if (t != null) { 7 Node tr = t.right; 8 //將t結點的右孩子的左結點設定為t結點的右孩子 9 t.right = tr.left; 10 if (tr.left != null) { 11 //重置其父節點 12 tr.left.parent = t; 13 } 14 //t結點旋轉下來,其右孩子相當於替換t結點的位置 15 //所以這裡同樣需要調整其右孩子的父節點為t結點的父節點 16 tr.parent = t.parent; 17 //整棵樹只有根結點沒有父節點,這裡檢測我們旋轉的是否為根結點 18 //如果是則需要重置root結點 19 if (t.parent == null) { 20 root = tr; 21 } else { 22 //如果t結點位於其父節點的左子樹,則旋轉上去的右結點則 23 //位於父節點的左子樹,反之一樣 24 if (t.parent.left == t) { 25 t.parent.left = tr; 26 } else if (t.parent.right == t) { 27 t.parent.right = tr; 28 } 29 } 30 //將t結點設定為其右子樹的左結點 31 tr.left = t; 32 //重置t結點的父節點 33 t.parent = tr; 34 } 35 }
程式碼基本上都加上了備註,對比左旋流程仔細分析一下,這裡需要注意一下,旋轉完後結點的父節點都需要重置。
好了,對於左旋操作,相信你已經有一定了解了,如果還有不明白的地方可以自己仔細想一下,實在想不明白可以關注我公眾號聯絡本人單獨交流。
接下來我們看看右旋是怎麼回事。
右旋操作
上圖就是對Y結點進行右旋操作的流程,有了左旋操作的基礎這裡應該很好理解了。
第一步同樣僅僅將Y結點右旋,成為X的一個結點,同樣這裡會出現問題X有了三個結點。
第二步,如果一開始Y左子樹存在右結點,上圖中也就是B結點,則將其設定為Y的右孩子。
到這裡一個標準的右旋流程就完成了。
右旋操作具體應用
我們看一個右旋的例子,如圖:
在我們插入結點1的時候就會出現不平衡現象,結點5的平衡因子變為2,這裡我們將結點5進行右旋,變為右圖就又變為一顆AVL樹了。
右旋操作程式碼實現
1 /** 2 * 右旋操作 3 * @param t 4 */ 5 private void right_rotate(AVL<E>.Node<E> t) { 6 if (t != null) { 7 Node<E> tl = t.left; 8 t.left =tl.right; 9 if (tl.right != null) { 10 tl.right.parent = t; 11 } 12 13 tl.parent = t.parent; 14 if (t.parent == null) { 15 root = tl; 16 } else { 17 if (t.parent.left == t) { 18 t.parent.left = tl; 19 } else if (t.parent.right == t) { 20 t.parent.right = tl; 21 } 22 } 23 tl.right = t; 24 t.parent = tl; 25 } 26 }
對於右旋操作程式碼實現,沒有加任何註釋,希望你自己沉下心來逐行分析一下,有了左旋程式碼基礎,這裡並不難。
好了,以上就是左旋與右旋的操作,這部分一定要搞明白,AVL樹與紅黑樹的構建過程出現不平衡情況主要通過左旋與右旋來使其重新達到平衡狀態。
四、分治思想,左平衡操作與右平衡操作
上面我們瞭解了左旋與右旋的概念,也通過具體案例明白到底怎麼通過左旋或者右旋來使二叉排序樹重新達到AVL樹的要求,但是這裡要明白有些情況並不是僅僅靠一次左旋或者右旋就能實現平衡的目的,這是就需要左旋右旋一起使用來使其達到平衡的目的。
那麼到底怎麼區分是使用左旋或者右旋或者左旋右旋一起使用才能使樹重新達到平衡呢?
這裡我們就需要仔細分情況來處理了,我們在構建AVL樹插入某一個元素候如果出現不平衡現象肯定是左子樹或者右子樹出現了不平衡現象,這裡有點繞,不過也很好理解,某一結點平衡因子絕對值超過1了,肯定是左子樹過高或者右子樹過高產生的,這裡,我們採用分治的思想來解決,分治思想是演算法思想的一種,就是把一個複雜的問題分成兩個或更多的相同或相似的子問題,直到最後子問題可以簡單的直接求解,原問題的解即子問題的解的合併。
這裡我們怎麼使用分治的思想呢?首先出現不平衡只有兩種可能,某一結左子樹或者右子樹過高導致的,我們可以先考慮左子樹過高該怎麼處理,然後考慮右子樹過高怎麼處理,當然這裡只是粗略的分為兩大解決問題的方向,往下還會繼續分析不同情況,接下來我們將會仔細分析。
左平衡操作
左平衡操作,即結點t的不平衡是因為左子樹過深造成的,這時我們需要對t左子樹分情況進行解決。
左平衡操作情況分類
1、如果新的結點插入後t的左孩子的平衡因子為1,也就是插入到t左孩子的左側,則直接對結點t進行右旋操作即可
2、如果新的結點插入後t的左孩子的平衡因子為-1,也就是插入到t左孩子的右側,則需要進行分情況討論
-
情況a:當t的左孩子的右子樹根節點的平衡因子為-1,這時需要進行兩步操作,先以tl進行左旋,在以t進行右旋。
經過上述過程,最終又達到了平衡狀態。
-
情況b:當p的左孩子的右子樹根節點的平衡因子為1,這時需要進行兩步操作,先以tl進行左旋,在以t進行右旋。
-
情況c:當p的左孩子的右子樹根節點的平衡因子為0,這時需要進行兩步操作,先以tl進行左旋,在以t進行右旋。
到這裡細心的同學肯定有一個疑問,情況a,b,c不都是先以tl左旋,再以t右旋嗎?為什麼還要拆分出來?
首先觀察a,b,c三種情況,旋轉之前是葉子結點的,在兩次旋轉之後依然是葉子結點,也就是說其平衡因子旋轉前後無變化,均是0。
但是再觀察一下t,tl,tlr這三個節點旋轉前後的平衡因子,不同情況下前後是不一樣的,所以這裡需要區分一下,具體旋轉後t,tl,tlr的平衡因子如下:
情況a:
t.balance = 0;
tlr.balance = 0;
tl.balance = 1;
情況b:
t.balance = -1;
tl.balance =0;
tlr.balance = 0;
情況c:
t.balance = 0;
tl.balance = 0;
tlr.balance = 0;
以上就是左平衡操作的所有情況,接下來看下左平衡具體程式碼:
1 /** 2 * 左平衡操作 3 * @param t 4 */ 5 private void leftBalance(AVL<E>.Node<E> t) { 6 Node<E> tl = t.left; 7 switch (tl.balance) { 8 case LH: 9 right_rotate(t); 10 tl.balance = EH; 11 t.balance = EH; 12 break; 13 case RH: 14 Node<E> tlr = tl.right; 15 switch (tlr.balance) { 16 case RH: 17 t.balance = EH; 18 tlr.balance = EH; 19 tl.balance = LH; 20 break; 21 case LH: 22 t.balance = RH; 23 tl.balance =EH; 24 tlr.balance = EH; 25 break; 26 case EH: 27 t.balance = EH; 28 tl.balance = EH; 29 tlr.balance =EH; 30 break; 31 //統一旋轉 32 default: 33 break; 34 } 35 //統一先以tl左旋,在以t右旋 36 left_rotate(t.left); 37 right_rotate(t); 38 break; 39 default: 40 break; 41 } 42 }
好了,左平衡操作所有情況講解以及具體程式碼實現,主要就是分治思想,加以細分然後逐個情況逐個解決的套路。
右平衡操作
右平衡操作,即結點t的不平衡是因為右子樹過深造成的,這時我們需要對t右子樹分情況進行解決。
右平衡操作情況分類
1、如果新的結點插入後t的右孩子的平衡因子為1,也就是插入到t左孩子的右側,則直接對結點t進行左旋操作即可
2、如果新的結點插入後t的右孩子的平衡因子為-1,也就是插入到t右孩子的左側,則需要進行分情況討論
-
情況a:當t的右孩子的左子樹根節點的平衡因子為1,這時需要進行兩步操作,先以tr進行右旋,在以t進行左旋。
-
情況b:當p的右孩子的左子樹根節點的平衡因子為-1,這時需要進行兩步操作,先以tr進行右旋,在以t進行左旋。
-
情況c:當p的右孩子的左子樹根節點的平衡因子為0,這時需要進行兩步操作,先以tr進行右旋,在以t進行左旋。
同樣,a,b,c三種情況旋轉前後葉子結點依然是葉子結點,變化的
只是t,tr,trl結點的平衡因子,並且三種情況trl最後平衡因子均為0.
右平衡程式碼實現:
1 /** 2 * 右平衡操作 3 * @param t 4 */ 5 private void rightBalance(AVL<E>.Node<E> t) { 6 Node<E> tr = t.right; 7 switch (tr.balance) { 8 case RH: 9 left_rotate(t); 10 t.balance = EH; 11 tr.balance = EH; 12 break; 13 case LH: 14 Node<E> trl = tr.left; 15 switch (trl.balance) { 16 case LH: 17 t.balance = EH; 18 tr.balance = RH; 19 break; 20 case RH: 21 t.balance = LH; 22 tr.balance = EH; 23 break; 24 case EH: 25 t.balance = EH; 26 tr.balance = EH; 27 break; 28 29 } 30 trl.balance = EH; 31 right_rotate(t.right); 32 left_rotate(t); 33 break; 34 default: 35 break; 36 } 37 }
到此,左平衡與右平衡操作也就講解完了,主要思想是採用的分治思想,大問題化為小問題,然後逐個解決,到這裡,如果能全部理解,那麼AVL樹的最核心部分就完全理解了,對於紅黑樹來說上面也是很核心的部分。
五、AVL樹的建立過程
這部分我們主要了解下怎麼建立AVL樹,也就是新增元素方法的整體邏輯。
先看下每個結點類所包含的資訊:
1public class Node<E extends Comparable<E>>{ 2 E element; // data 3 int balance = 0; // 每個結點的平衡因子 4 Node<E> left; 5 Node<E> right; 6 Node<E> parent; 7 public Node(E element, Node<E> parent) { 8 this.element = element; 9 this.parent = parent; 10 } 11 12 @Override 13 public String toString() { 14 // TODO Auto-generated method stub 15 return element + "BF: " + balance; 16 } 17 18 public E getElement() { 19 return element; 20 } 21 22 public void setElement(E element) { 23 this.element = element; 24 } 25 26 public int getBalance() { 27 return balance; 28 } 29 30 public void setBalance(int balance) { 31 this.balance = balance; 32 } 33 34 public Node<E> getLeft() { 35 return left; 36 } 37 38 public void setLeft(Node<E> left) { 39 this.left = left; 40 } 41 42 public Node<E> getRight() { 43 return right; 44 } 45 46 public void setRight(Node<E> right) { 47 this.right = right; 48 } 49 50 public Node<E> getParent() { 51 return parent; 52 } 53 54 public void setParent(Node<E> parent) { 55 this.parent = parent; 56 } 57 }
最主要的是每個結點類添加了一個balance屬性,也就是記錄自己的平衡因子,在插入元素的時候需要動態的調整。
我們看下插入元素方法的Java實現:
1 /** 2 * 新增元素方法 3 * @param 4 */ 5 public boolean addElement(E element) { 6 Node<E> t = root; 7 //t檢查root是否為空,如果為空則表示AVL樹還沒有建立, 8 //則需要建立根結點即可 9 if (t == null) { 10 root = new Node<E>(element, null); 11 size = 1; 12 root.balance = 0; 13 return true; 14 } else { 15 int cmp = 0; 16 Node<E> parent; 17 Comparable<? super E> e = (Comparable<? super E>)element; 18 //查詢父類的過程,邏輯和講解二叉排序樹時查詢父類是一樣的 19 do { 20 parent = t; 21 cmp = e.compareTo(t.element); 22 if (cmp < 0) { 23 t= t.left; 24 } else if (cmp > 0) { 25 t= t.right; 26 } else { 27 return false; 28 } 29 } while (t != null); 30 //建立結點,並掛載到父節點上 31 Node<E> child = new Node<E>(element, parent); 32 if (cmp < 0) { 33 parent.left = child; 34 } else { 35 parent.right = child; 36 } 37 //節點已經插入, 38 // 插入元素後 檢查平衡性,回溯查詢 39 while (parent != null) { 40 cmp = e.compareTo(parent.element); 41 //元素在左邊插入 42 if (cmp < 0) { 43 parent.balance++; 44 } else{ //元素在右邊插入 45 parent.balance --; 46 } 47 //插入之後父節點balance正好完全平衡,則不會出現平衡問題 48 if (parent.balance == 0) { 49 break; 50 } 51 //查詢最小不平衡二叉樹 52 if (Math.abs(parent.balance) == 2) { 53 //出現平衡問題 54 fix(parent); 55 break; 56 } else { 57 parent = parent.parent; 58 } 59 } 60 size++; 61 return true; 62 } 63 }
其大體流程主要分為兩大部分,前半部分和二叉排序樹插入元素的邏輯一樣,主要是查詢父節點,將其掛載到父節點上,而後半部分就是AVL樹特有的了,也就是查詢最小不平衡二叉樹然後對其修復,修復也就是通過左旋右旋操作使其達到平衡狀態,我們看下fix方法主要邏輯:
1 /** 2 * 發現最小不平衡樹,對其進行修復 3 * @param parent 4 */ 5 private void fix(AVL<E>.Node<E> parent) { 6 if (parent.balance == 2) { 7 leftBalance(parent); 8 } 9 if (parent.balance == -2) { 10 rightBalance(parent); 11 } 12 }
很簡單,就是判斷左邊與右邊哪邊不平衡,進而進行左平衡或者右平衡操作,至於左平衡右平衡上面已經詳細講解過,不在過多說明。
好了,以上就是構建一顆AVL樹的過程講解,如果有不懂得地方可以靜下心來自己好好分析一下。
六、AVL樹總結
本篇主要講解了AVL的概念以及通過最基礎的左旋,右旋使其保持樹中每一個結點的平衡因子值保證在「-1,0,1」中,這樣構建出來的樹具有很好的查詢特性。
AVL樹相對於紅黑樹來說是一顆嚴格的平衡二叉樹,平衡條件非常嚴格(樹高差只有1),只要插入或刪除不滿足上面的條件就要通過旋轉來保持平衡。由於旋轉是非常耗費時間的。AVL樹適合用於插入刪除次數比較少,但查詢多的情況。
在平衡二叉樹中應用比較多的是紅黑樹,紅黑樹對高度差要求沒有AVL那麼嚴格,用以保持平衡的左旋右旋操作次數比較少,用於搜尋時,插入刪除次數多的情況下通常用紅黑樹來取代AVL樹。TreeMap的實現以及JDK1.8以後的HashMap中都有紅黑樹的具體應用。
下一篇我可能先寫圖的概念以及圖一些經典演算法,放心紅黑樹我肯定會寫的,關於AVL樹與紅黑樹的差異在寫完紅黑樹在做詳細比較,以上簡單提一下。
好了,本篇就到此為止了,希望對你有用。