本文將從二叉搜索樹的定義和性質入手,帶領你們實現一個二分搜索樹,經過代碼實現讓你們深度認識二分搜索樹。html
後面會持續更新數據結構相關的博文。node
數據結構專欄:https://www.cnblogs.com/hello-shf/category/1519192.htmlgit
git傳送門:https://github.com/hello-shf/data-structure.gitgithub
說樹這種結構以前,咱們要先說一下樹這種結構存在的意義。在咱們的現實場景中,好比圖書館,咱們能夠根據分類快速找到咱們想要找到的書籍。好比咱們要找一本叫作《Java編程思想》這本書,咱們只須要根據,理工科 ==> 計算機 ==>Java語言分區就能夠快速找到咱們想要的這本書。這樣咱們就不須要像數組或者鏈表這種結構,咱們須要遍歷一遍才能找到咱們想要的東西。再好比,咱們所使用的電腦的文件夾目錄自己也是一種樹的結構。算法
從上面的描述咱們可知,樹這種結構具有自然的高效性能夠巧妙的避開咱們不關心的東西,只須要根據咱們的線索快速去定位咱們的目標。因此說樹表明着一種高效。編程
在瞭解二分搜索樹以前,咱們不得不瞭解一下二叉樹,由於二叉樹是實現二分搜索樹的基礎。就像咱們後面會詳細講解和實現AVL(平衡二叉樹),紅黑樹等樹結構,你不得不在此以前學習二分搜索樹同樣,他們都是互爲基礎的。數組
二叉樹也是一種動態的數據結構。每一個節點只有兩個叉,也就是兩個孩子節點,分別叫作左孩子,右孩子,而沒有一個孩子的節點叫作葉子節點。每一個節點最多有一個父親節點,最多有兩個孩子節點(也能夠沒有孩子節點或者只有一個孩子節點)。對於二叉樹的定義咱們不經過複雜的數學表達式來敘述,而是經過簡單的描述,讓你們瞭解一個二叉樹長什麼樣子。緩存
1 只有一個根節點。 2 每一個節點至多有兩個孩子節點,分別叫左孩子或者右孩子。(左右孩子節點沒有大小之分哦) 3 每一個子樹也都是一個二叉樹
知足以上三條定義的就是一個二叉樹。以下圖所示,就是一顆二叉樹數據結構
根據二叉樹的節點分佈大概能夠分爲如下三種二叉樹:徹底二叉樹,滿二叉樹,平衡二叉樹。對於如下樹的描述不使用數學表達式或者專業術語,由於那樣很難讓人想象到一棵樹到底長什麼樣子。post
滿二叉樹:從根節點到每個葉子節點所通過的節點數都是相同的。
以下圖所示就是一顆滿二叉樹。
徹底二叉樹:除去最後一層葉子節點,就是一顆徹底二叉樹,而且最後一層的節點只能集中在左側。
對於上面的性質,咱們從另外一個角度來講就是將滿二叉樹的葉子節點從右往左刪除若干個後就變成了一棵徹底二叉樹,也就是說,滿二叉樹必定是一棵徹底二叉樹,反之不成立。以下圖所示:除了圖3都是一棵徹底二叉樹。
平衡二叉樹:平衡二叉樹又被稱爲AVL樹(區別於AVL算法),它是一棵二叉樹,又是一棵二分搜索樹,平衡二叉樹的任意一個節點的左右兩個子樹的高度差的絕對值不超過1,即左右兩個子樹都是一棵平衡二叉樹。
1 二分搜索樹是一顆二叉樹 2 二分搜索樹每一個節點的左子樹的值都小於該節點的值,每一個節點右子樹的值都大於該節點的值 3 任意一個節點的每棵子樹都知足二分搜索樹的定義
上面咱們給出了二分搜索樹的定義,根據定義咱們可知,二分搜索樹是一種具有可比較性的樹,左孩子 < 當前節點 < 右孩子。這種可比較性爲咱們提供了一種高效的查找數據的能力。好比,對於下圖所示的二分搜索樹,若是咱們想要查詢數據14,經過比較,14 < 20 找到 10,14 > 10。只通過上面的兩步,咱們就找到了14這個元素,以下面gif所示。可見二分搜索樹的查詢是多麼的高效。
本章咱們的重點是實現一個二分搜索樹,那咱們規定該二分搜索樹應該具有如下功能:
1 以Node做爲鏈表的基礎存儲結構 2 使用泛型,並要求該泛型必須實現Comparable接口 3 基本操做:增刪改查
經過上面的分析,咱們可知,若是咱們要實現一個二分搜索樹,咱們須要咱們的節點有左右兩個孩子節點。
根據要求和定義,構建咱們的基礎代碼以下:
/** * 描述:二叉樹的實現 * 須要泛型是可比較的,也就是泛型必須實現Comparable接口 * * @Author shf * @Date 2019/7/22 9:53 * @Version V1.0 **/ public class BST<E extends Comparable> { /** * 節點內部類 */ private class Node{ private E e; private Node left, right;//左右孩子節點 public Node(E e){ this.e = e; this.left = right; } } /** * BST的根節點 */ private Node root; /** * 記錄BST的 size */ private int size; public BST(){ root = null; size = 0; } /** * 對外提供的獲取 size 的方法 * @return */ public int size(){ return size; } /** * 二分搜索樹是否爲空 * @return */ public boolean isEmpty(){ return size == 0; } }
對於二分搜索樹這種結構咱們要明確的是,樹是一種自然的可遞歸的結構,爲何這麼說呢,你們想一想二分搜索樹的每一棵子樹也是一棵二分搜索樹,恰好迎合了遞歸的思想就是將大任務無限拆分爲一個個小任務,直到求出問題的解,而後再向上疊加。因此在後面的操做中,咱們都經過遞歸實現。相信你們看了如下實現後會對遞歸有一個深層次的理解。
爲了讓你們對二分搜索樹有一個直觀的認識,咱們向二分搜索樹依次添加[20,10,6,14,29,25,33]7個元素。咱們來看一下這個添加的過程。
增長操做和上面的搜索操做基本是同樣的,首先咱們要先找到咱們要添加的元素須要放到什麼位置,這個過程其實就是搜索的過程,好比咱們要在上圖中的基礎上繼續添加一個元素15。以下圖所示,咱們通過一路尋找,最終找到節點14,咱們15>14因此須要將15節點放到14節點的右孩子處。
有了以上的基本認識,咱們經過代碼實現一下這個過程。
1 /** 2 * 添加元素 3 * @param e 4 */ 5 public void add(E e){ 6 root = add(root, e); 7 } 8 9 /** 10 * 添加元素 - 遞歸實現 11 * 時間複雜度 O(log n) 12 * @param node 13 * @param e 14 * @return 返回根節點 15 */ 16 public Node add(Node node, E e){ 17 if(node == null){// 若是當前節點爲空,則將要添加的節點放到當前節點處 18 size ++; 19 return new Node(e); 20 } 21 if(e.compareTo(node.e) < 0){// 若是小於當前節點,遞歸左孩子 22 node.left = add(node.left, e); 23 } else if(e.compareTo(node.e) > 0){// 若是大於當前節點,遞歸右孩子 24 node.right = add(node.right, e); 25 } 26 return node; 27 }
若是你還不是很理解上面的遞歸過程,咱們從宏觀角度分析一下,首先明確 add(Node node, E e) 這個方法是幹什麼的,這個方法接收兩個參數 node和e,若是node爲null,則咱們將實例化node。咱們的遞歸過程正是這樣,若是node不爲空並按照大小關係去找到左孩子節點仍是右孩子,而後對該孩子節點繼續執行 add(Node node, E e) 操做,經過按照大小規則一路查找直到找到一個符合條件的節點而且該節點爲null,執行node的實例化便可。
若是看了上面的解釋你仍是有點懵,沒問題,繼續往下看。劉慈欣的《三體》不只讓中國的硬科幻登上了世界的舞臺,更是給廣大讀者普及了諸如「降維打擊」之類的熱門概念。「降維打擊」之因此給人如此之震撼,在於它以極簡的方式,從更高的、全新的技術視角有效解決了當前困局。那麼在算法的世界中,「遞歸」就是這種牛叉哄哄的「降維打擊」技術。遞歸思想及:當前問題的求解是否能夠由規模小一點的問題求解疊加而來,後者是否能夠再由更小一點的問題求解疊加而來……依此類推,直到收斂爲一個極簡的出口問題的求解。若是你能從這段話概括出遞歸就是一種將大的問題不斷的進行拆分爲更小的問題,直到拆分到找到問題的解,而後再向大的問題逐層疊加而最終求得遞歸的解。
看了以上解釋相信你們應該對以上遞歸過程有了一個深層次的理解。若是你們還有疑問建議畫一畫遞歸樹,經過壓棧和出棧以及堆內存變化的方式詳細分析每個步驟便可。在我以前寫的文章,在分析鏈表反轉的時候對遞歸的微觀過程進行了詳細的分析,但願對你們有所幫助。
有了上面的基礎咱們實現一個查詢的方式,應該也不存在很大的難度了。咱們設計一個方法叫 contains 即判斷是否存在某個元素。
1 /** 2 * 搜索二分搜索樹中是否包含元素 e 3 * @param e 4 * @return 5 */ 6 public boolean contains(E e){ 7 return contains(root, e); 8 } 9 10 /** 11 * 搜索二分搜索樹中是否包含元素 e 12 * 時間複雜度 O(log n) 13 * @param node 14 * @param e 15 * @return 16 */ 17 public boolean contains(Node node, E e){ 18 if(node == null){ 19 return false; 20 } else if(e.compareTo(node.e) == 0){ 21 return true; 22 } else if(e.compareTo(node.e) < 0){ 23 return contains(node.left, e); 24 } else { 25 return contains(node.right, e); 26 } 27 }
從上面代碼咱們不難發現其實和add方法的遞歸思想是同樣的。那在此咱們就不作詳細解釋了。
爲了後面代碼的實現,咱們再設計兩個方法,即查找樹中的最大和最小元素。
經過二分搜索樹的定義咱們不難發現,左孩子 < 當前節點 < 右孩子。按照這個順序,對於一棵二分搜索樹中最小的那個元素就是左邊的那個元素,最大的元素就是最右邊的那個元素。
經過下圖咱們不難發現,最大的和最小的節點都符合咱們上面的分析,最小的在最左邊,最大的在最右邊,但不必定都是葉子節點。好比圖1中的6和33元素都不是葉子節點。
經過上面的分析,咱們應該能很容易的想到,查詢最小元素,就是使用遞歸從根節點開始,一直遞歸左孩子,直到一個節點的左孩子爲null。咱們就找到了該最小節點。查詢最大值同理。
1 /** 2 * 搜索二分搜索樹中以 node 爲根節點的最小值所在的節點 3 * @param node 4 * @return 5 */ 6 private Node minimum(Node node){ 7 if(node.left == null){ 8 return node; 9 } 10 return minimum(node.left); 11 } 12 13 /** 14 * 搜索二分搜索樹中的最大值 15 * @return 16 */ 17 public E maximum(){ 18 if (size == 0){ 19 throw new IllegalArgumentException("BST is empty"); 20 } 21 return maximum(root).e; 22 } 23 24 /** 25 * 搜索二分搜索樹中以 node 爲根節點的最大值所在的節點 26 * @param node 27 * @return 28 */ 29 private Node maximum(Node node){ 30 if(node.right == null){ 31 return node; 32 } 33 return maximum(node.right); 34 }
刪除操做咱們設計三個方法,即:刪除最小,刪除最大,刪除任意一個元素。
經過對上面3.2.3中的查最大和最小元素咱們不難想到首先咱們要找到最大或者最小元素。
如3.2.3中的圖2所示,若是待刪除的最大最小節點若是沒有葉子節點直接刪除。可是如圖1所示,若是待刪除的最大最小元素還有孩子節點,咱們該如何處理呢?對於刪除最小元素,咱們須要將該節點的右孩子節點提到被刪除元素的呃位置,刪除最大元素同理。而後咱們再看看圖2所示的狀況,使用圖1的刪除方式,也就是對於刪除最小元素,將該節點的右孩子節點提到該元素位置便可,只不過對於圖2的狀況,右孩子節點爲null而已。
1 /** 2 * 刪除二分搜索樹中的最小值 3 * @return 4 */ 5 public E removeMin(){ 6 if (size == 0){ 7 throw new IllegalArgumentException("BST is empty"); 8 } 9 E e = minimum(); 10 root = removeMin(root); 11 return e; 12 } 13 14 /** 15 * 刪除二分搜索樹中以 node 爲根節點的最小節點 16 * @param node 17 * @return 刪除後新的二分搜索樹的跟 18 */ 19 ////////////////////////////////////////////////// 20 // 12 12 // 21 // / \ / \ // 22 // 8 18 -----> 10 18 // 23 // \ / / // 24 // 10 15 15 // 25 ////////////////////////////////////////////////// 26 private Node removeMin(Node node){ 27 if(node.left == null){ 28 Node rightNode = node.right;// 將node.right(10) 賦值給 rightNode 保存 29 node.right = null;// 將node的right與樹斷開鏈接 30 size --; 31 return rightNode; // rightNode(10)返回給遞歸的上一層,賦值給 12 元素的左節點。 32 } 33 node.left = removeMin(node.left); 34 return node; 35 } 36 37 public E removeMax(){ 38 E e = maximum(); 39 root = removeMax(root); 40 return e; 41 } 42 43 /** 44 * 刪除二分搜索樹中以 node 爲根節點的最小節點 45 * @param node 46 * @return 47 */ 48 ////////////////////////////////////////////////// 49 // 12 12 // 50 // / \ / \ // 51 // 8 18 -----> 8 15 // 52 // \ / \ // 53 // 10 15 10 // 54 ////////////////////////////////////////////////// 55 private Node removeMax(Node node){ 56 if(node.right == null){ 57 Node leftNode = node.left; // 將node.right(15) 賦值給 leftNode 保存 58 node.left = null;// 將 node 的 left 與樹斷開鏈接 59 size --; 60 return leftNode; // leftNode (10)返回給遞歸的上一層,賦值給 12 元素的右節點。 61 } 62 node.right = removeMax(node.right); 63 return node; 64 }
待刪除元素可能存在的狀況以下:
1 第一種,只有左孩子; 2 第二種,只有右孩子; 3 第三種,左右孩子都有; 4 第四種,待刪除元素爲葉子節點;
第一種狀況和第二種狀況的樹形狀相似3.2.3中的圖1,其實他們的處理方式和刪除最大最小元素的處理方式是同樣的。這個就不過多解釋了,你們能夠本身手動畫出來一棵樹試試。那對於第四種狀況就是第一種或者第二種的特殊狀況了,也不須要特殊處理。和3.2.3中的圖1和圖2的處理方式都是同樣的。
那咱們重點說一下第三種狀況,這個狀況有點複雜。如上圖所示,若是咱們想刪除元素10,咱們該怎麼作呢?咱們經過二分搜索樹的定義分析一下,其實很簡單。首先10這個元素必定是大於他的左子樹的任意一個節點,並小於右子樹的任意一個節點。那咱們刪除了10這個元素,仍然不能打破平衡二叉樹的性質。通常思路,咱們得想辦法找個元素頂替下10這個元素。找誰呢?這個元素放到10元素的位置之後,仍然還能保證大於左子樹的任意元素,小於右子樹的任意元素。因此咱們很容易想到找左子樹中的最大元素,或者找右子樹中的最小元素來頂替10的位置,以下圖1所示。
以下圖所示,首先咱們用7頂替10的位置,以下圖2所示。咱們刪除了10這個元素後,用左子樹的最大元素替代10,依然能知足二分搜索樹的定義。同理咱們用右孩子最小的節點替換被刪除的元素也是徹底能夠的。在咱們後面的代碼實現中,咱們使用右孩子最小的節點替換被刪除的元素。
1 /** 2 * 從二分搜索樹中刪除元素爲e的節點 3 * @param e 4 */ 5 public void remove(E e){ 6 root = remove(root, e); 7 } 8 9 /** 10 * 刪除掉以node爲根的二分搜索樹中值爲e的節點, 遞歸算法 11 * @param node 12 * @param e 13 * @return 返回刪除節點後新的二分搜索樹的根 14 */ 15 private Node remove(Node node, E e){ 16 17 if( node == null ) 18 return null; 19 20 if( e.compareTo(node.e) < 0 ){ 21 node.left = remove(node.left , e); 22 return node; 23 } else if(e.compareTo(node.e) > 0 ){ 24 node.right = remove(node.right, e); 25 return node; 26 } else{ // e.compareTo(node.e) == 0 找到待刪除的節點 node 27 28 // 待刪除節點左子樹爲空,直接將右孩子替代當前節點 29 if(node.left == null){ 30 Node rightNode = node.right; 31 node.right = null; 32 size --; 33 return rightNode; 34 } 35 36 // 待刪除節點右子樹爲空,直接將左孩子替代當前節點 37 if(node.right == null){ 38 Node leftNode = node.left; 39 node.left = null; 40 size --; 41 return leftNode; 42 } 43 44 // 待刪除節點左右子樹均不爲空 45 // 找到右子樹最小的元素,替代待刪除節點 46 Node successor = minimum(node.right); 47 successor.right = removeMin(node.right); 48 successor.left = node.left; 49 50 node.left = node.right = null; 51 52 return successor; 53 } 54 }
二分搜索樹的遍歷大概能夠分爲一下幾種:
1,深度優先遍歷: (1)前序遍歷:父節點,左孩子,右孩子 (2)中序遍歷:左孩子,父節點,右孩子 (3)後序遍歷:左孩子,右孩子,父節點 2,廣度優先遍歷:按樹的高度從左至右進行遍歷
如上所示,大類分爲深度優先和廣度優先,深度有點的三種方式,你們不難發現,其實就是遍歷父節點的時機。廣度優先呢就是按照樹的層級,一層一層的進行遍歷。
前序遍歷是按照:父節點,左孩子,右孩子的順序對節點進行遍歷,因此按照這個順序對於以下圖所示的一棵樹,前序遍歷,應該是按照編號所示的順序進行遍歷的。
遞歸實現:雖然看着很複雜,其實遞歸代碼實現是十分簡單的。看代碼吧,請別驚掉下巴。
/** * 前序遍歷 */ public void preOrder(){ preOrder(root); } /** * 前序遍歷 - 遞歸算法 * @param node 開始遍歷的根節點 */ private void preOrder(Node node){ if(node == null){ return; } // 不作複雜的操做,僅僅將遍歷到的元素進行打印 System.out.println(node.e); preOrder(node.left); preOrder(node.right); } -------------前序遍歷------------ 20 10 6 14 29 25 33
非遞歸實現:若是咱們不使用遞歸如何實現呢?但是使用棧來實現,這是一個技巧,當咱們須要按照代碼執行的順序記錄(緩存)變量的時候,棧是一種再好不過的數據結構了。這也是棧的自然優點,由於JVM的棧內存正是棧這種數據結構。
從根節點開始,每次迭代彈出當前棧頂元素,並將其孩子節點壓入棧中,先壓右孩子再壓左孩子。爲何是先右孩子再左孩子?由於棧是後進先出的數據結構
1 /** 2 * 前序遍歷 - 非遞歸 3 */ 4 public void preOrderNR(){ 5 preOrderNR(root); 6 } 7 8 /** 9 * 前序遍歷 - 非遞歸實現 10 */ 11 private void preOrderNR(Node node){ 12 Stack<Node> stack = new Stack<>(); 13 stack.push(node); 14 while (!stack.isEmpty()){ 15 Node cur = stack.pop(); 16 System.out.println(cur.e); 17 if(cur.right != null){ 18 stack.push(cur.right); 19 } 20 if(cur.left != null){ 21 stack.push(cur.left); 22 } 23 } 24 }
中序遍歷:左孩子,父節點,右孩子。按照這個順序,咱們不難畫出下圖。紅色數字表示遍歷的順序。
遞歸實現:
1 /** 2 * 二分搜索樹的中序遍歷 3 */ 4 public void inOrder(){ 5 inOrder(root); 6 } 7 8 /** 9 * 中序遍歷 - 遞歸 10 * @param node 11 */ 12 private void inOrder(Node node){ 13 if(node == null){ 14 return; 15 } 16 inOrder(node.left); 17 System.out.println(node.e); 18 inOrder(node.right); 19 }
-------------中序遍歷------------
6
10
14
20
25
29
33
咱們觀察上面的遍歷結果,不難發現一個現象,打印結果正是按照從小到大的順序。其實這也是二分搜索樹的一個性質,由於咱們是按照:左孩子,父節點,右孩子。咱們二分搜索樹的其中一個定義:二分搜索樹每一個節點的左子樹的值都小於該節點的值,每一個節點右子樹的值都大於該節點的值。
非遞歸實現:依然是用棧保存。
1 /** 2 * 中序遍歷 - 非遞歸 3 */ 4 public void inOrderNR(){ 5 inOrderNR(root); 6 } 7 8 /** 9 * 中序遍歷 - 非遞歸實現 10 * 時間複雜度 O(n) 11 * @param node 12 */ 13 private void inOrderNR(Node node){ 14 Stack<Node> stack = new Stack<>(); 15 while(node != null || !stack.isEmpty()){ 16 while(node != null){ 17 stack.push(node); 18 node = node.left; 19 } 20 node = stack.pop(); 21 System.out.println(node.e); 22 node = node.right; 23 } 24 }
後序遍歷:左孩子,右孩子,父節點。遍歷順序以下圖所示。
1 /** 2 * 後序遍歷 3 */ 4 public void postOrder(){ 5 postOrder(root); 6 } 7 8 /** 9 * 後續遍歷 - 遞歸 10 * 時間複雜度 O(n) 11 * @param node 12 */ 13 public void postOrder(Node node){ 14 if(node == null){ 15 return; 16 } 17 postOrder(node.left); 18 postOrder(node.right); 19 System.out.println(node.e); 20 } 21 -------------後序遍歷------------ 22 6 23 14 24 10 25 25 26 33 27 29 28 20
非遞歸實現:
1 /** 2 * 後序遍歷 - 非遞歸 3 */ 4 public void postOrderNR(){ 5 postOrderNR(root); 6 } 7 8 /** 9 * 後序遍歷 - 非遞歸實現 10 * 時間複雜度 O(n) 11 * @param node 12 */ 13 private void postOrderNR(Node node){ 14 Stack<Node> stack = new Stack<>(); 15 Stack<Node> out = new Stack<>(); 16 stack.push(node); 17 while(!stack.isEmpty()){ 18 Node cur = stack.pop(); 19 out.push(cur); 20 21 if(cur.left != null){ 22 stack.push(cur.left); 23 } 24 if(cur.right != null){ 25 stack.push(cur.right); 26 } 27 } 28 while(!out.isEmpty()){ 29 System.out.println(out.pop().e); 30 } 31 }
廣度優先遍歷:又稱爲,層序遍歷,按照高度順序一層一層的訪問整棵樹,高層次的節點將會比低層次的節點先被訪問到。這種遍歷方式顯然是不適合遞歸求解的。至於爲何,相信通過咱們前面對遞歸的分析,你們已經很清楚了。
對於層序優先遍歷,咱們使用隊列來實現,利用隊列的先進先出(FIFO)的的特性。
1 /** 2 * 層序優先遍歷 3 * 時間複雜度 O(n) 4 */ 5 public void levelOrder(){ 6 Queue<Node> queue = new LinkedList<>(); 7 queue.add(root); 8 while(!queue.isEmpty()){ 9 Node node = queue.remove(); 10 System.out.println(node.e); 11 if(node.left != null){ 12 queue.add(node.left); 13 } 14 if(node.right != null){ 15 queue.add(node.right); 16 } 17 } 18 }
前面咱們講,二分搜索樹是一種高效的數據結構,其實這也不是絕對的,在極端狀況下,二分搜索樹會退化成鏈表,各類操做的時間複雜度大打折扣。好比咱們向咱們上面實現的二分搜索樹中按順序添加以下元素[1,2,3,4,5],以下圖所示,咱們發現咱們的二分搜索樹其實已經退化成了一個鏈表。關於這個問題,咱們在後面介紹平衡二叉樹(AVL)的時候會討論如何能讓二分搜索樹保持平衡,並避免這種極端狀況的發生。
《祖國》
小時候
覺得你就是遠在北京的天安門
長大了
才發現原來你就在個人內心
參考文獻:
《玩轉數據結構-從入門到進階-劉宇波》
《數據結構與算法分析-Java語言描述》
若有錯誤的地方還請留言指正。
原創不易,轉載請註明原文地址:https://www.cnblogs.com/hello-shf/p/11342907.html