堆與優先佇列

PRO_Z發表於2022-03-08

1 概念

  • 堆:即優先佇列,是基於完全⼆叉樹所定義的一種新的資料結構,其要求完全二叉樹中的任意三元組的根節點都是極大(小)值,並且樹的根節點是最大(小)值。

2 分類

  • ⼤頂(根)堆:在堆中,任意三元組中的根節點都是極⼤值,其可以求取全域性最大值、全域性次最大值。
  • ⼩頂(根)堆:在堆中,任意三元組中的根節點都是極小值,其可以求取全域性最小值、全域性次最小值。
堆與優先佇列

3 建堆方法

  • 利用陣列表示一顆完全二叉樹,其表示關係為:根節點i,左孩子2i,右孩子2i+1,i≥1。
堆與優先佇列

3.1 堆尾插入元素建堆法(自頂向下)

  • 條件:事先不知道有多少個元素,通過動態的往堆裡面插入元素進行調整來構建堆。
  • 構建過程:(堆調整順序:自下向上
    1. 在堆尾插入新的元素;
    2. 當前元素存在父節點,執行步驟3,否則建堆結束;
    3. 比較當前元素和它的父結點值;如果當前元素比父節點值大,則交換兩個元素(大頂堆),並繼續執行步驟2,否則建堆結束。
  • 大頂堆插入元素示意圖:
堆與優先佇列 堆與優先佇列 堆與優先佇列 堆與優先佇列
  • 經過堆調整後的結果展示:
堆與優先佇列
  • 從圖中可以看出,從堆尾插入元素並進行堆結構調整,其時間複雜度為o(lgn),n為節點個數;

3.2 線性建堆法(自下向上的)

  • 條件:堆元素已經確定好的情況下,使用線性建堆法,如堆排序;
  • 構建過程:
    1. 找到最後1個元素的父節點(parent_node ),即當前堆大小的一半(n/2),記作parent_node = n >> 2,n為堆中的節點大小;
    2. 從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. 用堆尾元素替換堆頂元素,並將堆大小減1;
    2. 自上向下的調整堆結構,保證任意一個三元組都滿足堆性質;
    3. 在當前節點編號大於節點數量 或 調整堆結構已發現滿足堆性質時,停止調整,否則繼續執行步驟2。
  • 刪除堆頂元素的示意圖:
堆與優先佇列 堆與優先佇列 堆與優先佇列 堆與優先佇列
  • 結論:刪除堆頂元素需要自上向下調整堆結構,其時間複雜度為o(lgn),n為節點數目

5 堆排序 - o(n*logn)

  • 堆排序步驟:
    1. 將堆頂元素與堆尾元素交換;
    2. 對前n-1元素重新建堆;(與刪除堆頂元素後的堆調整過程一樣)
    3. 重複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)】;
  • 堆之所以叫優先佇列,是因為可以像佇列從 堆尾插入元素、堆頂刪除元素,並且每次出隊權值都是最大(大頂堆)/最小(小頂堆)元素。

相關文章