1. 程式人生 > >初學資料結構--二分搜尋樹

初學資料結構--二分搜尋樹

開發十年,就只剩下這套架構體系了! >>>   

前言

之前我們一直專注線上性資料結構上,在這一章要開始學習計算機領域應用及其廣泛的--樹結構。

樹的概括

下圖的資料儲存方式便是一種樹結構。

和線性資料結構不同的是,線性的資料結構是把所有的資料排成一排,而樹結構更像是自然界中樹的枝杈,不斷延伸,不斷擴充套件,其本身也是一種天然的組織結構,類似於電腦中資料夾這種目錄結構。

將資料使用樹結構儲存後,出奇的高效,其中包括但不限於二分搜尋樹,平衡二叉樹,AVL,紅黑樹,堆,並查集,線段樹,Trie(字典樹,字首樹)

二分搜尋樹的基礎

在瞭解二分搜尋樹之前,我們首先明確二叉樹的概念,首先它和連結串列一樣,同樣屬於一種動態資料結構。

可以看粗,二叉樹中每個元素存在於節點內,和連結串列不同的是,除了要存放元素e,還要有指向其他節點的leftright兩個變數(引用)。

  • 對於樹結構來說,二叉樹也是最常用的一種資料結構,同時二分搜尋樹滿足二叉樹的性質(從本質上講,二分搜尋樹也是一棵二叉樹)
  • 對於一棵二叉樹來說,它只有一個根節點,並且這個根節點是唯一的(如下圖中28便是這棵二叉樹的根節點)
  • 對於每一個節點來說,它最多隻能有兩個子節點,指向左和右的兩個節點(也可以稱之為左孩子和右孩子)
  • 二叉樹中,一個孩子都沒有的節點,通常稱之為葉子節點

在這裡,不是所有二叉樹都像如下圖示的二叉樹這樣規整(每個節點都有兩個孩子,葉子節點也在最底層)

葉子節點:這個節點沒有任何孩子節點,這樣的節點稱之為葉子節點


  • 二叉樹有一個非常重要的性質,是具有天然的遞迴結構(對於每一個節點,它的左孩子同時也是一棵二分搜尋樹的根節點)。

如下圖示,這是一棵滿的二叉樹。即對於每一個節點來說,除了葉子節點外都有兩個孩子節點。

如下圖所示,只有左孩子,或者只有右孩子,或者左右孩子都為空,甚至退化成連結串列等等,這些都滿足二叉樹的定義。

下面可以來看下什麼是二分搜尋樹。二分搜尋樹除了滿足二叉樹的定義(只有一個根節點,有左右孩子節點或者左右孩子節點為空……),還有自己獨特的性質,即每個節點的值都要大於其左子樹下所有節點的值

,同時每個節點的值小於其右子樹下所有節點的值

如下圖示顧名思義,根節點28大於左子樹中最大的值22,並且根節點28小於右子樹最小的值29

注:二分搜尋樹當然有可能不是一棵滿的二叉樹


為了要能達到這樣的性質,就必須讓我們儲存的元素具有可比較性,那麼我們用二分搜尋樹來儲存資料的話,然後來查詢一個數據53就會非常簡單。由於41是根節點,所以根據二分搜尋樹的性質53這個節點就一定在41的右子樹。這樣一來,41的左子樹所儲存的這些資料就會被忽略,這就大大地加快了查詢速度

二分搜尋樹中新增元素

首先在最開始的時候,在二分搜尋樹裡一個節點都沒有

  • 現在新增一個新的元素41,顯然這個節點會成為根節點
  • 再來一個新元素28,從根節點出發,判斷28比根節點41小,根據二分搜尋樹的定義、因此新增到41的左子樹中
  • 利用二分搜尋樹的定義,每次新增一個新的元素,從根節點開始,如果小於根節點就插到根節點的左子樹,反之插入到根節點的右子樹
  • 這個過程以此類推……
    注:我們此時的二分搜尋樹不包含重複元素

如果想包含重複元素的話,只需定義:左子樹小於等於節點;或者右子樹大於等於節點

過程如圖示:

  • 程式碼如下:
private class Node{
    public E e;
    public Node left, right;

    public Node(E e){
        this.e = e;
        left = null;
        right = null;
    }
}
private Node add(Node node, E e){
    if(node == null){
        // 將null的節點連線起來
        size++;
        return new Node(e);
    }
    // 這裡用遞迴,不斷呼叫左孩子節點,然後比較插入的元素和節點e
    if(e.compareTo(node.e) < 0){
        node.left = add(node.left, e);
    }else if(e.compareTo(node.e) > 0){ //e.compareTo(node.e) > 0
        node.right = add(node.right, e);
    }
    // 對相等的情況不做判斷了
    return node;
}

