重學資料結構和演算法(二)之二叉樹、紅黑樹、遞迴樹、堆排序

夢和遠方發表於2021-03-01

最近學習了極客時間的《資料結構與演算法之美]》很有收穫,記錄總結一下。
歡迎學習老師的專欄:資料結構與演算法之美
程式碼地址:https://github.com/peiniwan/Arithmetic

樹是無向、連通的無環圖。

“高度”這個概念,其實就是從下往上度量,比如我們要度量第 10 層樓的高度、第 13 層樓的高度,起點都是地面。所以,樹這種資料結構的高度也是一樣,從最底層開始計數,並且計數的起點是 0。
“深度”這個概念在生活中是從上往下度量的,比如水中魚的深度,是從水平面開始度量的。所以,樹這種資料結構的深度也是類似的,從根結點開始度量,並且計數起點也是 0。
“層數”跟深度的計算類似,不過,計數起點是 1,也就是說根節點的位於第 1 層。

二叉樹

如何表示(或者儲存)一棵二叉樹

一種是基於指標或者引用的二叉鏈式儲存法
一種是基於陣列的順序儲存法。
鏈式儲存法
每個節點有三個欄位,其中一個儲存資料,另外兩個是指向左右子節點的指標。我們只要拎住根節點,就可以通過左右子節點的指標,把整棵樹都串起來。這種儲存方式我們比較常用。大部分二叉樹程式碼都是通過這種結構來實現的

基於陣列的順序儲存法
把根節點儲存在下標 i = 1 的位置,那左子節點儲存在下標 2 * i = 2 的位置,右子節點儲存在 2 * i + 1 = 3 的位置。以此類推,B 節點的左子節點儲存在 2 * i = 2 * 2 = 4 的位置,右子節點儲存在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

二叉樹的遍歷

前序遍歷、中序遍歷和後序遍歷

寫遞迴程式碼的關鍵,就是看能不能寫出遞推公式,而寫遞推公式的關鍵就是,如果要解決問題 A,就假設子問題 B、C 已經解決,然後再來看如何利用 B、C 來解決 A。所以,我們可以把前、中、後序遍歷的遞推公式都寫出來。

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

二叉查詢樹(Binary Search Tree)

二叉查詢樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。
叉查詢樹最大的特點就是,支援動態資料集合的快速插入、刪除、查詢操作。不需要有序

1. 二叉查詢樹的查詢操作

