七、基本資料結構(樹形結構)

abc_十號發表於2020-06-17

一、數的概念 Tree

樹形結構
  • 如上圖所示,是一個樹形機構,這裡面每個元素叫作“節點”,用來連線相鄰節點之間的關係,叫作“父子關係”。
  • A 節點是 B 節點的父節點, B 節點是 A 節點的子節點。
  • B、 C、 D 這三個節點的父節點是同一個節點,所以它們之間互稱為兄弟節點。
  • 沒有父節點的節點叫作根節點,也就是圖中的節點 E。
  • 我們把沒有子節點的節點叫作葉子節點或者葉節點,比如圖中的 G、 H、 I、 J、 K、 L 都是葉子節點。
  • 節點的高度:節點到葉子節點的最長路徑(邊數)。
  • 節點的深度:根節點到這個節點所經歷的邊的個數。
  • 節點的層數:節點的深度 + 1.
  • 樹的高度:就是根節點的高度。

二、二叉樹

2.1、二叉樹介紹

  • 二叉樹,顧名思義,每個節點最多有兩個“叉”,也就是兩個子節點,分別是左子節點和右子節點。

  • 不過,二叉樹並不要求每個節點都有兩個子節點,有的節點只有左子節點,有的節點只有右子節點。

  • 二叉樹
  • 編號2的二叉樹中,葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫作滿二叉樹

  • 編號3的二叉樹中,葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫作完全二叉樹

  • 完全二叉樹

2.2、二叉樹的儲存

儲存一棵二叉樹,有兩種方法,一種是基於指標或者引用的二叉鏈式儲存法,一種是基於陣列的順序儲存法。

1.鏈式儲存法

  • 鏈式儲存比較簡單、直觀。
  • 如下圖所示,每個節點有三個欄位,其中一個儲存資料,另外兩個是指向左右子節點的指標。
  • 只要拎住根節點,就可以通過左右子節點的指標,把整棵樹都串起來。
  • 這種儲存方式我們比較常用。大部分二叉樹程式碼都是通過這種結構來實現的。
  • 鏈式儲存

2.基於陣列的順序儲存

  • 如下圖所示,把根節點儲存在下標 i = 1 的位置,那左子節點儲存在下標 2 * i = 2 的位置,右子節點儲存在 2 * i + 1 = 3 的位置。

  • 以此類推, B 節點的左子節點儲存在 2 * i = 2 * 2 = 4 的位置,右子節點儲存在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

  • 如果節點 X 儲存在陣列中下標為 i 的位置,下標為 2 * i 的位置儲存的就是左子節點,下標為 2 * i + 1 的位置儲存的就是右子節點。

  • 反過來,下標為 i/2 的位置儲存就是它的父節點。

  • 通過這種方式,我們只要知道根節點儲存的位置(一般情況下,為了方便計運算元節點,根節點會儲存在下標為1的位置),這樣就可以通過下標計算,把整棵樹都串起來。

2.3。二叉樹的遍歷

  • 前序遍歷:對於樹中的任意節點來說,先列印這個節點,然後再列印它的左子樹,最後列印它的右子樹。

  • 中序遍歷:對於樹中的任意節點來說,先列印它的左子樹,然後再列印它本身,最後列印它的右子樹。

  • 後序遍歷:對於樹中的任意節點來說,先列印它的左子樹,然後再列印它的右子樹,最後列印這個節點本身。

  • 二叉樹的遍歷
  • 實際上,二叉樹的前、中、後序遍歷就是一個遞迴的過程。

  • 比如,前序遍歷,其實就是先列印根節點,然後再遞迴地列印左子樹,最後遞迴地列印右子樹。

  • 前序遍歷的遞推公式:preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)。

  • 中序遍歷的遞推公式:inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)

  • 後序遍歷的遞推公式:postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r

  • 從上面的前、中、後序遍歷的順序圖,可以看出來,每個節點最多會被訪問兩次,所以遍歷操作的時間複雜度,跟節點的個數 n 成正比,也就是說二叉樹遍歷的時間複雜度是 O(n)

三、二叉查詢樹(Binary Search Tree)

  • 二叉查詢樹是二叉樹中最常用的一種型別,也叫二叉搜尋樹。
  • 二叉查詢樹是為了實現快速查詢而生的。
  • 它不僅僅支援快速查詢一個資料,還支援快速插入、刪除一個資料。
  • 二叉查詢樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值
  • 二叉查詢樹最大的特點就是,支援動態資料集合的快速插入、刪除、查詢操作。
  • 中序遍歷二叉查詢樹,可以輸出有序的資料序列,時間複雜度是 O(n),非常高效。
  • 二叉查詢樹

