陣列手撕堆,你學會了嗎?

時間最考驗人發表於2021-11-17

一、堆的基本介紹

1.堆的概念:

如果有一個關鍵碼的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉樹的順序儲存方式儲存在一個一維陣列中,並滿足:Ki <= K2i+1 且 Ki<=K2i+2 ,則稱為小堆(或大堆)。

將根節點最大的堆叫做最大堆或大根堆,根節點最小的堆叫做最小堆或小根堆。

堆是一棵樹,其每個節點都有一個鍵值,且每個節點的鍵值都大於等於/小於等於其父親的鍵值。

每個節點的鍵值都大於等於其父親鍵值的堆叫做小根堆,否則叫做大根堆。STL 中的 priority_queue 其實就是一個大根堆。

2.堆的性質:

1.堆中某個節點的值總是不大於或不小於其父節點的值;

2.堆總是一棵完全二叉樹。

完全二叉樹:除最後一層節點以外,其它層的節點都是滿的,且最後一層從左到右依次排布的二叉樹就稱為完全二叉樹。

3.堆的兩種結構:

小根堆:堆頂元素最小

大根堆:堆頂元素最大

image

3.堆/完全二叉樹的儲存:

用一個一維陣列來維護

x的左孩子(位置):2i

x的右孩子(位置):2i + 1

注:下標從1開始,方便維護

二、堆的五個操作

1.插入一個數

插入一個數:在堆的最後一個位置新增x;不斷往上移(調整堆,使其滿足小根堆性質)

heap[++ sizes] = x;

up(size)

2.求堆的最小值

求堆的最小值:小根堆的第一個元素就是最小值

heap[1];

3.刪除最小值

刪除操作指刪除堆中最小的元素,即刪除根結點。

但是如果直接刪除,則變成了兩個堆,難以處理。

所以不妨考慮插入操作的逆過程,設法將根結點移到最後一個結點,然後直接刪掉。

然而實際上不好做,我們通常採用的方法是,把根結點和最後一個結點直接交換。

於是直接刪掉(在最後一個結點處的)根結點(size --),但是新的根結點可能不滿足堆性質……

向下調整:在該結點的兒子中,找一個最小的,與該結點交換,重複此過程直到底層。

可以證明,刪除並向下調整後,沒有其他結點不滿足堆性質。

時間複雜度 O(logn)。

// 用最後一個點覆蓋第一個點;size --;down(1)

heap[1] = heap[size]; // 用最後一個點覆蓋第一個點 (**陣列模擬我們直接覆蓋就行**)
size --; // 將最後一個點從陣列中刪去
down(1); // 讓1號點往下走(調整堆使滿足小根堆性質)

4.刪除任意一個元素

刪除任意一個元素與刪除最小值(根節點很相像)

刪除第k的元素,我們將最後一個元素將他覆蓋後,無非就這三種情況:不變,比他大,比他小,因此就是不同的維護方式(但只會選擇一種方式去維護),不管37 21 down(k), up(k);都執行就可以了,down(k), up(k)只會執行一個。

// 刪除第k個元素
heap[k] = heap[size];
size --;
down(k), up(k); //只會執行一個

5.修改任意元素

當我們將第k個元素修改之後,就要維護堆(同上述操作4)

heap[k] = x;
down(k), up(k); //只會執行一個

三、堆的調整/維護:

上述的五個操作都可以通過down(i)和up(i)操作調整維護堆來實現!

調整是對是位置(下標)進行調整,下標對應著陣列中的值!

1.向下調整:down(i)

down(i):當某個節點的值大了,就將它往下調整

image

down()操作其實是一個遞迴的過程,從根節點開始,如果當前這個點比某一個子節點要大的話,就將它們交換,換完之後遞迴處理即可!

void down(int i)
{
    int t = i;
    if(2 * i <= sizes && heap[2 * i] < heap[t]) t = 2 * i; // 如果左孩子存在,且小於根節點
    if(2 * i + 1 <= sizes && heap[2 * i + 1] < heap[t]) t = 2 * i + 1;// 如果右孩子存在,且小於根節點
    if(t != i) // heap[t] 是最小值
    {
        swap(heap[i], heap[t]);
        down(t); // 交換之後繼續維護調整堆
    }
}

1.1.初建堆

