七、基本數據結構(樹形結構)


一、數的概念 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)

static class Node {
    Node rightNode;
    Node leftNode;
    String data;
}

/**
 * 先序遍歷,先打印本身,在打印左節點,在打印右節點
 *
 * @param node
 */
public static void preOrderTraverse(Node node) {
    if (node == null) {
        return;
    }
    System.out.print(node.data + " ");
    preOrderTraverse(node.leftNode);
    preOrderTraverse(node.rightNode);
}

/**
 * 中序遍歷,先打印左節點,在打印本身,在打印右節點
 *
 * @param node
 */
public static void inOrderTraverse(Node node) {
    if (node == null) {
        return;
    }
    inOrderTraverse(node.leftNode);
    System.out.print(node.data + " ");
    inOrderTraverse(node.rightNode);
}

/**
 * 后序遍歷,先打印左節點,在打印右節點,在打印自己
 *
 * @param node
 */
public static void postOrderTraverse(Node node) {
    if (node == null) {
        return;
    }
    preOrderTraverse(node.rightNode);
    preOrderTraverse(node.leftNode);
    System.out.print(node.data + " ");

}

/**
 * 數的層級遍歷
 *
 * @param root
 */
public static void levelTraverse(Node root) {
    if (root == null) {
        return;
    }

    LinkedList<Node> queue = new LinkedList<Node>();
    Node current = null;
    // 根節點入隊
    queue.offer(root);

    // 左側數的深度
    int leftNum = 0;
    // 右側數的深度
    int rightNum = 0;

    // 只要隊列中有元素,就可以一直執行,非常巧妙地利用了隊列的特性
    while (!queue.isEmpty()) {
        // 出隊隊頭元素
        current = queue.poll();
        System.out.print("-->" + current.data);
        // 左子樹不為空,入隊
        if (current.leftNode != null) {
            queue.offer(current.leftNode);
            leftNum++;
        }

        // 右子樹不為空,入隊
        if (current.rightNode != null) {
            queue.offer(current.rightNode);
            rightNum++;
        }
    }
    System.out.println(rightNum + "\t" + leftNum);
}

三、二叉查找樹(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)僅僅大了一倍,在性能上,下降得並不多。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM