樹結構簡介
- 在線性數據結構中,數據都是排成一排存放的;而樹結構則是非線性的,存儲在其中的數據是按分支關系組織起來的結構,就像自然界中的樹那樣。如下圖所示:
- 從圖可以看出樹結構是有一種層次感的,每一個點可以有多個分支,這種組織結構是非常有優勢的,簡單來說樹結構本身是一種天然的組織結構。
- 對於這種組織結構,日常生活中是非常常見的,比如電腦磁盤中的文件夾、公司中的人員組織結構、家族的族譜等等,如以下幾圖所示:
- 除了組織數據,使用樹結構存儲數據時,在某些情況下,處理數據是十分高效的。而我們就可以針對各種特殊情況去設計出各情況適合的高效率的樹結構。
- 舉個例子:比如對於查找一個數據,在線性結構中如果不知道具體位置的話需要在一堆數據里一個一個地去尋找;而對於樹結構,因為樹結構分支的形式,各個數據可以存在不同的分支中,在查找時就可以依據這些分支快速地找到想要的數據。
- 比如,磁盤中不同的文件夾存放不同的文件,我們在查找一個文件時,就可以根據文件夾名稱去找到想要的文件。
- 以上就是樹結構的一些特點的簡單介紹,接下來就開始分析一下樹結構中的二分搜索樹的基礎知識以及實現出這個數據結構的一些常用操作。
二分搜索樹的基礎知識
二叉樹的基本概念
- 在了解二分搜索樹之前,需要先了解二叉樹的基本概念,因為二分搜索樹是基於二叉樹的。實際上,二叉樹是樹結構中最常見的樹結構,也是樹結構中最為基礎的結構。
- 對於二叉樹,和鏈表一樣,也是一種動態的數據結構,用戶不需要擔心容量的問題,設計者也不需要手動地設計動態伸縮容量的方法。
- 同時,二叉樹也是由一個一個節點組成的,而對於其中的每一個節點,除了存儲數據之外,還需要有兩個子節點,分別指向這個節點的左節點和右節點(也稱為左孩子和右孩子)。
- 此外,二叉樹還具有以下特性:
- 二叉樹具有唯一根節點。
- 二叉樹每個節點最多有兩個孩子。
- 二叉樹中沒有孩子的節點稱為葉子節點。
- 二叉樹每個節點最多只有一個父親節點。
- 二叉樹具有天然的遞歸結構:
- 每個節點的左子樹也是二叉樹。(每個節點的左節點是左子樹的根節點)
- 每個節點的右子樹也是二叉樹。(每個節點的右節點是右子樹的根節點)
- 二叉樹不一定是滿的:
- 一個節點也是二叉樹。(左右孩子為空)
- NULL(空)也是二叉樹。
- 以上特性歸納為圖片表示如下:
- 二叉樹的幾種常見形態
- 空二叉樹
- 只有一個節點的二叉樹
- 只有左節點的二叉樹
- 只有右節點的二叉樹
- 完全二叉樹
- 滿二叉樹
- 空二叉樹
二分搜索樹的基本概念
- 在了解了以上二叉樹的基本概念之后,那么對於二分搜索樹就不需要再了解以上概念了,因為二分搜索樹就是一棵二叉樹,只不過它有屬於它自己的一些特性。
- 對於二分搜索樹,它具有以下特性:
- 二分搜索樹的每個節點的值大於其左子樹的所有節點的值。
- 二分搜索樹的每個節點的值小於其右子樹的所有節點的值。
- 二分搜索樹的每一棵子樹也是二分搜索樹。
- 二分搜索樹存儲的元素必須有可比較性。
- 二分搜索樹圖示
二分搜索樹的基本結構代碼實現
-
綜上以上基本概念,可設計二分搜索樹的基本結構的代碼如下:
/** * 二分搜索樹數據結構實現類 * 支持具有可比較性的泛型 * * @author 踏雪彡尋梅 * @date 2020/3/2 - 23:05 */ public class BST<E extends Comparable<E>> { /** * 二分搜索樹的節點 */ private class Node { /** * 存儲在節點中的元素 */ public E element; /** * 左節點(左孩子) */ public Node left; /** * 右節點(右孩子) */ public Node right; /** * 節點類構造函數 * 構造一個沒有左右孩子的節點 * @param element 存儲在節點中的元素 */ public Node(E element) { this.element = element; this.left = null; this.right = null; } } /** * 二分搜索樹的根節點 */ private Node root; /** * 二分搜索樹當前節點個數 */ private int size; /** * 二分搜索樹類構造函數 * 構造一個空的二分搜索樹 */ public BST() { this.root = null; this.size = 0; } /** * 獲取二分搜索樹當前節點個數 * @return 返回當前二分搜索樹中節點個數 */ public int size() { return size; } /** * 判斷當前二分搜索樹是否為空樹 * @return 返回 true,表示當前二分搜索樹為空樹;返回 false,表示當前二分搜索樹不為空樹 */ public boolean isEmpty() { // 判斷 size 是否為 0 即可 return size == 0; } }
二分搜索樹的常見基本操作實現
添加操作
添加操作初步實現
-
對於二分搜索樹的添加操作,可分為以下兩種情況(為了更加簡潔明了,此處使用動圖表示):
- 空樹時添加一個新元素:
- 樹中已有元素時添加新元素:
- 空樹時添加一個新元素:
-
以上就是添加元素的過程,其中需要注意的是在這里的二分搜索樹的實現中,不包含重復元素,如果添加的元素已有了直接返回忽略。
-
如果需要包含重復元素,只需要定義為:左子樹小於等於父節點,右子樹大於等於父節點。
-
對於其代碼實現,可以使用非遞歸方式也可以使用遞歸方式,對於非遞歸方式,實現步驟其實和鏈表比較相像;但是在樹結構中,遞歸實現是比非遞歸方式簡單的,所以在這里使用遞歸的方式來實現。
-
不過使用遞歸方式也是有一定的局限性的。比如在添加元素的最壞的情況下,二分搜索樹會形成一個鏈表(只添加左節點或右節點),這種情況下一直添加元素,由於不斷地遞歸,遞歸高度越來越高,內存將會被撐爆,這一點也是需要了解的。
-
從以上圖示中,可歸納為以下步驟(遞歸實現):
- 添加元素前,先判斷當前二分搜索樹是否為空。如果為空將元素添加到根節點中;如果不為空,從根節點開始,遞歸將元素添加到樹中。
- 遞歸終止條件:
- 添加的元素在樹中已有,直接忽略掉返回即可。(不包含重復元素)
- 如果添加的元素添加到某個節點的左節點中且當前這個節點的左節點為空,則將元素添加到當前這個節點的左節點中並返回回去。
- 如果添加的元素添加到某個節點的右節點中且當前這個節點的右節點為空,則將元素添加到當前這個節點的右節點中並返回回去。
- 如果不滿足以上終止條件,進行遞歸添加元素:
- 如果添加的元素比當前節點的元素小,添加到當前節點的左子樹中。
- 如果添加的元素比當前節點的元素大,添加到當前節點的右子樹中。
-
以上步驟實現為代碼形式如下:
/** * 向二分搜索樹中添加新的元素 element * @param element 添加的新元素 */ public void add(E element) { // 判斷二分搜索樹是否為空 if (root == null) { // 二分搜索樹為空,將新元素添加到根節點中 root = new Node(element); size++; } else { // 二分搜索樹不為空,從根節點開始遞歸地添加新元素 add(root, element); } } /** * 向根節點為 node 的二分搜索樹中添加元素 element * 遞歸函數 * @param node 添加元素的二分搜索樹的根節點 * @param element 添加的元素 */ private void add(Node node, E element) { // 終止條件 if (node.element.equals(element)) { // 添加的元素已有,忽略返回 return; } else if (element.compareTo(node.element) < 0 && node.left == null) { // 添加的元素添加到某個節點的左節點時(該節點的左節點為空) node.left = new Node(element); size++; return; } else if (element.compareTo(node.element) > 0 && node.right == null) { // 添加的元素添加到某個節點的右節點時(該節點的右節點為空) node.right = new Node(element); size++; return; } // 不滿足終止條件時,遞歸的進行添加元素 if (element.compareTo(node.element) < 0) { // 比當前節點小,添加到左子樹中 add(node.left, element); } else { // 比當前節點大,添加到右子樹中 add(node.right, element); } }
添加操作改進
-
在上面的添加操作實現中,在添加新元素的時候進行了兩輪的比較,第一輪是在終止條件時比較,第二輪是不滿足終止條件時比較,這樣子看起來終止條件顯得比較臃腫。
-
而對於二叉樹而言,空(null)也可以是一顆二叉樹。所以可以設計為添加元素時當遞歸到某個節點的左節點或右節點時或者樹為空時添加元素時,這個節點正好為空(null),此時就可以 new 一個節點將元素添加進去並返回這個節點給上一層的二分搜索樹的左節點或右節點接收或者給根節點 root 接收,此時返回的這個節點就是一棵二分搜索樹同時也是這棵樹的根節點,對於相等的元素則不做處理,最后再返回遞歸最開始的根節點回去給上層節點或者根節點 root 接收即可。
-
此時在初始調用添加操作時,就不需要再判斷二分搜索樹是否為空了,只需使用 root 接收調用結果即可。
-
以上過程用動圖表示如下:
-
代碼改進后如下:
/** * 向二分搜索樹中添加新的元素 element * @param element 添加的新元素 */ public void add(E element) { root = add(root, element); } /** * 向根節點為 node 的二分搜索樹中添加元素 element * 遞歸函數 * @param node 添加元素的二分搜索樹的根節點 * @param element 添加的元素 * @return 返回插入新節點后的二分搜索樹的根節點 */ private Node add(Node node, E element) { // 終止條件 if (node == null) { // 遞歸到空節點時,new 一個根節點將元素添加到該節點中並返回該節點給上一層的二分搜索樹的左節點或右節點或者 root 根節點接收 size++; return new Node(element); } // 不滿足終止條件時,遞歸的進行添加元素 if (element.compareTo(node.element) < 0) { // 比當前節點小,添加到左子樹中,並使用當前節點的左節點接收結果 node.left = add(node.left, element); } else if (element.compareTo(node.element) > 0) { // 比當前節點大,添加到右子樹中,並使用當前節點的右節點接收結果 node.right = add(node.right, element); } // 最后返回起始時的 node 節點給上層左節點或右節點或者 root 根節點接收 return node; }
查詢操作
-
對於二分搜索樹的查詢操作,這里實現一個 contains 方法用於判斷要查找的元素是否存在於二分搜索樹中,如果存在返回 true,如果不存在返回 false。
-
對於這個操作的實現步驟如下(遞歸實現):
- 首先判斷當前遞歸到的根節點是否為空,如果為空則說明當前遞歸到的樹沒有元素,返回 false。
- 接着判斷當前遞歸到的根節點中的元素是否為要查找的元素,如果是則返回 true,否則進行后續的判斷。
- 對於剩下的判斷則是判斷要查找的元素是大於還是小於當前遞歸到的根節點,大於就在右子樹中繼續尋找,小於則在左子樹中繼續尋找,接着繼續以上 1、2、3 步的操作,直至出現結果為止。
-
此操作實現代碼為下:
/** * 查看二分搜索樹中是否包含元素 element * @param element 查找的元素 * @return 包含元素 element 返回 true;反之返回 false */ public boolean contains(E element) { // 在整棵樹中查找 return contains(root, element); } /** * 查看以 node 為根的二分搜索樹中是否包含元素 element * 遞歸函數 * @param node 進行查找的二分搜索樹的根節點 * @param element 查找的元素 * @return 包含元素 element 返回 true;反之返回 false */ private boolean contains(Node node, E element) { if (node == null) { // 當前查找的二分搜索樹的根節點為空的話,返回 false return false; } if (element.compareTo(node.element) == 0) { // 當前遞歸到的根節點的元素為 element,包含,返回 true return true; } else if (element.compareTo(node.element) < 0) { // 如果 element 比當前根節點的元素小,在左子樹中尋找 return contains(node.left, element); } else { // 如果 element 比當前根節點的元素大,在右子樹中尋找 return contains(node.right, element); } }
遍歷操作
- 對於遍歷,這個操作是十分常見的。在二分搜索樹中,遍歷操作就是把所有的節點都訪問一遍。在前面的線性結構中,遍歷是及其容易的,使用循環就行了。不過在樹結構中遍歷也比線性結構難不了多少,也是比較簡單的。在樹結構中有這么幾種遍歷:前序遍歷、中序遍歷、后序遍歷、層序遍歷,下面就一一簡單地實現出二分搜索樹中的這幾種遍歷方式。
前序遍歷
-
對於二分搜索樹的前序遍歷操作,同樣也是使用遞歸來實現。對於前序遍歷,遍歷的順序是先訪問當前節點,接着訪問該節點的左子樹繼而訪問該節點的右子樹,在子樹中也是重復此步驟。當遍歷到一個節點為 null 時直接返回即可。用圖來表示這個過程就是以下所示:
-
以上過程實現為代碼形式如下:
/** * 二分搜索樹的前序遍歷 */ public void preOrder() { // 從根節點開始遍歷 preOrder(root); } /** * 前序遍歷以 node 為根的二分搜索樹 * 遞歸函數 * @param node 前序遍歷的二分搜索樹的根節點 */ private void preOrder(Node node) { // 終止條件 if (node == null) { // 遍歷到空節點時直接返回即可 return; } // 訪問節點操作(此處簡單的打印一下節點中存儲的元素) System.out.println("element: " + node.element); // 遍歷當前節點的左子樹 preOrder(node.left); // 遍歷當前節點的右子樹 preOrder(node.right); }
-
實現了代碼之后,做個小測試驗證一下是否正確,測試代碼如下:
/** * 測試 BST */ public static void main(String[] args) { BST<Integer> bst = new BST<>(); int[] nums = {8, 4, 9, 10, 5, 3}; for (int num : nums) { bst.add(num); } //形成的二分搜索樹// ///////////////// // 8 // // / \ // // 4 9 // // / \ \ // // 3 5 10 // ///////////////// // 前序遍歷 bst.preOrder(); }
-
測試結果如下,可以看出結果是符合前序遍歷的規則的,驗證了代碼的正確性:
-
在實現了前序遍歷之后,可以使用前序遍歷的方式為這個 BST 類重寫一下 toString 方法打印出二分搜索樹以便觀察。
-
實現代碼如下:
/** * 重寫 toString 方法打印二分搜索樹 */ @Override public String toString() { StringBuilder result = new StringBuilder(); generateBSTString(root, 0, result); return result.toString(); } /** * 生成以 node 為根節點,深度從 depth 開始的二分搜索樹的字符串 * @param node 根節點 * @param depth 深度 * @param result 生成的結果 */ private void generateBSTString(Node node, int depth, StringBuilder result) { if (node == null) { result.append(generateDepthString(depth) + "null\n"); return; } result.append(generateDepthString(depth) + node.element + "\n"); result.append("left : "); generateBSTString(node.left, depth + 1, result); result.append("right: "); generateBSTString(node.right, depth + 1, result); } /** * 根據當前深度打印出當前深度對應的 -- 數量 * @param depth 當前深度 * @return 返回當前深度對應數量的 -- 字符串 */ private String generateDepthString(int depth) { StringBuilder result = new StringBuilder(); for (int i = 0; i < depth; i++) { result.append("--"); } return result.toString(); }
-
同樣也對此測試一下:
/** * 測試 BST */ public static void main(String[] args) { BST<Integer> bst = new BST<>(); int[] nums = {8, 4, 9, 10, 5, 3}; for (int num : nums) { bst.add(num); } //形成的二分搜索樹// ///////////////// // 8 // // / \ // // 4 9 // // / \ \ // // 3 5 10 // ///////////////// // 前序遍歷 bst.preOrder(); System.out.println("\n==========\n"); System.out.println(bst); }
-
運行效果如下,可以看出同一層的節點都打印了正確的深度,遍歷的順序也滿足了前序遍歷的規則:
-
至此,就完成了前序遍歷的實現,對於下面的中序遍歷和后序遍歷本質上實現方式和前序遍歷差不了多少,只是節點的訪問順序變化了。
中序遍歷
-
對於中序遍歷,遍歷的順序是先訪問當前節點的左子樹,接着訪問該節點繼而訪問該節點的右子樹,在子樹中也是重復此步驟。當遍歷到一個節點為 null 時直接返回即可。用圖來表示這個過程就是以下所示:
-
以上過程實現為代碼形式如下:
/** * 二分搜索樹的中序遍歷 */ public void inOrder() { inOrder(root); } /** * 中序遍歷以 node 為根的二分搜索樹 * 遞歸函數 * @param node 中序遍歷的二分搜索樹的根節點 */ private void inOrder(Node node) { // 終止條件 if (node == null) { // 遍歷到空節點時直接返回即可 return; } // 遍歷當前節點的左子樹 inOrder(node.left); // 訪問節點操作(此處簡單的打印一下節點中存儲的元素) System.out.println("element: " + node.element); // 遍歷當前節點的右子樹 inOrder(node.right); }
-
同樣對此也調用該方法測試一下,運行結果如下,可以看出輸出的順序符合中序遍歷的規則,驗證了代碼的正確性:
-
從結果中也可以看出中序遍歷的一個特點:輸出的結果是排好序后的。原因在於二分搜索樹的左子樹是小於父親節點,右子樹大於父親節點,而中序遍歷的順序正好是先訪問左子樹,接着訪問父親節點,最后再訪問右子樹。所以輸出的結果是按從小到大的順序輸出的。
后序遍歷
-
對於后序遍歷,遍歷的順序是先訪問當前節點的左子樹,接着訪問該節點的右子樹繼而訪問該節點,在子樹中也是重復此步驟。當遍歷到一個節點為 null 時直接返回即可。用圖來表示這個過程就是以下所示:
-
以上過程實現為代碼形式如下:
/** * 二分搜索樹的后序遍歷 */ public void postOrder() { postOrder(root); } /** * 后序遍歷以 node 為根的二分搜索樹 * 遞歸函數 * @param node 后序遍歷的二分搜索樹的根節點 */ private void postOrder(Node node) { // 終止條件 if (node == null) { // 遍歷到空節點時直接返回即可 return; } // 遍歷當前節點的左子樹 postOrder(node.left); // 遍歷當前節點的右子樹 postOrder(node.right); // 訪問節點操作(此處簡單的打印一下節點中存儲的元素) System.out.println("element: " + node.element); }
-
同樣對此也調用該方法測試一下,運行結果如下,可以看出輸出的順序符合中序遍歷的規則,驗證了代碼的正確性:
-
從結果中也可以看出后序遍歷是按從后往前的順序由子節點開始一一遍歷到父節點的,這種特性也應對了一種應用:為二分搜索樹釋放內存。當想要為一個符合二分搜索樹特性的模型釋放內存的時候,就可以使用二分搜索樹的后序遍歷來完成。
前、中、后序遍歷的非遞歸實現
- 在實現了前、中、后序遍歷的遞歸方式之后,可以使用非遞歸方式對這三種遍歷一一再實現一次,加深對二分搜索樹的理解。
- 在對遞歸的學習中,可以知道遞歸調用函數的時候是會被壓入到系統棧中記錄執行順序的,當執行完之后就進行出棧回到上一次調用函數的地方繼續執行余下操作。
- 所以對於非遞歸的實現,可以借助棧這個數據結構,手動模擬系統棧的方式實現二分搜索樹的前、中、后序遍歷。接下來一一實現這三種遍歷的非遞歸方式。
前序遍歷的非遞歸實現
-
對於使用棧來幫助模擬系統棧來實現前序遍歷的過程,用動圖來表示如下所示:
-
對於以上過程,由於棧的后進先出的特性,加上前序遍歷的規則,所以在遍歷完一個節點將其出棧后是先將該節點的右節點先入棧再入棧左節點,這樣子后續出棧遍歷節點后就滿足了前序遍歷的規則。
-
以上過程代碼實現如下:
/** * 二分搜索樹的非遞歸方式的前序遍歷 */ public void preOrderNotRecursive() { // 如果樹為空,則不遍歷,沒有元素可遍歷 if (root == null) { return; } // 借助一個棧來模擬系統棧實現前序遍歷 Stack<Node> stack = new Stack<>(); // 從根節點開始遍歷 stack.push(root); // 當棧非空時,循環往復以下過程對二分搜索樹進行前序遍歷 while (!stack.isEmpty()) { // 當前遍歷的節點 Node currentNode = stack.pop(); System.out.println("element: " + currentNode.element); // 如果當前遍歷的節點的左右節點不為空,按右節點、左節點的順序入棧 if (currentNode.right != null) { stack.push(currentNode.right); } if (currentNode.left != null) { stack.push(currentNode.left); } } }
-
實現之后,調用之前的遞歸方式和這個非遞歸方式比對兩者的結果是否一致:
-
從結果可看出,非遞歸方式的實現是正確的。對比遞歸實現的步驟,非遞歸的實現相對來說還是比較復雜的,不過通過這樣的實現也能加強對於二分搜索樹前序遍歷的理解,還是相當有好處的,接着繼續實現中序遍歷和后序遍歷的非遞歸方式的實現。
中序遍歷的非遞歸實現
-
對於中序遍歷的非遞歸實現,同樣也是使用一個棧來模擬系統棧的遞歸過程來實現,首先在這里先使用一個內部類用於封裝當前的指令(繼續模擬遞歸、打印節點)和當前模擬遞歸到的節點,以便模擬棧遞歸實現中序遍歷。
-
對於這個內部類的具體實現如下所示,其中 s 代表指令,如果 s 為 go 則表示繼續模擬遞歸,如果 s 為 print 則表示打印當前節點信息。
/** * 用於封裝模擬系統棧時的指令和遞歸到的節點信息 */ private class Command { // s: 指令 // go: 表示繼續模擬遞歸 // print: 表示打印當前節點信息 private String s; // 當前模擬遞歸到的節點 private Node node; public Command(String s, Node node){ this.s = s; this.node = node; } }
-
當封裝了這么一個內部類之后,非遞歸方式的中序遍歷就比較好實現了,具體過程為:
- 初始時將根節點和指令 go 入棧,表示從根節點開始繼續模擬遞歸下去。
- 接着開始循環,只要棧不為空,就重復循環中的內容:
- 首先先從棧頂取出當前棧頂信息。
- 接着判斷棧頂信息中的指令是否為 print,如果為 print 打印節點信息,否則做下面的操作。
- 如果指令為 go,則將當前節點的右子樹和指令 go 先入棧。(和前面的非遞歸前序遍歷一樣,由於棧的后入先出特性,需要反過來入棧,后面出棧時才能符合中序遍歷)
- 接着將當前節點和指令 print 入棧,當后面這個節點出棧時就可以判斷到 print 指令打印這個節點。
- 最后再將當前節點的左子樹和指令 go 入棧,這樣子后面最先出棧的就是左節點再到左節點的父節點再到右節點,滿足中序遍歷的規則。
- 這樣子,重復以上過程,就可以模擬系統棧的遞歸實現出二分搜索樹的中序遍歷了,以上過程的圖示演示如下:
-
具體代碼實現如下:
/** * 二分搜索樹的非遞歸方式的中序遍歷 */ public void inOrderNotRecursive() { // 如果樹為空,則不遍歷,沒有元素可遍歷 if (root == null) { return; } // 借助一個棧來模擬系統棧實現中序遍歷 Stack<Command> stack = new Stack<>(); // 初始時將根節點和指令 go 入棧 stack.push(new Command("go", root)); // 當棧非空時,循環往復以下過程對二分搜索樹進行中序遍歷 while (!stack.isEmpty()) { // 將棧頂信息出棧,判斷其中的指令做相應操作 Command command = stack.pop(); if ("print".equals(command.s)) { System.out.println("element: " + command.node.element); } else { if (command.node.right != null) { stack.push(new Command("go", command.node.right)); } stack.push(new Command("print", command.node)); if (command.node.left != null) { stack.push(new Command("go", command.node.left)); } } } }
-
同樣地,也對此進行測試,驗證是否編寫正確,運行結果如下:
-
從測試結果可以看出,遍歷的結果是符合預期的,和之前實現的遞歸方式的結果是一致的,驗證了代碼的正確性。在實現完了非遞歸方式的中序遍歷后,對於后序遍歷也就手到擒來了,原理也是相似的,接下來就實現后序遍歷的非遞歸方式。
后序遍歷的非遞歸實現
-
對於后序遍歷的非遞歸方式的實現,同樣也是使用 Command 來封裝入棧的信息。其中的具體實現過程如下:
- 初始時將根節點和指令 go 入棧,表示從根節點開始繼續模擬遞歸下去。
- 接着開始循環,只要棧不為空,就重復循環中的內容:
- 首先先從棧頂取出當前棧頂信息。
- 接着判斷棧頂信息中的指令是否為 print,如果為 print 打印節點信息,否則做下面的操作。
- 如果指令為 go,則將當前節點和指令 print 先入棧,當后面這個節點出棧時就可以判斷到 print 指令打印這個節點。(和前面的非遞歸前序遍歷一樣,由於棧的后入先出特性,需要反過來入棧,后面出棧時才能符合后序遍歷)
- 接着將當前節點的右子樹和指令 go 入棧。
- 最后再將當前節點的左子樹和指令 go 入棧,這樣子后面最先出棧的就是左節點再到右節點再到左右節點的父節點,滿足后序遍歷的規則。
- 這樣子,重復以上過程,就可以模擬系統棧的遞歸實現出二分搜索樹的后序遍歷了,以上過程的圖示演示如下:
-
具體代碼實現如下:
/** * 二分搜索樹的非遞歸方式的后序遍歷 */ public void postOrderNotRecursive() { // 如果樹為空,則不遍歷,沒有元素可遍歷 if (root == null) { return; } // 借助一個棧來模擬系統棧實現后序遍歷 Stack<Command> stack = new Stack<>(); // 初始時將根節點和指令 go 入棧 stack.push(new Command("go", root)); // 當棧非空時,循環往復以下過程對二分搜索樹進行后序遍歷 while (!stack.isEmpty()) { // 將棧頂信息出棧,判斷其中的指令做相應操作 Command command = stack.pop(); if ("print".equals(command.s)) { System.out.println("element: " + command.node.element); } else { stack.push(new Command("print", command.node)); if (command.node.right != null) { stack.push(new Command("go", command.node.right)); } if (command.node.left != null) { stack.push(new Command("go", command.node.left)); } } } }
-
同樣地,也對此進行測試,驗證是否編寫正確,運行結果如下:
-
從測試結果可以看出,遍歷的結果是符合預期的,和之前實現的遞歸方式的結果是一致的,驗證了代碼的正確性。至此,就將前、中、后序遍歷的非遞歸方式都實現了一遍了,使用的是模擬系統棧的方式,如此也加深了對這三種遍歷的理解以及對遞歸的理解,接下來就實現二分搜索樹中的最后一種遍歷層序遍歷。
層序遍歷
-
在前面的前、中、后序三種遍歷方式的實現過程中,可以發現這三種遍歷方式總是會先到最下層的節點處再往上返回,這種方式也就是深度優先遍歷。
-
而對於層序遍歷而言,它是按一層一層從左往右的順序來遍歷的,這種方式也就是廣度優先遍歷。
-
對於這種遍歷方式,通常使用的實現方式是非遞歸方式的實現,同時可以借助隊列這個數據結構來實現。
-
對於實現過程,當一個節點入隊並出隊時,這個節點就被遍歷到了,同時在該節點出隊時,由於隊列的先入先出特性以及層序遍歷的規則,此時將該節點的左右節點按左節點、右節點的順序入隊,此時再當隊首的節點出隊時,又再將出隊節點的左右節點按左節點、右節點的順序入隊。以此類推循環往復,就完成了二分搜索樹的層序遍歷操作,這個過程用圖來表示如下所示:
-
以上過程代碼實現如下:
/** * 二分搜索樹的層序遍歷 */ public void levelOrder() { // 借助一個隊列來實現層序遍歷 Queue<Node> queue = new LinkedList<>(); // 從根節點開始遍歷 queue.add(root); // 當隊列非空時,循環往復以下過程對二分搜索樹進行層序遍歷 while (!queue.isEmpty()) { // 當前遍歷的節點 Node currentNode = queue.remove(); System.out.println(currentNode.element); // 如果當前遍歷的節點的左右節點不為空,按左節點、右節點的順序入隊 if (currentNode.left != null) { queue.add(currentNode.left); } if (currentNode.right != null) { queue.add(currentNode.right); } } }
-
此時調用該方法驗證是否正確:
-
從結果可看出,遍歷的順序符合了預期,驗證了代碼的正確性。至此,二分搜索樹的幾種遍歷方式也就都實現了,接下來實現最后的刪除操作。
刪除操作
刪除最大元素和最小元素
-
對於刪除操作,首先先從刪除二分搜索樹的最大值和最小值開始,因為根據二分搜索樹的特性,最左邊的節點就是整棵樹的最小值,最右邊的節點就是整棵樹的最大值,所以相對來說這兩個操作比較容易實現,同時先實現了這兩個操作后,對於后續的刪除任意元素也有輔助的作用。以下是最大值和最小值的幾個示例圖:
-
在實現刪除的操作之前,先實現兩個函數用於找到二分搜索樹的最小元素和最大元素以備刪除時使用,具體實現如下:
/** * 找到二分搜索樹的最小元素 * @return 返回當前二分搜索樹的最小元素 */ public E minimum() { // 判斷當前二分搜索樹是否為空樹 if (size == 0) { throw new IllegalArgumentException("Minimum failed. Current BST is empty!"); } return minimum(root).element; } /** * 返回以 node 為根的二分搜索樹的最小值所在的節點 * @param node 尋找最小值的二分搜索樹的根節點 * @return 返回當前二分搜索樹的最小元素 */ private Node minimum(Node node) { // 當一個節點的左節點為空時,該節點就是樹中的最左節點了 if (node.left == null) { return node; } // 否則繼續往左子樹中尋找 return minimum(node.left); } /** * 找到二分搜索樹的最大元素 * @return 返回當前二分搜索樹的最大元素 */ public E maximum() { // 判斷當前二分搜索樹是否為空樹 if (size == 0) { throw new IllegalArgumentException("Maximum failed. Current BST is empty!"); } return maximum(root).element; } /** * 返回以 node 為根的二分搜索樹的最大值所在的節點 * @param node 尋找最大值的二分搜索樹的根節點 * @return 返回當前二分搜索樹的最大元素 */ private Node maximum(Node node) { // 當一個節點的右節點為空時,該節點就是樹中的最右節點了 if (node.right == null) { return node; } // 否則繼續往右子樹中尋找 return maximum(node.right); }
-
在實現完以上函數之后,就可以進行刪除的操作了。
-
首先先進行最小值的刪除,對於最小值的刪除,有兩種情況:刪除的節點是葉子節點、刪除的節點有右子樹。
-
對於葉子節點,直接刪除即可。而對於有右子樹的節點,刪除的邏輯也很簡單,即將當前的節點和樹脫離,再將這個節點的右子樹接到它原來的位置即可。而對於葉子節點,它的左右節點都是 null 的,所以對於葉子節點也可以使用這個邏輯來刪除,只不過接到節點原來位置的是 null 而已。
-
以上過程的圖示如下:
-
代碼實現如下:
/** * 刪除二分搜索樹中最小值所在的節點並且返回刪除的最小值 * @return 返回刪除的節點中的元素 */ public E removeMin() { // 先接收當前二分搜索樹中的最小值,以備刪除后返回 E delElement = minimum(); // 刪除操作 root = removeMin(root); // 返回刪除的最小值 return delElement; } /** * 刪除以 node 為根的二分搜索樹的最小節點 * @param node 刪除最小節點的二分搜索樹的根節點 * @return 返回刪除節點后新的二分搜索樹的根,即刪除的節點的右子樹的根節點 */ private Node removeMin(Node node) { // 當遞歸到一個節點的左節點為空時,此節點為最小節點,進行刪除操作 if (node.left == null) { // 先將刪除的節點 node 的右子樹記錄下來 Node rightNode = node.right; // 將 node 和它的右子樹脫離 node.right = null; size--; // 返回 node 的右子樹給上層節點接收,此時 node 和上層節點也脫離了 return rightNode; } // 左節點不為空時,繼續往左子樹遞歸,使用當前根節點的左節點接收 node.left = removeMin(node.left); // 最后返回當前根節點,完成刪除 return node; }
-
在處理完了最小元素的刪除之后,對於最大的元素刪除就容易許多了,刪除的邏輯總體上還是一樣的,也就是把刪除節點的左子樹接到節點原來的位置即可。刪除過程圖示如下:
-
代碼實現如下:
/** * 刪除二分搜索樹中最大值所在的節點並且返回刪除的最大值 * @return 返回刪除的節點中的元素 */ public E removeMax() { // 先接收當前二分搜索樹中的最大值,以備刪除后返回 E delElement = maximum(); // 刪除操作 root = removeMax(root); // 返回刪除的最小值 return delElement; } /** * 刪除以 node 為根的二分搜索樹的最大節點 * @param node 刪除最大節點的二分搜索樹的根節點 * @return 返回刪除節點后新的二分搜索樹的根,即刪除的節點的左子樹的根節點 */ private Node removeMax(Node node) { // 當遞歸到一個節點的右節點為空時,此節點為最大節點,進行刪除操作 if (node.right == null) { // 先將刪除的節點 node 的左子樹記錄下來 Node leftNode = node.left; // 將 node 和它的左子樹脫離 node.left = null; size--; // 返回 node 的左子樹給上層節點接收,此時 node 和上層節點也脫離了 return leftNode; } // 右節點不為空時,繼續往右子樹遞歸,使用當前根節點的右節點接收 node.right = removeMax(node.right); // 最后返回當前根節點,完成刪除 return node; }
-
在實現了以上兩個操作之后,對它們進行一下測試。
-
測試的邏輯為:隨機生成 1000 個 [0, 10000) 的數添加到二分搜索樹中,然后分別使用這兩個操作將刪除的元素添加到一個 ArrayList 中,再對這個 ArrayList 進行校驗,看看里面的元素是不是按從小到大的順序或從大到小的順序排列,校驗成功的話說明以上的操作實現的代碼是正確的。
-
測試代碼如下所示:
private static void testRemoveMin() { // 測試刪除最小節點 BST<Integer> bst = new BST<>(); Random random = new Random(); int n = 1000; for (int i = 0; i < n; i++) { bst.add(random.nextInt(10000)); } ArrayList<Integer> nums = new ArrayList<>(); while (!bst.isEmpty()) { nums.add(bst.removeMin()); } // 校驗 nums 是否按從小到大的順序排列 for (int i = 1; i < nums.size(); i++) { if (nums.get(i - 1) > nums.get(i)) { // 如果有一個數比后面的大,則說明刪除最小節點的操作的實現是錯誤的 throw new IllegalArgumentException("removeMin error!"); } } // 運行到此處校驗通過 System.out.println("removeMin test completed."); } private static void testRemoveMax() { // 測試刪除最大節點 BST<Integer> bst = new BST<>(); Random random = new Random(); int n = 1000; for (int i = 0; i < n; i++) { bst.add(random.nextInt(10000)); } ArrayList<Integer> nums = new ArrayList<>(); while (!bst.isEmpty()) { nums.add(bst.removeMax()); } // 校驗 nums 是否按從大到小的順序排列 for (int i = 1; i < nums.size(); i++) { if (nums.get(i - 1) < nums.get(i)) { // 如果有一個數比后面的小,則說明刪除最大節點的操作的實現是錯誤的 throw new IllegalArgumentException("removeMax error!"); } } // 運行到此處校驗通過 System.out.println("removeMax test completed."); }
-
運行結果
-
從運行結果中,可以看出以上實現的兩個刪除操作都是正確的,接下來就可以實現任意元素的刪除了。
刪除任意元素
-
對於刪除二分搜索樹中的任意元素,同樣也分為幾種情況:刪除只有左孩子的節點、刪除只有右孩子的節點、刪除左右都有孩子的節點。
-
對於前面兩種情況:刪除只有左孩子的節點、刪除只有右孩子的節點,具體步驟其實和前面的刪除最大節點和刪除最小節點差不多,也是將刪除的節點的左子樹或者右子樹掛接在這個節點原來的位置,將原來的節點從二分搜索樹中脫離出去,所以對於這兩種情況的刪除,實現起來和前面的基本相同。
-
而對於刪除左右都有孩子的節點這種情況,就不能使用前面的法子了,這個時候可以使用 Hibbard 提出的 Hibbard Deketion 原理進行刪除。
-
具體步驟是這樣的:
- 先將要刪除的節點記錄為 d,然后從 d 的右子樹中找到子樹中最小的節點用 s 記錄下來,這個 s 也就是 d 的后繼節點。
- 然后將 d 的右子樹中刪除掉最小的節點,也就是 s,並將刪除 s 后的這個右子樹的根賦值給 s 的右節點。也就是 s 從右子樹中最小的位置移到了根的位置。
- 接着再將 d 的左子樹賦值給 s 的左子樹。
- 最后將 d 從二分搜索樹中脫離出來,將 s 接到 d 的位置。此時,d 就從樹中刪除掉了。
-
以上步驟簡單來說就是 d 的右子樹的節點都是大於 d 的,而其中的最小節點就是排在它后面的節點,此時如果將這個 s 放到 d 的位置,這個 s 節點的左子樹依然滿足都小於它的特性、同樣右子樹也滿足都大於它的特性,此時就可以達到刪除 d 的效果了。
-
同理,也可以在 d 的左子樹中找它的前驅,也就是左子樹中最大的節點,用 p 記錄下來,再將這個 p 按照以上操作 s 的原理將 p 放置到 d 的位置,也可以達成刪除 d 的效果。這里就不實現這個找前驅的操作了,實現找后繼 s 的操作來刪除 d。
-
對於以上刪除的步驟,表示為圖為以下所示:
- 刪除只有左孩子的節點示例:
- 刪除只有右孩子的節點示例:
- 刪除左右孩子均有的節點示例:
- 刪除只有左孩子的節點示例:
-
代碼實現如下:
/** * 從二分搜索樹中刪除元素為 element 的節點 * @param element 要從二分搜索樹中刪除的元素 */ public void remove(E element) { root = remove(root, element); } /** * 刪除以 node 為根的二分搜索樹中值為 element 的節點 * 遞歸函數 * @param node 要刪除元素的二分搜索樹的根節點 * @param element 要刪除的元素 * @return 返回刪除節點后新的二分搜索樹的根 */ private Node remove(Node node, E element) { if (node == null) { // 如果根節點為空,沒有要刪除的元素,返回 null 即可 return null; } if (element.compareTo(node.element) < 0) { // 如果要刪除的元素比當前根節點的元素小,在左子樹中繼續尋找 element 進行刪除,並使用當前根節點的左節點接收結果 node.left = remove(node.left, element); // 刪除后返回當前根節點給上層節點接收 return node; } else if (element.compareTo(node.element) > 0) { // 如果要刪除的元素比當前根節點的元素大,在右子樹中繼續尋找 element 進行刪除,並使用當前根節點的右節點接收結果 node.right = remove(node.right, element); // 刪除后返回當前根節點給上層節點接收 return node; } else { // 當前根節點的元素為 element,進行刪除,三種情況 // 當前刪除節點只有右子樹 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; } // 當前刪除節點左右子樹均不為空 // 找到當前刪除節點大的最小節點,即刪除節點右子樹的最小節點 // 用這個最小節點代替刪除節點的位置 Node successor = minimum(node.right); // 將右子樹的最小節點移動到右子樹的根位置 successor.right = removeMin(node.right); // 將最小節點的左子樹賦為刪除節點的左子樹 successor.left = node.left; // 將刪除節點和二分搜索樹脫離 node.left = node.right = null; // 返回 successor 給上層節點接收,頂替 node 的位置,將 node 刪除掉 return successor; } }
-
實現完之后,也對此測試一下,以驗證代碼的正確性,測試代碼如下:
/** * 測試 BST */ public static void main(String[] args) { BST<Integer> bst = new BST<>(); int[] nums = {8, 4, 9, 10, 5, 3}; for (int num : nums) { bst.add(num); } System.out.println("刪除前: "); System.out.println(bst); //形成的二分搜索樹// ///////////////// // 8 // // / \ // // 4 9 // // / \ \ // // 3 5 10 // ///////////////// // 刪除 4 所在的節點 bst.remove(4); //刪除后的二分搜索樹// ////////////////// // 8 /// // / \ /// // 5 9 /// // / \ /// // 3 10 /// ////////////////// System.out.println("刪除后: "); System.out.println(bst); // 層序遍歷 // bst.levelOrder(); // 前序遍歷 // bst.preOrder(); // System.out.println("\n==========\n"); // 非遞歸前序遍歷 // bst.preOrederNotRecursive(); // 中序遍歷 // bst.inOrder(); // System.out.println("\n==========\n"); // 后序遍歷 // bst.postOrder(); // System.out.println(bst); // 校驗刪除最小節點操作是否成功 // testRemoveMin(); // 校驗刪除最大節點操作是否成功 // testRemoveMax(); }
-
運行結果
-
從運行結果,可以看出是符合預期的,驗證了代碼編寫正確。至此,二分搜索樹的這幾種操作就都實現完成了。
-
如有寫的不足的,還請見諒,也請大家多多指教。(*^▽^*)