純資料結構Java實現(4/11)(BST)
個人感覺,BST(二叉查詢樹)應該是眾多常見樹的爸爸,而不是弟弟,儘管相比較而言,它比較簡單。
二叉樹基礎
理論定義,程式碼定義,滿,完全等定義
不同於線性結構,樹結構用於儲存的話,通常操作效率更高。就拿現在說的二叉搜尋樹(排序樹)來說,如果每次操作之後會讓剩餘的資料集減少一半,這不是太美妙了麼?(當然不當的運用樹結構儲存,也會導致從 O(logN) 退化到 O(n))。
- 值得說明,O(logN) 其實並不準確,準確來說應該說 O(樹的高度)
定義&性質&行話
樹裡面常見的二叉樹: BST, AVL,特殊的AVL(比如2-3樹,即紅黑樹)。
不常見的有(包括多叉樹): 線段樹,Trie。(但我們說的不常見,不見得真的不常見,可能核心或者框架裡面有用到,而寫應用的沒有用到;所以常見或者不常見是沒有一個確定概率基準的,個人把一般寫應用的標準定義為基準)
二叉樹就是一個最多: 一個爸爸,倆孩子 的樹。
這樣說,不形象,那形象點兒,直接上程式碼:
class Node {
E e;
Node left;
Node right;
}
當然,理論教科書上肯定不會這麼傻帽的直接告訴你具體情況,它要繞一下,先來一個遞迴定義,把你繞暈,有圖有真相:
然後一個節點也是樹,空(null) 也是樹。
這裡有一些行話,包括上面的認為 logN 就是樹高(在一般性的時間複雜度分析時),解釋:
- 深度為 n 的二叉樹,每層最多有 2^(n-1) 個節點
- 深度為 n 的二叉樹,總共元素,最多有 2^n -1 個節點
(深度從 1 開始,從上往下看)(自己畫一下圖,腦海中想一下就知道了)
其他行話,滿二叉樹,完全二叉樹,尤其完全二叉樹這個概念,後續樹結構中有很重要的意義。
- 滿的很好理解吧,圖上理解,就是所有父親都有倆孩子;資料上就是元素總共為 2^n-1
- 完全二叉樹: 如果你從上到下,從左到右給樹的節點編號,那麼樹應該是你看到的方向排布。
設二叉樹的深度為h,除第 h 層(最後一層)外,其它各層 (1~h-1) 的結點數都達到最大個數,第 h 層所有的結點都連續集中在最左邊,這就是完全二叉樹。
個人經驗,直接看最後一層是否靠左。
BST基礎
BST 說白了也是一種查詢樹,只是它的儲存是有序的。
- (刪除的時候要調整,就知道維護的代價了)
存入的值要麼是可比較的,要麼不能自身提供比較方法時,要主動傳入比較器(Comparator)
也就是說,任意一個節點(它作為根節點看待),它的左子樹的值都比它要小,右子樹的值都比它大。(正是因為其儲存的時候就有序,所以取的時候也高效)
哦,BST 和滿二叉樹沒啥關係。
BST實現
基本框架
先設定好節點資訊,大概如下:
package bst;
//二分搜尋樹
public class BST<E extends Comparable<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 getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
}
清晰,簡介,然後來補充增刪查改,遍歷等方法。
先做一個規定,對於重複元素的處理是: do nothing。
(它已經在樹上了,就不要動了;或者你可以給 Node 增加一個屬性 count,專門記錄)
增加操作
想象一下,放一個元素到樹上,從根節點開始比較,以後的比較也是根據當前樹的根元素,從而決定放在左子樹,還是右子樹上。(上面已經說了,相等的元素,什麼也不幹)
上面的分析其實已經很清楚了,那麼下面考慮一下寫法:
- 遞迴怎麼寫: 每次切換當前 root 元素,即要搜尋的子樹
- 終結條件(base condition): 找到了相關的位置(null),然後放入元素,返回以該節點為根的子樹給上級樹,的左/右孩子接收
- 子樹不為空,且元素值不等,說明不是終極條件,那接著切換子樹找啊
- 迴圈怎麼寫: 思想類似上面,不斷下探的過程改成while迴圈的解法,即不斷遞推替換 currNode 為其左或者右子樹的根節點(就是孩子啦)
- 技巧是,在尋找合適位置時,不僅需要記錄 curr,還要記錄 parent
- 並且實際插入的時候,還是以 parent,即子樹的根為合適位置的依據(這根遞迴做法不同)
一般教科書上才會介紹迴圈的寫法,大致如下: 《演算法-C描述》
OK,我們還是用 遞迴去寫吧,不是偷懶,而是遞迴更易於巨集觀理解啊傻
遞迴的寫法: 不斷的切換每層呼叫的 root 節點的指向 (相當於切換到子樹)
大致寫法如下: (注意柯里化包裹 -- 柯里化參考阮一峰的部落格JS語言部分)
- 上面已經說了優化過的遞迴終止條件,但是也可以像下面這樣沒有優化(即還沒有判斷到底就開始分情況返回) --- 作為思考的中間結果吧 --- 優化的結果看最後面的實現。
//向二分搜尋樹中新增元素
private void add(E e) {
if(root == null) {
root = new Node(e);
size++;
} else {
//切換當前 root 節點進行遞迴呼叫
add(e, root); //第一次呼叫時已經確定 root != null 了
}
}
private void add(E e, Node node) {
//這裡的 node 相當於一個子樹的 root
//複雜的遞迴終止條件
if(e.equals(node.e)) {
//已經存在了,do nothing
return;
} else if(e.compareTo(node.e)>0 && node.right == null) {
//不存在子樹的情況
//右子樹為空,直接插入
node.right = new Node(e);
size++;
return;
} else if(e.compareTo(node.e)<0 && node.left == null) {
node.left = new Node(e);
size++;
return;
}
//一般的情況,即存在左或者右子樹的情況
//此時已經判斷 right 或者 left 不為 null 了
if(e.compareTo(node.e)>0) {
//應該在右子樹上找位置
add(e, node.right);
} else {
//在左子樹上找位置
add(e, node.left);
}
}
但是終止條件可以寫的更加簡單一點,即一直找到空位置再說插入的事兒,而不是分情況判斷時就作為終止條件:
- 找到那個空位置,就在那個位置插入
此時,可以看到,遞迴函式 add 也需要修改成有返回值了,然後讓這個返回值和原BST掛接。(返回以當前節點為根的子樹,掛接給上級才對;思想就是: 上級只關心完整的結果!)
//返回插入新節點後BST的根 (每層呼叫都是如此)
private Node add(E e, Node root) {
if (root == null) {
size++;
return new Node(e);
}
if (e.compareTo(root.e) > 0) {
//放在右子樹
//因為add 函式返回的就是 插入新節點後BST的根
root.right = add(e, root.right);
} else if (e.compareTo(root.e) < 0) {
root.left = add(e, root.left);
}
//相等的情況?抱歉,啥也做
return root;
}
- 這裡的癥結就在於,給當前root的子樹(不管左右哪一個),插入完成後,都會對當前 root 的左或者右孩子進行重新賦值(後半句是,所以才不關心左右子樹空不空)
而遞迴程式碼中已經包含了 root == null 的情況,即空BST的情況,所以主調函式可以直接呼叫 private 的 add,而不必再判斷 root
為 null:
//client 呼叫
//向二分搜尋樹中新增元素
private void add(E e) {
root = add(e, root);
}
查詢操作
這就相當於二分查詢,不斷的折半 — 這裡判斷是否存在即可
也可以分為用遞迴和非遞迴的寫法,然而遞迴的寫法會簡單很多,能寫遞迴也能寫迴圈。
遞迴的寫法
- 不斷的更換子樹 (以根為子樹的依據)
//查詢元素是否存在
public boolean contains(E e) {
return contains(root, e);
}
//以 root 為根的BST中是否存在 e
private boolean contains(Node root, E e) {
if(root == null) {
return false;
}
if (e.compareTo(root.e) > 0) {
//去查右子樹
return contains(root.right, e);
} else if (e.compareTo(root.e) < 0) {
return contains(root.left, e);
}
//return root.e.compareTo(e) == 0;
return true;
}
最後的root.e.compareTo(e)
沒有必要,因為除了 > 和 < 剩下的就是 == 的情況了。
遍歷操作(遞迴)
遍歷相對來說還是比較容易的,這裡先說深度優先。
深度優先: 先序遍歷,中序遍歷,後序遍歷;這些都是相對於根元素來說的。
以 先根遍歷
來舉個例子:
public void preOrder() {
preOrder(root);
}
private void preOrder(Node root) {
if(root == null) {
return;
}
System.out.println(root.e);
preOrder(root.left);
preOrder(root.right);
}
到這裡可以簡單測試一下:
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);
}
bst.preOrder(); //5 3 2 4 6 8
}
其他的遍歷方式,其實就非常容易改寫了。
例如 中根遍歷:
//中序遍歷
public void inOrder() {
inOrder(root);
}
private void inOrder(Node root) {
if (root == null) {
return;
}
inOrder(root.left);
System.out.println(root.e);
inOrder(root.right);
}
//測試輸出: 2 3 4 5 6 8 (相當於一個排序結果)
從輸出的結果可以看到一個性質,BST的中序遍歷結果是一個排序結果。
例如 後序遍歷:
//後序遍歷
public void postOrder() {
postOrder(root);
}
private void postOrder(Node root) {
if (root == null) {
return;
}
postOrder(root.left);
postOrder(root.right);
System.out.println(root.e);
}
先把孩子處理好,再來弄自己的事兒。。。可以聯絡到記憶體釋放,先釋放子視窗的,最後在關閉 main。
遍歷操作(非遞迴)
這裡用變數的迭代器換也能寫,不過我見過的比較高明的手段,用棧來寫。
用棧記錄後面需要操作(當前還不需要執行),當前處理的則出棧(這裡運用上,有一些技巧),我直接說了:
- 每次都出棧當前子樹根元素(列印操作),然後把它的左右子節點反序放入棧中(反序是因為棧是後入先出的);
- 什麼時候結束,樹遍歷完畢的時候;即棧裡沒有內容了
可以看下程式碼實現:
//先序遍歷,非遞迴寫法
public void preOrderNR() {
Stack<Node> stack = new Stack<>();
stack.push(root); //開始之前的準備,真正的根入棧
while (!stack.isEmpty()) {
Node curr = stack.pop(); //出棧當前的根元素
System.out.println(curr.e);
if (curr.right != null) {
stack.push(curr.right);
}
if (curr.left != null) {
stack.push(curr.left);
}
}
}
開始之前,根元素入棧,保證棧裡面有內容,然後第一次檢查棧,出棧一個元素,即真正的根元素,先列印它,接著其左右孩子反序入棧。下次迴圈,後入棧的左子樹根出棧,進行左邊子樹的整個操作,全部完畢後才輪到右子樹(的根出棧,然後完成類似結果)。
舉一個形象的例子:
16 出棧後,此時壓入 16 的右孩子 22,然後壓入左孩子 13。(遍歷的反序)
中序遍歷,後序遍歷的非遞迴實現?麻煩自己寫一下,貌似沒有太多實際參考。
層序遍歷
就是廣度優先啦,這個概念貌似應該用於圖,但樹也可以用到。
廣度優先覺得更應該用於決策,因為它會避免你一條路走到黑。(不至於把一棵子樹全部找完,然後發現沒找到,才去找另一個子樹。。。)
這個我比較熟悉,一般需要藉助佇列,寫非遞迴實現:
- 每次出隊一個元素(當前的根節點)時,把其孩子也先後放入佇列中(等待後續遍歷)
- 從結果來看,就是層序遍歷了 (可以想象一下資料夾的遍歷,把當前資料夾的子資料夾放入佇列的尾部)
在腦海中可以想象一下,現實司令級別的班子先過,每次檢查到一個司令(出隊),就把他手下的軍長排在佇列的尾部(即所有司令的後部),這樣一直不斷,知道司令外部出去,檢查第一個軍長,同理把他手下的師長全部入隊(即所有軍長的後面)...直到佇列為空,全部遍歷完畢。
直接寫程式碼:
//廣度優先-層序遍歷
public void levelOrder() {
Queue<Node> queue = new LinkedList<>(); //util linkedlist 實現了 queue 介面
queue.add(root);
while((!queue.isEmpty()) {
Node curr = queue.remove();
System.out.println(curr.e);
if(curr.left != null) {
queue.add(curr.left);
}
if(curr.right != null) {
queue.add(curr.right);
}
}
}
忘了說了,層序遍歷,先左後右。
在圖中的遍歷還需要記錄某個節點是否訪問過,因為二叉樹的爸爸只有一個,但是圖中節點的前驅可能有多個的,為避免重複遍歷,所以必須標記一下是否訪問過。
刪除操作(重點)
整個 BST 的難點就在這裡,想也知道啊,我刪了一個元素,那麼以這個元素為根的樹怎麼調整才能與當前元素的上級掛接(嫁接)好呢?
(複雜可能在於,需要重新排序樹)
題外話:
我自個兒也是琢磨了一會兒,然後發現對付複雜,**先從特例上找規律,然後推廣到一般**。
特例的規律如若不能推廣到一般,那麼就按照第二種思路: 分分合合。
* 分: 詳細分析情況,不漏掉情況(暴力窮舉)
* 合: 這些具體的,分情況的方案能否合併,上升到一個層級規律
這樣的好處,即便不能合,那麼分情況的窮舉(暴力解法),我們也能保底。(解決的不優雅,但按性質劃分還是屬於已解決的分類,有政治優勢)
直接說結論,這裡採用的是(找到元素)替換(覆蓋)要被刪除的元素
找一個怎麼樣的節點去替換啊? 找一個最接近被刪除元素大小的元素替換。
- 此時其實挪動和改變最少,且滿足刪除後該樹仍舊是一棵BST的要求
最接近的元素: 要刪除元素的左子樹上找最大,要刪除元素的右子樹上找最小。
先看看一棵子樹上怎麼找最大最小:(鋪墊)
- 查詢最小值: 一直尋找左孩子,直到最左邊的一個節點沒有左孩子(null)。
- 查詢最大值: 一直尋找右孩子,直到最右邊的一個節點沒有右孩子(null)。
大致實現如下:
//獲取樹的最小值 (遞迴寫法)
public E getMin() {
if(isEmpty()) {
throw new IllegalArgumentException("Tree is Empty");
}
return getMin(root).e;//呼叫遞迴的寫法
}
private Node getMin(Node node) {
if(node.left == null) {
return node;
}
return getMin(node.left);
}
//樹的最大值 (非遞迴寫法)
public E getMax() {
if(isEmpty()) {
throw new IllegalArgumentException("Tree is Empty");
}
Node curr = root;
while(curr.right != null) {
curr = curr.right;
}
return curr.e;
}
這裡是拿到值,所以簡單,但是刪除的話,處理的是 Node,而不僅僅是值。
所以拿到最小元素的節點(當然順帶也要刪除這個值),以及拿到最大元素的節點(順帶也要刪除這個值),這兩個值一定是最接近當前元素的,用它們中的一個,來覆蓋當前被刪除的元素的位置即可。
找到並刪除(當前元素相關的)最小元素:
- 最小元素: 向左找沒有左節點的最終節點
- 該節點沒有孩子,(左子樹一定為空了),直接刪除 —— 葉子節點的情況
- 該節點有右子樹,直接返回該右節點 (作為子樹的根,進行下一次迴圈或者遞迴起點) —— 非葉子節點
- 維護 size 的大小
程式碼實現:
//刪除最小值 (遞迴寫法)
public E removeMin() {
E ret = getMin(); //不需要 isEmpty判斷了
root = removeMin(root); //操作完畢返回新樹的根
return ret;
}
//刪除以 node 為根的子樹的最小節點;返回該子樹的根
private Node removeMin(Node node){
//base 情況
if(node.left == null) {
//當前子樹的左子樹為空(不管右子樹如何),說明當前節點最小
//當然,如果有右子樹,要把右子樹嫁接到父節點(返回右子樹的根給上級即可)
//刪除同時還要把當前節點的孩子置空
Node rightNode = node.right; //當前節點的左子樹可能為空
node.right = null; //(當前節點的左子樹已經為空了)
size--;
return rightNode;//返回給上級(具體說,上級的左子樹)
}
//一般情況 (以 node 為根,還有左子樹)
node.left = removeMin(node.left); //遞迴過程返回調整後的子樹(根節點)
return node; //每次都返回該子樹刪除最小值之後的結果
}
找到並刪除(當前元素相關的)最大元素:
此過程類似於刪除最小元素,遞迴操作時,每次都返回刪除該node為根的子樹的根節點,讓上級節點的右節點接收。(末尾節點(右子樹一定空)如果沒有子節點,即左節點也為空,那麼 base 就返回 null,否則把左子樹嫁接到上級,具體說就是上級節點的右子樹)
程式碼實現:
//刪除最大值 (遞迴寫法)
public E removeMax() {
E ret = getMax();
root = removeMax(root);
return ret;
}
//返回刪除最大值後的根節點(子樹)給上級節點
private Node removeMax(Node root) {
//Base 情況(末尾節點)
if(root.right == null) { //沒右子樹了
//判斷其是否還有左子樹,然後把左子樹返回給上級節點(的右子樹接收)
//同上,不必判斷,因為 root.left 即便為 null 也包含在這種情況中
Node leftNode = root.left;
root.left = null;
size--;
return leftNode;
}
root.right = removeMax(root.right);//有右子樹那就在右子樹上找
return root;
}
從上面刪除最大,最小的邏輯,可以推廣到一般情況,具體來說,舉個例子:
- 刪除 58 很容易,直接把 60 這個節點返回給右子樹 (因為 41 為根的這棵樹已經有左子樹)
- 刪除 22 則情況至少有兩種(左右都有節點),一種 15 上位(作為 41 的左子樹),然後 33 接在 15 的右子樹上,另外一種則是 33 上位,然後 15 接在 33 的左子樹上
- 這裡 22 的子樹比較特殊(只有半邊子樹),所以可以很容易看出兩種情況
- 也就是說,可以在左邊找前驅,也可以在右邊找後繼
- 葉子節點更簡單,不必單獨拿出來,直接返回給上級一個 null 即可作為子樹即可
若某節點的子樹都是滿的,則不那麼容易,以後繼為例,講一下思路:
其實就是在右子樹上找一個最節點最接近當前值的節點,替代當前要刪除的節點。即 min(d->right)。換句話說,右子樹中找到最小元素(其實是刪除掉這個元素),然後在 d 的位置填補這個元素,圖例如下:
上面把 s 的一切設定好(left, right),此時 s 就是整個調整完畢的子樹根,把它返回給上級樹的右子樹即可。
這裡已經可以看出,結論如下:
- 但凡有一邊子樹是空的,就是特殊情況,方便處理(下面程式碼也會證明這一點)
- 整整困難的是兩邊都是滿的,此時需要 找前驅或者後繼,替換 這種思路(至於用前驅還是後繼,隨意; 因為都能保證之後的樹是一棵BST)
那麼程式碼實現: (參考刪除最大最小的邏輯,推到刪除的一般邏輯,以後繼為例)
/*找後繼的實現*/
//使用者指定刪除某個值 e 的節點
public void remove(E e) {
root = remove(root, e); //每次遞迴都是返回操作後的子樹(根節點)
}
/*
刪除以 root 為根的子樹中值為 e 的節點
返回操作完畢後子樹的根
*/
private Node remove(Node root, E e) {
//先要找到這個元素
//base 情況 (前一次遞迴呼叫返回的是 null 的情況)
if (root == null) {
return null; //這個空值最終會作為上級樹的孩子
}
//(分情況在子樹上找)
if (e.compareTo(root.e) > 0) {
//在右子樹上找
root.right = remove(root.right, e);
} else if (e.compareTo(root.e) < 0) {
//在左子樹上找
root.left = remove(root.left, e);
} else {
// 找到的情況 e.compareTo(root.e) == 0
//從其左右子樹上找替換元素 (這裡採用後繼進行替換)
//同時需要返回新的根節點給上級
/*
1.左右子樹都在 (融合的情況) -- 找到右子樹上最小元素進行替代
2.左子樹在,但是沒有右子樹 (類似於刪除最大值的邏輯)
3.右子樹在,但是沒有左子樹 (類似於刪除最小值的邏輯)
4.左右子樹都空了,葉子節點(直接 return null) 即把空子樹返回給上級節點(這一種情況已經包含在2, 3的寫法中了)
先寫 2, 3, 4 這類簡單的情況 (返回值接在上層左子樹還是右子樹不確定,這要看上層的遞迴呼叫是哪種情況)
*/
if (root.right == null) {
Node leftNode = root.left;
root.left = null;
size--;
return leftNode; //包含了 leftNode 也為 null 的情況
}
if (root.left == null) {
//類似於查詢最小值的情況
Node rightNode = root.right;
root.right = null;
size--;
return rightNode;
}
//左右子樹都不空的情況(先找到替代節點 successorNode )
Node successorNode = getMin(root.right);
successorNode.right = removeMin(root.right); //返回的是刪除最小元素後的根節點
successorNode.left = root.left;
//置空原來 root 的 left 和 right
root.left = root.right = null;
return successorNode; //返回設定好的元素給上級
}
return root;
}
上面的程式碼,找到要替換的元素時,分情況分析,即:
1.左右子樹都在 (融合的情況) -- 找到右子樹上最小元素進行替代
2.左子樹在,但是沒有右子樹 (類似於刪除最大值的邏輯) --- 程式碼類似
3.右子樹在,但是沒有左子樹 (類似於刪除最小值的邏輯) --- 程式碼類似
4.左右子樹都空了,葉子節點(直接 return null) 即把空子樹返回給上級節點(這一種情況已經包含在2, 3的寫法中了)
這相當於在暴力窮舉了,然後發現貌似確實不能合併,所以就這樣劃分了。
BST總結
這裡會總一些可能有用的結論:
最重要的一條,BST的有序性(放入其中的元素,有序儲存)是有維護代價的
- 遍歷:中序遍歷,所有元素是升序排列。
- 最大值,最小值: 不斷向左找左子樹,不斷向右找右子樹。
- 前驅和後繼(節點): 左子樹中找最大值就是前驅(predecessor),右子樹中找最小值就是後繼(successor)。
- 每個節點Node可以維護額外的屬性,比如 count, rank, depth 等,適用於業務查詢
- floor 和 ceil: 尋找某個值的 floor 和 ceil
- 該元素可以不在樹上
整個看下來,也就是刪除上要仔細考慮好幾種情況以及破天荒的有 替換
的思維。
它的實現基本上就先這樣(應用上有很多變形,但這與其實現無關,後面再說)。
老規矩,程式碼請參考 gayhub。