數據結構之:二分搜索樹

爲何要研究樹結構

爲何要研究樹結構?首先由於樹在計算機程序中是很是重要的數據結構之一,而且樹結構自己是一種自然的組織結構。在不少狀況下將數據使用樹結構存儲後,會發現出奇的高效。甚至有些問題,必需要使用樹結構纔可以解決。java

樹這種結構,在咱們平常生活中也常常看到,例如咱們操做系統的文件夾,公司的部門層級,圖書館書籍分類等:
數據結構之:二分搜索樹node

能夠看到樹是一種一對多的數據結構,因此在現實生活中,遇到一對多的問題須要處理時,就能夠想到使用到樹這種數據結構。咱們來舉一個例子,公司裏某一天CEO要找一個程序員,他只須要到研發部就能找到想要找的人。這是由於公司內部的人員編排都是根據部門層級劃分的,而後部門裏又規定了哪些人員受誰管理,這種層級劃分和人員關係就很好的體現了一個樹形的結構,因此咱們纔可以在公司內快速的找到某個員工。程序員

若公司內的人員編排是線性的,那麼在最壞的狀況下就須要找遍整個公司的員工才能找出來想要找的人。就像咱們要在數組中找一個元素,而這個元素恰好在數組的末尾,若咱們在不知道索引的狀況下,就須要遍歷整個數組纔可以找到該元素。能夠看到在同一問題下這兩種結構的對比,樹結構的效率是要高得多的,這也是咱們爲何要學習樹的緣由。算法

樹結構有不少中,常見的有:數組

  • 二分搜索樹
  • 線段樹
  • Trie
  • B+樹
  • AVL
  • 紅黑樹

二分搜索樹基礎

在介紹二分搜索樹以前咱們先來看二叉樹,二叉樹是最基本的樹形結構,二叉樹由一個根節點和多個子節點組成,包括根節點在內的每一個節點最多擁有左右兩個子節點,俗稱左孩子和右孩子。樹和鏈表同樣也是動態的數據結構:
數據結構之:二分搜索樹
數據結構之:二分搜索樹
數據結構之:二分搜索樹
數據結構之:二分搜索樹
數據結構之:二分搜索樹數據結構

二分搜索樹在二叉樹的基礎上增長了一些規則:
數據結構之:二分搜索樹
數據結構之:二分搜索樹ide

咱們先來編寫二分搜索樹節點的結構以及二分搜索樹基礎的屬性和方法,代碼以下:函數

/**
 * @author 01
 * @program Data-Structure
 * @description 二分搜索樹-存儲的數據需具備可比較性,因此泛型需繼承Comparable接口
 * @create 2018-11-13 17:02
 * @since 1.0
 **/
public class BinarySearchTree<E extends Comparable<E>> {
    /**
     * 二分搜索樹節點的結構
     */
    private class Node {
        E e;
        Node left;
        Node right;

        public Node() {
            this(null, null, null);
        }

        public Node(E e) {
            this(e, null, null);
        }

        public Node(E e, Node left, Node right) {
            this.e = e;
            this.left = left;
            this.right = right;
        }
    }

    /**
     * 根節點
     */
    private Node root;

    /**
     * 表示樹裏存儲的元素個數
     */
    private int size;

    /**
     * 獲取樹裏的元素個數
     *
     * @return 元素個數
     */
    public int size() {
        return size;
    }

    /**
     * 樹是否爲空
     *
     * @return 爲空返回true,不然返回false
     */
    public boolean isEmpty() {
        return size == 0;
    }
}

向二分搜索樹中添加元素

咱們的二分搜索樹不包含重複元素,若是想讓樹包含重複元素的話,也很簡單,只須要改變定義爲:左子樹小於等於節點;或者右子樹大於等於節點。post

二分搜索樹添加元素的非遞歸寫法,和鏈表很像,只不過鏈表中不須要與節點進行比較,而樹則須要比較後決定是添加到左子樹仍是右子樹。學習

具體的實現代碼以下:

/**
 * 向二分搜索樹中添加一個新元素e
 *
 * @param e 新元素
 */
public void add(E e) {
    if (root == null) {
        // 根節點爲空的處理
        root = new Node(e);
        size++;
    } else {
        add(root, e);
    }
}

/**
 * 向以node爲根的二分搜索樹中插入元素e,遞歸實現
 *
 * @param node
 * @param e
 */
