C++堆

2023-09-20 09:19 更新

「堆 heap」是一種滿足特定條件的完全二叉樹,主要可分為圖 8-1 所示的兩種類型。

  • 「大頂堆 max heap」:任意節(jié)點的值 ≥ 其子節(jié)點的值。
  • 「小頂堆 min heap」:任意節(jié)點的值 ≤ 其子節(jié)點的值。

小頂堆與大頂堆

圖 8-1   小頂堆與大頂堆

堆作為完全二叉樹的一個特例,具有以下特性。

  • 最底層節(jié)點靠左填充,其他層的節(jié)點都被填滿。
  • 我們將二叉樹的根節(jié)點稱為“堆頂”,將底層最靠右的節(jié)點稱為“堆底”。
  • 對于大頂堆(小頂堆),堆頂元素(即根節(jié)點)的值分別是最大(最小)的。

堆常用操作

需要指出的是,許多編程語言提供的是「優(yōu)先隊列 priority queue」,這是一種抽象數(shù)據(jù)結(jié)構(gòu),定義為具有優(yōu)先級排序的隊列。

實際上,堆通常用作實現(xiàn)優(yōu)先隊列,大頂堆相當于元素按從大到小順序出隊的優(yōu)先隊列。從使用角度來看,我們可以將“優(yōu)先隊列”和“堆”看作等價的數(shù)據(jù)結(jié)構(gòu)。因此,本書對兩者不做特別區(qū)分,統(tǒng)一使用“堆“來命名。

堆的常用操作見表 8-1 ,方法名需要根據(jù)編程語言來確定。

表 8-1   堆的操作效率

方法名 描述 時間復雜度
push() 元素入堆 O ( log ? n )
pop() 堆頂元素出堆 O ( log ? n )
peek() 訪問堆頂元素(大 / 小頂堆分別為最大 / 小值) O ( 1 )
size() 獲取堆的元素數(shù)量 O ( 1 )
isEmpty() 判斷堆是否為空 O ( 1 )

在實際應用中,我們可以直接使用編程語言提供的堆類(或優(yōu)先隊列類)。

Tip

類似于排序算法中的“從小到大排列”和“從大到小排列”,我們可以通過修改 Comparator 來實現(xiàn)“小頂堆”與“大頂堆”之間的轉(zhuǎn)換。

heap.cpp

/* 初始化堆 */
// 初始化小頂堆
priority_queue<int, vector<int>, greater<int>> minHeap;
// 初始化大頂堆
priority_queue<int, vector<int>, less<int>> maxHeap;

/* 元素入堆 */
maxHeap.push(1);
maxHeap.push(3);
maxHeap.push(2);
maxHeap.push(5);
maxHeap.push(4);

/* 獲取堆頂元素 */
int peek = maxHeap.top(); // 5

/* 堆頂元素出堆 */
// 出堆元素會形成一個從大到小的序列
maxHeap.pop(); // 5
maxHeap.pop(); // 4
maxHeap.pop(); // 3
maxHeap.pop(); // 2
maxHeap.pop(); // 1

/* 獲取堆大小 */
int size = maxHeap.size();

/* 判斷堆是否為空 */
bool isEmpty = maxHeap.empty();

/* 輸入列表并建堆 */
vector<int> input{1, 3, 2, 5, 4};
priority_queue<int, vector<int>, greater<int>> minHeap(input.begin(), input.end());

堆的實現(xiàn)

下文實現(xiàn)的是大頂堆。若要將其轉(zhuǎn)換為小頂堆,只需將所有大小邏輯判斷取逆(例如,將 ≥ 替換為 ≤ )。感興趣的讀者可以自行實現(xiàn)。

1.   堆的存儲與表示

我們在二叉樹章節(jié)中學習到,完全二叉樹非常適合用數(shù)組來表示。由于堆正是一種完全二叉樹,我們將采用數(shù)組來存儲堆。

當使用數(shù)組表示二叉樹時,元素代表節(jié)點值,索引代表節(jié)點在二叉樹中的位置。節(jié)點指針通過索引映射公式來實現(xiàn)。

如圖 8-2 所示,給定索引 i ,其左子節(jié)點索引為 2i+1 ,右子節(jié)點索引為 2i+2 ,父節(jié)點索引為 (i?1)/2(向下取整)。當索引越界時,表示空節(jié)點或節(jié)點不存在。

堆的表示與存儲

圖 8-2   堆的表示與存儲

我們可以將索引映射公式封裝成函數(shù),方便后續(xù)使用。

my_heap.cpp

/* 獲取左子節(jié)點索引 */
int left(int i) {
    return 2 * i + 1;
}

/* 獲取右子節(jié)點索引 */
int right(int i) {
    return 2 * i + 2;
}

/* 獲取父節(jié)點索引 */
int parent(int i) {
    return (i - 1) / 2; // 向下取整
}

2.   訪問堆頂元素

堆頂元素即為二叉樹的根節(jié)點,也就是列表的首個元素。

my_heap.cpp

/* 訪問堆頂元素 */
int peek() {
    return maxHeap[0];
}

3.   元素入堆

給定元素 val ,我們首先將其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立條件可能已被破壞。因此,需要修復從插入節(jié)點到根節(jié)點的路徑上的各個節(jié)點,這個操作被稱為「堆化 heapify」。

