玩轉資料結構——第五章:二分搜尋樹
內容概要:
- 為什麼要研究樹結構
- 二分搜尋樹基礎
- 向二分搜尋樹中新增元素
- 改進新增操作:深入理解遞迴終止條件
- 二分搜尋樹的查詢操作
- 二手搜尋樹的前序遍歷
- 二分搜尋樹的中序遍歷和後序遍歷
- 深入理解二分搜尋樹的前中後遍歷(深度遍歷)
- 二分搜尋樹是的前序遍歷的非遞迴實現
- 二分搜尋樹的層序遍歷(廣度遍歷)
- 刪除二分搜尋樹的最大元素和最小元素
- 刪除二分搜尋數的任意元素
1-為什麼要研究樹結構
- 樹結構本身是一種天然的組織結構
- 高效
- 將資料使用樹結構儲存後,出奇的高效
2- 二分搜尋樹基礎
什麼是二叉樹?
-
跟連結串列一樣,二叉樹也是一種動態資料結構,即,不需要在建立時指定大小。
-
跟連結串列不同的是,二叉樹中的每個節點,除了要存放元素e,它還有兩個指向其它節點的引用,分別用Node left和Node right來表示。
-
類似的,如果每個節點中有3個指向其它節點的引用,就稱其為"三叉樹"...
-
二叉樹具有唯一的根節點。
-
二叉樹中每個節點最多指向其它的兩個節點,我們稱這兩個節點為"左孩子"和"右孩子",即每個節點最多有兩個孩子。
-
一個孩子都沒有的節點,稱之為"葉子節點"。
-
二叉樹的每個節點,最多隻能有一個父親節點,沒有父親節點的節點就是"根節點"。
-
二叉樹的形象化描述如下圖:
Class Node{
E e;
Node left;//左孩子
Node right;//右孩子
}
- 二叉樹具有天然的遞迴結構
- 每個節點的左子樹也是二叉樹
- 每個節點的右子數也是二叉樹
- 二叉樹不一定是"滿"的
- 二叉樹不一定是"滿的",即,某些節點可能只有一個子節點;
- 更極端一點,整棵二叉樹可以僅有一個節點;在極端一點,整棵二叉樹可以一個節點都沒有;
什麼是二分搜尋樹(Binary Search Tree)?
- 二分搜尋樹是二叉樹
- 二分搜尋樹的每一個節點的值:
- 大於其左子樹的所有節點的值
- 小於其右子樹的所有節點的值
- 每一顆子樹也是二分搜尋樹
- 不一定每個二分搜尋樹都是"滿"的
- 儲存的元素必須有可比較性
二分搜尋樹的底層程式碼
/**
* 二分搜尋樹 Binray searc Tree
*/
public class BST<E extends Comparable<E>> {//E的型別必須滿足可比較性
private class Node {
public E e;//存放元素
public Node left, right;
//帶參建構函式
public Node(E e) {
this.e = e;
left = null;
right = null;
}
}
//成員變數
private Node root;//根節點
private int size;//儲存多少個元素
//二分搜尋樹的建構函式
public BST() {
root = null;
size = 0;
}
//成員函式,當前儲存多少元素
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
}
3-向二分搜尋樹中新增元素
compareTo() 方法
compareTo() 方法用於將 Number 物件與方法的引數進行比較。可用於比較 Byte, Long, Integer等。
該方法用於兩個相同資料型別的比較,兩個不同型別的資料不能用此方法來比較。
A.compareTo(B) :A是指定的數、B是引數
如果指定的數與引數相等返回0。(A=B)
如果指定的數小於引數返回 -1。(A<B)
如果指定的數大於引數返回 1。(A>B)
使用遞迴的方式解決二分搜尋樹的新增操作
- 首先最基本的問題,執行到函式最底層時要進行終止(返回),否則遞迴就會一直下去
- 最基本的問題是:當某個不為空的節點插入新的節點,假設插入右節點,那麼插入的數肯定比當前節點大,
- 然後繼續往下,沒有節點了
- node此時為空,則當node為空時(返回要插入的這個元素的節點)<——這是最基本問題(最底層)
- 當前的節點的右節點就可以順利插入元素e;
/**二分搜尋樹:當前節點
* 大於其左子樹的所有節點的值
* 小於其右子樹的所有節點的值
* @param node
* @param e
* @return
*/
//向以node為根節點的二分搜尋樹插入元素e,遞迴演算法
//返回插入新節點後二分搜尋樹的根
private Node add(Node node,E e){
//1找出最基本的問題,執行到函式最底層時要進行終止(返回),否則遞迴就會一直下去
//最基本的問題是:當某個不為空的節點插入新的節點,假設插入右節點,那麼插入的數肯定比當前節點大,
// 然後繼續往下,沒有節點了
// node此時為空,則當node為空時(返回要插入的這個元素的節點)<——這是最基本問題(最底層)
// 當前的節點的右節點就可以順利插入元素e;
//1最基本問題的解決
if(node==null) {//還有一種是從空節點的二叉樹中插入元素
size++;//記錄元素個數
return new Node(e);
}
if(e.compareTo(node.e)<0){//當前指定的元素e小於node的值,則在左邊插入
node.left=add(node.left,e);//呼叫遞迴
}
else if(e.compareTo(node.e)>0) {//當前指定的元素e大於node的值,則在右邊插入
node.right=add(node.right,e);
}
return node;//返回插入新節點後二分搜尋樹的根
}
//在二分搜尋樹中新增元素e
public void add(E e){
root= add(root,e);
}
5-二分搜尋樹的查詢操作(遞迴演算法)
這是最基本的問題:(類似終止條件)
- 1:空二分搜尋樹
- 2:沒有找到
- 3:找到了
//使用者使用的新增方法
public void add(E e) {
root = add(root, e);
}
//看以node為根的二分搜尋樹是否包含元素e,遞迴演算法
private boolean contains(Node node, E e) {
if (node == null)//(這是最基本的問題1:空二分搜尋樹 2:找到最底部都沒有 3:找到了)
return false;
//找到這個元素(這是也最基本的問題)
if (e.compareTo(node.e) == 0)
return true;
//如果這個元素在node的左邊,它將遞迴查詢左邊的數
else if (e.compareTo(node.e) < 0)
return contains(node.left, e);
//如果這個元素在node的右邊
else //e.compareTo(node.e)>0
return contains(node.right, e);
}
//看二分搜尋樹中是否包含元素e
public boolean contain(E e) {
return contains(root, e);
}
6-二分搜尋樹的遍歷(深度優先遍歷)
所謂的遍歷:
對於每個節點而言,可能會有左、右兩個孩子,所以分成下圖中3個點,每次遞迴過程中會經過這3個點
- 前序遍歷:先訪問當前節點,再依次遞迴訪問左右子樹
- 中序遍歷:先遞迴訪問左子樹,再訪問自身,再遞迴訪問右子樹
- 後續遍歷:先遞迴訪問左右子樹,再訪問自身節點
1. 前序遍歷
(1)演算法思想
前序遍歷:先訪問當前節點,再依次遞迴訪問左右子樹。檢視以下動畫即可
圖片來源:https://blog.csdn.net/ITermeng/article/details/77737480
其實在遍歷過程中每個節點都訪問了3次,對應著這3個小點,順序為前-> 中 -> 後,只有在“前”點時才會列印該節點元素值。
最終列印結果:
程式碼實現:
//二分搜尋樹的前序遍歷
public void preOrder() {
preOrder(root);
}
//前序遍歷以node為根節點的二分搜尋樹,遞迴演算法
private void preOrder(Node node) {
if (node == null)//遞迴的終止條件
return;
System.out.println(node.e);//列印它走過的節點
preOrder(node.left);
preOrder(node.right);
}
測試準備:
@Override
public String toString() {
StringBuilder res = new StringBuilder();
generateBSTString(root, 0, res);
return res.toString();
}
// 生成以node為根節點,深度為depth的描述二叉樹的字串
private void generateBSTString(Node node, int depth, StringBuilder res) {
if (node == null) {
res.append(generateDepthString(depth) + "null\n");
return;
}
res.append(generateDepthString(depth) + node.e + "\n");
generateBSTString(node.left, depth + 1, res);
generateBSTString(node.right, depth + 1, res);
}
private String generateDepthString(int depth) {
StringBuilder res = new StringBuilder();
for (int i = 0; i < depth; i++)
res.append("--");
return res.toString();
}
在Main中測試
public class Main {
public static void main(String[] args) {
BST<Integer> bst = new BST<>();
int[] nums = {5, 3, 6, 8, 4, 2};
for(int num: nums)
bst.add(num);
/////////////////
// 5 //
// / \ //
// 3 6 //
// / \ \ //
// 2 4 8 //
/////////////////
bst.preOrder();
System.out.println();
System.out.println(bst);
}
}
結果:
5
3
2
4
6
8
5
--3
----2
------null
------null
----4
------null
------null
--6
----null
----8
------null
------null
2. 中序遍歷
(1)演算法思想
中序遍歷:先遞迴訪問左子樹,再訪問自身,再遞迴訪問右子樹。
在遍歷過程中每個節點都訪問了3次,對應著這3個小點,順序為前-> 中 -> 後,只有在“中”點時才會列印該節點元素值。
最終列印結果:
檢視其列印結果,是按照從小到大的順序進行列印的,所以在進行實際應用時,可使用二分搜尋輸的中序遍歷將元素按照從小到大順序輸出。其原因與二分搜尋樹定義相關的!
程式碼實現:
//二分搜尋樹的中序遍歷
public void inOrder() {
inOrder(root);
}
//中序遍歷以node為根的二分搜尋樹,遞迴演算法
private void inOrder(Node node) {
if (node == null)
return;
inOrder(node.left);
System.out.println(node.e);//列印當前根節點
inOrder(node.right);
}
Main函式測試:
public class Main {
public static void main(String[] args) {
BST<Integer> bst = new BST<>();
int[] nums = {5, 3, 6, 8, 4, 2};
for(int num: nums)
bst.add(num);
/////////////////
// 5 //
// / \ //
// 3 6 //
// / \ \ //
// 2 4 8 //
/////////////////
bst.inOrder();
System.out.println();
}
}
結果:
2
3
4
5
6
8
3. 後序遍歷
(1)演算法思想
後續遍歷:先遞迴訪問左右子樹,再訪問自身節點。
在遍歷過程中每個節點都訪問了3次,對應著這3個小點,順序為前-> 中 -> 後,只有在“後”點時才會列印該節點元素值。
最終列印結果:
程式碼實現:
//二分搜尋樹的後序遍歷
public void postOrder() {
postOrder(root);
}
//後序遍歷以node為根的二分搜尋樹,遞迴演算法
public void postOrder(Node node) {
if (node == null)//遞迴終止條件
return;
/*先查詢左邊子樹,再遍歷右子樹,再遍歷這個節點
*/
postOrder(node.left);
postOrder(node.right);
System.out.println(node.e);
}
測試:結果
/////////////////
// 5 //
// / \ //
// 3 6 //
// / \ \ //
// 2 4 8 //
/////////////////
2
4
3
8
6
5
以上所有深度優先遍歷程式碼實現可分為3個步驟:
- 遞迴左孩子
- 遞迴右孩子
- 列印自身
以上遍歷只是交換了這3個步驟的執行順序。
9-二分搜尋樹是的前序遍歷的非遞迴實現
28進隊後出隊,其右左節點進隊(後進先出),然後左節點16出隊,對16節點進行訪問,16節點有左右節點,將16的左右節點先右節點、後左節點的方式存入..
/*用非遞迴的方式(藉助棧)實現二分搜尋樹的前序遍歷
*/
public void preNROrder() {
Stack<Node> stack = new Stack<>();
stack.push(root);//把根節點放到棧裡面
while (!stack.isEmpty()) {//當棧裡面不為空
Node cur = stack.pop();//拿到棧頂元素,放到cur裡面
System.out.println(cur.e);
//棧是後入先出的(要實現先訪問左子樹再訪問右子樹,就先把右子樹放進棧裡
if (cur.right != null)
stack.push(cur.right);
if (cur.left != null)
stack.push(cur.left);
}
}
10-二分搜尋樹的層序遍歷(廣度遍歷)
前序、中序、後序遍歷本質都是深度優先遍歷
- 層序遍歷:根節點設定為第0層;先遍歷第0層28、再遍歷第1層16、30;
- 再遍歷第2層13、22、29、42;逐層向下遍歷的節點在廣度上進行拓展;
- 這種遍歷方式也稱為廣度優先遍歷;通常使用非遞迴的方式實現(藉助佇列Queue)
圖解:用佇列的方式實現層序遍歷
1.每一次一個元素入隊,從隊尾的位置進入佇列,初始化時將根節點入隊
2.以後每一次要做的事就是先看隊首(看該到誰開始遍歷了),出隊的就是根節點28,訪問28對其進行相應的操作,這樣28就遍歷完了;
3.將根節點 28 的左右孩子(16和30)分別入隊【對於佇列來說,是先進先出,所以我們按照從左到右的順序進行入隊,所以先入隊16後入隊30】
4.現在的隊首是16,將16拿出來,對其進行訪問
5.將 16 的左右孩子 13 和 22 入隊,
6.對隊首元素 30 出隊,對其進行操作,並將 30 的左右孩子(29和42)入隊
7.對隊首元素 13 出隊,對其進行操作,但13沒有左右節點,他是個葉子節點;
8.然後與上述相同,依次對隊首元素進行操作,佇列全部出隊;【到這一步,佇列的排序就與層序遍歷相同了】
//二分搜尋樹的層序遍歷
public void levelOrder() {
Queue<Node> q = new LinkedList<>();
q.add(root);
while (!q.isEmpty()) {//如果佇列不為空
Node cur = q.remove();//出隊元素放到cur
System.out.println(cur.e);
if (cur.left != null)//有左孩子
q.add(cur.left);
if (cur.right != null)//有右孩子
q.add(cur.right);
}
}
測試結果
/////////////////
// 5 //
// / \ //
// 3 6 //
// / \ \ //
// 2 4 8 //
/////////////////
5
3
6
2
4
8
11-刪除二分搜尋樹的最大元素和最小元素
random.nextInt(int n)
該方法的作用是生成一個隨機的int值,該值介於[0,n)的區間,也就是0到n之間的隨機int值,包含0而不包含n。
尋找二分搜尋樹的最小元素:
最小元素都在node的左邊
// 尋找二分搜尋樹的最小元素
public E minimum() {
if (size == 0)
throw new IllegalArgumentException("BST is empty");
Node minNode = minimum(root);
return minNode.e;
}
// 返回以node為根的二分搜尋樹的最小值所在的節點
private Node minimum(Node node) {
if (node.left == null)
return node;
return minimum(node.left);
}
尋找二分搜尋樹的最大元素:
最小元素都在node的右邊
// 尋找二分搜尋樹的最大元素
public E maximum() {
if (size == 0)
throw new IllegalArgumentException("BST is empty");
return maximum(root).e;
}
// 返回以node為根的二分搜尋樹的最大值所在的節點
private Node maximum(Node node) {
if (node.right == null)
return node;
return maximum(node.right);
}
刪除二分搜尋樹最小節點
// 從二分搜尋樹中刪除最小值所在節點, 返回最小值
public E removeMin() {
E ret = minimum();
root = removeMin(root);
return ret;
}
// 刪除掉以node為根的二分搜尋樹中的最小節點
// 返回刪除節點後新的二分搜尋樹的根
private Node removeMin(Node node) {
//遞迴的終止條件(最基本問題)
if (node.left == null) {//當此時node沒有左子樹
Node rightNode = node.right;//儲存node右子樹
node.right = null;//另當前節點node.left=node.right=null 脫離二分搜尋樹
size--;//控制二分搜尋樹的大小
return rightNode;//返回它的右子樹
}
//遞迴操作
node.left = removeMin(node.left);
return node;
}
刪除二分搜尋樹的最大節點
// 從二分搜尋樹中刪除最大值所在節點
public E removeMax() {
E ret = maximum();
root = removeMax(root);
return ret;
}
// 刪除掉以node為根的二分搜尋樹中的最大節點
// 返回刪除節點後新的二分搜尋樹的根
private Node removeMax(Node node) {
if (node.right == null) {
Node leftNode = node.left;
node.left = null;
size--;
return leftNode;
}
node.right = removeMax(node.right);
return node;
}
刪除二分搜尋樹的任意節點
三種情況(葉子節點可以是以下任意一種情況)
1.要刪除的節點沒有左子樹
2.要刪除的節點沒有右子樹
3.要刪除的節點左右子樹都有
當要刪除的節點左右子樹都有:
可以採用2種方式:
1.將該節點的左子樹的最大值(前驅),頂替該節點的位置,然後讓該節點脫離二分搜尋樹
2.將該節點的右子樹的最小值(後繼),頂替該節點的位置,然後讓該節點脫離二分搜尋樹
以下程式碼是第二種後繼的方式:
//從二分搜尋樹中刪除元素為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) {//要刪除的元素e比當前元素小
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.equals(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;//讓當前的node與二分搜尋樹脫離 滿足node.left=node.right=null
size--;
return leftNode;
}
//第三種情況
//待刪除的節點左右子樹均不為空
//找到比待刪除節點到達的最小節點,即待刪除節點的右節點的最小節點
//用這個節點頂替待刪除節點的位置
Node successor = minimum(node.right);//找到當前節點右子樹最小的值
//successor為頂替待刪除的節點(後繼)
successor.right = removeMin(node.right);//將node.right的最小值給刪除
successor.left = node.left;
node.left = node.right = null;//讓當前node與二分搜尋樹脫離關係
return successor;
}
}
(轉自發條魚)