private void add(Node node, E e) {
    // 遞歸的終止條件
    if (e.equals(node.e)) {
        // 不存儲重複元素
        return;
    } else if (e.compareTo(node.e) < 0 && node.left == null) {
        // 元素e小於node節點的元素,而且node節點的左孩子爲空,因此成爲node節點的左孩子
        node.left = new Node(e);
        size++;
        return;
    } else if (e.compareTo(node.e) > 0 && node.right == null) {
        // 元素e大於node節點的元素,而且node節點的右孩子爲空,因此成爲node節點的右孩子
        node.right = new Node(e);
        size++;
        return;
    }

    if (e.compareTo(node.e) < 0) {
        // 元素e小於node節點的元素,往左子樹走
        add(node.left, e);
    } else {
        // 元素e大於node節點的元素,往右子樹走
        add(node.right, e);
    }
}

改進添加操做:深刻理解遞歸終止條件

上面所實現的往二叉樹裏添加元素的代碼雖然是沒問題的,可是還有優化的空間。一是在add(E e)方法中對根節點作了判空處理,與後面的方法在邏輯上有些不統一,實際上能夠放在後面的方法中統一處理;二是add(Node node, E e)方法中遞歸的終止條件比較臃腫,能夠簡化。

優化後的實現代碼以下:

/**
 * 向二分搜索樹中添加一個新元素e
 *
 * @param e 新元素
 */
public void add2(E e) {
    root = add2(root, e);
}

/**
 * 向以node爲根的二分搜索樹中插入元素e,精簡後的遞歸實現
 *
 * @param node
 * @param e
 * @return 返回插入新節點後二分搜索樹的根節點
 */
private Node add2(Node node, E e) {
    // 遞歸的終止條件
    if (node == null) {
        // node爲空時必然是能夠插入新節點的
        size++;
        return new Node(e);
    }

    if (e.compareTo(node.e) < 0) {
        // 元素e小於node節點的元素,往左子樹走
        node.left = add2(node.left, e);
    } else if (e.compareTo(node.e) > 0) {
        // 元素e大於node節點的元素,往右子樹走
        node.right = add2(node.right, e);
    }

    // 相等什麼也不作
    return 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);
    }

    // 找右子樹
    return contains(node.right, e);
}

二分搜索樹的前序遍歷

什麼是遍歷操做:

  • 遍歷操做就是把全部節點都訪問一遍,使得能夠對全部節點元素進行操做。在線性結構下,遍歷是極其容易的,一個循環就解決了。可是在樹結構下就稍微有些麻煩了,由於對於樹的遍歷操做,兩棵子樹都要顧及

二叉樹的遍歷方式主要有這麼幾種:前序遍歷、中序遍歷、後序遍歷以及層序遍歷。本小節將要演示的是前序遍歷,所謂前序遍歷就是先遍歷根節點,而後再遍歷左子樹和右子樹。前序遍歷是最天然、最經常使用的遍歷方式。

前序遍歷使用遞歸實現起來很是的簡單,代碼以下:

/**
 * 二分搜索樹的前序遍歷
 */
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);
}

二分搜索樹的中序遍歷和後序遍歷

瞭解了前序遍歷後,中序遍歷和後序遍歷就很簡單了,無非就是換了個順序。其中中序遍歷就是先遍歷左子樹,而後遍歷根節點,再遍歷右子樹。因此中序遍歷的這個「中序」就體如今了根節點是在左右子樹的中間進行遍歷的。具體的實現代碼以下:

/**
 * 二分搜索樹的中序遍歷
 */
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);
}
  • 二分搜索樹的中序遍歷的特性是能夠按照元素從小到大的順序訪問節點,將遍歷過程輸出就能夠看到是有序的

一樣的,後序遍歷也是換了個順序,是先遍歷左子樹,而後遍歷右子樹,再遍歷根節點。具體的實現代碼以下:

/**
 * 二分搜索樹的後序遍歷
 */
public void postOrder() {
    postOrder(root);
}

/**
 * 後序遍歷以node爲根的二分搜索樹,遞歸實現
 */
private void postOrder(Node node) {
    if (node == null) {
        return;
    }

    // 先遍歷左子樹
    postOrder(node.left);
    // 而後遍歷右子樹
    postOrder(node.right);
    // 最後遍歷根節點
    System.out.println(node.e);
}
  • 後序遍歷一般用於須要先處理左右子樹,最後再處理根節點的場景,例如爲二分搜索樹釋放內存(C++)

二分搜索樹前序遍歷的非遞歸實現