我們如何將一個無序序列(陣列)調整成堆(小根堆)呢?
$$
要將一個無序序列(陣列)調整成堆,就必須將其所對應的完全二叉樹中以每一個節點為根的子樹都調整成堆。顯然,只有一個節點的樹必是堆,而在完全二叉樹中,所有序號大於\lfloor n/2 \rfloor的節點都是葉子節點,因此以這些節點為根的子樹已經是堆了。這樣通過down()操作來維護堆,從最後一個分支節點\lfloor n/2 \rfloor開始,依次將序號為\lfloor n/2 \rfloor、\lfloor n/2 \rfloor-1...、1的節點作為根的子樹都調整為堆即可!
$$

    // 將陣列建成堆:從n/2 down()到1(調整到1)
    for (int i = n/2; i >= 1 ; i --) down(i);

1.2.堆排序

康康這個例題:

輸入一個長度為 n 的整數數列,從小到大輸出前 m 小的數。

輸入格式

第一行包含整數 n 和 m。

第二行包含 n 個整數,表示整數數列。

輸出格式

共一行,包含 m 個整數,表示整數數列中前 m 小的數。

資料範圍

51≤m≤n≤105,
1≤數列中元素≤109

輸入樣例:

5 3
4 5 1 3 2

輸出樣例:

1 2 3

思路:

構造一個小根堆,每次將堆頂元素輸出,然後刪去堆頂元素,維護小根堆down()操作(得到第二小的堆頂元素).....

【參考程式碼】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int heap[N], sizes;
// down()操作其實是一個**遞迴**的過程,從該根節點開始,
// 如果當前這個點比某一個子節點要大的話,就將它們交換,換完之後遞迴處理即可!
void down(int i)
{
    int t = i;
    if(2 * i <= sizes && heap[2 * i] < heap[t]) t = 2 * i; // 如果左孩子存在,且小於根節點
    if(2 * i + 1 <= sizes && heap[2 * i + 1] < heap[t]) t = 2 * i + 1;// 如果右孩子存在,且小於根節點
    if(t != i) // heap[t] 是最小值
    {
        swap(heap[i], heap[t]);
        down(t); // 交換之後繼續維護調整堆
    }
}

int main()
{
    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++ ) scanf("%d", &heap[i]);
    sizes = n;
    
    // 將陣列建成堆:從n/2 down()到1(調整到1)
    for (int i = n/2; i >= 1 ; i --) down(i);
    
    while (m -- )
    {
        printf("%d ", heap[1]); // 每次輸出堆頂元素(最小值)
        // 輸出堆頂元素(最小值)後,將其刪去,維護堆,拿到下一個min
        heap[1] = heap[sizes];
        sizes --;
        down(1);
        
    }
    
    return 0;
}

2.向上調整:up(i)

up(x):當某個節點的值小了,就將它往上調整

image

具體程式碼:(遞迴小根堆)

    void up(int u)
    {
        int t=u;                                //up中的t儲存的是父結點
        if(u/2 && h[u/2]>h[t]) t=u/2;           //up操作中只需要判斷up兒子與根的大小就可

        if(t!=u)                                //遞迴操作
        {
            h_swap(t,u);
            up(t);
        }
    }

具體程式碼:(y總迴圈)

    void up(int u)
    {
        while(u/2 && h[u/2]>h[u])
        {
            h_swap(u/2,u);
            u/=2;
        }
    }

四、堆完整模板

// h[N]儲存堆中的值, h[1]是堆頂,x的左兒子是2x, 右兒子是2x + 1
// ph[k]儲存第k個插入的點在堆中的位置
// hp[k]儲存堆中下標是k的點是第幾個插入的
int h[N], ph[N], hp[N], size;

// 交換兩個點,及其對映關係
void heap_swap(int a, int b)
{
    swap(ph[hp[a]],ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

void down(int u)
{
    int t = u;
    if (u * 2 <= size && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t)
    {
        heap_swap(u, t);
        down(t);
    }
}

void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }
}

// O(n)建堆
for (int i = n / 2; i; i -- ) down(i);


陣列模擬堆

維護一個集合,初始時集合為空,支援如下幾種操作:

  1. I x,插入一個數 xx;
  2. PM,輸出當前集合中的最小值;
  3. DM,刪除當前集合中的最小值(資料保證此時的最小值唯一);
  4. D k,刪除第 kk 個插入的數;
  5. C k x,修改第 k 個插入的數,將其變為 x;

現在要進行 N 次操作,對於所有第 2 個操作,輸出當前集合的最小值。

輸入格式

第一行包含整數 NN。

接下來 NN 行,每行包含一個操作指令,操作指令為 I xPMDMD kC k x 中的一種。

