資料結構-二分搜尋樹(Binary Search Tree)
特點
-
動態資料結構
-
是一顆二叉樹
-
二分搜尋樹的每個節點的值:
- 每個節點的值都大於其左子樹的所有節點的值
- 每個節點的值都小於其右子樹的所有節點的值
-
每一顆子樹也是二分搜尋樹
-
儲存的元素必須有可比較性, Java中的話就要求二分搜尋樹儲存的資料型別要實現Comparable介面, 或者使用額外的比較器實現
-
一般二分搜尋樹不包含重複元素, 當然也可以定義包含重複元素的二分搜尋樹
如果想要包含重複元素的話, 只需要定義二分搜尋樹的左節點的值小於等於當前節點的值或右節點的值大於等於當前節點即可
-
二分搜尋樹天然的具有遞迴特性
下面是二分搜尋樹的幾個樣例 ?*
操作
在進行相關操作之前, 先定義一個支援泛型的節點類, 用於儲存二分搜尋樹每個節點的資訊, 這個類作為二分搜尋樹的一個內部類, 二分搜尋樹的類宣告以及Node節點類宣告如下:
/**
* 遞迴實現二分搜尋樹
* 這裡設計的樹是不儲存重複元素的, 重複新增元素只儲存一個
* @author 七夜雪
*
*/
public class BSTree<E extends Comparable<E>> {
// 根節點
private Node root ;
// 樹容量
private int size ;
public BSTree() {
this .root = null ;
this.size = 0 ;
}
public boolean isEmpty() {
return size == 0 ;
}
public int getSize(){
return size;
}
/**
* 二分搜尋樹節點類
* @author 七夜雪
*
*/
private class Node {
public E e ;
// 左右子樹
public Node left , right ;
public Node(E e) {
this.e = e ;
this .left = null ;
this.right = null ;
}
}
}
新增元素
由於二分搜尋樹本身的遞迴特性, 所以可以很方便的使用遞迴實現向二分搜尋樹中新增元素, 步驟如下:
-
定義一個公共的add方法, 用於新增元素
-
定義一個遞迴的add方法用於實際向二分搜尋樹中新增元素
對於這個遞迴方法, 設定的方法語義為,向以node為根節點的樹上新增一個元素, 並返回插入新節點後的二分搜尋樹的根節點
具體程式碼實現如下,:
// 類宣告 : public class BSTree<E extends Comparable<E>>
/**
* 向二分搜尋樹上新增節點,
* @param e
*/
public void add(E e) {
root = add(root, e) ;
}
/**
* 向以node節點為根節點的樹上新增元素E
* 遞迴方法
* @param node
* @param e
* @return 返回插入新節點後的二分搜尋樹的根節點
*/
private Node add(Node node, E e) {
/*
* 遞迴終止條件, node為null, 表示查詢到要新增的節點了
if (node == null) {
size++ ;
return new Node(e) ;
}
// 新增的元素小於當前元素, 向左遞迴
if (node.e.compareTo(e) > 0) {
/*
* 由於遞迴的add方法的語義是新增新元素, 並返回新的二分搜尋樹的根節點
* 所以這裡需要使用node.left = add(node.left, e), 接收遞迴方法返回的值
*/
node.left = add(node.left, e) ;
// 新增的元素小於當前元素, 向右遞迴
} else if (node.e.compareTo(e) < 0) {
// 和向左遞迴一個道理
node.right = add(node.right, e) ;
}
// 由於定義的二分搜尋樹不儲存重複元素, 所以針對node.e.compareTo(e) == 0的這種情況這裡不做任何處理
return node ;
}
查詢元素
由於二分搜尋樹沒有下標, 所以針對二分搜尋樹的查詢操作, 這裡定義一個contains方法, 檢視二分搜尋樹是否包含某個元素, 返回一個布林型變數, 這個查詢的操作一樣是一個遞迴的過程, 具體程式碼實現如下:
/**
* 判斷樹中是否包含元素e
* @param e
* @return
*/
public boolean contains(E e) {
return contains(root, e) ;
}
/**
* 判斷以node為根節點的二分搜尋樹中是否包含元素e
* 遞迴方法
* @param node
* @param e
* @return
*/
private boolean contains(Node node, E e) {
// 遞迴終結條件, 當node等於null時, 表示已經查詢到根節點了, 但是沒有找到對應的元素E
if (node == null) {
return false ;
}
// 遞迴終結條件
if (node.e.compareTo(e) == 0) { // node.e == e
return true;
// 根據二分搜尋樹特性, e小於當前節點的值時, 向左遞迴
} else if (node.e.compareTo(e) > 0) { // node.e > e
return contains(node.left, e) ;
// 根據二分搜尋樹特性, e大於當前節點的值時, 向右遞迴
} else { // node.e < e
return contains(node.right, e) ;
}
}
遍歷操作
-
遍歷操作就是把所有的節點都訪問一遍
-
訪問的原因和業務相關
-
遍歷分類
深度優先遍歷 :
- 前序遍歷 : 對當前節點的遍歷在對左右孩子節點的遍歷之前, 遍歷順序 : 當前節點->左孩子->右孩子
- 中序遍歷 : 對當前節點的遍歷在對左右孩子節點的遍歷中間, 遍歷順序 : 左孩子->當前節點->右孩子
- 後序遍歷 : 對當前節點的遍歷在對左右孩子節點的遍歷之後, 遍歷順序 : 左孩子->右孩子->當前節點
廣度優先遍歷 :
- 層序遍歷 : 按層從左到右進行遍歷
前序遍歷
- 最自然的遍歷方式
- 最常用的遍歷方式
這裡一樣使用遞迴來實現遍歷, 對於一顆二分搜尋樹進行遍歷, 如果要使用非遞迴方式實現的話, 可以使用一個棧來賦值進行遍歷, 程式碼如下:
/**
* 前序遍歷樹
*/
public void preOrder() {
preOrder(root) ;
}
/**
*
* 前序遍歷的遞迴方法, 深度優先
* 前序遍歷是指,先訪問當前節點, 然後再訪問左右子節點
* @param node
*/
private void preOrder(Node node) {
// 遞迴終止條件
if (node == null) {
return ;
}
// 1. 前序遍歷先訪問當前節點
System.out.println(node.e) ;
// 2. 前序遍歷訪問左孩子
preOrder(node.left) ;
// 3. 前序遍歷訪問右孩子
preOrder(node.right) ;
}
非遞迴寫法 :
/**
* 前序遍歷的非遞迴方法, 深度優先
* 這裡使用棧進行輔助實現
* 前序遍歷是指,先訪問當前節點, 然後再訪問左右子節點
*/
public void preOrderNr() {
// 使用棧輔助實現前序遍歷
Stack<Node> stack = new Stack<>();
/*
* 前序遍歷的過程就是先訪問當前節點, 然後再訪問左子樹, 然後右子樹
* 所以使用棧實現時, 可以先將當前節點入棧, 當前節點出棧時,
* 分別將當前節點的右孩子, 左孩子壓入棧
*/
// 首先將根節點壓入棧
stack.push(root);
while (!stack.isEmpty()) {
Node cur = stack.pop();
// 前序遍歷當前節點
System.out.println(cur.e) ;
// 由於棧是後入先出, 前序遍歷是先左孩子, 再右孩子, 所以這裡需要先將右孩子壓入棧
if (cur.right != null) {
stack.push(cur.right);
}
if (cur.left != null) {
stack.push(cur.left);
}
}
}
中序遍歷
- 二分搜尋樹的中序遍歷的結果是順序的
/**
* 中序遍歷樹, 深度優先
*/
public void inOrder() {
inOrder(root) ;
}
/**
*
* 中序遍歷的遞迴方法, 深度優先
* 中序遍歷指的是訪問當前元素的順序放在訪問左右子節點之間
* 中序遍歷的結果是有序的
* @param node
*/
private void inOrder(Node node) {
// 遞迴終止條件
if (node == null) {
return ;
}
// 1. 中序遍歷訪問左孩子
inOrder(node.left) ;
// 2. 中序遍歷先訪問當前節點
System.out.println(node.e) ;
// 3. 中序遍歷訪問右孩子
inOrder(node.right) ;
}
後序遍歷
- 後序遍歷的一個應用 : 為二分搜尋樹釋放記憶體, Java中其實並不需要手動釋放記憶體
/**
*
* 後序遍歷的遞迴方法, 深度優先
* 後序遍歷指的是訪問當前元素的順序放在訪問左右子節點之後
* @param node
*/
private void postOrder(Node node) {
// 遞迴終止條件
if (node == null) {
return ;
}
// 1. 後序遍歷訪問左孩子
postOrder(node.left) ;
// 2. 後序遍歷訪問右孩子
postOrder(node.right) ;
// 3. 後序遍歷先訪問當前節點
System.out.println(node.e) ;
}
前,中,後序遍歷總結
可以認為在遍歷的時候每個節點要訪問三次, 對當前節點進行遍歷操作時一次, 訪問當前節點左子樹時一次, 訪問當前節點右子樹時一次, 可以認為前序遍歷就是在第一次訪問當前節點時進行操作, 以方便我們理解遍歷結果, 下面幾張圖演示前中後序遍歷的訪問順序, 藍色的點表示在這次訪問時對當前節點進行遍歷操作
前序遍歷示意圖:
中序遍歷示意圖 :
後序遍歷示意圖 :
層序遍歷
- 按層從左到右進行遍歷
- 廣度優先遍歷
- 針對上面那顆樹, 遍歷結果為 : 28 16 32 13 22 29 42
層序遍歷程式碼實現, 使用佇列進行輔助實現 :
/**
* 層序遍歷, 從左到右一層一層遍歷
* 藉助佇列實現
*/
public void levelOrder(){
// LinkedList實現了Queue介面
Queue<Node> queue = new LinkedList<>();
/*
* 遍歷過程:
* 1. 首先根節點入隊
* 2. 每次出隊時, 都將當前節點的左右孩子先後入隊
* 3. 如果佇列為空的話, 則表示層序遍歷結束
* 5
* / \
* 3 6
* / \ \
* 2 4 8
* 針對上面的二分搜尋樹, 詳細描述一下層序遍歷步驟
* 1. 5入隊, 佇列元素 : head->[5]<-tail
* 2. 5出隊, 5的左子樹3, 6入隊, 由於佇列是先入先出(FIFO), 所以先左後右, 佇列元素 : head->[3, 6]<-tail
* 3. 3出隊, 2, 4入隊, 佇列元素 : head->[6, 2, 4]<-tail
* 4. 6出隊, 左孩子為空,所以8入隊, 佇列元素 : head->[2, 4, 8]<-tail
* 5. 2,4,8依次出隊, 由於這三個節點都是葉子節點, 無子節點, 所以這三個節點出隊後佇列為空, 層序遍歷完成
* 6. 按照出隊的順序演示的遍歷結果為 : 5 3 6 2 4 8
*/
queue.add(root);
while(!queue.isEmpty()){
Node cur = queue.poll();
if (cur.left != null) {
queue.add(cur.left);
}
if (cur.right != null) {
queue.add(cur.right);
}
}
}
廣度優先遍歷的意義 :
- 更快找到問題的解
- 常用於演算法設計中-最短路徑
刪除節點
在刪除節點前, 先看兩種特殊的情況, 刪除最小節點和刪除最大節點
刪除最小值
在刪除最小節點之前, 先要找到這個最小節點, 根據二分搜尋樹的特性可知, 從根節點一直往左找, 最後一個沒有左子樹的節點一定就是整棵樹的最小值。
對於刪除最小值時,存在兩種情況 :
- 最小值就是一個葉子節點, 直接刪除該節點即可
- 如果最小值所在的節點還有右子樹, 則用右子樹的根節點替換當前節點即可, 如下圖所示
程式碼實現如下 :
/**
* 查詢樹中最小元素
* @return
*/
public E minimum(){
if (size == 0) {
throw new IllegalArgumentException("BSTree is empty");
}
return minimum(root).e;
}
/**
* 查詢以node為根節點的最小元素, 遞迴方法
* @param node
* @return
*/
private Node minimum(Node node){
if (node.left == null) {
return node;
}
return minimum(node.left);
}
/**
* 刪除二分搜尋樹中的最小值
* @return
*/
public E removeMin(){
E ret = minimum();
root = removeMin(root);
return ret;
}
/**
* 刪除以node為根節點的樹的最小值
* @param node
* @return 返回刪除後的新的二分搜尋樹的根
*/
private Node removeMin(Node node){
// 遞迴終止條件
if(node.left == null){
Node rightNode = node.right;
node.right = null;
size --;
return rightNode;
}
node.left = removeMin(node.left);
return node;
}
刪除最大值
最大值的刪除和最小值刪除原理是一樣的, 先右找到最後一個沒有右子樹的節點就是整顆樹的最大值, 然後進行刪除最大值操作 :
-
最大值為葉子節點時, 直接刪除該節點即可
-
最大值所在節點還有左子樹時, 則使用最大值左子樹的根節點替換當前節點即可, 如下圖所示
程式碼實現如下 :
/**
* 查詢樹中最大元素
* @return
*/
public E maximum(){
if (size == 0) {
throw new IllegalArgumentException("BSTree is empty");
}
return maximum(root).e;
}
/**
* 查詢以node為根節點的最大元素, 遞迴方法
* @param node
* @return
*/
private Node maximum(Node node){
if (node.right == null) {
return node;
}
return maximum(node.right);
}
/**
* 刪除二分搜尋樹中的最大值
* @return
*/
public E removeMax(){
E ret = maximum();
root = removeMax(root);
return ret;
}
/**
* 刪除以node為根節點的樹的最大值
* @param node
* @return 返回刪除後的新的二分搜尋樹的根
*/
private Node removeMax(Node node){
// 遞迴終止條件
if(node.right == null){
Node leftNode = node.left;
node.left = null;
size --;
return leftNode;
}
node.right = removeMin(node.right);
return node;
}
刪除任意節點
刪除任意節點可以分為以下幾種情況 :
-
刪除葉子節點, 直接刪除即可
-
刪除只有右子樹的節點, 邏輯同刪除最小值, 雖然這個節點不一定是最小值, 但是刪除邏輯是一樣的
-
刪除只有左子樹的節點, 邏輯同刪除最大值
-
刪除同時具有左右子樹的節點, 這個時候刪除節點的步驟稍微複雜一些
- 首先找到要刪除的節點
- 然後找到對應節點的前驅或者後繼節點, 前驅就是指當前節點的左子樹中最大的元素節點, 後繼就是指當前節點右子樹中最小的元素節點, 下圖就是基於後繼節點的刪除演示
- 使用後繼節點替換當前節點, 然後再刪除要刪除的節點
具體程式碼實現如下 :
/**
* 刪除指定元素e所在的節點
* @param e
*/
public void remove(E e){
root = remove(root, e);
}
/**
* 刪除以node為根節點中的二分搜尋樹中
* @param node
* @param e
* @return 返回刪除後的新二分搜尋樹的根節點
*/
private Node remove(Node node, E e){
if (node == null) {
return null;
}
// node.e > e
if (node.e.compareTo(e) > 0) {
node.left =remove(node.left, e);
return node;
// node.e < e
} else if (node.e.compareTo(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;
}
// 待刪除節點左右子樹均不為空的情況
// 查詢待刪除節點的後繼節點
// 用後繼節點替換當前待刪除節點
// 查詢後繼節點, 從待刪除節點的右子樹,查詢最小值
Node successor = minimum(node.right);
/*
* 需要注意的是, 這裡removeMin中進行了size--操作,
* 但是實際上最小的元素變成了successor, 並沒有刪除
* 所以按照邏輯的話, 這裡應該有一個size++
* 但是後面刪除了元素之後,需要再進行一次size--, 所以這裡就不對size進行操作了
*/
successor.right = removeMin(node.right);
successor.left = node.left;
// 後繼節點完成替換, 刪除當前節點
node.left = node.right = null;
return successor;
}
}
至此就完成了整個二分搜尋樹的全部程式碼, 完整程式碼如下 :
package tree.bst ;
import java.util.LinkedList ;
import java.util.Queue ;
import java.util.Stack ;
/**
* 遞迴實現二分搜尋樹
* 這裡設計的樹是不儲存重複元素的, 重複新增元素只儲存一個
* @author 七夜雪
*
*/
public class BSTree<E extends Comparable<E>> {
// 根節點
private Node root ;
// 樹容量
private int size ;
public BSTree() {
this.root = null ;
this.size = 0 ;
}
public boolean isEmpty() {
return size == 0 ;
}
public int getSize(){
return size;
}
/**
* 向二分搜尋樹上新增節點
* @param e
*/
public void add(E e) {
root = add(root, e) ;
}
/**
* 向以node節點為根節點的樹上新增節點e
* 遞迴方法
* @param node
* @param e
*/
private Node add(Node node, E e) {
// 遞迴終止條件
if (node == null) {
size++ ;
return new Node(e) ;
}
// 新增的元素小於當前元素, 向左遞迴
if (node.e.compareTo(e) > 0) {
node.left = add(node.left, e) ;
// 新增的元素小於當前元素, 向右遞迴
} else if (node.e.compareTo(e) < 0) {
node.right = add(node.right, e) ;
}
return node ;
}
/**
* 判斷樹中是否包含元素e
* @param e
* @return
*/
public boolean contains(E e) {
return contains(root, e) ;
}
/**
* 判斷樹中是否包含元素e
* 遞迴方法
* @param node
* @param e
* @return
*/
private boolean contains(Node node, E e) {
// 遞迴終結條件
if (node == null) {
return false ;
}
if (node.e.compareTo(e) == 0) {
return true;
} else if (node.e.compareTo(e) > 0) {
return contains(node.left, e) ;
} else { // node.e < e
return contains(node.right, e) ;
}
}
/**
* 前序遍歷樹
*/
public void preOrder() {
preOrder(root) ;
}
/**
*
* 前序遍歷的遞迴方法, 深度優先
* 前序遍歷是指,先訪問當前節點, 然後再訪問左右子節點
* @param node
*/
private void preOrder(Node node) {
// 遞迴終止條件
if (node == null) {
return ;
}
// 1. 前序遍歷先訪問當前節點
System.out.println(node.e) ;
// 2. 前序遍歷訪問左孩子
preOrder(node.left) ;
// 3. 前序遍歷訪問右孩子
preOrder(node.right) ;
}
/**
* 前序遍歷