數據結構與算法--樹的理論和應用

目錄

二叉查找樹

紅黑樹

遞歸樹

堆排序

堆的應用

參考


 

樹的高度,深度,層數的定義下面用圖來說明這三個概念的區別

下面用圖來說明這三個概念的區別

高度,這個概念跟生活中的樓層一樣,從下往上數如第10層,12層起點都是地面
深度,是從上往下度量的,比如水中魚的深度,是從水平面開始度量讀
層數,跟深度類似,但計數起點是1

二叉樹
滿二叉樹,葉子節點全都在最底層,出了葉子節點之外,每個節點都有左右兩個子節點
完全二叉樹,葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,除了最後一層其他層的節點個數都要達到最大

完全二叉樹這種特殊的定義,在用數組存儲二叉樹時候可以避免浪費,數組的節點都用上了沒有空間浪費

二叉樹的遍歷
前序遍歷,對於樹種的任意節點,先打印這個節點,再打印它的左子樹,最後打印它的右子樹
中序遍歷,對於樹種的任意節點來說,先打印它的左子樹,然後打印它本身,最後打印它的右子樹
後序遍歷,對於樹種任意節點來說,先打印它的左子樹,然後再打印它的右子樹,最後打印這個節點本身

三種遍歷的代碼實現

void preOrder(Node* root) {
  if (root == null) return;
  print root // 此處爲僞代碼,表示打印 root 節點
  preOrder(root->left);
  preOrder(root->right);
}

void inOrder(Node* root) {
  if (root == null) return;
  inOrder(root->left);
  print root // 此處爲僞代碼,表示打印 root 節點
  inOrder(root->right);
}

void postOrder(Node* root) {
  if (root == null) return;
  postOrder(root->left);
  postOrder(root->right);
  print root // 此處爲僞代碼,表示打印 root 節點
}

遍歷二叉樹,每個節點最多會被訪問兩次
所以二叉樹遍歷的時間複雜度是O(n)
一組數據,1,3,5,6,9,10  可以構建出多少種不同的二叉樹
這是卡特蘭數,是C[n,2n]/(n+1)種形式,c是組合數,節點的不同又是一種全排列
一共是n! * C[n,2n]/(n+1) 個二叉樹

 

 

二叉查找樹

在樹種任意一個節點,其左字數中的每個節點的值,都要小於這個節點的值,而右字數節點的值都大於這個節點的值
查找,更新,插入都很容,刪除比較複雜有三種情況
1.如果要刪除的節點沒有子節點,更新父節點指向null即可
2.要刪除的節點只有一個子節點(只有左子節點或右子節點),只要更新父節點指向要刪除的節點的子節點即可
3.如果有兩個子節點,需要找到這個節點右子樹中的最小節點,把它替換到要刪除的節點上,再刪除這個最小節點,也可以加個刪除標記

支持重複數據的二叉查找樹
插入時,如果碰到一個節點的值與要插入的值相同,就將要插入的數據放到這個節點的右子樹
查找時,遇到相同節點,繼續在右子樹種查找,直到遇到葉子節點

刪除時,先查找每個要刪除的節點,再按之前的刪除方式,以此刪除

完全二叉樹的的高度小於等於logn

有了散列表爲什麼還要用二叉樹

  • 散列表中斷數據是無序存儲的,如果要輸出有序數據,需要先排序,二叉樹的中序遍歷O(n)時間就可以輸出了
  • 散列表擴容時很耗時,遇到散列衝突性能不穩定,平衡二叉樹的性能非常穩定
  • 儘管散列表的查找操作時間複雜度是常量級,但因哈希衝突這個常量不一定比logn小,實際操作不一定比O(logn)塊,加上函數的耗時,也不一定就比平衡二叉樹效率高
  • 散列表的構造比二叉樹要複雜,需要考慮散列函數的設計,衝突解決,擴容縮容,平衡二叉樹只要考慮平衡這一個問題
  • 爲避免過多的散列衝突,散列表的裝載因子不能太大,特別是基於開放尋址法解決衝突時
     

 

紅黑樹

平衡二叉樹

  • AVL樹,查找效率高,但刪除/增加維護成本高
  • 紅黑樹,查找/刪除/增加的時間複雜度爲0(logn)
  • Treap,
  • Splay Tree,這兩個大部分情況下操作效率都很高,但極端情況下時間複雜度會降低