雖然使用遞歸實現對樹的遍歷會比較簡單,但一般在實際開發中並不會太多的去使用遞歸,一是怕數據量大時遞歸深度太深致使棧溢出,二是爲了減小遞歸函數調用的開銷。中序遍歷和後序遍歷的非遞歸實現,實際應用不廣,因此本小節主要演示一下前序遍歷的非遞歸實現。

前序遍歷的非遞歸實現思路有好幾種,這裏主要介紹一種遞歸算法轉非遞歸實現的比較通用的思路。理解這種思路後咱們也能夠將其應用到其餘的遞歸轉非遞歸實現的場景上,這種方法就是本身用額外的容器模擬一下系統棧。具體的代碼實現以下:

/**
 * 二分搜索樹的非遞歸前序遍歷實現
 */
public void preOrderNR() {
    // 使用 java.util.Stack 來模擬系統棧
    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);
        }
    }
}

以這樣一顆樹爲例,簡單描述下以上代碼的執行過程:
數據結構之:二分搜索樹

  1. 首先根節點入棧
  2. 進入循環,棧頂元素出棧,輸出28
  3. 當前出棧元素的右節點不爲空,將右節點30壓入棧中
  4. 當前出棧元素的左節點不爲空,將左節點16壓入棧中
  5. 此時棧不爲空,繼續循環,棧頂元素出棧,輸出16(後進先出)
  6. 當前出棧元素的右節點不爲空,將右節點22壓入棧中
  7. 當前出棧元素的左節點不爲空,將左節點13壓入棧中
  8. 繼續循環,棧頂元素出棧,輸出13
  9. 當前出棧元素的右節點爲空,什麼都不作
  10. 當前出棧元素的左節點爲空,什麼都不作
  11. 繼續循環,棧頂元素出棧,輸出22
  12. 重複第九、10步
  13. 繼續循環,棧頂元素出棧,輸出30
  14. 當前出棧元素的右節點不爲空,將右節點42壓入棧中
  15. 當前出棧元素的左節點不爲空,將左節點29壓入棧中
  16. 繼續循環,棧頂元素出棧,輸出29
  17. 重複第九、10步
  18. 繼續循環,棧頂元素出棧,輸出42
  19. 重複第九、10步
  20. 此時棧中沒有元素了,爲空,跳出循環
  21. 最終的輸出爲:28 16 13 22 30 29 42

二分搜索樹的層序遍歷

瞭解了前中後序遍歷,接下來咱們看看二分搜索樹的層序遍歷。所謂層序遍歷就是按照樹的層級自根節點開始從上往下遍歷,一般根節點所在的層級稱爲第0層或第1層,我這裏習慣稱之爲第1層。以下圖所示:
數據結構之:二分搜索樹

  • 當遍歷第1層時,訪問到的是28這個根節點;遍歷第2層時,訪問到的是16以及30這個兩個節點;遍歷第3層時,則訪問到的是1三、2二、29及42這四個節點

能夠看出層序遍歷與前中後序遍歷不太同樣,前中後序遍歷都是先將其中一顆子樹遍歷到底,而後再返回來遍歷另外一顆子樹,其實這也就是所謂的深度優先遍歷,而層序遍歷也就是所謂的廣度優先遍歷了。

一般層序遍歷會使用非遞歸的實現,而且會使用一個隊列容器做爲輔助,因此代碼寫起來與以前的非遞歸實現前序遍歷很是相似,只不過容器由棧換成了隊列。具體的代碼實現以下:

/**
 * 二分搜索樹的層序遍歷實現
 */
public void levelOrder() {
    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);
        }
    }
}

以上面的那棵樹爲例,咱們也來分析下層序遍歷代碼的執行過程:

  1. 首先根節點入隊
  2. 進入循環,隊頭元素出隊,輸出28
  3. 當前出隊元素的左節點不爲空,將左節點16入隊
  4. 當前出隊元素的右節點不爲空,將右節點30入隊
  5. 此時隊列不爲空,繼續循環,隊頭元素出隊,輸出16(先進先出)
  6. 當前出隊元素的左節點不爲空,將左節點13入隊
  7. 當前出隊元素的右節點不爲空,將右節點22入隊
  8. 繼續循環,隊頭元素出隊,輸出30
  9. 當前出隊元素的左節點不爲空,將左節點29入隊
  10. 當前出隊元素的右節點不爲空,將右節點42入隊
  11. 繼續循環,隊頭元素出隊,輸出13
  12. 當前出隊元素的左節點爲空,什麼都不作
  13. 當前出隊元素的右節點爲空,什麼都不作
  14. 繼續循環,隊頭元素出隊,輸出22
  15. 重複第十二、13步
  16. 繼續循環,隊頭元素出隊,輸出29
  17. 重複第十二、13步
  18. 繼續循環,隊頭元素出隊,輸出42
  19. 重複第十二、13步
  20. 此時棧中沒有元素了,爲空,跳出循環
  21. 最終的輸出爲:28 16 30 13 22 29 42