二分搜尋樹的查詢

對於查詢元素操作來說,只需要看一下節點node所存的元素就好了,不必牽扯新增一個元素之後如何掛接到整個二分搜尋樹中,因此是相對簡單的。

  • 程式碼如下
// 看二分搜尋樹中是否包含元素e
public boolean contains(E e){
    return contains(root, e);
}

// 看以node為根的二分搜尋樹中是否包含元素e,遞迴演算法
private boolean contains(Node node, E e){
    if(node == null){
        return false;
    }
    if(e.compareTo(node.e) == 0){
        return true;
    }else if(e.compareTo(node.e) < 0){
        return contains(node.left, e);
    }else{ // e.compareTo(node.e) > 0
        return contains(node.right, e);
    }
}

二分搜尋樹的前中後序遍歷

前序遍歷

  • 對於一個數據結構的遍歷,本質很簡單,就是把這個資料結構中所儲存的所有元素都訪問一遍。對應到二分搜尋樹便是訪問一遍所有節點。
  • 對於線性資料(無論陣列還是連結串列),從頭到尾做一層迴圈就解決了,但是對於樹結構來說卻不是。要訪問整個二叉樹所有節點,需要考慮左子樹所有節點,還要考慮右子樹所有節點。

訪問完根節點先訪問了左子樹再訪問了右子樹,這就完成了二叉樹的遍歷操作,而這種遍歷操作也稱之為前序遍歷

  • 用程式碼來實現:
// 二分搜尋樹的前序遍歷
public void preOrder(){
    preOrder(root);
}

// 前序遍歷以node為根的二分搜尋樹,遞迴演算法
private void preOrder(Node node){
    if(node == null){
        return;
    }
    preOrder(node.left);
    preOrder(node.right);
}

中序遍歷

通常而言,對於二分搜尋來說。前序遍歷是最自然的一種遍歷方式,同時呢也是最常用的一種遍歷方式。
如果我們改變節點的訪問順序,使之 先訪問該節點的左子樹,再訪問該節點,最後訪問該節點的右子樹,這樣的遍歷操作稱之為中序遍歷。

  • 用程式碼實現
//中序遍歷以node為根的二分搜尋樹,遞迴演算法
public void inOrder(){
    inOrder(root);
}

private void inOrder(Node node){
    if(node == null){
        return;
    }
    inOrder(node.left);
    inOrder(node.right);
}

後序遍歷

同理,先訪問該節點的左子樹,再訪問該節點的右子樹,最後訪問這個節點,這樣的遍歷操作便是後序遍歷。

  • 用程式碼實現
// 二分搜尋樹的後序遍歷
public void postOrder(){
    postOrder(root);
}

// 後序遍歷以node為根的二分搜尋樹,遞迴演算法
private void postOrder(Node node){
    if(node == null){
        return;
    }
    postOrder(node.left);
    postOrder(node.right);
}

二分搜尋樹的層序(廣度優先)遍歷

上述二分搜尋樹的前中後序遍歷,本質上都是深度優先的遍歷。於此相對應的是廣度優先遍歷(即層序遍歷)。

廣度優先遍歷

對於二分搜尋樹來說,每一個節點對應右一個深度值,通常會把根節點叫做深度為0相應的節點。層序遍歷就是,先遍歷第0層的節點(28),再遍歷第1層的節點(16,30),最後遍歷第2層的節點(13,22,29,42)。

如下圖示:

  • 用程式碼實現
// 二分搜尋樹的層序遍歷
public void levelOrer(){
    Queue<Node> queue = new LinkedList<>();
    queue.add(root);
    while(!queue.isEmpty()){
        // 當前遍歷的節點 等於 佇列中的元素出隊之後的那個元素
        Node cur = queue.remove();
        System.out.println(cur.e);
        if(cur.left != null){
            queue.add(cur.left);
        }
        if(cur.right != null){
            queue.add(cur.right);
        }
    }
}

意義

  • 更快的找到問題的解
  • 常用於演算法設計中-最短路徑
  • 圖中的深度優先遍歷和廣度優先遍歷

二分搜尋樹刪除節點

刪除最大值和最小值