紅黑樹的定義

  • 根節點是黑色的
  • 每個葉子節點都是黑色的空節點(NIL),即葉子節點不存儲數據
  • 任何相鄰的節點都不能同時爲紅色,即紅色節點是被黑色節點隔開的
  • 每個節點,從該節點達到其可達葉子節點的所有路徑,都包含相同數目的黑色節點

增加,刪除操作,可能會破壞第三,第四點
平衡調整操作,就是把第三,第四點恢復過來
紅黑樹的左旋和右旋操作


插入操作
紅黑樹規定,插入的節點必須是紅色的,插入的新節點都在葉子節點上,這裏有兩個特殊情況

  • 如果插入節點的父節點是黑色的,那什麼都不用做,它仍滿足紅黑樹的定義
  • 如果插入節點是根節點,直接改變它的顏色,變成黑色就可以了

其他情況都會違背紅黑樹的定義,就需要用左旋,右旋來調整,並改變顏色
紅黑樹的平衡調整是一個迭代的過程,把正在處理的節點叫做 關注節點,關注節點會隨着不停迭代處理,
而不斷變化,最開始的關注節點就是新插入的節點
新節點插入後,如果紅黑樹的平衡被打破,一般會有三種情況,只需要根據每種情況的特點,不斷調整,
就可以讓紅黑樹繼續符合規定,也就是繼續保持平衡

CASE1,如果關注節點是a,它的叔叔節點d是紅色,就執行下面操作

  • 將關注節點a的父節點b,叔叔節點d的顏色都設置成黑色
  • 將關注節點a的祖父節點c的顏色設置成紅色
  • 關注節點變成a的祖父節點c
  • 跳到CASE2或者CASE3

CASE2,如果關注節點是a,它的叔叔節點d是黑色,關注節點a是其父節點b的右子節點,就執行下面操作

  • 關注節點變成a的父節點b
  • 圍繞新的關注節點b左旋
  • 跳到CASE3

CASE3,如果關注節點是a,它的叔叔節點d是黑色,關注節點a是其父節點b的左子節點,就執行下面操作

  • 圍繞關注節點a的祖父節點c右旋
  • 將關注節點a的父節點b,兄弟節點c顏色互換
  • 調整結束

 

刪除操作

紅黑樹的刪除操作就要複雜很多,刪除分爲兩步
第一步是針對刪除節點初步調整,初步調整至保證整個紅黑樹滿足最後一條定義
每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點
第二步是針對關注節點進行二次調整,讓它滿足紅黑樹的第三條定義,不存在兩個相鄰的紅色節點

1.針對刪除節點初步調整
紅黑樹的定義中,只包含紅色節點和黑色節點,經過初步調整後,爲了保住滿足紅黑樹定義的最後一條,有些節點會被
標記成兩種顏色,紅-黑 或者 黑-黑,
CASE1,如果要刪除的節點是a,它只有一個子節點b,執行下面操作

  • 刪除節點a,並且把節點b替換到節點a的位置,這一部分操作跟普通的二叉樹的刪除操作一樣
  • 節點a只能是黑色,街邊b也只能是紅色,其他情況均不符合紅黑樹的定義,所以把節點b改成黑色
  • 調整結束,不需要進行二次調整

CASE2,如果要刪除的節點a有兩個非空子節點,並且它的後繼節點就是節點a的右子節點c,進行下面操作

  • 如果節點a的後繼節點就是右子節點c,那右子節點c肯定沒有左子樹,把節點a刪除,並且將節點c替換到節點a的位置
  • 把節點c的顏色設置爲跟節點a相同的顏色
  • 如果節點c是黑色,爲了不違反紅黑樹最後一條定義,將c的右子節點d多加一個黑色,d就變成了紅-黑 或者 黑-黑
  • 此時關注節點變成節點d,第二步調整操作就會針對關注節點來做

CASE3,如果要刪除的節點是a,它有兩個非空子節點,並且節點a的後繼節點不是右子節點,執行下面操作

  • 找到後繼節點d,將其刪除,刪除後繼節點d的過程類似CASE1
  • 將a節點他喜歡成後繼節點d
  • 把節點d的顏色設置成跟節點a相同的顏色
  • 如果節點d是黑色,爲了不違反紅黑樹的最後一條定義,給節點d的右子節點c多加一個黑色,此時節點c是紅-黑 或者 黑-黑
  • 這時候,關注節點變成了節點c,第二步的調整操作就會針對關注節點來做

