原文:https://loubobooo.com/2018/11/04/%E5%88%9D%E5%AD%A6%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84-%E4%BA%8C%E5%88%86%E6%90%9C%E7%B4%A2%E6%A0%91/
前言
之前我們一直專注線上性資料結構上,在這一章要開始學習計算機領域應用及其廣泛的–樹結構。
樹的概括
下圖的資料存儲方式便是一種樹結構。
和線性資料結構不同的是,線性的資料結構是把所有的資料排成一排,而樹結構更像是自然界中樹的枝杈,不斷延伸,不斷擴充,其本身也是一種天然的組織結構,類似于電腦中檔案夾這種目錄結構。
将資料使用樹結構存儲後,出奇的高效,其中包括但不限于二分搜尋樹,平衡二叉樹,AVL,紅黑樹,堆,并查集,線段樹,Trie(字典樹,字首樹)
二分搜尋樹的基礎
在了解二分搜尋樹之前,我們首先明确二叉樹的概念,首先它和連結清單一樣,同樣屬于一種動态資料結構。
可以看粗,二叉樹中每個元素存在于節點内,和連結清單不同的是,除了要存放元素e,還要有指向其他節點的left和right兩個變量(引用)。
- 對于樹結構來說,二叉樹也是最常用的一種資料結構,同時二分搜尋樹滿足二叉樹的性質(從本質上講,二分搜尋樹也是一棵二叉樹)
- 對于一棵二叉樹來說,它隻有一個根節點,并且這個根節點是唯一的(如下圖中28便是這棵二叉樹的根節點)
- 對于每一個節點來說,它最多隻能有兩個子節點,指向左和右的兩個節點(也可以稱之為左孩子和右孩子)
- 二叉樹中,一個孩子都沒有的節點,通常稱之為葉子節點
在這裡,不是所有二叉樹都像如下圖示的二叉樹這樣規整(每個節點都有兩個孩子,葉子節點也在最底層)
葉子節點:這個節點沒有任何孩子節點,這樣的節點稱之為葉子節點
- 二叉樹有一個非常重要的性質,是具有天然的遞歸結構(對于每一個節點,它的左孩子同時也是一棵二分搜尋樹的根節點)。
如下圖示,這是一棵滿的二叉樹。即對于每一個節點來說,除了葉子節點外都有兩個孩子節點。
如下圖所示,隻有左孩子,或者隻有右孩子,或者左右孩子都為空,甚至退化成連結清單等等,這些都滿足二叉樹的定義。
下面可以來看下什麼是二分搜尋樹。二分搜尋樹除了滿足二叉樹的定義(隻有一個根節點,有左右孩子節點或者左右孩子節點為空……),還有自己獨特的性質,即每個節點的值都要大于其左子樹下所有節點的值,同時每個節點的值小于其右子樹下所有節點的值。
如下圖示顧名思義,根節點28大于左子樹中最大的值22,并且根節點28小于右子樹最小的值29
注:二分搜尋樹當然有可能不是一棵滿的二叉樹
為了要能達到這樣的性質,就必須讓我們存儲的元素具有可比較性,那麼我們用二分搜尋樹來存儲資料的話,然後來查找一個資料53就會非常簡單。由于41是根節點,是以根據二分搜尋樹的性質,53這個節點就一定在41的右子樹。這樣一來,41的左子樹所存儲的這些資料就會被忽略,這就大大地加快了查詢速度。
二分搜尋樹中添加元素
首先在最開始的時候,在二分搜尋樹裡一個節點都沒有
- 現在添加一個新的元素41,顯然這個節點會成為根節點
- 再來一個新元素28,從根節點出發,判斷28比根節點41小,根據二分搜尋樹的定義、是以添加到41的左子樹中
- 利用二分搜尋樹的定義,每次添加一個新的元素,從根節點開始,如果小于根節點就插到根節點的左子樹,反之插入到根節點的右子樹
-
這個過程以此類推……
注:我們此時的二分搜尋樹不包含重複元素
如果想包含重複元素的話,隻需定義:左子樹小于等于節點;或者右子樹大于等于節點
過程如圖示:
- 代碼如下:
private class Node{ public E e; public Node left, right; public Node(E e){ this.e = e; left = null; right = null; } } private Node add(Node node, E e){ if(node == null){ // 将null的節點連接配接起來 size++; return new Node(e); } // 這裡用遞歸,不斷調用左孩子節點,然後比較插入的元素和節點e if(e.compareTo(node.e) < 0){ node.left = add(node.left, e); }else if(e.compareTo(node.e) > 0){ //e.compareTo(node.e) > 0 node.right = add(node.right, e); } // 對相等的情況不做判斷了 return node; }
二分搜尋樹的查詢
對于查詢元素操作來說,隻需要看一下節點node所存的元素就好了,不必牽扯添加一個元素之後如何挂接到整個二分搜尋樹中,是以是相對簡單的。
- 代碼如下
// 看二分搜尋樹中是否包含元素e public boolean contains(E e){ return contains(root, e); } // 看以node為根的二分搜尋樹中是否包含元素e,遞歸算法 private boolean contains(Node node, E e){ if(node == null){ return false; } if(e.compareTo(node.e) == 0){ return true; }else if(e.compareTo(node.e) < 0){ return contains(node.left, e); }else{ // e.compareTo(node.e) > 0 return contains(node.right, e); } }
二分搜尋樹的前中後序周遊
前序周遊
- 對于一個資料結構的周遊,本質很簡單,就是把這個資料結構中所存儲的所有元素都通路一遍。對應到二分搜尋樹便是通路一遍所有節點。
- 對于線性資料(無論數組還是連結清單),從頭到尾做一層循環就解決了,但是對于樹結構來說卻不是。要通路整個二叉樹所有節點,需要考慮左子樹所有節點,還要考慮右子樹所有節點。
在通路完根節點,先通路了左子樹,再通路了右子樹,這就完成了二叉樹的周遊操作,而這種周遊操作也稱之為前序周遊。
- 用代碼來實作:
// 二分搜尋樹的前序周遊 public void preOrder(){ preOrder(root); } // 前序周遊以node為根的二分搜尋樹,遞歸算法 private void preOrder(Node node){ if(node == null){ return; } preOrder(node.left); preOrder(node.right); }
中序周遊
通常而言,對于二分搜尋來說。前序周遊是最自然的一種周遊方式,同時呢也是最常用的一種周遊方式。
如果我們改變節點的通路順序,使之 先通路該節點的左子樹,再通路該節點,最後通路該節點的右子樹,這樣的周遊操作稱之為中序周遊。
- 用代碼實作
//中序周遊以node為根的二分搜尋樹,遞歸算法 public void inOrder(){ inOrder(root); } private void inOrder(Node node){ if(node == null){ return; } inOrder(node.left); inOrder(node.right); }
後序周遊
同理,先通路該節點的左子樹,再通路該節點的右子樹,最後通路這個節點,這樣的周遊操作便是後序周遊。
- 用代碼實作
// 二分搜尋樹的後序周遊 public void postOrder(){ postOrder(root); } // 後序周遊以node為根的二分搜尋樹,遞歸算法 private void postOrder(Node node){ if(node == null){ return; } postOrder(node.left); postOrder(node.right); }
二分搜尋樹的層序(廣度優先)周遊
上述二分搜尋樹的前中後序周遊,本質上都是深度優先的周遊。于此相對應的是廣度優先周遊(即層序周遊)。
廣度優先周遊
對于二分搜尋樹來說,每一個節點對應右一個深度值,通常會把根節點叫做深度為0相應的節點。層序周遊就是,先周遊第0層的節點(28),再周遊第1層的節點(16,30),最後周遊第2層的節點(13,22,29,42)。
如下圖示:
- 用代碼實作
// 二分搜尋樹的層序周遊 public void levelOrer(){ Queue<Node> queue = new LinkedList<>(); queue.add(root); while(!queue.isEmpty()){ // 目前周遊的節點 等于 隊列中的元素出隊之後的那個元素 Node cur = queue.remove(); System.out.println(cur.e); if(cur.left != null){ queue.add(cur.left); } if(cur.right != null){ queue.add(cur.right); } } }
意義
- 更快的找到問題的解
- 常用于算法設計中-最短路徑
- 圖中的深度優先周遊和廣度優先周遊
二分搜尋樹删除節點
删除最大值和最小值
對于二分搜尋樹來說,删除一個節點相對是比較複雜的操作。是以分解删除操作,從最簡單的,删除二分搜尋樹的最小值和最大值開始。
- 首先,要想删除二分搜尋樹的最小值和最大值,就需要先找到二分搜尋樹的最小值和最大值
- 對于二分搜尋樹來說,其一個節點的左子樹中所有節點都小于該節點
- 是以删除最小值一定是從根節點開始,不停的向左走,直到向左走再也走不動為止,那個值一定是最小值
- 删除最大值同理
用代碼實作:
|
删除任意節點
在二分搜尋樹中删除任意節點,會出現以下3種情況:
- 删除隻有左孩子的節點
要删除的節點元素58這種情況,與删除最大值的邏輯相同,并在删除完成之後,讓左孩子所在的二叉樹挂接到原來父親節點
過程如下圖所示:
- 用代碼實作:
// 待删除節點右子樹為空的情況 if(node.right == null){ Node leftNode = node.left; node.left = null; size--; return leftNode; }
- 删除隻有右孩子的節點
要删除的節點元素58這種情況,與删除最大值的邏輯相同,并在删除完成之後,讓左孩子所在的二叉樹挂接到原來父親節點
過程如下圖所示:
- 用代碼實作:
// 待删除節點左子樹為空的情況 if(node.left == null){ Node rightNode = node.right; node.right = null; size--; return rightNode; }
- 删除左右都有孩子的節點d
- 找到d節點右子樹中最小的值,即s=min(d->right)
- 删除s節點,即delMin(d->right)
- 連接配接上之前d的左子樹,即s->right。連接配接上之前d的右子樹,即s->left
- 删除d,使s成為新的子樹的根
注:
->
表示指向
過程如下圖所示:
- 用代碼實作:
// 待删除節點左右子樹均不為空的情況 // 找到比待删除節點大的最小節點,即待删除節點右子樹的最小節點 // 用這個節點頂替待删除節點的位置,successor --> 後繼 Node successor = minimum(node.right); successor.right = removeMin(node.right); successor.left = node.left; node.left = node.right = null; return successor;
- 完整代碼實作
// 從二分搜尋樹中删除元素為e的節點 public void remove(E e){ root = remove(root, e); } // 删除以node為根的二分搜尋樹中值為e的節點,遞歸算法 // 傳回删除節點後新的二分搜尋樹的根 private Node remove(Node node, E e){ if(node == null){ return null; } if(e.compareTo(node.e) < 0){ node.left = remove(node.left, e); return node; }else if(e.compareTo(node.e) > 0){ node.right = remove(node.right, e); return node; }else{ // e == node.e // 待删除節點左子樹為空的情況 if(node.left == null){ Node rightNode = node.right; node.right = null; size--; return rightNode; } // 待删除節點右子樹為空的情況 if(node.right == null){ Node leftNode = node.left; node.left = null; size--; return leftNode; } // 待删除節點左右子樹均不為空的情況 // 找到比待删除節點大的最小節點,即待删除節點右子樹的最小節點 // 用這個節點頂替待删除節點的位置,successor --> 後繼 Node successor = minimum(node.right); successor.right = removeMin(node.right); successor.left = node.left; node.left = node.right = null; return successor; } }