3.1、二叉查詢樹的查詢操作

  • 先取根節點,如果它等於要查詢的資料,就返回。
  • 如果要查詢的資料比根節點的值小,那就在左子樹中遞迴查詢;
  • 如果要查詢的資料比根節點的值大,那就在右子樹中遞迴查詢。
  • 二叉樹查詢
public class BinarySearchTree {

    private Node tree;

    public Node find(int data) {
        Node p = tree;
        while (p != null) {
            if (data < p.data) p = p.leftNode;
            else if (data > p.data) p = p.rightNode;
            else return p;
        }
        return null;
    }


    class Node {
        private int data;
        private Node leftNode;
        private Node rightNode;

        public Node(int data) {
            this.data = data;
        }
    }
}

3.2、二叉查詢樹的插入操作

  • 二叉查詢樹的插入過程有點類似查詢操作。
  • 新插入的資料一般都是在葉子節點上,所以只需要從根節點開始,依次比較要插入的資料和節點的大小關係。
  • 如果要插入的資料比節點的資料大,並且節點的右子樹為空,就將新資料直接插到右子節點的位置;
  • 如果不為空,就再遞迴遍歷右子樹,查詢插入位置。
  • 同理,如果要插入的資料比節點數值小,並且節點的左子樹為空,就將新資料插入到左子節點的位置;如果不為空,就再遞迴遍歷左子樹,查詢插入位置。
    二叉樹插入
/**
 * 插入操作
 *
 * @param data
 * @return
 */
public Boolean insert(int data) {
    if (tree == null) {
        tree = new Node(data);
        return true;
    }
    Node p = tree;
    while (p != null) {
        if (data > p.data) {
            // 插入右節點
            if (p.rightNode == null) {
                p.rightNode = new Node(data);
                return true;
            }
            p = p.rightNode;
        } else {
            // 插入 左節點
            if (p.leftNode == null) {
                p.leftNode = new Node(data);
                return true;
            }
            p = p.leftNode;
        }
    }
    return false;
}

3.3、二叉查詢樹的刪除操作

  • 針對要刪除節點的子節點個數的不同,需要分三種情況來處理。
  • 第一種情況是,如果要刪除的節點沒有子節點
    • 只需要直接將父節點中,指向要刪除節點的指標置為 null。
    • 比如圖中的刪除節點 55。
  • 第二種情況是,如果要刪除的節點只有一個子節點(只有左子節點或者右子節點)
    • 只需要更新父節點中,指向要刪除節點的指標,讓它指向要刪除節點的子節點就可以了。
    • 比如圖中的刪除節點 13。
  • 第三種情況是,如果要刪除的節點有兩個子節點
    • 需要找到這個節點的右子樹中的最小節點,把它替換到要刪除的節點上。
    • 然後再刪除掉這個最小節點,因為最小節點肯定沒有左子節點(如果有左子結點,那就不是最小節點了)。
    • 所以,可以應用上面兩條規則來刪除這個最小節點。比如圖中的刪除節點 18。
  • 刪除
/**
 * 刪除
 * @param data
 */
public void delete(int data) {
    // p指向要刪除的節點,初始化指向根節點
    Node p = tree;
    // pp記錄的是p的父節點
    Node pp = null;

    // 查詢要刪除的節點位置,及其父節點
    while (p != null && p.data != data) {
        pp = p;
        if (data > p.data) {
            p = p.rightNode;
        } else {
            p = p.leftNode;
        }
    }
    if (p == null) {
        return;// 沒有找到
    }
    // 要刪除的節點有兩個子節點
    if (p.leftNode != null && p.rightNode != null) {
        // 查詢右子樹中最小節點
        Node minp = p.rightNode;
        Node minpp = p; // minPP表示minP的父節點
        while (minp.leftNode != null) {
            minpp = minp;
            minp = minp.leftNode;
        }
        // 將 minp 的資料替換到 p 中
        p.data = minp.data;
        // 下面就變成了刪除 minp 了
        p = minp;
        pp = minpp;
    }
    // 刪除節點是葉子節點或者僅有一個子節點
    Node child; // p 的子節點
    if (p.leftNode != null) {
        child = p.leftNode;
    } else if (p.rightNode != null) {
        child = p.rightNode;
    } else {
        child = null;
    }
    if (pp == null) {
        // 刪除的是根節點
        tree = child;
    } else if (pp.leftNode == p) {
        pp.leftNode = child;
    } else {
        pp.rightNode = child;
    }
}
  • 實際上,關於二叉查詢樹的刪除操作,還有個非常簡單、取巧的方法,就是單純將要刪除的節點標記為“已刪除”,但是並不真正從樹中將這個節點去掉
  • 這樣原本刪除的節點還需要儲存在記憶體中,比較浪費記憶體空間,但是刪除操作就變得簡單了很多。
  • 而且,這種處理方法也並沒有增加插入、查詢操作程式碼實現的難度。