2.針對關注節點進行二次 調整
經過初步調整之後,關注節點變成了 紅-黑 或者 黑-黑,針對這個關注節點
再分爲四種情況來進行二次調整,二次調整是爲了讓紅黑樹中不存在相鄰的紅色節點
CASE1,如果關注節點是a,它的兄弟節點c是紅色,則執行下面操作

  • 圍繞關注節點a的父節點b左旋
  • 關注節點a的父節點b和祖父節點c交換顏色
  • 關注節點不變
  • 繼續從四種情況中選擇合適的規則來調整

CASE2,如果關注節點是a,它的兄弟節點c是黑色的,並且節點c的左右子節點d,e都是黑色的,則執行下面操作

  • 將關注節點a的兄弟節點c顏色變成紅色
  • 從關注節點a中去掉一個黑色,這時候節點a就是單純的紅色或者黑色了
  • 給關注節點的a的父節點b添加一個黑色,這時候節點b就變成了紅-黑 或者 黑-黑
  • 關注節點從a變成其父節點b
  • 繼續從四種情況中選擇符合的規則來調整

CASE3,如果關注節點是a,它的兄弟節點c是黑色,c的左子節點d是紅色,c的右子節點e是黑色

  • 圍繞關注節點a的兄弟節點c右旋
  • 節點c和節點d交換顏色
  • 關注節點不變
  • 跳轉到CASE4,繼續調整

CASE4,如果關注節點a的兄弟節點c是黑色的,並且c的右子節點是紅色的

  • 圍繞關注節點a的父節點b左旋
  • 將關注節點a的兄弟節點c的顏色,跟關注節點a的父節點b設置成相同的顏色
  • 將關注節點a的父節點b的顏色設置爲黑色
  • 從關注節點a中去掉一個黑色,節點a就變成了單純的紅色或者黑色
  • 將關注節點a的叔叔節點e設置爲黑色
  • 調整結束

 

 

遞歸樹

藉助遞歸樹來分析遞歸算法的時間複雜度
遞歸的思想是,將大問題分解爲小問題來求解,再將小問題分解爲小小問題
把這個一層一層分解的過程畫成圖,就是一棵樹,這棵樹就是遞歸樹
下面是歸併排序的遞歸樹,每一次是O(n),一共logn層,所以複雜度是O(n*logn)

快速排序分析
最好的情況下是區間劃分後,每次都是一分爲二,但這很難
假設分區後,兩個分區的大小比列是1:k,當k=9時,用遞推公式就是 T(n)=T(n/10)+T(9n/10)+n
用遞歸樹表示如下

每次分區都要遍歷待分區的所有數據,每一層區分操作所遍歷的數據個數之和就是n,樹的高度是h,複雜度是O(h*n)
快速排序的結束條件是分區大小爲1,從根節點n到葉子節點1,遞歸樹種最長路徑每次要乘以9/10,最短要乘以1/10

遍歷數據的個數綜合就介於 nlog10n和nlog10/9 n之間,根據大O表示法,可以計算成O(n*logn)
如果k=99,或者999,這個推到仍然是成立的,從概率論角度來說,快速排序平均時間複雜度是O(n*logn)

 

斐波那契數列的時間複雜度
代碼如下

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

遞歸樹如下

f(n)分解爲f(n-1)和f(n-2),每次數據規模都是-1或者-2,葉子節點的規模是1或者2
從根節點到葉子節點,每條路徑長度不一樣,如果每次都-1那最長路徑是n,如果每次-2,那最長路徑是n/2
每次分解之後合併操作只需要一次加法運算,消耗時間記做1,從上往下第一層總時間消耗是1,第二層是2,第三層是2^2
第k層是2^(k-1),整個算法的總時間消耗是每一層時間消耗之和
這個算法的時間複雜度介於O(2^n)和O(2^(n/2))之間,所以時間複雜度是指數級的

 

全排列的時間複雜度
比如把1,2,3 這三個數字做全排列,結果如下

123
132
213
231
312
321

它的遞歸樹如下

第一層有n次交換操作,第二層是n*(n-1),第三層是n*(n-1)*(n-2)
最後一個數,n*(n-1)*(n-2)*...*2*1 等於n!
前面的n-1個數都小雨最後一個數,所以綜合肯定小於n*n!
所以全排列的遞歸算法時間複雜度大於O(n!),小於O(n*n!),這個時間複雜度非常高

思考
1個細胞的生命週期是3小時,1小時分裂一次,求n小時候,容器內有多少細胞?

 

 

