前言
最近明顯文章更新頻率降低了,那是因為我在惡補數據結構和算法的相關知識,相當於是從零開始學習。
找了很多視頻和資料,最后發現 b 站尚硅谷的視頻教程還是相對不錯的,總共 195 集。每個小節都是按先概念、原理,然后代碼實現的步驟講解。如果你也准備入門數據結構和算法,我推薦可以看下這個系列教程。
昨天一天一下子肝了 40 多集,從樹的后半部分到圖的全部部分。可以看到,每一集其實時間也不算長,短的幾分鍾,長的也就半個小時。開 2 倍速看,倍兒爽。
話不多說,下面進入正題。
二叉堆介紹
我們知道,樹有很多種,最常用的就是二叉樹了。二叉樹又有滿二叉樹和完全二叉樹。而二叉堆,就是基於完全二叉樹的一種數據結構。它有以下兩個特性。
- 首先它是一個完全二叉樹
- 其次,堆中的任意一個父節點的值都大於等於(或小於)它的左右孩子節點。
因此,根據第二個特性,就把二叉堆分為大頂堆(或叫最大堆),和小頂堆(或叫最小堆)。
顧名思義,大頂堆,就是父節點大於等於左右孩子節點的堆,小頂堆就是父節點小於左右孩子節點的堆。
看一下大頂堆的示例圖,小頂堆類似,只不過是小值在上而已。
注意:大頂堆只保證父節點大於左右孩子節點的值,不需要保證左右孩子節點之間的大小順序。如圖中,7 的左子節點 6 比右子節點 1 大,而 8 的左子節點 4 卻比右子節點 5 小。(小頂堆同理)
構建二叉堆
二叉堆的定義我們知道了,那么給你一個無序的完全二叉樹,怎么把它構建成二叉堆呢?
我們以大頂堆為例。給定以下一個數組,(完全二叉樹一般用數組來存儲)
{4, 1, 9, 3, 7, 8, 5, 6, 2}
我們畫出它的初始狀態,然后分析怎么一步一步構建成大頂堆。
由於大頂堆,父節點的值都大於左右孩子節點,所以樹的根節點肯定是所有節點中值最大的。因此,我們需要從樹的最后一層開始,逐漸的把大值向上調整(左右孩子節點中較大的節點和父節點交換),直到第一層。
其實,更具體的說,應該是從下面的非葉子節點開始調整。想一想,為什么。
反向思考一下,如果從第一層開始調整的話,例如圖中就是 4 和 9 交換位置之后,你不能保證 9 就是所有節點的最大值(額,圖中的例子可能不是太好,正好是 9 最大)。如果下邊還有比 9 大的數字,你最終還是需要從下面向上遍歷調整。那么,我還不如一開始就直接從下向上調整呢。
另外,為什么從從最下面的非葉子節點(圖中節點 3 )開始。因為葉子節點的下面已經沒有子節點了,它只能和父節點比較,從葉子節點開始沒有意義。
第一步,以 3 為父節點開始,比較他們的子節點 6和 2 ,6最大,然后和 3 交換位置。
第二步,6 和 7 比較,7 最大,7 和 1 交換位置。
第三步,7 和 9 比較,9 最大,9 和 4 交換位置。
第四步,我們發現交換位置之后,4 下邊還有比它大的,因此還需要以 4 為父節點和它的左右子節點進行比較。發現 8 最大,然后 8 和 4 交換位置。
最終,實現了一個大頂堆的構建。下面以代碼實現交換過程。
/**
* 調整為大頂堆
* @param arr 待調整的數組
* @param parent 當前父節點的下標
* @param length 需要對多少個元素進行調整
*/
private static void adjustHeap(int[] arr, int parent, int length){
//臨時保存父節點
int temp = arr[parent];
//左子節點的下標
int child = 2 * parent + 1;
//如果子節點的下標大於等於當前需要比較的元素個數,則結束循環
while(child < length){
//判斷左子節點和右子節點的大小,若右邊大,則把child定位到右邊
if(child + 1 < length && arr[child] < arr[child + 1]){
child ++;
}
//若child大於父節點,則交換位置,否則退出循環
if(arr[child] > temp){
//父子節點交換位置
arr[parent] = arr[child];
//因為交換位置之后,不能保證當前的子節點是它子樹的最大值,所以需要繼續向下比較,
//把當前子節點設置為下次循環的父節點,同時,找到它的左子節點,繼續下次循環
parent = child;
child = 2 * parent + 1;
}else{
//如果當前子節點小於等於父節點,則說明此時的父節點已經是最大值了,
//因此無需繼續循環
break;
}
}
//把當前節點值替換為最開始暫存的父節點值
arr[parent] = temp;
}
public static void main(String[] args) {
int[] arr = {4,1,9,3,7,8,5,6,2};
//構建一個大頂堆,從最下面的非葉子節點開始向上遍歷
for (int i = arr.length/2 - 1 ; i >= 0; i--) {
adjustHeap(arr,i,arr.length);
}
System.out.println(Arrays.toString(arr));
}
//打印結果: [9, 7, 8, 6, 1, 4, 5, 3, 2]。 和我們分析的結果一模一樣
在 while 循環中,if(arr[child] > temp) else的邏輯, 對應的就是圖中的第三步和第四步。即需要確保,交換后的子節點要比它下邊的孩子節點都大,不然需要繼續循環,調整位置。
堆排序
堆排序就是利用大頂堆或者小頂堆的特性來進行排序的。
它的基本思想就是:
- 把當前數組構建成一個大頂堆。
- 此時,根節點肯定是所有節點中最大的值,讓它和末尾元素交換位置,則最后一個元素就是最大值。
- 把剩余的 n - 1個元素重新構建成一個大頂堆,就會得到 n-1 個元素中的最大值。重復執行此動作,就會把所有的元素調整為有序了。
步驟:
還是以上邊的數組為例,看一下堆排序的過程。
一共有九個元素,把它調整為大頂堆,然后把堆頂元素 9 和末尾元素 2 交換位置。
此時,9已經有序了,不需要調整。然后把剩余八個元素調整為大頂堆,再把這八個元素的堆頂元素和末尾元素交換位置,如下,8 和 3 交換位置。
此時,8和 9 已經有序了,不需要調整。然后把剩余七個元素調整為大頂堆,再把這七個元素的堆頂元素和末尾元素交換位置。如下, 7 和 2 交換位置。
以此類推,經過 n - 1 次循環調整,到了最后只剩下一個元素的時候,就不需要再比較了,因為它已經是最小值了。
看起來好像過程很復雜,但其實是非常高效的。沒有增刪,直接在原來的數組上修改就可以。因為我們知道數組的增刪是比較慢的,每次刪除,插入元素,都要移動數組后邊的 n 個元素。此外,也不占用額外的空間。
代碼實現:
//堆排序,大頂堆,升序
private static void heapSort(int[] arr){
//構建一個大頂堆,從最下面的非葉子節點開始向上遍歷
for (int i = arr.length/2 - 1 ; i >= 0; i--) {
adjustHeap(arr,i,arr.length);
}
System.out.println(Arrays.toString(arr));
//循環執行以下操作:1.交換堆頂元素和末尾元素 2.重新調整為大頂堆
for (int i = arr.length - 1; i > 0; i--) {
//將堆頂最大的元素與末尾元素互換,則數組中最后的元素變為最大值
int temp = arr[i];
arr[i] = arr[0];
arr[0] = temp;
//從堆頂開始重新調整結構,使之成為大頂堆
// i代表當前數組需要調整的元素個數,是逐漸遞減的
adjustHeap(arr,0,i);
}
}
時間復雜度和空間復雜度:
堆排序,每次調整為大頂堆的時間復雜度為 O(logn),而 n 個元素,總共需要循環調整 n-1 次 ,所以堆排序的時間復雜度就是 O(nlogn)。它的數學推導比較復雜,感興趣的同學可以自己查看相關資料。
由於沒有占用額外的內存空間,因此,堆排序的空間復雜度為 O(1)。