目錄
樹
樹的高度,深度,層數的定義下面用圖來說明這三個概念的區別
下面用圖來說明這三個概念的區別
高度,這個概念跟生活中的樓層一樣,從下往上數如第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小時候,容器內有多少細胞?
堆
堆的特點
- 堆是一個完全二叉樹
- 堆中的每個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值
堆化包括兩種
從下往上的堆化
從上往下的堆化
從下往上堆化的代碼實現
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個關鍵詞即可
參考