3.4、支援重複資料的二叉查詢樹

  • 上文提到的二叉查詢樹,預設樹中節點儲存的都是數字。很多時候,在二叉查詢樹中儲存的,是一個包含很多欄位的物件。
  • 利用物件的某個欄位作為鍵值(key)來構建二叉查詢樹。把物件中的其他欄位叫作衛星資料。
  • 前面講的二叉查詢樹的操作,針對的都是不存在鍵值相同的情況。
  • 那如果儲存的兩個物件鍵值相同,解決方法如下:
    • 第一種方法比較容易。二叉查詢樹中每一個節點不僅會儲存一個資料,因此通過連結串列和支援動態擴容的陣列等資料結構,把值相同的資料都儲存在同一個節
      點上。

    • 第二種方法是,每個節點仍然只儲存一個資料。

    • 在查詢插入位置的過程中,如果碰到一個節點的值,與要插入資料的值相同,就將這個要插入的資料放到這個節點的右子樹,也就是說,把這個新插入的資料當作大於這個節點的值來處理。

    • 當要查詢資料的時候,遇到值相同的節點,並不停止查詢操作,而是繼續在右子樹中查詢,直到遇到葉子節點,才停止。這樣就可以把鍵值等於要查詢值的所有節點都找出來。

    • 對於刪除操作,也需要先查詢到每個要刪除的節點,然後再按前面講的刪除操作的方法,依次刪除。

3.5、二叉查詢樹的時間複雜度分析

  • 二叉查詢樹的形態各式各樣。如下圖所示,對於同一組資料,構造了三種二叉查詢樹。它們的查詢、插入、刪除操作的執行效率都是不一樣的。

  • 圖中第一種二叉查詢樹,根節點的左右子樹極度不平衡,已經退化成了連結串列,所以查詢的時間複雜度就變成了 O(n)。

  • 從前面的例子、圖,以及還有程式碼來看,不管操作是插入、刪除還是查詢, 時間複雜度其實都跟樹的高度成正比,也就是 O(height)。

  • 既然這樣,現在問題就轉變成另外一個了,也就是,如何求一棵包含 n 個節點的完全二叉樹的高度?

  • 樹的高度就等於最大層數減一,為了方便計算,轉換成層來表示。

  • 從圖中可以看出,包含 n 個節點的完全二叉樹中,第一層包含1個節點,第二層包含2個節點,第三層包含4個節點,依次類推,下面一層節點個數是上一層的 2 倍,第 K 層包含的節點個數就是 2^(K-1)。

  • 不過,對於完全二叉樹來說,最後一層的節點個數有點兒不遵守上面的規律了。它包含的節點個數在 1 個到 2^(L-1) 個之間(假設最大層數是L)。

  • 如果我們把每一層的節點個數加起來就是總的節點個數 n。也就是說,如果節點的個數是 n,那麼 n 滿足這樣一個關係:

  • n >= 1+2+4+8+...+2^(L-2)+1

  • n <= 1+2+4+8+...+2(L-2)+2(L-1)

  • 藉助等比數列的求和公式,我們可以計算出,L 的範圍是 [log2(n+1), log2n +1]。完全二叉樹的層數小於等於 log2n +1,也就是說,完全二叉樹的高度小於等於 log2n。

  • 顯然,極度不平衡的二叉查詢樹,它的查詢效能肯定不能滿足需求。

  • 需要構建一種不管怎麼刪除、插入資料,在任何時候,都能保持任意節點左右子樹都比較平衡的二叉查詢樹,這就是平衡二叉查詢樹。

  • 平衡二叉查詢樹的高度接近 logn,所以插入、刪除、查詢操作的時間複雜度也比較穩定,是 O(logn)。

四、紅黑樹

  • 二叉查詢樹是最常用的一種二叉樹,它支援快速插入、刪除、查詢操作,各個操作的時間複雜度跟樹的高度成正比,理想情況下,時間複雜度是 O(logn)。
  • 不過,二叉查詢樹在頻繁的動態更新過程中,可能會出現樹的高度遠大於 log2n 的情況,從而導致各個操作的效率下降。
  • 極端情況下,二叉樹會退化為連結串列,時間複雜度會退化到 O(n)。
  • 要解決這個複雜度退化的問題,需要設計一種平衡二叉查詢樹,比如紅黑樹。

