BST 二分搜索樹
文章目錄
1、什麼是樹
樹顧名思義,就是來源於我們生活中的樹,一支分多支,無限延展下去的結構。在數據結構中,樹結構主要是和真實的樹反着的。類似於這種。
樹結構在我們生活中最常見的就是文件存儲、家譜結構和公司職能分佈等等都是採用的樹這種結構。
1.1、什麼是二叉樹
上面那種結構就是二叉樹,一個節點連接着兩個節點。具體的程序實現如下:
private class Node{ //內部類
public E e;
public Node left, right; //左右節點
}
可以看出這種結構和鏈表結構很像,只不過鏈表節點只存儲一個一個節點信息。當然我們依然可以定義三叉樹,四叉樹等等。
我們定義最頂層的節點尾根節點,對於左右孩子都會空的稱之爲葉子節點。
注: 紅色爲跟節點,綠色爲葉子節點,根據結構我們可以看出來二叉樹既有天然的遞歸性。孩子節點依然是一個新的二叉樹。這就體現了遞歸性。
我們從上面可以看出來二叉樹不一定是滿的,而且一個節點也是二叉樹,NULL也是二叉樹。
1.2、什麼是二分搜索樹
二分搜索樹也是一種二叉樹,只不過相對於二叉樹增加了一些規則。二分搜索樹要求。
規則:一個節點的元素值必須大於它的左子樹所有節點的值小於它的右子樹所有節點的值。
這也就正好驗證了搜索這個概念。如果我們想要查找的元素大於該節點,我們就去它的右子樹去找,小於就與左子樹就找。但是我們需要注意,這裏我們只是以數字作爲例子,其實這種結構要求我們的節點具有可比較性,也就是自己實現的類對象必須繼承Comparable這個類,並重寫compareto這個函數。也就是存儲的元素必須有可比性。
2、二分搜索樹的基本函數
作爲一種高級數據結構,它依然避免不了進行增刪改查四個操作,下面我們就逐一進行實現。
2.1、添加元素
我們知道二分搜索樹的基本規則是一個節點的元素值必須大於它的左子樹所有節點的值小於它的右子樹所有節點的值。我們就是用這條規則進行添加元素。從根節點出發,如果添加的元素大於我們跟節點元素值,我們就去跟節點的右子樹繼續添加元素,直到待插入節點爲NULL,我們就把節點插入該位置。
注: 我們可以看出,我們的二分搜索樹不包含重複的元素,如果我們希望包含的重複的元素,我們只需要重新定義規則:一個節點的元素值必須大於它的左子樹所有節點的值小於等於它的右子樹所有節點的值。
對於二分搜索樹來說,具有天然的遞歸性,所以我們就哪遞歸來實現增加這個操作。
由於我們遞歸的思想就是把一個問題分成一節一節去處理,所以我們實現遞歸的時候需要返回我們處理完的頭結點。所以我們引入私有函數,返回添加後的根節點。
程序實現:
public void add(E e) {
root = add(root, e);
}
private Node add(Node node, E e) {
// 終止條件
if (node == null){
size++;
return new Node(e);
}
// 開始遞歸
if (e.compareTo(node.e) < 0) //去左子樹添加元素
node.left = add(node.left, e);
else if (e.compareTo(node.e) > 0) //去右子樹添加元素
node.right = add(node.right, e);
return node;
}
2.2、查詢元素
我們並不先介紹刪除操作,因爲刪除操作最爲複雜,我們先介紹查詢操作,最後再說刪除操作。
2.2.1 contains操作
contains函數,返回bool判斷樹結構中是否包含我們待查元素。同添加元素相同,我們需要採用遞歸的形式查詢元素。那麼我們就需要新建私有函數來傳入我們要查詢的節點。
程序實現:
public boolean contains(E e) {
return contains(root, e);
}
private boolean contains(Node node, E e) {
if (node == null) //遞歸到底
return false;
if (e.compareTo(node.e) < 0)
return contains(node.left, e);
else if (e.compareTo(node.e) > 0)
return contains(node.right, e);
else //相同
return true;
}
2.2.2 最大值和最小值函數
我們根據二叉樹的定義我們知道,大樹值的元素都是向右子樹去添加,同理小數值的元素都是向左子樹去添加。所以最大值存在於樹的最右邊,最小值存在於樹的最左邊。
最小值程序實現:
public void minimum() {
if (size == 0)
throw new IllegalArgumentException("BST is empty");
System.out.println(minimum(root).e);
}
private Node minimum(Node node) {
if (node.left == null)
return node;
return minimum(node.left);
}
最大值程序實現:
public void maximum() {
if (size == 0)
throw new IllegalArgumentException("BST is empty");
System.out.println(maximum(root).e);
}
private Node maximum(Node node) {
if (node.right == null)
return node;
return minimum(node.right);
}
2.3、改變元素
改變元素同查詢元素相同,找到元素後修改後即可。仿照查詢元素的程序就可以實現改變元素的函數
程序實現:
public void set(E e) {
set(root, e);
}
private void set(Node node, E e) {
if (node == null) //遞歸到底
return;
if (e.compareTo(node.e) < 0)
set(node.left, e);
else if (e.compareTo(node.e) > 0)
set(node.right, e);
else //相同
node.e = e;
}
2.4、遍歷元素
樹的遍歷操作同線性結構的遍歷不同,線性結構需要從頭遍歷到尾,對於樹結構來說遍歷分爲很多種情況。
- 前序遍歷
- 中序遍歷
- 後續遍歷
- 層序遍歷
1-3 遍歷爲深度優先的算法,而層序遍歷爲廣度優先的算法,下面我們逐個進行說明。所謂的前中後指的就是啥時候訪問節點信息。在左子樹前面,在左子樹和右子樹中間,還是在右子樹後面。
2.4.1 前序遍歷
前序遍歷的主要思想就是先訪問這個元素,然後再對它的左右子樹分別進行前序遍歷操作。
根據圖我們可以得出前序遍歷的順序如下:
- 訪問元素5,然後按照紅色箭頭,對左子樹(綠色)進行前序遍歷;
- 訪問元素2,然後按照紅色箭頭遍歷左右子樹,由於左右子樹均爲葉子節點,所以遍歷結果爲2-1-4;
- 返回去遍歷根節點的右子樹(黃色),按照紅色箭頭,順序爲8-6-9;
- 最終遍歷的結果是:5-2-1-4-8-6-9;
程序實現:
/** 前序遍歷 **/
public void preOrder(){
preOrder(root);
}
private void preOrder(Node node) {
if (node == null)
return;
System.out.println(node.e); // 先訪問節點信息
preOrder(node.left); //前序遍歷左子樹
preOrder(node.right); //前序遍歷右子樹
}
2.4.2 中序遍歷
中序遍歷和前序遍歷類似,中序遍歷就是將訪問元素的環節放在了中間,先遍歷其左子樹,然後訪問其節點元素,最後遍歷其右子樹。
根據圖我們可以得出中序遍歷的順序如下:
- 先前序遍歷根節點的左子樹,按照紅色箭頭的方向,結果爲1-2-4;
- 訪問該元素,結果爲 5;
- 最後遍歷右子樹,結果爲6-8-9;
- 最後的結果爲1-2-4-5-6-8-9;
我們不難發現中序遍歷的特點就是具有順序性,本次中序遍歷是從小到大,如果先遍歷右子樹,最後遍歷左子樹,結果就爲從大到小排列。
程序實現:
public void inOrder() {
inOrder(root);
}
private void inOrder(Node node) {
if (node == null)
return;
inOrder(node.left);
System.out.println(node.e); // 在中間訪問節點信息
inOrder(node.right);
}
2.4.3 後序遍歷
有了前面兩個基礎,後序遍歷就比較簡單了,就是最後再訪問元素,先遍歷左子樹,後遍歷右子樹,最後訪問該節點信息。
根據圖我們可以得出後序遍歷的順序如下:
- 先後序遍歷左子樹,結果爲:1-4-2;
- 再後序遍歷右子樹,結果爲:6-9-8;
- 最後訪問節點信息,結果爲5;
- 最終結果爲1-4-2-6-9-8-5;
程序實現:
public void lastOrder() {
lastOrder(root);
}
private void lastOrder(Node node) {
if (node == null)
return;
lastOrder(node.left);
lastOrder(node.right);
System.out.println(node.e); //最後訪問節點信息
}
2.4.4 層序遍歷
層序遍歷和前面的不同,層序遍歷是廣度優先的算法。也就是一層一層的訪問。將某一個深度的信息全部訪問完,在訪問下一深度節點信息。這裏就會用到我們之前講的隊列的數據結構。
核心思想就是:入隊的元素是將要出隊的左右節點的元素
針對前面所展示的二分搜索樹,層序遍歷我們用圖來表示就是:
步驟:
- 先放入根節點;
- 出隊隊首的元素,同時入隊根節點的左右節點;
- 開始循環,沒出隊一個元素,就要將他的孩子節點入隊,如果沒有則不入,直到隊列中的元素爲空;
- 最後我們輸出的就是:5-2-8-1-4-6-9。
程序實現:
public void levelOrder() {
Queue<Node> q = new LinkedList<>();
q.add(root); //先放入根節點
while (!q.isEmpty()) { //循環知道隊列元素爲空
Node cur = q.remove(); //出隊隊首元素
System.out.println(cur.e);
if (cur.left != null) //同時入隊剛出隊元素的左右節點
q.add(cur.left);
if (cur.right != null)
q.add(cur.right);
}
}
2.5、刪除元素
刪除元素是最難的一個操作,我們不從刪除任意元素開始,我們先從刪除最大值和最小值入手,一步步深入瞭解刪除操作
2.5.1 刪除最小值元素
我們在查詢元素那一節中提到,最小值存在於BST的最左邊。這裏又得分情況。最左邊的元素存在的狀態分爲兩種。
- 若是葉子節點,直接刪除該元素即可;
- 若無左孩子節點,右孩子節點頂替待刪除節點。
刪除元素-葉子節點:
刪除元素-無左孩子:
程序實現中,主要就是對兩種情況進行分析。實際上,我們發現兩者情況均是無左孩子節點(必然),所以最底的情況就是到達了最靠左的位置(左孩子爲null),然後返回刪除節點的全部右子樹。第一種情況返回null,也就刪除最小值。第二中情況也就是代替原來的位置。
程序實現:
public void removeMinimum() {
if (size == 0)
throw new IllegalArgumentException("BST is empty");
removeMinimum(root);
}
private Node removeMinimum(Node node) {
if (node.left == null) { // 到達最小值的節點
Node rightNode = node.right;
node.right = null;
size--;
return rightNode;
}
node.left = removeMinimum(node.left);
return node;
}
2.5.2 刪除最大值元素
有了前面的刪除最小值元素的概念,刪除最大值就是向右走到底,刪除最大值元素也分爲兩種,只不過方向相反而已。根據上面的程序我們就可以仿製刪除最大值元素。
程序實現:
public void removeMaximum() {
if (size == 0)
throw new IllegalArgumentException("BST is empty");
removeMaximum(root);
}
private Node removeMaximum(Node node) {
if (node.right == null) { //到達最大值的節點
Node leftNode = node.left;
node.left = null;
size--;
return leftNode;
}
node.right = removeMaximum(node.right);
return node;
}
2.5.3 刪除任意元素
刪除元素就沒有像其他操作那麼簡單了。這裏呢我們引入一個方法,這是方法是在1962年 Hibbard 提出來的。下面我們用實例進行演示。
對於這個樹結構,我們以最難的節點進行刪除,也就是根節點進行刪除,刪除的步驟如下:
- 找到一個節點來代替根節點,這裏我們選擇待刪除節點5的後繼。後繼:就是該節點右子樹中離節點大小最近的節點,也就是右子樹中的最小值節點。這裏指的就是節點6。
- 找到了該節點6,就需要將原樹中的節點6刪除。這樣6就從樹結構中脫離出來。
- 將節點6的右子樹指向節點8,節點6的左子樹指向節點2。
這樣就將節點5的後繼節點6代替了節點5。完成了刪除操作
程序實現:
public void remove(E e) {
root = remove(root, 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 { //找到了待刪除節點
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 = removeMinimum(node.right); // 已經隊size進行了自減操作
successor.left = node.left;
node.left = node.right = null;
return successor;
}
}
3、時間複雜度分析
這裏並沒有做時間複雜度分析,是因爲這個樹結構並不完美,有可能退化成鏈表這種數據結構。大家可以想一想,add(1),add(2),add(4),add(5)。當我以順序的方式增加元素的時候,會發現樹結構退化成了鏈表。
對於一般性的樹結構,我們可以發現,我們查找一個元素並不需要遍歷整個樹結構,時間只取決於樹的高度,樹的高度越低速度越快。
對於一個滿的二叉樹來說,假設樹的高度爲M,那麼整個樹結構的元素個數爲2^M - 1;也就是N = 2 ^ M - 1。做近似的話就是N = 2 ^ M。則
時間複雜度相對於O(N)來說,提升巨大。尤其針對大數據而言。
舉一個例子:
假設N = 1000000 ;M = log(N) = 20,也就是提升了50000倍,隨着數據量的提升,效果會更加明顯.這也就體現了在大數據的情況下,樹結構的優勢。
最後
更多精彩內容,大家可以轉到我的主頁:曲怪曲怪的主頁
或者關注我的微信公衆號:TeaUrn
源碼地址:可在公衆號內回覆 數據結構與算法源碼 即可獲得。