1 概念
- 堆:即優先佇列,是基於完全⼆叉樹所定義的一種新的資料結構,其要求完全二叉樹中的任意三元組的根節點都是極大(小)值,並且樹的根節點是最大(小)值。
2 分類
- ⼤頂(根)堆:在堆中,任意三元組中的根節點都是極⼤值,其可以求取全域性最大值、全域性次最大值。
- ⼩頂(根)堆:在堆中,任意三元組中的根節點都是極小值,其可以求取全域性最小值、全域性次最小值。
3 建堆方法
- 利用陣列表示一顆完全二叉樹,其表示關係為:根節點i,左孩子2i,右孩子2i+1,i≥1。
3.1 堆尾插入元素建堆法(自頂向下)
- 條件:事先不知道有多少個元素,通過動態的往堆裡面插入元素進行調整來構建堆。
- 構建過程:(堆調整順序:自下向上)
- 在堆尾插入新的元素;
- 當前元素存在父節點,執行步驟3,否則建堆結束;
- 比較當前元素和它的父結點值;如果當前元素比父節點值大,則交換兩個元素(大頂堆),並繼續執行步驟2,否則建堆結束。
- 大頂堆插入元素示意圖:
- 經過堆調整後的結果展示:
- 從圖中可以看出,從堆尾插入元素並進行堆結構調整,其時間複雜度為o(lgn),n為節點個數;
3.2 線性建堆法(自下向上的)
- 條件:堆元素已經確定好的情況下,使用線性建堆法,如堆排序;
- 構建過程:
- 找到最後1個元素的父節點(parent_node ),即當前堆大小的一半(n/2),記作parent_node = n >> 2,n為堆中的節點大小;
- 從parent_node = (n / 2)位置開始,自上向下的調整堆結構,直到parent_node = 0為止;(遍歷條件:1 ≤ parent_node ≤ n / 2 )
- 大頂堆建堆示意圖:
3.3 兩種建堆方法的時間複雜度分析
在樹高為h的二叉樹中,根節點為第1層,每層的節點數量為2^(N-1),其中N為每層的編號,N∈[1, h]。
- 樹的高度與節點關係為:
\[s_n = \sum_{N=1}^h2^{(N-1)} = 2^0 + 2^1 + 2^2 + ... + 2^{(h-2)} + 2^{(h-1)} = 2^h - 1,N∈[1, h]
\]
\[h = log_2(s_n+1)
\]
3.3.1 插入建堆法的時間複雜度分析- o(n*logn)
\[T = (N-1)*2^{(N-1)} + (N-2)*2^{(N-2)} + (N-3)*2^{(N-3)} + .... + 1*2^1 + 0*2^0
\]
- 上式中,每項的第1個引數:該節點向上調整的次數,每項的第2個引數:該層的節點數目;如第1項,(N-1)*2^(N-1) 表示第N層有2^(N-1)個節點需要經過(N-1)次向上堆調整過程。
\[T' = 2*T = (N-1)*2^{N} + (N-2)*2^{(N-1)} + (N-3)*2^{(N-2)} + .... + 1*2^2 + 0*2^1
\]
\[T = T'-T = (N-1)*2^{N} -(2^{(N-1)} + 2^{(N-2)} + .... + 2^2 + 2^1) = (N-1)*2^{N} + (2 - 2^{N}) = N*2^{N} + 2^{(N+1)} +2
\]
\[o(T) = o(h*2^h - 2^{(h +1)} + 2) ≈ o(h*2^h) = o((s_n+1)*log_2(s_n+1))=o(nlog_2n)
\]
3.3.2 線性建堆法的時間複雜度分析:- o(n)
\[T = 0*2^{(N-1)} + 1*2^{(N-2)} + 2*2^{(N-3)} + .... + (N-2)*2^1 + (N-1)*2^0
\]
- 上式中,每項的第1個引數:該層節點向下調整的次數,每項的第2個引數:該層的節點數目
\[T' = 2*T = 1*2^{(N-1)} + 2*2^{(N-2)} + .... + (N-2)*2^2 + (N-1)*2^1
\]
\[T = T'-T = 2^{(N-1)} + 2^{(N-2)} + .... + 2^2 + 2^1 - (N-1)*2^0 = 2^N - N - 1
\]
\[o(T) = o(2^h - h -1) ≈ o(2^h) = o(s_n+1)=o(n)
\]
- 時間複雜度結論:插入建堆 — o(nlgn),線性建堆 — o(n),n為節點個數
4 刪除堆頂元素- o(logn)
- 刪除步驟:
- 用堆尾元素替換堆頂元素,並將堆大小減1;
- 自上向下的調整堆結構,保證任意一個三元組都滿足堆性質;
- 在當前節點編號大於節點數量 或 調整堆結構已發現滿足堆性質時,停止調整,否則繼續執行步驟2。
- 刪除堆頂元素的示意圖:
- 結論:刪除堆頂元素需要自上向下調整堆結構,其時間複雜度為o(lgn),n為節點數目
5 堆排序 - o(n*logn)
- 堆排序步驟:
- 將堆頂元素與堆尾元素交換;
- 對前n-1元素重新建堆;(與刪除堆頂元素後的堆調整過程一樣)
- 重複1、2 兩個過程,直到堆中的元素為1時停止;
- 結論:採取大頂堆的調整方式,為升序排序;而採取小頂堆的調整方式,為降序排序。
6 程式碼演示
6.1 插入建堆法
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#define SWAP(a, b) {\
__typeof(a) __temp = a; a = b; b = __temp;\
}
typedef struct priority_queue {
int *data;
int cnt, size; // cnt:堆中的元素個數,size:堆空間的容量
} priority_queue;
priority_queue* init(int size) {
priority_queue* q = (priority_queue*)malloc(sizeof(priority_queue));
// 多申請1個空間,是因為堆頂元素的編號為1,這樣在建堆過程中可以減少1次加法運算
q->data = (int*)malloc((size + 1) * sizeof(int));
q->cnt = 0;
q->size = size;
return q;
}
int empty(priority_queue* q) {
return q->cnt == 0;
}
// 獲取堆頂元素
int top(priority_queue* q) {
return q->data[1];
}
// 堆尾插入元素
int push(priority_queue* q, int v) {
if (q == NULL) return 0;
if (q->cnt == q->size) return 0;
// 將元素插入堆尾
q->data[++(q->cnt)] = v;
// 重新調整堆結構(大頂堆)--- 自下向上
int ind = q->cnt; // 獲取當前元素的編號
while (ind >> 1 && q->data[ind] > q->data[ind >> 1]) {
SWAP(q->data[ind], q->data[ind >> 1]);
ind = ind >> 1;
}
return 1;
}
// 刪除堆頂元素
int pop(priority_queue* q) {
if (q == NULL) return 0;
if (q->cnt == 0) return 0;
// 將堆尾元素賦值堆頂
q->data[1] = q->data[(q->cnt)--];
// 重新調整堆結構(大頂堆)--- 自上向下
int ind = 1; // 堆頂元素的編號
while ((ind << 1) <= q->cnt) {
int temp = ind, lnode = ind << 1, rnode = ind << 1 | 1;
if (q->data[temp] < q->data[lnode]) temp = lnode;
if (rnode <= q->cnt && q->data[temp] < q->data[rnode]) temp = rnode;
if (temp == ind) break; // 當前三元組結構未發生變化
SWAP(q->data[temp], q->data[ind]);
ind = temp;
}
return 1;
}
void clear(priority_queue* q) {
if (q == NULL) return ;
if (q->data) free(q->data);
free(q);
}
int main() {
srand(time(0));
const int N = 10;
priority_queue* q = init(N);
for (int i = 1; i <= N; i++) {
int v = rand() % 100;
push(q, v);
}
for (int i = 1; i <= q->cnt; i++) {
printf("%d ", q->data[i]);
}
printf("\n");
while (!empty(q)) {
printf("%d ", top(q));
pop(q);
}
printf("\n");
clear(q);
return 0;
}
6.2 堆排序(線性建堆法)
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// 線性建堆法:建堆時間 o(n)
// 堆排序:建堆時間 + 堆排序時間 = o(n) + o(n*lgn) = o(n*lgn)
#define SWAP(a, b) {\
__typeof(a) __temp = a; a = b; b = __temp;\
}
// 根節點:i,左子樹節點:2*i,右子樹節點:2*i+1,i >= 1;
// arr:輸入陣列,n:陣列元素的個數,ind:代表完全二叉樹的節點編號
void downUpdate(int *arr, int n, int ind) {
// ind << 1:下一層節點編號,即當前節點的左子樹節點編號,其節點編號代表元素的個數
while ((ind << 1) <= n) {
int temp = ind, l = ind << 1, r = ind << 1 | 1; // l:下一層的左子樹節點編號,r:下一層的右子樹節點編號
// 大頂堆構建(堆排序:從小到大排序),任意三元組的父節點為極大值
if (arr[l] > arr[temp]) temp = l;
if (r <= n && arr[r] > arr[temp]) temp = r;
// 小頂堆構建(堆排序:從大到小排序),任意三元組的父節點為極小值
// if (arr[l] < arr[temp]) temp = l;
// if (r <= n && arr[r] < arr[temp]) temp = r;
if (ind == temp) break; // ind == temp:三元組中的父節點為極大(小)值節點
swap(arr[temp], arr[ind]);
ind = temp;
}
return ;
}
// arr:待排序的陣列,n:陣列元素的個數
void heap_sort(int *arr, int n) {
// 待排序的陣列索引從0開始編號,而堆結構採取從1開始編號,故需要arr -= 1
arr -= 1;
// 線性建堆法 -- o(n)
for (int i = n >> 1; i >= 1; i--) {
downUpdate(arr, n, i);
}
// 堆排序的步驟:
// 1. 將堆頂元素與堆尾元素交換
// 2. 對前n-1元素重新建堆
// 3. 重複1、2 兩個過程,直到堆中的元素為1時停止
for (int i = n; i > 1; i--) { // o(n * lgn)
swap(arr[i], arr[1]);
downUpdate(arr, i - 1, 1);
}
return ;
}
void output(int *arr, int n) {
printf("[");
for (int i = 0; i < n; i++) {
i && printf(" ");
printf("%d", arr[i]);
}
printf("]\n");
return ;
}
int main() {
srand(time(0));
#define MAX_N 10
int arr[MAX_N] = {0};
for (int i = 0; i < MAX_N; i++) {
arr[i] = rand() % 100;
}
output(arr, MAX_N);
heap_sort(arr, MAX_N);
output(arr, MAX_N);
#undef MAX_N
return 0;
}
7 總結
- 從堆尾插入元素,需要自下向上調整堆結構,需要o(logn)次;
- 從堆頂刪除元素,需要自上向下調整堆結構,需要o(logn)次;
- 需要動態的往堆裡插入元素時,採用插入建堆法【o(n*logn)】;
- 明確元素數量時,可以採用線性建堆法【o(n)】,如堆排序【o(n*logn)】;
- 堆之所以叫優先佇列,是因為可以像佇列從 堆尾插入元素、堆頂刪除元素,並且每次出隊權值都是最大(大頂堆)/最小(小頂堆)元素。