數據結構之:二分搜索樹

爲什麼要研究樹結構

爲什麼要研究樹結構?首先因爲樹在計算機程序中是非常重要的數據結構之一,並且樹結構本身是一種天然的組織結構。在很多情況下將數據使用樹結構存儲後,會發現出奇的高效。甚至有些問題,必須要使用樹結構才能夠解決。

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

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

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

樹結構有很多中,常見的有:

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

二分搜索樹基礎

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




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

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

/**
 * @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;
    }
}

向二分搜索樹中添加元素

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

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

具體的實現代碼如下:

/**
 * 向二分搜索樹中添加一個新元素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. 重複第9、10步
  13. 繼續循環,棧頂元素出棧,輸出30
  14. 當前出棧元素的右節點不爲空,將右節點42壓入棧中
  15. 當前出棧元素的左節點不爲空,將左節點29壓入棧中
  16. 繼續循環,棧頂元素出棧,輸出29
  17. 重複第9、10步
  18. 繼續循環,棧頂元素出棧,輸出42
  19. 重複第9、10步
  20. 此時棧中沒有元素了,爲空,跳出循環
  21. 最終的輸出爲:28 16 13 22 30 29 42

二分搜索樹的層序遍歷

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

  • 當遍歷第1層時,訪問到的是28這個根節點;遍歷第2層時,訪問到的是16以及30這個兩個節點;遍歷第3層時,則訪問到的是13、22、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. 重複第12、13步
  16. 繼續循環,隊頭元素出隊,輸出29
  17. 重複第12、13步
  18. 繼續循環,隊頭元素出隊,輸出42
  19. 重複第12、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;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章