廣度優先遍歷的意義:

  • 更快的找到問題的解
  • 經常使用於算法設計中:最短路徑

刪除二分搜索樹的最大元素和最小元素

二分搜索樹的刪除操做是相對比較複雜的,因此咱們先來解決一個相對簡單的任務,就是刪除二分搜索樹中的最大元素和最小元素。因爲二分搜索樹的特性,其最小值就是最左邊的那個節點,而最大元素則是最右邊的那個節點。

如下面這棵二分搜索樹爲例,看其最左和最右的兩個節點,就能知道最小元素是13,最大元素是42:
數據結構之:二分搜索樹

再來看一種狀況,如下這棵二分搜索樹,往最左邊走只能走到16這個節點,往最右邊走只能走到30這個節點,因此最大最小元素不必定會是葉子節點:
數據結構之:二分搜索樹

  • 在這種狀況下,刪除最大最小元素時,因爲還有子樹,因此須要將其子樹掛載到被刪除的節點上

咱們先來看看如何找到二分搜索樹的最大元素和最小元素。代碼以下:

/**
 * 獲取二分搜索樹的最小元素
 */
public E minimum() {
    if (size == 0) {
        throw new IllegalArgumentException("BST is empty!");
    }

    return minimum(root).e;
}

/**
 * 返回以node爲根的二分搜索樹的最小元素所在節點
 */
private Node minimum(Node node) {
    if (node.left == null) {
        return node;
    }

    return minimum(node.left);
}

/**
 * 獲取二分搜索樹的最大元素
 */
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 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;
}

/**
 * 刪除二分搜索樹中的最小元素所在節點,並返回該元素
 */
public E removeMin() {
    E ret = minimum();
    root = removeMin(root);
    return ret;
}

/**
 * 刪除以node爲根的二分搜索樹中的最小節點
 * 返回刪除節點後新的二分搜索樹的根
 */
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;
}

刪除二分搜索樹的任意元素

有了上面的基礎後,就應該對實現刪除二分搜索樹的任意元素有必定的思路了。首先,咱們來看看在實現過程當中會遇到的一些狀況,第一種狀況就是要刪除的目標節點只有一個左子樹,例如刪除下圖中的58:
數據結構之:二分搜索樹

  • 在這種狀況下,只須要將左子樹掛到被刪除的目標節點上便可,與刪除最大元素的基本邏輯相似

第二種狀況與第一種狀況相反,就是要刪除的目標節點只有一個右子樹:
數據結構之:二分搜索樹

  • 一樣的,把右子樹掛到被刪除的目標節點上便可,與刪除最小元素的基本邏輯相似

第三種狀況是要刪除的目標節點是一個葉子節點,這種狀況直接複用以上任意一種狀況的處理邏輯便可,由於咱們也能夠將葉子節點視爲有左子樹或右子樹,只不過爲空而已。

比較複雜的是第四種狀況,也就是要刪除的目標節點有左右兩個子節點,以下圖所示:
數據結構之:二分搜索樹

對於這種狀況,咱們得把58這個節點下的左右兩顆子樹融合在一塊兒,此時就能夠採用1962年,Hibbard提出的Hibbard Deletion方法解決。

首先,咱們將要刪除的這個節點稱之爲 $d$,第一步是從 $d$ 的右子樹中找到最小的節點 $s$,這個 $s$ 就是 $d$ 的後繼了。第二步要作的事情就很簡單了,將 $s$ 從原來的樹上摘除並將 $s$ 的右子樹指向這個刪除後的右子樹,而後再將 $s$ 的左子樹指向 $d$ 的左子樹,最後讓 $d$ 的父節點指向 $s$,此時就完成了對目標節點 $d$ 的刪除操做。以下圖:
數據結構之:二分搜索樹

具體的實現代碼以下:

/**
 * 從二分搜索樹中刪除元素爲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;
    }

    // 找到了要刪除的節點
    // 待刪除的節點左子樹爲空的狀況
    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.right = removeMin(node.right);
    successor.left = node.left;

    return successor;
}
相關文章
相關標籤/搜索