public class BinarySearchTree {
  private Node tree;

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

2. 二叉查詢樹的插入操作
二叉查詢樹的插入過程有點類似查詢操作。新插入的資料一般都是在葉子節點上,所以我們只需要從根節點開始,依次比較要插入的資料和節點的大小關係。


public void insert(int data) {
  if (tree == null) {
    tree = new Node(data);
    return;
  }

  Node p = tree;
  while (p != null) {
    if (data > p.data) {
      if (p.right == null) {
        p.right = new Node(data);
        return;
      }
      p = p.right;
    } else { // data < p.data
      if (p.left == null) {
        p.left = new Node(data);
        return;
      }
      p = p.left;
    }
  }
}

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

中序遍歷二叉查詢樹,可以輸出有序的資料序列,時間複雜度是 O(n),非常高效。因此,二叉查詢樹也叫作二叉排序樹。
看圖2
查詢、插入、刪除等很多操作的時間複雜度都跟樹的高度成正比

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

我們現在來分析一個最理想的情況,二叉查詢樹是一棵完全二叉樹(或滿二叉樹)。這個時候,插入、刪除、查詢的時間複雜度是多少呢?
從我前面的例子、圖,以及還有程式碼來看,不管操作是插入、刪除還是查詢,時間複雜度其實都跟樹的高度成正比,也就是 O(height)。既然這樣,現在問題就轉變成另外一個了,也就是,如何求一棵包含 n 個節點的完全二叉樹的高度?
樹的高度就等於最大層數減一,為了方便計算,我們轉換成層來表示。從圖中可以看出,包含 n 個節點的完全二叉樹中,第一層包含 1 個節點,第二層包含 2 個節點,第三層包含 4 個節點,依次類推,下面一層節點個數是上一層的 2 倍,第 K 層包含的節點個數就是 2^(K-1)。
平衡二叉查詢樹的高度接近 logn,所以插入、刪除、查詢操作的時間複雜度也比較穩定,是 O(logn)。

二叉查詢樹和雜湊表

雜湊表的插入、刪除、查詢操作的時間複雜度可以做到常量級的 O(1),非常高效。而二叉查詢樹在比較平衡的情況下,插入、刪除、查詢操作時間複雜度才是 O(logn),相對雜湊表,好像並沒有什麼優勢,那我們為什麼還要用二叉查詢樹呢?

第一,雜湊表中的資料是無序儲存的,如果要輸出有序的資料,需要先進行排序。而對於二叉查詢樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間複雜度內,輸出有序的資料序列。

第二,雜湊表擴容耗時很多,而且當遇到雜湊衝突時,效能不穩定,儘管二叉查詢樹的效能不穩定,但是在工程中,我們最常用的平衡二叉查詢樹的效能非常穩定,時間複雜度穩定在 O(logn)。

第三,籠統地來說,儘管雜湊表的查詢等操作的時間複雜度是常量級的,但因為雜湊衝突的存在,這個常量不一定比 logn 小,所以實際的查詢速度可能不一定比 O(logn) 快。加上雜湊函式的耗時,也不一定就比平衡二叉查詢樹的效率高。

第四,雜湊表的構造比二叉查詢樹要複雜,需要考慮的東西很多。比如雜湊函式的設計、衝突解決辦法、擴容、縮容等。平衡二叉查詢樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。

最後,為了避免過多的雜湊衝突,雜湊表裝載因子不能太大,特別是基於開放定址法解決衝突的雜湊表,不然會浪費一定的儲存空間。

紅黑樹

平衡二叉查詢樹

平衡二叉樹的嚴格定義是這樣的:二叉樹中任意一個節點的左右子樹的高度相差不能大於 1。從這個定義來看,上一節我們講的完全二叉樹、滿二叉樹其實都是平衡二叉樹,但是非完全二叉樹也有可能是平衡二叉樹。

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

平衡二叉查詢樹中“平衡”的意思,其實就是讓整棵樹左右看起來比較“對稱”、比較“平衡”,不要出現左子樹很高、右子樹很矮的情況。這樣就能讓整棵樹的高度相對來說低一些,相應的插入、刪除、查詢等操作的效率高一些。

AVL樹不存在變色的問題,只有左旋轉、右旋轉這兩種操作。

如何定義一棵“紅黑樹”?

新加入的就是紅節點
漫話
紅黑樹的英文是“Red-Black Tree”,簡稱 R-B Tree。
顧名思義,紅黑樹中的節點,一類被標記為黑色,一類被標記為紅色。除此之外,一棵紅黑樹還需要滿足這樣幾個要求:

  • 根節點是黑色的;
  • 每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不儲存資料;
  • 任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;
  • 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;
    這裡的第二點要求“葉子節點都是黑色的空節點”,稍微有些奇怪,它主要是為了簡化紅黑樹的程式碼實現而設定的,下一節我們講紅黑樹的實現的時候會講到。這節我們暫時不考慮這一點,所以,在畫圖和講解的時候,我將黑色的、空的葉子節點都省略掉了。

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

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

紅黑樹的高度不是很好分析,我帶你一步一步來推導。
首先,我們來看,如果我們將紅色節點從紅黑樹中去掉,那單純包含黑色節點的紅黑樹的高度是多少呢?
紅色節點刪除之後,有些節點就沒有父節點了,它們會直接拿這些節點的祖父節點(父節點的父節點)作為父節點。所以,之前的二叉樹就變成了四叉樹。

前面紅黑樹的定義裡有這麼一條:從任意節點到可達的葉子節點的每個路徑包含相同數目的黑色節點。我們從四叉樹中取出某些節點,放到葉節點位置,四叉樹就變成了完全二叉樹。所以,僅包含黑色節點的四叉樹的高度,比包含相同節點個數的完全二叉樹的高度還要小。
完全二叉樹的高度近似 log2n,這裡的四叉“黑樹”的高度要低於完全二叉樹,所以去掉紅色節點的“黑樹”的高度也不會超過 log2n。
我們現在知道只包含黑色節點的“黑樹”的高度,那我們現在把紅色節點加回去,高度會變成多少呢?
從上面我畫的紅黑樹的例子和定義看,在紅黑樹中,紅色節點不能相鄰,也就是說,有一個紅色節點就要至少有一個黑色節點,將它跟其他紅色節點隔開。紅黑樹中包含最多黑色節點的路徑不會超過 log2n,所以加入紅色節點之後,最長路徑不會超過 2log2n,也就是說,紅黑樹的高度近似 2log2n。
所以,紅黑樹的高度只比高度平衡的 AVL 樹的高度(log2n)僅僅大了一倍,在效能上,下降得並不多。這樣推匯出來的結果不夠精確,實際上紅黑樹的效能更好。

為什麼工程中都喜歡用紅黑樹,而不是其他平衡二叉查詢樹呢?
AVL 樹是一種高度平衡的二叉樹,所以查詢的效率非常高,但是,有利就有弊,AVL 樹為了維持這種高度的平衡,就要付出更多的代價。每次插入、刪除都要做調整,就比較複雜、耗時。所以,對於有頻繁的插入、刪除操作的資料集合,使用 AVL 樹的代價就有點高了。
紅黑樹只是做到了近似平衡,並不是嚴格的平衡,所以在維護平衡的成本上,要比 AVL 樹要低。
紅黑樹是一種平衡二叉查詢樹。它是為了解決普通二叉查詢樹在資料更新的過程中,複雜度退化的問題而產生的。紅黑樹的高度近似 log2n,所以它是近似平衡,插入、刪除、查詢操作的時間複雜度都是 O(logn)。

遞迴樹分析演算法複雜度

藉助遞迴樹來分析遞迴演算法的時間複雜度。
遞迴的思想就是,將大問題分解為小問題來求解,然後再將小問題分解為小小問題。這樣一層一層地分解,直到問題的資料規模被分解得足夠小,不用繼續遞迴分解為止。
如果我們把這個一層一層的分解過程畫成圖,它其實就是一棵樹。我們給這棵樹起一個名字,叫作遞迴樹

遞迴樹與時間複雜度分析


每一層歸併操作消耗的時間總和是一樣的,跟要排序的資料規模有關。我們把每一層歸併操作消耗的時間記作 n。
現在,我們只需要知道這棵樹的高度 h,用高度 h 乘以每一層的時間消耗 n,就可以得到總的時間複雜度 O(n∗h)。
歸併排序遞迴樹是一棵滿二叉樹。我們前兩節中講到,滿二叉樹的高度大約是 log2​n,(第 K 層包含的節點個數就是 2^(K-1))
所以,歸併排序遞迴實現的時間複雜度就是 O(nlogn)。

接下來我會通過三個實際的遞迴演算法,帶你實戰一下遞迴的複雜度分析。學完這節課之後,你應該能真正掌握遞迴程式碼的複雜度分析。

實戰一:分析快速排序的時間複雜度
我們還是取 k 等於 9,也就是說,每次分割槽都很不平均,一個分割槽是另一個分割槽的 9 倍。如果我們把遞迴分解的過程畫成遞迴樹,就是下面這個樣子:

排序的過程中,每次分割槽都要遍歷待分割槽區間的所有資料,所以,每一層分割槽操作所遍歷的資料的個數之和就是 n。我們現在只要求出遞迴樹的高度 h,這個快排過程遍歷的資料個數就是 h∗n ,也就是說,時間複雜度就是 O(h∗n)。

實戰二:分析斐波那契數列的時間複雜度

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}


每次分解之後的合併操作只需要一次加法運算,我們把這次加法運算的時間消耗記作 1。所以,從上往下,第一層的總時間消耗是 1,第二層的總時間消耗是 2,第三層的總時間消耗就是 2^n。依次類推,第 k 層的時間消耗就是 2^k−1,那整個演算法的總的時間消耗就是每一層時間消耗之和。

如果路徑長度都為 n,那這個總和就是 2^n−1。

這個演算法的時間複雜度就介於上面之間。雖然這樣得到的結果還不夠精確,只是一個範圍,但是我們也基本上知道了上面演算法的時間複雜度是指數級的,非常高。