對於二分搜尋樹來說,刪除一個節點相對是比較複雜的操作。因此分解刪除操作,從最簡單的,刪除二分搜尋樹的最小值和最大值開始。

  • 首先,要想刪除二分搜尋樹的最小值和最大值,就需要先找到二分搜尋樹的最小值和最大值
  • 對於二分搜尋樹來說,其一個節點的左子樹中所有節點都小於該節點
  • 因此刪除最小值一定是從根節點開始,不停的向左走,直到向左走再也走不動為止,那個值一定是最小值
  • 刪除最大值同理

用程式碼實現:

// 尋找二分搜尋樹的最大元素
public E maximum(){
    if(size == 0){
        throw new IllegalArgumentException("BST is empty!");
    }
    return minimum(root).e;
}

// 返回以node為根的二分搜尋樹的最大值所在的節點
private Node maximum(Node node){
    if(node.right == null){
        return node;
    }
    return minimum(node.right);
}

// 從二分搜尋樹中刪除最小值所在節點,返回最小值
public E removeMin(){
    E ret = minimum();
    root = removeMin(root);
    return ret;
}

// 刪除以node為根的二分搜尋樹中的最小節點
// 返回刪除節點後新的二分搜尋樹的根
private Node removeMin(Node node){
    // 現在最小節點就是node本身,然後接上node.right
    if(node.left == null){
        Node rightNode = node.right;
        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){
    // 現在最小節點就是node本身,然後接上node.right
    if(node.right == null){
        Node leftNode = node.left;
        node.left = null;
        size--;
        return leftNode;
    }
    node.right = removeMax(node.right);
    return node;
}

刪除任意節點

在二分搜尋樹中刪除任意節點,會出現以下3種情況:

  • 刪除只有左孩子的節點

要刪除的節點元素58這種情況,與刪除最大值的邏輯相同,並在刪除完成之後,讓左孩子所在的二叉樹掛接到原來父親節點

過程如下圖所示:

  • 用程式碼實現:
// 待刪除節點右子樹為空的情況
if(node.right == null){
    Node leftNode = node.left;
    node.left = null;
    size--;
    return leftNode;
}
  • 刪除只有右孩子的節點

要刪除的節點元素58這種情況,與刪除最大值的邏輯相同,並在刪除完成之後,讓左孩子所在的二叉樹掛接到原來父親節點

過程如下圖所示:

  • 用程式碼實現:
// 待刪除節點左子樹為空的情況
if(node.left == null){
    Node rightNode = node.right;
    node.right = null;
    size--;
    return rightNode;
}
  • 刪除左右都有孩子的節點d
    1. 找到d節點右子樹中最小的值,即s=min(d->right)
    2. 刪除s節點,即delMin(d->right)
    3. 連線上之前d的左子樹,即s->right。連線上之前d的右子樹,即s->left
    4. 刪除d,使s成為新的子樹的根

注:->表示指向
過程如下圖所示:

  • 用程式碼實現:
// 待刪除節點左右子樹均不為空的情況
// 找到比待刪除節點大的最小節點,即待刪除節點右子樹的最小節點
// 用這個節點頂替待刪除節點的位置,successor --> 後繼
Node successor = minimum(node.right);
successor.right = removeMin(node.right);
successor.left = node.left;

node.left = node.right = null;
return successor;
  • 完整程式碼實現
// 從二分搜尋樹中刪除元素為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){
        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 == 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;
        }
        // 待刪除節點左右子樹均不為空的情況
        // 找到比待刪除節點大的最小節點,即待刪除節點右子樹的最小節點
        // 用這個節點頂替待刪除節點的位置,successor --> 後繼
        Node successor = minimum(node.right);
        successor.right = removeMin(node.right);
        successor.left = node.left;

        node.left = node.right = null;
        return successor;
    }
}

總結

在這一章,簡單的向大家介紹了向二分搜尋樹中新增元素,刪除元素,查詢元素(包括二分搜尋樹的前中後序的遍歷)。與此同時,在刪除二分搜尋樹的元素時,可以很方便拿到二分搜尋樹的最大值和最小值,這也是二分搜尋樹中最重要的性質——順序性

順序性:放入二分搜尋樹中所有的元素都是有序的。當使用中序遍歷所有元素時,得到的結果是所有的元素從小到大排列了起來

關於一些二分搜尋樹的拓展,再向二分搜尋樹中新增元素的時候,可以支援具有重複元素的存在。這種情況下,定義的每一個根節點中左子樹的所有節點都小於等於(<=)該節點的值右子樹的所有節點都大於(>)該節點的值,這樣就完