考慮從入堆節(jié)點開始,從底至頂執(zhí)行堆化。如圖 8-3 所示,我們比較插入節(jié)點與其父節(jié)點的值,如果插入節(jié)點更大,則將它們交換。然后繼續(xù)執(zhí)行此操作,從底至頂修復堆中的各個節(jié)點,直至越過根節(jié)點或遇到無須交換的節(jié)點時結(jié)束。

元素入堆步驟

heap_push_step2

heap_push_step3

heap_push_step4

heap_push_step5

heap_push_step6

heap_push_step7

heap_push_step8

heap_push_step9

圖 8-3   元素入堆步驟

設節(jié)點總數(shù)為 n ,則樹的高度為  O ( log ? n )  。由此可知,堆化操作的循環(huán)輪數(shù)最多為  O ( log ? n )  ,元素入堆操作的時間復雜度為 O(log?n) 。

my_heap.cpp

/* 元素入堆 */
void push(int val) {
    // 添加節(jié)點
    maxHeap.push_back(val);
    // 從底至頂堆化
    siftUp(size() - 1);
}

/* 從節(jié)點 i 開始,從底至頂堆化 */
void siftUp(int i) {
    while (true) {
        // 獲取節(jié)點 i 的父節(jié)點
        int p = parent(i);
        // 當“越過根節(jié)點”或“節(jié)點無須修復”時,結(jié)束堆化
        if (p < 0 || maxHeap[i] <= maxHeap[p])
            break;
        // 交換兩節(jié)點
        swap(maxHeap[i], maxHeap[p]);
        // 循環(huán)向上堆化
        i = p;
    }
}

4.   堆頂元素出堆

堆頂元素是二叉樹的根節(jié)點,即列表首元素。如果我們直接從列表中刪除首元素,那么二叉樹中所有節(jié)點的索引都會發(fā)生變化,這將使得后續(xù)使用堆化修復變得困難。為了盡量減少元素索引的變動,我們采用以下操作步驟。

  1. 交換堆頂元素與堆底元素(即交換根節(jié)點與最右葉節(jié)點)。
  2. 交換完成后,將堆底從列表中刪除(注意,由于已經(jīng)交換,實際上刪除的是原來的堆頂元素)。
  3. 從根節(jié)點開始,從頂至底執(zhí)行堆化。

如圖 8-4 所示,“從頂至底堆化”的操作方向與“從底至頂堆化”相反,我們將根節(jié)點的值與其兩個子節(jié)點的值進行比較,將最大的子節(jié)點與根節(jié)點交換。然后循環(huán)執(zhí)行此操作,直到越過葉節(jié)點或遇到無須交換的節(jié)點時結(jié)束。

堆頂元素出堆步驟

heap_pop_step2

heap_pop_step3

heap_pop_step4

heap_pop_step5

heap_pop_step6

heap_pop_step7

heap_pop_step8

heap_pop_step9

heap_pop_step10

圖 8-4   堆頂元素出堆步驟

與元素入堆操作相似,堆頂元素出堆操作的時間復雜度也為 O(log?n) 。

my_heap.cpp

/* 元素出堆 */
void pop() {
    // 判空處理
    if (isEmpty()) {
        throw out_of_range("堆為空");
    }
    // 交換根節(jié)點與最右葉節(jié)點(即交換首元素與尾元素)
    swap(maxHeap[0], maxHeap[size() - 1]);
    // 刪除節(jié)點
    maxHeap.pop_back();
    // 從頂至底堆化
    siftDown(0);
}

/* 從節(jié)點 i 開始,從頂至底堆化 */
void siftDown(int i) {
    while (true) {
        // 判斷節(jié)點 i, l, r 中值最大的節(jié)點,記為 ma
        int l = left(i), r = right(i), ma = i;
        // 若節(jié)點 i 最大或索引 l, r 越界,則無須繼續(xù)堆化,跳出
        if (l < size() && maxHeap[l] > maxHeap[ma])
            ma = l;
        if (r < size() && maxHeap[r] > maxHeap[ma])
            ma = r;
        // 若節(jié)點 i 最大或索引 l, r 越界,則無須繼續(xù)堆化,跳出
        if (ma == i)
            break;
        swap(maxHeap[i], maxHeap[ma]);
        // 循環(huán)向下堆化
        i = ma;
    }
}

堆常見應用

  • 優(yōu)先隊列:堆通常作為實現(xiàn)優(yōu)先隊列的首選數(shù)據(jù)結(jié)構(gòu),其入隊和出隊操作的時間復雜度均為 O(log?n) ,而建隊操作為 O(n) ,這些操作都非常高效。
  • 堆排序:給定一組數(shù)據(jù),我們可以用它們建立一個堆,然后不斷地執(zhí)行元素出堆操作,從而得到有序數(shù)據(jù)。然而,我們通常會使用一種更優(yōu)雅的方式實現(xiàn)堆排序,詳見后續(xù)的堆排序章節(jié)。
  • 獲取最大的 k 個元素:這是一個經(jīng)典的算法問題,同時也是一種典型應用,例如選擇熱度前 10 的新聞作為微博熱搜,選取銷量前 10 的商品等。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號