堆的特點

  1. 堆是一個完全二叉樹
  2. 堆中的每個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值

堆化包括兩種
從下往上的堆化
從上往下的堆化

從下往上堆化的代碼實現

public class Heap {
  private int[] a; // 數組,從下標 1 開始存儲數據
  private int n;  // 堆可以存儲的最大數據個數
  private int count; // 堆中已經存儲的數據個數

  public Heap(int capacity) {
    a = new int[capacity + 1];
    n = capacity;
    count = 0;
  }

  public void insert(int data) {
    if (count >= n) return; // 堆滿了
    ++count;
    a[count] = data;
    int i = count;
    while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
      swap(a, i, i/2); // swap() 函數作用:交換下標爲 i 和 i/2 的兩個元素
      i = i/2;
    }
  }
 }

刪除頂點元素後的堆化方式

從上往下的堆化代碼實現

public void removeMax() {
  if (count == 0) return -1; // 堆中沒有數據
  a[1] = a[count];
  --count;
  heapify(a, count, 1);
}

private void heapify(int[] a, int n, int i) { // 自上往下堆化
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

 

堆排序

1.建堆大根堆
  實現方式可以每次插入一個元素(從下往上堆化)
  從上往下堆化
2.排序
  每次輸出堆頂元素
  再從將最後一個元素移到堆頂,並做堆化操作

排序過程

堆排序的代碼實現

// n 表示數據的個數,數組 a 中的數據從下標 1 到 n 的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}

private static void heapify(int[] a, int n, int i) {
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

堆排序的時間複雜度分析

建堆的時間複雜度爲O(n),排序爲O(n*logn)

堆排序 vs 快速排序
1.堆排序數據訪問方式沒有快速排序友好,對CPU緩存不友好
2.對同樣的數據,在排序過程中,堆排序算法的數據交換次數要多於快速排序

 

堆的應用

優先級隊列
1.合併小文件
100個有序文件合併到一個大文件中
以此從100個文件中拿出第一個元素,組成小根堆
每次刪除堆頂元素,再插入一個新元素
重複上述步驟,直到100個文件都遍歷完

2.高性能定時器
將定時任務存儲在小根堆中,比較當前時間和堆頂元素的時間差
sleep相應的時間,再取堆頂元素
重複上述步驟,直到堆內元素取完

求Top K
對n個元素先取前K個,建立小根堆
之後遍歷 k+1 到 n個元素,如果當前元素比堆頂元素大,刪除堆頂元素,並插入新元素
如果比堆頂元素小則不做處理
如果是又有新元素要添加,也是一樣的步驟
當要求top k時候,直接輸出k個元素數組即可

求中位數
中位數,也就是在中間位置的元素
如果數據個數是奇數,則中間元素是 n/2+1
如果數據個數是偶數,則中間位置元素是 第n/2和第n/2+1

維護兩個堆,一個大根堆,一個小根堆
大根堆存儲前半部分數據,小根堆存儲後半部分數據
並且小根堆中的數據都大於 大根堆中的數據
於是只要獲取大根堆的堆頂元素,小根堆的堆頂元素,即可求出中位數

假設大根堆元素超過一半了,則刪除堆頂元素,插入到小根堆中
這樣時刻保持兩邊平衡,就可以用O(1)時間求出中位數


99%響應時間
一堆數據中前99%的元素,就是99%響應時間

n個數據,將數據從小到大排列之後,99百分位就是第 n*99%個數據
同理,80百分位就是第n*80%個數據
建立大根堆和小根堆,大根堆中的數據佔99%,小根堆中的數據佔1%
每次新插入元素後,如果大根堆中的數據超過了99%,則要刪除插入到小根堆中,保持兩邊平衡

10個搜索關鍵詞日誌文件中,獲取Top10最熱門關鍵詞
遍歷10億個搜索關鍵詞並做hash,key是關鍵詞,value是出現的次數
之後建立大小爲10的小頂堆,遍歷散列表,依次和堆頂元素比較,如果大於則刪除堆頂元素並插入
考慮到內存有限制
可以對10億個搜索關鍵詞通過hash分片到10個文件中,比如關鍵詞對10取模就可以了
對每個文件建立Top10的堆,再把這10個堆放在一起,取出100個關鍵詞中出現最多的10個關鍵詞即可

 

 

參考

清晰理解紅黑樹的演變

從2-3樹到紅黑樹

紅黑樹的演示

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章