實戰三:分析全排列的時間複雜度

如何把 n 個資料的所有排列都找出來
如果我們確定了最後一位資料,那就變成了求解剩下 n−1 個資料的排列問題。而最後一位資料可以是 n 個資料中的任意一個,因此它的取值就有 n 種情況。所以,“n 個資料的排列”問題,就可以分解成 n 個“n−1 個資料的排列”的子問題。

假設陣列中儲存的是1,2, 3...n。
f(1,2,...n) = {最後一位是1, f(n-1)} + {最後一位是2, f(n-1)} +...+{最後一位是n, f(n-1)}。

// 呼叫方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要處理的子陣列的資料個數
public void printPermutations(int[] data, int n, int k) {
  if (k == 1) {
    for (int i = 0; i < n; ++i) {
      System.out.print(data[i] + " ");
    }
    System.out.println();
  }

  for (int i = 0; i < k; ++i) {
    int tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;

    printPermutations(data, n, k - 1);

    tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;
  }
}

堆排序

  • 堆排序是利用堆這種資料結構而設計的一種排序演算法,它的最壞,最好,平均時間複雜度均為O(nlogn),它也是不穩定排序。堆是具有以下性質的完全二叉樹:每個結點的值都大於或等於其左右孩子結點的值,稱為大頂堆;或者每個結點的值都小於或等於其左右孩子結點的值,稱為小頂堆。一般升序採用大頂堆,降序採用小頂堆。
  • 堆排序的基本思想是:將待排序序列構造成一個大頂堆,此時,整個序列的最大值就是堆頂的根節點。將其與末尾元素進行交換,此時末尾就為最大值。然後將剩餘n-1個元素重新構造成一個堆,這樣會得到n個元素的次小值。如此反覆執行,便能得到一個有序序列了
public class HeapSort {
    public static void main(String[] args) {
        HeapSort heapSort = new HeapSort();
        int[] array = {19, 8, 27, 6, 35, 14, 3, 12, 1, 0, 9, 10, 7};
        //{35, 8, 27, 6, 19, 14, 3, 12, 1, 0, 9, 10, 7}
        System.out.println("Before heap:");
        heapSort.printArray(array);

        heapSort.heapSort(array);

        System.out.println("After heap sort:");
        heapSort.printArray(array);
    }

    //(1)
    public void heapSort(int[] array) {
        buildMaxHeap(array);//建立最大堆
        for (int i = array.length - 1; i >= 1; i--) {
            //最大的在0位置,那麼開始沉降,這樣每交換一次最大的值就丟到最後了
            exchangeElements(array, 0, i);
            //繼續獲取0位置最大值,將第一次排序後到了最後面的最大值排除
            //重新調整結構,使其滿足堆,然後繼續交換堆頂元素與當前末尾元素,
            //反覆執行調整+交換步驟,直到整個序列有序。
            maxHeap(array, i, 0);
        }
    }

    //(2)建立最大堆
    private void buildMaxHeap(int[] array) {
        if (array == null || array.length <= 1) {
            return;
        }
        int half = (array.length - 1) / 2;//從一半開始,6
        for (int i = half; i >= 0; i--) {
            maxHeap(array, array.length, i);
        }
    }

    private void maxHeap(int[] array, int heapSize, int index) {//index堆頭
        int left = index * 2 + 1;
        int right = index * 2 + 2;
        int largest = index;
        //三者找最大值
        if (left < heapSize && array[left] > array[index]) {
            largest = left;
        }
        if (right < heapSize && array[right] > array[largest]) {
            largest = right;
        }
        if (index != largest) {
            exchangeElements(array, index, largest);
            //繼續構造下面的大堆
            maxHeap(array, heapSize, largest);
        }
    }

    //(3)換位置
    public void exchangeElements(int[] array, int index1, int index2) {
        int temp = array[index1];
        array[index1] = array[index2];
        array[index2] = temp;
    }


    public void printArray(int[] array) {
        System.out.print("{");
        for (int i = 0; i < array.length; i++) {
            System.out.print(array[i]);
            if (i < array.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("}");
    }
}

相關文章