4.1、平衡二叉查詢樹

  • 平衡二叉樹的嚴格定義是這樣的:二叉樹中任意一個節點的左右子樹的高度相差不能大於 1

  • 從這個定義來看,完全二叉樹、滿二叉樹其實都是平衡二叉樹,但是非完全二叉樹也有可能是平衡二叉樹。

  • 平衡二叉查詢樹
  • 平衡二叉查詢樹不僅滿足上面平衡二叉樹的定義,還滿足二叉查詢樹的特點。

  • 最先被發明的平衡二叉查詢樹是 AVL 樹,它嚴格符合剛講到的平衡二叉查詢樹的定義,即任何節點的左右子樹高度相差不超過 1,是一種高度平衡的二叉查詢樹。

  • 但是很多平衡二叉查詢樹其實並沒有嚴格符合上面的定義(樹中任意一個節點的左右子樹的高度相差不能大於1),比如紅黑樹,它從根節點到各個葉子節點的最長路徑,有可能會比最短路徑大一倍。

  • 發明平衡二叉查詢樹這類資料結構的初衷是,解決普通二叉查詢樹在頻繁的插入、刪除等動態更新的情況下,出現時間複雜度退化的問題。

  • 所以, 平衡二叉查詢樹中“平衡”的意思,其實就是讓整棵樹左右看起來比較“對稱”、比較“平衡”,不要出現左子樹很高、右子樹很矮的情況。

  • 這樣就能讓整棵樹的高度相對來說低一些,相應的插入、刪除、查詢等操作的效率高一些。

  • 所以,如果現在設計一個新的平衡二叉查詢樹,只要樹的高度不比 log2n 大很多(比如樹的高度仍然是對數量級的),儘管它不符合嚴格的平衡二叉查詢樹的定義,但仍然可以說,這是一個合格的平衡二叉查詢樹。

4.2、紅黑樹

  • 平衡二叉查詢樹其實有很多,比如, Splay Tree(伸展樹)、 Treap(樹堆)等,但是提到平衡二叉查詢樹,聽到的基本都是紅黑樹。
  • 他的出鏡率甚至要高於“平衡二叉查詢樹”這幾個字,有時候,甚至預設平衡二叉查詢樹就是紅黑樹。
  • 紅黑樹的英文是“Red-Black Tree”,簡稱R-B Tree。它是一種不嚴格的平衡二叉查詢樹,它的定義是不嚴格符合平衡二叉查詢樹的定義的。
  • 顧名思義,紅黑樹中的節點,一類被標記為黑色,一類被標記為紅色。除此之外,一棵紅黑樹還需要滿足這樣幾個要求:
    • 根節點是黑色的;
    • 每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不儲存資料;
    • 任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;
    • 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;
    • 這裡的第二點要求“葉子節點都是黑色的空節點”,它主要是為了簡化紅黑樹的程式碼實現而設定的。
    • 下圖中將黑色的、空的葉子節點都省略掉了。

1.為什麼說紅黑樹是“近似平衡”的?

  • 平衡二叉查詢樹的初衷,是為了解決二叉查詢樹因為動態更新導致的效能退化問題。所以,“平衡”的意思可以等價為效能不退化。

  • “近似平衡”就等價為效能不會退化的太嚴重。

  • 二叉查詢樹很多操作的效能都跟樹的高度成正比。

  • 一棵極其平衡的二叉樹(滿二叉樹或完全二叉樹)的高度大約是 log2n,所以如果要證明紅黑樹是近似平衡的,只需要分析,紅黑樹的高度是否比較穩定地趨近 log2n 就好了。

  • 如果將紅色節點從紅黑樹中去掉,那單純包含黑色節點的紅黑樹的高度是多少呢?

  • 紅色節點刪除之後,有些節點就沒有父節點了,它們會直接拿這些節點的祖父節點(父節點的父節點)作為父節點。所以,之前的二叉樹就變成了四叉樹。

  • 前面紅黑樹的定義裡有這麼一條:從任意節點到可達的葉子節點的每個路徑包含相同數目的黑色節點。

  • 從四叉樹中取出某些節點,放到葉節點位置,四叉樹就變成了完全二叉樹。所以,僅包含黑色節點的四叉樹的高度,比包含相同節點個數的完全二叉樹的高度還要小。

  • 完全二叉樹的高度近似 log2n,這裡的四叉“黑樹”的高度要低於完全二叉樹,所以去掉紅色節點的“黑樹”的高度也不會超過 log2n。

  • 現在知道只包含黑色節點的“黑樹”的高度,那我們現在把紅色節點加回去,高度會變成多少呢?

  • 從上面畫的紅黑樹的例子和定義看,在紅黑樹中,紅色節點不能相鄰,也就是說,有一個紅色節點就要至少有一個黑色節點,將它跟其他紅色節點隔開。

  • 紅黑樹中包含最多黑色節點的路徑不會超過l og2n,所以加入紅色節點之後,最長路徑不會超過 2log2n,也就是說,紅黑樹的高度近似 2log2n。

  • 所以,紅黑樹的高度只比高度平衡的AVL樹的高度(log2n)僅僅大了一倍,在效能上,下降得並不多。

  • 這樣推匯出來的結果不夠精確,實際上紅黑樹的效能更好。

相關文章