引言
上節我們學習完二叉樹后[數據結構——lesson9.二叉樹],這節我們將學習數據結構——堆
學習目標
1.堆的概念及結構
堆是一種特殊的完全二叉樹結構,在計算機科學和數據結構中廣泛應用,特別是在堆排序算法和優先隊列的實現中,堆可以通過數組來表示,這種表示方式利用了完全二叉樹的性質,即除了最后一層外,每一層都被完全填滿,并且所有節點都盡可能地向左對齊。
1.1堆的概念
堆通常是一個可以被看作一棵完全二叉樹的數組對象,若滿足:
(1)任意節點的值>=其子節點的值。則稱為大根堆。
(2)任意節點的值<=其子節點的值。則稱為小根堆。
1.2堆的結構
- 邏輯結構:堆是一棵完全二叉樹,除了最后一層外,每一層都被完全填滿,并且所有節點都盡可能地向左對齊。
- 物理結構:堆在物理上通常采用順序存儲,即使用數組來表示。根節點位于數組的第一個位置(下標通常為 0 或 1)。對于下標從 1 開始的數組,任意節點 i 的左子節點為 2i,右子節點為 2i+1;對于下標從 0 開始的數組,左子節點為 2i+1,右子節點為 2i+2。通過這種方式,可以利用數組的隨機訪問特性高效地模擬樹的結構。
如圖所示:
?操作系統和數據結構這兩門學科中都有棧和堆的概念,如何區分 ?
數據結構角度
- 棧:是一種運算受限的線性表2。只允許在表的一端進行插入和刪除操作,遵循后進先出(LIFO)原則。主要操作有入棧(Push)和出棧(Pop)。可使用數組或鏈表作為底層數據結構,分別稱為順序棧和鏈式棧。
- 堆:是一種特殊的樹形數據結構。通常可分為最大堆和最小堆,最大堆中父節點的值大于等于子節點的值,最小堆中父節點的值小于等于子節點的值。主要操作包括插入元素和刪除堆頂元素等,并需重新調整堆的結構。常用于實現優先隊列、堆排序等。
操作系統角度
- 棧:是進程地址空間中的一塊區域,用于存儲函數調用時的局部變量、函數參數和返回地址等信息。其內存由操作系統自動分配和釋放,函數調用時壓棧,函數返回時彈棧。棧的大小有限,一般每個進程的棧大小相對較小,如 64 位 Linux 系統默認棧大小通常為 10MB。內存地址生長方向是從高地址向低地址增長。
- 堆:也是進程地址空間中的一部分,用于動態內存分配。程序運行時通過 malloc 或 new 等函數分配內存,大小可變。內存由程序員手動管理,需調用相應函數釋放內存,否則易引發內存泄漏。堆的內存地址通常從低地址向高地址增長,由于是通過分散的內存塊管理,其內存空間在物理上不連續,頻繁操作可能產生內存碎片。
二者聯系
1.3堆的調整算法
在實現堆的結構之前我們先要了解一下堆的調整算法
1.堆的數值交換
思路:堆的交換還是比較簡單的,跟之前寫的沒什么區別,記得傳地址。
void swap(HPDatatype* x, HPDatatype* y) {int temp = 0;temp = *x;*x = *y;*y = temp; }
2.堆的向上調整算法?(應用于堆的數據插入)
此算法是為了確保插入數據后的堆依然是符合堆的性質而單獨封裝出來的函數,就好比如我們后續要插入的數字10(小堆)
- 基本思路:將新增元素插入到堆數組的末尾,然后將該元素與其父結點進行比較。若該元素的值小于父結點的值(小根堆)或大于父結點的值(大根堆),則交換兩者的位置,之后將原父結點當作新的目標結點,繼續向上進行比較和交換操作,直到該元素的值不小于父結點的值(小根堆)或不大于父結點的值(大根堆),或者到達根節點為止。
為了確保在插入數字10后依然是個小根堆,所以要將10和28交換,依次比較父結點parent和子結點child的大小,當父小于子結點的時候,就返回,反之就一直交換,直到根部while(child>0)截止。
:由前文的得知的規律,parent = (child - 1) / 2,我們操控的是數組,但要把它想象成二叉樹。畫圖演示調整過程:
代碼實現:
// 向上調整函數(沒有改變動態數組的首地址,不需要用指針傳輸) // 時間復雜度O(logN) // 傳入了 動態數組 和 最后一個位置的下標(也就是孩子的下標) // 向上調整的條件;上面的數據是堆 void AdjustUp(HPDatatype* a, int child) {// 計算父親的小標int parent = (child - 1) / 2;// 當 child 的小標大于 0 就繼續 (也就小于是根節點位置)while (child > 0){// 小堆 <// 大堆 >if (a[child] < a[parent]){swap(&a[child], &a[parent]);child = parent;parent = (child - 1) / 2;}else{break;}} }
核心邏輯:
- 計算父節點下標:parent = (child - 1) / 2
- 循環比較子節點與父節點的值
- 如果子節點值小于父節點值(符合小堆特性),則交換兩者位置
- 更新子節點和父節點下標,繼續向上比較
- 當子節點成為根節點(child > 0不成立)或滿足堆特性時,循環結束
3.堆的向下調整算法(應用于堆的數據刪除)
此算法是為了確保刪除數據后的堆依然是符合堆的性質而單獨封裝出來的函數,就好比如我們后續要刪除數字10(小堆)
- 基本思路:首先將堆頂元素與堆中最后一個元素進行交換,然后刪除最后一個元素(此時堆頂元素為原來的堆尾元素)。接著從堆頂開始,將堆頂元素與它的子節點進行比較,如果子節點中有比它更小(小根堆)或更大(大根堆)的元素,則將堆頂元素與該子節點交換位置,之后繼續以交換后的位置為新的 “堆頂”,重復上述比較和交換操作,直到滿足堆的性質為止。
如下圖:
(我們這里首先將堆頂元素(10)與堆中最后一個元素進行交換(28),然后刪除最后一個元素(10))
此時我們看到,這個二叉樹整體上不符合堆的性質,但是其根部的左子樹和右子樹均滿足堆的性質。?接下來,就要進行向下調整,確保其最終是個堆。只需三部曲。
?:找出左右孩子中最小的那個
?:跟父親比較,如果比父親小,就交換
?:再從交換的孩子位置繼續往下調整
代碼實現:
// 向下調整 // 向下調整的前提:后面的數據是堆 void AdjustDown(HPDatatype* a, int n, int parent) {// 左孩子int child = parent * 2 + 1;// 孩子可能會超出數組范圍while (child < n){// 如果右孩子小于左孩子// 找出小的那個孩子// 保證右孩子存在且不越界// 大堆 >// 小堆 <if (child + 1 < n && a[child+1] < a[child]){//跳轉到有孩子那里child++;}// 開始向下調整// 大堆 >// 小堆 <if (a[child] < a[parent]){swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;}else{break;}} }
核心邏輯:
- 先計算左孩子下標:child = parent * 2 + 1
- 循環條件:孩子下標小于總元素個數(未超出邊界)
- 首先判斷是否存在右孩子,并且比較左右孩子大小,選擇較小的那個孩子(小堆特性)
- 比較父節點與選中的孩子節點值,如果孩子值更小,則交換兩者位置
- 更新父節點和孩子節點下標,繼續向下比較
- 當父節點滿足堆特性或沒有孩子節點時,循環結束
2.堆的實現方式
雖然堆是一種特殊的二叉樹,它既可以用數組存儲也可以用鏈式存儲。但是考慮到其完全二叉樹的特性,我們在這里采用數組存儲的方式,因為這樣既方便訪問,也并不會浪費格外的空間。
數組與堆的映射關系(這里實現根的下標為0):
1.若某節點在數組中的下標為i(i從0開始),則其左子節點(若存在)的下標為2i+1,右子節點(若存在)的下標為2i+2,其父節點(若存在)的下標為(i-1)/2。
2.堆的根節點在數組的下標為0。
2.1堆的聲明
由于我們使用數組來實現堆,因此堆的聲明與順序表類似。
2.2堆的功能實現
注意:我們這里實現的是小根堆(大根堆實現原理一樣)
我們要實現的堆的基本功能:
1.堆的初始化
2.堆的插入
3.堆的刪除
4.獲取堆頂的元素
5.堆的元素個數
6.堆的判空
7.銷毀堆
2.2.1堆的初始化
(1)代碼實現
// 堆的初始化
void HPInit(HP* php)
{assert(php);php->a = NULL;php->size = php->capacity = 0;
}
🧐🧐🧐堆的核心功能
2.2.2堆的插入
(1)代碼實現
思路:堆的插入不像先前順序表一般,可以頭插,任意位置插入等等,因為是堆,要符合大根堆或小根堆的性質,不能改變堆原本的結構,所以尾插才是最適合的,并且尾插后還要檢查是否符合堆的性質。
- 尾插的原因:堆一般使用數組存儲,完全二叉樹的結點從上到下、從左到右依次連續,用數組存儲不會浪費空間。在數組尾部插入數據的時間復雜度是 O (1),效率較高。若采用頭插或其他任意位置插入,會破壞堆的完全二叉樹結構,導致難以高效地維護堆的性質,所以尾插是最適合堆的插入方式。
比如我們有一串數組,該數組是按照小根堆的性質存儲的。現在想在數組尾部插入一個數字10,如圖:
這顆樹在沒插入數字10之前是個小堆,在插入后就不是了,改變了小根堆的性質了。因為子結點10小于其父結點28。
首先最基本的,在插入之前就要先判斷該堆的容量是否還夠插入數據,先檢查要不要擴容,擴容完畢后。我們可以發現,插入的10只會影響到從自己本身開始到根,也就是祖先,只要這條路上符合堆的性質,插入即成功。
核心思想:向上調整算法。當我們看到插入的10比父親28小時,此時交換數字,但是此時10又要比18小,再次交換,最終發現10比15還小,再次交換,此時結束,到根部了。當然這是最壞的情況,如果在中間換的過程中滿足了堆的性質,那么就不需要再換了,直接返回即可。這就叫向上調整算法,直接套用上面的函數即可。
代碼如下:
// 交換 void Swap(HPDataType* p1, HPDataType* p2) {HPDataType tmp = *p1;*p1 = *p2;*p2 = tmp; }// 堆向上調整 void AdjustUp(HPDataType* a, int child) {int parent = (child - 1) / 2;while (child > 0){if (a[child] < a[parent]){Swap(&a[child], &a[parent]);child = parent;parent = (child - 1) / 2;}else{break;}} } // 堆插入數據 void HPPush(HP* php, HPDataType x) {assert(php);if (php->size == php->capacity){int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));if (tmp == NULL){perror("realloc fail");return;}php->a = tmp;php->capacity = newcapacity;}php->a[php->size] = x;php->size++;AdjustUp(php->a, php->size - 1); }
2.2.3堆的刪除
思路:在上文堆的插入中,我們明確插完依舊是堆,而這里堆的刪除同樣也要確保刪除后依舊是堆,注意:這里堆的刪除是刪除堆頂的數據。以小根堆為例,刪除堆頂的數據,也就是把最小的數據刪掉,那么還要保證依舊是堆
如果我們直接刪除堆頂元素并往前覆蓋就可能打亂原有的親緣關系。所以我們可以先將堆頂的元素與末尾元素交換,然后再進行向下調整·。
- 首先,把第一個數據和最后一個數據進行交換
- 交換后,此時的堆就不符合其性質了,因為原先最后一個數據肯定是比第一個數據大的,現在最后一個數據到了堆頂,就不是堆了,但是根結點的左子樹和右子樹不受影響,單獨看它們依舊是堆
- 接著,--size,確保刪除堆頂數據
- 因為此時堆頂的數據已經到了堆尾,只需要像順序表那樣--size,確保有效數據減1也就是確保了堆頂的刪除
- 最后,運用向下調整算法,確保其是堆結構
(1)代碼實現
// 堆向下調整 void AdjustDown(HPDataType* a, int n, int parent) {// 假設左孩子比右孩子小int child = parent * 2 + 1;while (child < n){if (child + 1 < n && a[child + 1] < a[child]){++child;}if (a[child] < a[parent]){Swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;}else{break;}} }// 刪除堆頂數據 void HPPop(HP* php) {assert(php);assert(php->size > 0);// 將堆頂元素(即php->a[0])與堆的最后一個元素交換Swap(&php->a[0], &php->a[php->size - 1]);php->size--;AdjustDown(php->a, php->size, 0); }
5.4.獲取堆頂的元素
思路:直接返回堆頂即可。前提是得斷言size>0
(1)代碼實現
// 讀取堆頂數據
HPDataType HPTop(HP* php)
{assert(php);assert(php->size > 0);return php->a[0];
}
5.6.堆的判空
思路:堆的判空很簡單,跟之前棧順序表一樣,若size為0,直接返回即可。
(1)代碼實現
// 判斷堆是否為空
bool HPEmpty(HP* php)
{assert(php);return php->size == 0;
}
5.7.銷毀堆
(1)代碼實現
// 堆的銷毀
void HPDestroy(HP* php)
{assert(php);free(php->a);php->a = NULL;php->size = php->capacity = 0;
}
5.8堆的構建
對于堆的創建這一塊,有兩種方法,一種是直接利用我們上面所寫的【Init】和【Push】聯合向上調整建堆;另一種則是利用數據拷貝進行向下調整建堆
方法1
- 首先我們來看第一種。很簡單,就是利用【Init】和【Push】聯合向上調整進行建堆
/*建堆*/
void HeapCreate1(Hp* hp, HpDataType* a, int n)
{assert(hp);HeapInit(hp);for (int i = 0; i < n; ++i){HeapPush(hp, a[i]);}
}
方法2?
- 接著是第二種,比較復雜一些,不會像【向上調整算法】一樣插入一個調整一個,而是為這個堆的存放數據的地方單獨開辟出一塊空間,然后將數組中的內容拷貝過來,這里使用到了memcpy,不懂的小伙伴可以先去了解一下它的用法
當把這些數據都拿過來之后,我們去整體性地做一個調整,那就不可以做向上調整了,需要去進行一個【向下調整】,我們通過圖解來看看
?
HeapInit(hp);
HpDataType* tmp = (HpDataType*)malloc(sizeof(HpDataType) * n); //首先申請n個空間用來存放原來的堆
if (tmp == NULL)
{perror("fail malloc");exit(-1);
}
hp->a = tmp;//void * memcpy ( void * destination, const void * source, size_t num );
memcpy(hp->a, a, sizeof(HpDataType) * n); //將數組a中n個數據拷貝到堆中的數組
hp->size = n;
hp->capacity = n;
- 可以看到,對于即將要調整的根結點,首先我們要回憶一下向下調整算法的先決條件,就是當要調整的結點的左右子樹均為大堆或者小堆,只有待調整的結點不滿足時,才可以使用這個算法,但是可以看到,【4】下面的兩個子樹均不是大堆(我這里默認建大堆),那有同學說這該怎么辦呢?此時我們應該先去調整其左右子樹,使他們先符合條件才行
- 然后可以看到左子樹這一邊,當【47】作為要調整的結點時,它的左右子樹依舊不是一個大堆,此時我們需要做的就是再去調整其左右子樹,直到其符合條件為止,那此時我們應該去調整【3】【14】,那還需要再去調整其左右子樹嗎?可以看到【1】和【36】確實也是不符合,但是呢對于葉子結點來說是沒有孩子的,所以調不調整都一個樣,因此我們只需要從倒數第二層開始調整就行,也就是最后一個非葉子結點,即【14】
- 那要如何去找到和這個【14】呢,這個好辦,我們可以知道它的孩子,就是堆底的末梢元素,那對于數組來說最后一個數據的下標為【n - 1】,在上面有說到過已知孩子結點去求結點其父親結點【(child - 1)/2】,那這里的【child】我們使用【n - 1】帶入即可,然后通過循環來一直進行調整,但是在調整完【14】這棵子樹后要怎么越位到【3】這棵子樹呢,上面說到了,堆存放在一個數組中,因此我們直接將這個【parent - 1】就可以繼續往前調整了。最后直到根節點為止就是我們上面講解【向下調整算法】時的樣子
//向下調整 /* * (n - 1)代表取到數組最后一個數據,不可以訪問n * (x - 1)/2 代表通過孩子找父親 */ for (int i = ((n - 1) - 1) / 2; i >= 0; --i) {Adjust_Down(hp->a, n, i); }
- 下面是【向下調整算法建堆】執行的全過程
完整代碼
// 堆結構體定義(底層是動態數組)
typedef int HpDataType;
typedef struct Heap {HpDataType* a; // 存儲堆元素的數組int size; // 當前堆中元素個數int capacity; // 堆的最大容量
} Hp;// Way2:基于數組拷貝+向下調整的建堆函數
void HeapCreate2(Hp* hp, HpDataType* a, int n) {assert(hp); // 斷言堆指針非空,避免非法訪問HeapInit(hp); // 先初始化堆(將a置NULL,size和capacity置0)// 步驟1:為堆數組開辟內存(大小為n個HpDataType)HpDataType* tmp = (HpDataType*)malloc(sizeof(HpDataType) * n);if (tmp == NULL) { // 檢查內存開辟是否成功perror("fail malloc"); // 打印錯誤信息exit(-1); // 終止程序(內存開辟失敗無法繼續)}hp->a = tmp; // 將開辟的內存地址賦值給堆的數組指針// 步驟2:拷貝原數組a的n個元素到堆數組hp->a中memcpy(hp->a, a, sizeof(HpDataType) * n); // 說明:memcpy(dest, src, num),num=元素大小*個數,確保拷貝所有元素// 步驟3:更新堆的size和capacity(此時堆已有n個元素,容量為n)hp->size = n;hp->capacity = n;// 步驟4:從最后一個非葉子節點開始,逐個向下調整// 計算最后一個非葉子節點的下標:(n-2)/2for (int i = ((n - 1) - 1) / 2; i >= 0; --i) {Adjust_Down(hp->a, hp->size, i); // 對每個節點i執行向下調整}
}// 向下調整算法(核心依賴,建大堆為例)
void Adjust_Down(int* a, int n, int parent) {int child = parent * 2 + 1; // 先假設左孩子是較大的子節點while (child < n) { // 循環條件:孩子節點存在(未越界)// 1. 找到左右孩子中較大的那個(需先判斷右孩子是否存在)if (child + 1 < n && a[child + 1] > a[child]) {++child; // 右孩子存在且更大,切換到右孩子}// 2. 比較父節點與較大孩子:若父節點小,則交換并繼續向下調整if (a[child] > a[parent]) {swap(&a[child], &a[parent]); // 交換父子節點(swap是自定義交換函數)parent = child; // 父節點下移到交換后的孩子位置child = parent * 2 + 1; // 重新計算新的左孩子位置} else {break; // 父節點 >= 孩子,當前子樹已符合堆性質,退出循環}}
}
Way2 的關鍵優勢:效率遠高于 Way1
Way2 的核心優勢體現在時間復雜度上:
建堆方式 | 時間復雜度 | 核心原因 |
Way1(向上調整) | O(NlogN) | 每個元素插入時需向上調整,平均調整次數為樹的高度(logN),N 個元素總復雜度為 N*logN |
Way2(向下調整) | O(N) | 從最后一個非葉子節點開始調整,底層節點(數量多)調整次數少(1 次),頂層節點(數量少)調整次數多(logN 次),通過錯位相減計算總次數約為 N |
完整代碼
Heap.h
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>typedef int HPDataType;typedef struct Heap
{HPDataType* a;int size;int capacity;
}HP;void Swap(HPDataType* p1, HPDataType* p2);
void AdjustUp(HPDataType* a, int child);
void AdjustDown(HPDataType* a, int n, int parent);void HPInit(HP* php);
void HPDestroy(HP* php);
void HPPush(HP* php, HPDataType x);
void HPPop(HP* php);
HPDataType HPTop(HP* php);
bool HPEmpty(HP* php);
int HeapSize(HP* php);
Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"// 交換
void Swap(HPDataType* p1, HPDataType* p2)
{HPDataType tmp = *p1;*p1 = *p2;*p2 = tmp;
}// 堆向上調整
void AdjustUp(HPDataType* a, int child)
{int parent = (child - 1) / 2;while (child > 0){if (a[child] < a[parent]){Swap(&a[child], &a[parent]);child = parent;parent = (child - 1) / 2;}else{break;}}
}//堆向下調整
void AdjustDown(HPDataType* a, int n, int parent)
{//先假設左孩子比右孩子小int child = parent * 2 + 1;while (child < n){if (child + 1 < n && a[child + 1] < a[child]){++child;}if (a[child] < a[parent]){Swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}//堆的初始化
void HPInit(HP* php)
{assert(php);php->a = NULL;php->size = php->capacity = 0;
}//堆的銷毀
void HPDestroy(HP* php)
{assert(php);free(php->a);php->a = NULL;php->size = php->capacity = 0;
}void HPPush(HP* php, HPDataType x)
{assert(php);if (php->size == php->capacity){int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));if (tmp == NULL){perror("realloc fail");return;}php->a = tmp;php->capacity = newcapacity;}php->a[php->size] = x;php->size++;AdjustUp(php->a, php->size - 1);
}// 刪除堆頂數據
void HPPop(HP* php)
{assert(php);assert(php->size > 0);Swap(&php->a[0], &php->a[php->size - 1]);php->size--;AdjustDown(php->a, php->size, 0);
}//讀取堆頂數據
HPDataType HPTop(HP* php)
{assert(php);assert(php->size > 0);return php->a[0];
}//判斷堆是否為空
bool HPEmpty(HP* php)
{assert(php);return php->size == 0;
}// 獲取堆的個數
int HeapSize(HP* php)
{assert(php);return php->size;
}
測試用例:
void TestHeap()
{HP hp;HPInit(&hp);if (HPEmpty(&hp)){printf("堆空\n");}else{printf("堆非空\n");}int a[] = { 4,2,8,1,5,6,9,7,3,2,23,55,232,66,222,33,7,1,66,3333,999 };for (size_t i = 0; i < sizeof(a) / sizeof(int); i++){HPPush(&hp, a[i]);}if (HPEmpty(&hp)){printf("堆空\n");}else{printf("堆非空\n");}printf("堆的數據個數:%d\n", HeapSize(&hp));while (!HPEmpty(&hp)){printf("%d ", HPTop(&hp));HPPop(&hp);}printf("\n");printf("堆的數據個數:%d\n", HeapSize(&hp));HPDestroy(&hp);
}
輸出結果:
結束語:
本節我們學習了新的二叉樹——堆,接下來還會繼續更新對數據結構-------堆的應用(排序,TopK問題)
感謝您的三連支持!!!