輸出格式

對於每個輸出指令 PM,輸出一個結果,表示當前集合中的最小值。

每個結果佔一行。

資料範圍

1≤N≤105
−109≤x≤109
資料保證合法。

輸入樣例:

8
I -10
PM
I -10
D 1
C 2 8
I 6
PM
DM

輸出樣例:

-10
6

指標模擬指向的堆不常用,但在迪傑斯特拉演算法裡面需要用到堆,平常遇到的堆沒那麼複雜,要麼就是用優先佇列來操作。

查詢元素時需要知道它在當前堆中的位置在哪裡,才好進行 down 或 up,所以需要hp陣列(第幾個插入的數字 對應 目前堆裡的位置),但是單純swap交換數字,不會改變位置指標,所以需要ph陣列來用作位置指標(目前堆裡的位置 對應 第幾個插入的數字)

    /* 
     * p: pointer, h: heap
     * ph[]: 代表位置到堆的對映 比如:ph[k]:代表第k次插入的數 在堆的什麼位置(下標)
     * hp[]: 代表堆到位置的對映 比如:hp[j]:代表在堆的下標為j的元素 是第哪一次插入的
     * 對映關係: 若hp[j] = k,則ph[k] = j。 
     *
     * 為什麼要有兩個對映?因為,當刪除或修改第k個插入的數,可能會發生交換,
     *    交換的時候通過下標(hp)來找到對應的是哪一次
     *    插入的數,從而維持兩者的ph指向的下標(通過ph找到第k個插入元素的位置)
     */

image

image

【參考程式碼】

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;
int heap[N];      //堆
int ph[N];     //存放第k個插入點的下標
int hp[N];     //存放堆中點的插入次序
int sizes;  // sizes 記錄的是堆當前的資料多少

void heap_swap(int a, int b)
{
    swap(ph[hp[a]], ph[hp[b]]); // 交換左指向右的指標
    swap(hp[a], hp[b]); // 交換右指向左的指標
    swap(heap[a], heap[b]); // 交換值
}

void down(int i)
{
    int t = i;
    if(2 * i <= sizes && heap[2 * i] < heap[t]) t = 2 * i;
    if(2 * i + 1 <= sizes && heap[2 * i +1] < heap[t]) t = 2 * i + 1;
    if(t != i)
    {
        heap_swap(i, t);
        down(t);
    }
}

void up(int i)
{
    int t = i;
    if(i / 2 && heap[i / 2] > heap[t]) t = i / 2;
    if(t != i)
    {
        heap_swap(t, i);
        up(t);
    }
}
int main()
{
    
    int n, m = 0; // m表示第幾個插入的數
    cin >> n;
    while (n -- )
    {
        int k, x;
        string opt;
        cin >> opt;
        if(opt == "I") // 插入一個數x:在堆的末尾位置插入
        {
            cin >> x;
            sizes ++;
            m ++;
            ph[m] = sizes, hp[sizes] = m;
            heap[sizes] = x;
            up(sizes);
        }
        else if(opt == "PM") cout << heap[1] << endl;
        else if(opt == "DM")
        {
            heap_swap(1, sizes);
            sizes --;
            down(1);
            
        }
        
        else if(opt == "D") // 刪除第k個插入的數
        {
            cin >> k;
             // 將第k次插入的元素,轉換為堆中的下標
            k = ph[k]; // 刪除第k個插入的數,就先要找到第k個插入的數的下標(位置)
            heap_swap(k, sizes);
            sizes --;
            down(k);
            up(k);
        }
        else if(opt == "C")// 修改第k個插入的數
        {
            cin >> k >> x;
            k = ph[k]; // // 修改第k個插入的數,就先要找到第k個插入的數的下標(位置)
            heap[k] = x;
            down(k);
            up(k);
        }
        
        
    }
    
    return 0;
}

部分內容參考學習:

1、二叉堆 - OI Wiki (oi-wiki.org)

2、acwing演算法基礎課

五、總結

陣列模擬堆簡單的操作還是比較容易理解的,但當涉及到修改和刪除操作時那兩個指標陣列對應的對映關係就比較繞(也不常用到),多畫圖模擬過程,就比較好理解一些!

注:如果文章有任何錯誤或不足,請各位大佬盡情指出,評論留言留下您寶貴的建議!如果這篇文章對你有些許幫助,希望可愛親切的您點個贊推薦一手,非常感謝啦

相關文章