一、堆的概念及結構
1、概念
堆(Heap)是計算機科學中一類特殊的數據結構的統稱。堆通常是一個可以被看做一棵 完全二叉樹的 數組對象。 堆是非線性數據結構,相當于一維數組,有兩個直接后繼。如果有一個關鍵碼的集合K = { k?,k?,k? ,k? ,…,k??? ?},把它的所有元素按完全二叉樹的順序存儲方式存儲,在一個一維數組中,并滿足:K? ?<= K? *??? ?且 K? ?<= K? *??? ?(K? ?>= K? *?? ??且 K? ?>= K? *??? ) i = 0,1,2…,則稱為小堆 (或大堆)。將根節點最大的堆叫做最大堆或大根堆,根節點最小的堆叫做最小堆或小根堆。【大根堆和小根堆】:
根結點最大的堆叫做大根堆,樹中所有父親都大于或等于孩子。
根結點最小的堆叫做小根堆,樹中所有父親都小于或等于孩子。
這個大根堆和小根堆有什么特點呢?
最值總在 0 號位,根據這個特點我們可以做很多事情。比如 TopK 問題 (在一堆數據里面找到前 K 個最大 / 最小的數)。生活中也有很多實例,比如某外賣軟件中有上千家店鋪,我想選出當地好評最多的十家烤肉店,這時我們不用對所有數據進行排序,只需要取出前 K 個最大 / 最小數據。使用堆排序效率也更高。?
2、性質
- 堆中某個節點的值總是不大于或不小于其父節點的值;
- 堆總是一棵完全二叉樹。
二、堆的實現
1、堆向下調整算法
給出一個數組,邏輯上看做一顆完全二叉樹。我們通過從根節點開始的 向下調整算法 可以把它調整成一個 小堆 。向下調整算法有一個前提:左右子樹必須是一個堆(包括大堆和小堆) ,才能調整。
- 從根節點開始,不斷往下調。
- 選出根節點的左右孩子中小的那個孩子,再與父親進行比較。
- (1)如果父親小于孩子,就不需處理了,整個樹已經是小堆了。
- (2) 如果父親大于孩子,就跟父親交換位置,并將原來小的孩子的位置當成父親繼續向下進行調整,直到調整到葉子結點為止。
int array[] = {27,15,19,18,28,34,65,49,25,37}; // 根節點的左右子樹都是小堆
// 向下調整算法 -- 調成小堆,把大的節點往下調整
void AdjustDown(int* a, int size, int parent)
{int child = parent * 2 + 1;while (child < size){// 判斷右孩子是否存在 選出左右孩子中小的那個if (child + 1 < size && a[child + 1] < a[child]){++child;}// 孩子跟父親比較if (a[child] < a[parent]){Swap(&a[child], &a[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}
【時間復雜度】我們以滿二叉樹計算,最壞情況下,向下調整算法最多進行滿二叉樹的高度減 1 次比較,則說明向下調整算法最多調整滿二叉樹的高度減 1 次,n 個節點的滿二叉樹高度為 log?(n+1),估算后所以時間復雜度為 O(log?n)。?
2、堆的創建
給出一個數組,這個數組邏輯上可以看做一顆完全二叉樹,但還不是一個堆,現在我們可以通過算法,把它構建成一個堆。根節點左右子樹不是堆,我們怎么調整呢?我們從倒數的第一個非葉子節點的子樹開始調整,一直調整到根節點的樹,就可以調整成堆。
為什么要倒著調整呢?
因為這樣我們可以把倒數第一個非葉子節點的子樹的左右子樹看成是一個 (大 / 小) 堆,滿足向下調整的前提條件,這時才能去使用向下調整算法。?
// 建大堆
int a[] = {1,5,3,8,7,6};
建堆過程演示(以建大堆為例):從下到上,依次遍歷完所有非葉子節點,分別對每個子樹進行向下調整。依次進行每一步,方框內的樹進行向下調整為一個大堆。
調換 1 和 8 的位置時,8 的其左子樹構成的遞歸結構被破壞。因此,在每一次發生元素交換時,都需要遞歸調用重新構造堆的結構。
#include <stdio.h>
#include <stdlib.h>void Swap(HPDataType* p1, HPDataType* p2)
{HPDataType tmp = *p1;*p1 = *p2;*p2 = tmp;
}// 向下調整算法 -- 建大堆,把小的節點往下調整
void AdjustDown(int* a, int size, int parent)
{int child = parent * 2 + 1;while (child < size){// 判斷右孩子是否存在 選出左右孩子中大的那個if (child + 1 < size && 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 HeapSort(int* a, int n)
{// O(N)// botto-top(自底向上),依次遍歷完所有子樹,分別對其進行調整for (int i=((n-1)-1)/2; i>=0; i--) // 從最后一個葉子節點的父親的下標開始{AdjustDown(a, n, i);}// 升序// O(N*logN)int end = n - 1; // 記錄堆中最后一個元素的下標while (end > 0){Swap(&a[0], &a[end]); // 將堆頂元素和堆中最后一個元素交換,把最大的數(堆頂)放到最后AdjustDown(a, end, 0);--end;}
}
【拓展】堆的創建(采用向上調整法)
下面給出一個數組,這個數組邏輯上可以看做一顆完全二叉樹,但不是一個堆,我們需要通過向上調整算法把它構建成一個堆。如果根節點左右子樹不是一個 (大 / 小) 堆,我們應該怎么調整呢?
我們從上到下,從第一個節點(也就是根節點)的左孩子開始,依次遍歷完所有節點,分別對每個節點進行向上調整,一直到最后一個節點,就可以建成一個 (大 / 小) 堆。
我們把數組中的第一個元素看作是一個堆,剩余的元素依次插入到這個堆中。這跟堆的插入接口原理相同,就是向上調整。
如果堆的創建過程使用向上調整算法,那么每次插入一個新元素時都需要進行一次向上調整操作,以確保新插入的元素能夠滿足堆的性質。
【時間復雜度】
????????假設堆中已經有 n 個元素,那么堆的高度 h = log?(n+1),在插入一個新元素的過程中,需要進行的向上調整操作次數為?h-1,則?h = log?(n+1) -?1,該操作的時間復雜度為O(log?n)。需要注意的是,這個時間復雜度是針對單次 “向上建堆” 操作而言的。如果需要對一個無序數組進行完整的堆排序,則需要進行 n/2 次 “向上建堆”?操作,那么整個堆排序的時間復雜度為 O(nlog n)。所以,如果使用向上調整算法來創建堆排序,那么堆的創建過程的時間復雜度為 O(n*log?n)。
? ? ? ?結論:?使用向上調整算法創建堆需要進行多次調整操作,而使用向下調整算法只需要進行一次調整操作。因此,從實際操作的角度來看,使用向下調整算法創建堆更為高效。同時,向下調整算法也更為直觀,容易理解和實現。因此,在實際應用中,一般會選擇使用向下調整算法來創建堆。
【堆排序】
利用 堆刪除 思想來進行排序(后文有介紹)建堆和堆刪除中都用到了 向下調整 ,因此掌握了向下調整,就可以完成堆排序。![]()
(1)升序 -- 建大堆
【思考】排升序,建小堆可以嗎?-- 可以(但不推薦)。
?首先對 n 個數建小堆,選出最小的數,接著對剩下的 n-1 個數建小堆,選出第2小的數,不斷重復上述過程。【時間復雜度】建 n 個數的堆時間復雜度是 O(N),所以上述操作時間復雜度為 O(N2),效率太低,尤其是當數據量大的時候,效率就更低。同時堆的價值也沒有被體現出來,這樣不如用直接排序。
?【最佳方法】排升序,因為數字依次遞增,需要找到最大的數字,得建大堆。
首先對 n 個數建大堆。將最大的數(堆頂)和最后一個數交換,把最大的數放到最后。前面 n-1 個數的堆結構沒有被破壞(最后一個數不看作在堆里面的),根節點的左右子樹依然是大堆,所以我們進行一次向下調整成大堆即可選出第 2 大的數,放到倒數第二個位置,然后重復上述步驟。
【時間復雜度】:建堆時間復雜度為 O(N),向下調整時間復雜度為 O(log?N),這里我們最多進行N-2 次向下調整,所以堆排序時間復雜度為 O(N*log?N),效率相較而言是很高的。
(2)降序 -- 建小堆(與建大堆同理)
【最佳方法】排降序,因為數字越來越小,需要找到最小的數字,得建小堆。
首先對 n 個數建小堆。將最小的數(堆頂)和最后一個數交換,把最小的數放到最后。前面 n-1 個數的堆結構沒有被破壞(最后一個數不看做堆里面的),根節點的左右子樹依舊是小堆,所以我們進行一次向下調整成小堆即可選出第2小的數,放到倒數第二個位置,然后重復上述步驟。
【時間復雜度】:建堆時間復雜度為 O(N),向下調整時間復雜度為 O(log?N),這里我們最多進行N-2 次向下調整,所以堆排序時間復雜度為O(N*log?N),效率相較而言是很高的。
3、建堆時間復雜度
等比數列求和公式:
建堆要從倒數第一個非葉子節點開始調整,也即是從倒數第二層開始調,可得出時間復雜度公式:
????????????????????????T ( n ) = ∑ ( 每 層 節 點 數 ? ( 堆 的 高 度 ? 當 前 層 數 ) )?建堆的時間復雜度為O(N)。(向下調整算法)
4、堆的插入
5、堆的刪除
- 將堆頂元素和最后一個元素交換(這樣就變成尾刪了)。
- 刪除堆中最后一個元素。
- 從根節點開始,對剩下元素進行向下調整,調成(大 / 小)堆。
6、堆的代碼實現
(1)文件的創建
- test.c(主函數、測試堆各個接口功能)
- Heap.c(堆接口函數的實現)
- Heap.h(堆的類型定義、接口函數聲明、引用的頭文件)
(2)Heap.h 頭文件代碼
// Heap.h
#pragma once#include <stdio.h>
#include<stdlib.h> // malloc, free
#include <assert.h> // assert
#include <stdbool.h> // bool
#include<string.h> // memcpytypedef int HPDataType;typedef struct Heap
{HPDataType* a; // 指向動態開辟的數組int size; // 數組中有效元素個數int capacity; // d容量
}Heap;// 交換函數
void Swap(HPDataType* p, HPDataType* q);
// 向下調整函數(調成大堆,把小的往下調)
void AdjustDown(HPDataType* a, int size, int parent);
// 向上調整函數(調成大堆,把大的往上調)
void AdjustUp(HPDataType* a, int child);
// 堆的打印
void HeapPrint(Heap* hp);// 堆的構建
void HeapCreate(Heap* hp, HPDataType* arr, int n);
// 堆的銷毀
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的刪除
void HeapPop(Heap* hp);
// 取堆頂的數據
HPDataType HeapTop(Heap* hp);
// 堆的數據個數
int HeapSize(Heap* hp);
// 堆的判空
bool HeapEmpty(Heap* hp);
(3)接口的實現(以大堆為例)
I.堆的創建(初始化)
堆的初始化一般是使用數組進行初始化的,還需要先實現一個向下調整算法。
//交換
void Swap(HPDataType* p, HPDataType* q)
{HPDataType tmp = *p;*p = *q;*q = tmp;
}// 向下調整(調成大堆,把小的往下調)
void AdjustDown(HPDataType* a, int size, int parent)
{int child = parent * 2 + 1;while (child < size){//選出左右孩子中大的那個if (child + 1 < size && 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 HeapCreate(Heap* hp, HPDataType* arr, int n)
{assert(hp);//斷言hp->a = (HPDataType*)malloc(sizeof(HPDataType) * n);//動態開辟n個空間if (hp->a == NULL){printf("malloc fail\n");exit(-1);}memcpy(hp->a, arr, sizeof(HPDataType) * n);//把給定數組的各元素值拷貝過去hp->size = hp->capacity = n;//建堆(建大堆)int parent = ((hp->size - 1) - 1) / 2; //倒數第一個非葉子節點下標for (int i = parent; i >= 0; i--){AdjustDown(hp->a, hp->size, i);}
}
II.堆的打印?
// 打印
void HeapPrint(Heap* hp)
{assert(hp);for (int i = 0; i < hp->size; i++){printf("%d ", hp->a[i]);}printf("\n");
}
III.堆的銷毀
// 堆的銷毀
void HeapDestory(Heap* hp)
{assert(hp);free(hp->a); // 釋放動態開辟的空間hp->a = NULL;hp->size = hp->capacity = 0;
}
IV.堆的插入
堆的插入數據不分頭插、尾插。將數據插入后,原來堆的屬性不變。先放在數組的最后一個位置,再進行向上調整。堆的插入,首先需要實現一個向上調整算法。
//向上調整(調成大堆,把大的往上調)
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 HeapPush(Heap* hp, HPDataType x)
{assert(hp);if (hp->size == hp->capacity){int newcapacity = hp->capacity == 0 ? 4 : (hp->capacity) * 2;HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);if (tmp == NULL){printf("realloc fail\n");exit(-1);}hp->a = tmp;hp->_capacity = newcapacity;}// 插入元素hp->a[hp->size] = x;hp->size++;AdjustUp(hp->a, hp->size - 1); // 從插入的元素開始,進行向上調整,保持它依然是堆
}
注意:這里的 while 括號里的條件不能寫成 (parent >= 0),因為 parent 在這里不會小于 0。(假設child 為 0,則 parent = (0-1) / 2 = 0)。
V.堆的刪除
刪除堆頂元素,刪除后保持它依然是堆。
// 堆的刪除
void HeapPop(Heap* hp)
{assert(hp);assert(!HeapEmpty(hp)); // 堆不能為空Swap(&(hp->a[0]), &(hp->a[hp->size - 1])); // 將堆頂元素和最后一個元素交換hp->size--; // 刪除堆中最后一個元素// 從根節點開始,對剩下元素進行向下調整成大堆,保持它依然是堆AdjustDown(hp->a, hp->size, 0);
}
VI.取堆頂元素
// 取堆頂的數據(最值)
HPDataType HeapTop(Heap* hp)
{assert(hp);assert(!HeapEmpty(hp)); // 堆不能為空return hp->a[0];
}
VII.堆的數據個數
// 堆的數據個數
int HeapSize(Heap* hp)
{assert(hp);return hp->size;
}
VIII.堆的判空
判斷堆是否為空,為空返回true,不為空返回false。
// 堆的判空
bool HeapEmpty(Heap* hp)
{assert(hp);return hp->size == 0;
}
(4)代碼整合
// Heap.c
#include "Heap.h"//交換
void Swap(HPDataType* p, HPDataType* q)
{HPDataType tmp = *p;*p = *q;*q = tmp;
}// 打印
void HeapPrint(Heap* hp)
{assert(hp);for (int i = 0; i < hp->size; i++){printf("%d ", hp->a[i]);}printf("\n");
}// 向下調整(調成大堆,把小的往下調)
void AdjustDown(HPDataType* a, int size, int parent)
{int child = parent * 2 + 1;while (child < size){//選出左右孩子中大的那個if (child + 1 < size && 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 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 HeapCreate(Heap* hp, HPDataType* arr, int n)
{assert(hp);//斷言hp->a = (HPDataType*)malloc(sizeof(HPDataType) * n);//動態開辟n個空間if (hp->a == NULL){printf("malloc fail\n");exit(-1);}memcpy(hp->a, arr, sizeof(HPDataType) * n);//把給定數組的各元素值拷貝過去hp->size = hp->capacity = n;//建堆(建大堆)int parent = ((hp->size - 1) - 1) / 2; //倒數第一個非葉子節點下標for (int i = parent; i >= 0; i--){AdjustDown(hp->a, hp->size, i);}
}// 堆的銷毀
void HeapDestory(Heap* hp)
{assert(hp);free(hp->a); // 釋放動態開辟的空間hp->a = NULL;hp->size = hp->capacity = 0;
}// 堆的插入
void HeapPush(Heap* hp, HPDataType x)
{assert(hp);if (hp->size == hp->capacity){int newcapacity = hp->capacity == 0 ? 4 : (hp->capacity) * 2;HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);if (tmp == NULL){printf("realloc fail\n");exit(-1);}hp->a = tmp;hp->_capacity = newcapacity;}// 插入元素hp->a[hp->size] = x;hp->size++;AdjustUp(hp->a, hp->size - 1); // 從插入的元素開始,進行向上調整,保持它依然是堆
}// 堆的刪除
void HeapPop(Heap* hp)
{assert(hp);assert(!HeapEmpty(hp)); // 堆不能為空Swap(&(hp->a[0]), &(hp->a[hp->size - 1])); // 將堆頂元素和最后一個元素交換hp->size--; // 刪除堆中最后一個元素// 從根節點開始,對剩下元素進行向下調整成大堆,保持它依然是堆AdjustDown(hp->a, hp->size, 0);
}// 取堆頂的數據(最值)
HPDataType HeapTop(Heap* hp)
{assert(hp);assert(!HeapEmpty(hp)); // 堆不能為空return hp->a[0];
}// 堆的數據個數
int HeapSize(Heap* hp)
{assert(hp);return hp->size;
}// 堆的判空
bool HeapEmpty(Heap* hp)
{assert(hp);return hp->size == 0;
}
(5)程序運行效果
三、堆的應用
【TOP-K問題】?
TOP-K 問題:即求數據結合中前 K 個最大的元素或者最小的元素,一般情況下數據量都比較大。在生活中,也有不少例子:班級前 10 名、世界 500 強、富豪榜等。方法一:對于 Top-K 問題,能想到的最簡單直接的方式就是排序,但是:如果數據量非常大,排序就不太可取了(可能數據都不能一下子全部加載到內存中)。排序 --【時間復雜度為】:O(N*logN)。方法二:建一個 N 個數的堆(優先級隊列),不斷選數,選出前 K 個。【時間復雜度】:?O(N+K*log(N))?假設 N 是十億,顯然前兩個方法都不適用。
【最佳方法】-- 方法三:最佳的方式就是用堆來解決,基本思路如下:1、用數據集合中前 K 個元素來建堆。
- 前k個最大的元素,則建小堆
- 前k個最小的元素,則建大堆
2、用剩余的 N-K 個元素依次與堆頂元素來比較,不滿足則替換堆頂元素。將剩余 N-K 個元素依次與堆頂元素比完之后,堆中剩余的 K 個元素就是所求的前 K 個最小或者最大的元素。
// test.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <time.h>void PrintTopK(int* a, int n, int k)
{// 1、建堆 -- 用a中前k個元素建堆(0~k-1)int* kMinHeap = (int*)malloc(sizeof(int) * k);assert(kMinHeap);for (int i = 0; i < k; ++i){kMinHeap[i] = a[i];}for (int i = (k - 1 - 1) / 2; i >= 0; --i){AdjustDown(kMinHeap, k, i);}// 2、將剩余n-k個元素依次與堆頂元素交換,不滿則則替換(k~n)for (int j = k; j < n; ++j){if (a[j] > kMinHeap[0]){kMinHeap[0] = a[j];AdjustDown(kMinHeap, k, 0);}}for (int i = 0; i < k; ++i){printf("%d ", kMinHeap[i]);}printf("\n");
}void TestTopk()
{int n = 10000;int* a = (int*)malloc(sizeof(int) * n);// 創建隨機數種子srand((unsigned int)time(0));for (int i = 0; i < n; ++i){a[i] = rand() % 1000000; // 生成隨機數}// 如果找出這10個數,說明TOP-K算法是正確的a[5] = 1000000 + 1;a[1231] = 1000000 + 2;a[531] = 1000000 + 3;a[5121] = 1000000 + 4;a[120] = 1000000 + 5;a[99] = 1000000 + 6;a[0] = 1000000 + 7;a[76] = 1000000 + 8;a[423] = 1000000 + 9;a[3144] = 1000000 + 10;PrintTopK(a, n, 10);
}