【DSA】堆-堆詳解(以最大堆爲例)

【定義】
堆(Heap)是計算機科學中一類特殊的數據結構的統稱。堆通常是一個可以被看做一棵完全二叉樹的數組對象。

【注意】

  • 這裏講的堆是一種數據結構,不是內存模型中堆的概念。
  • 這裏的堆是一種邏輯結構。

【性質】

  • 堆中任意節點的值總是不大於(不小於)其子節點的值;
  • 堆總是一棵完全樹。

【說明】

  • 將根節點最大的堆叫做最大堆大根堆,根節點最小的堆叫做最小堆小根堆。常見的堆有二叉堆、斐波那契堆等。
  • 堆是非線性數據結構,相當於一維數組,有兩個直接後繼。

二叉堆

二叉堆是完全二叉樹或者是近似完全二叉樹,它分爲兩種:最大堆和最小堆。

完全二叉樹:若設二叉樹的深度爲h,除第 h 層外,其它各層 (1~h-1) 的結點數都達到最大個數,第 h 層所有的結點都連續集中在最左邊,這就是完全二叉樹。如下圖所示都是完全二叉樹。
在這裏插入圖片描述
最大堆:父結點的鍵值總是大於或等於任何一個子節點的鍵值;
最小堆:父結點的鍵值總是小於或等於任何一個子節點的鍵值。
示意圖如下:在這裏插入圖片描述

二叉堆實現

二叉堆一般都通過"數組"來實現。數組實現的二叉堆,父節點和子節點的位置存在一定的關係。有時候,我們將"二叉堆的第一個元素"放在數組索引0的位置,有時候放在1的位置。當然,它們的本質一樣(都是二叉堆),只是實現上稍微有一丁點區別。

【注意】本文二叉堆的實現統統都是採用"二叉堆第一個元素在數組索引爲0"的方式!

上圖大根堆就有兩種實現方式:

  1. 第一個元素放在 索引 0 的位置
    此時,數組下表與節點的關係如下:
  • 索引爲 i 的左孩子的數組下標是 (2*i+1)
  • 索引爲 i 的右孩子的數組下標是 (2*i+2)
  • 索引爲 i 的父節點的數組下標是 ((i-1)/2)
    直觀理解:
    當數組下標爲0時,父節點就是a[0],左孩子是a[1],右孩子是a[2]
    當數組下標爲1時,父節點就是a[0],左孩子是a[3],右孩子是a[4]
    當數組下標爲2時,父節點就是a[0],左孩子是a[5],右孩子是a[6]
    在這裏插入圖片描述
  1. 第一個元素放在 索引 1 的位置
  • 索引爲 i 的左孩子的數組下標是 (2*i)
  • 索引爲 i 的右孩子的數組下標是 (2*i+1)
  • 索引爲 i 的父節點的數組下標是 (2/2)
    這裏不再贅述。
    在這裏插入圖片描述

二叉堆的操作

二叉堆操作的方法的核心是【添加節點】、【刪除節點】。以下示例均已大根堆爲例。

添加節點示意圖

插入節點85
在這裏插入圖片描述
第一步:
將 新節點 插入數組的末尾。
在這裏插入圖片描述
第二步:
比較新插入的節點和父節點的大小,這裏 85 > 40, 則與父節點交換位置
在這裏插入圖片描述
第三步:
重複上述比較步驟。
在這裏插入圖片描述
移動這步發現,85小於100,則停止移動。

刪除節點示意圖

以刪除根節點爲例
第一步:清除根節點的數據
在這裏插入圖片描述
第二步:將最末位的節點移到根節點
在這裏插入圖片描述
第三步:與兩個子節點比較,選取較大的子節點與之交換
在這裏插入圖片描述
第四步:重複第三步
在這裏插入圖片描述

注意:如果刪除的不是根節點,需要注意,刪除完之後,還要保證替換後的樹要是大根堆,並且是完全二叉樹。

實現代碼

#include <stdio.h>
#include <stdlib.h>

#define ARRAY_LEN(arr) ((sizeof(arr))/sizeof(arr[0]))

#define MAX_NUM (128)

typedef int Type;

static Type heap_arr[MAX_NUM];
static int  heap_size = 0; // 堆數組的大小

/**
 * 根據數據 data 從對中獲取對應的索引
 * @param  data [description]
 * @return      [description]
 */
int get_data_index_from_heap(int data)
{
    for (int i = 0; i < heap_size; ++i)
    {
        if (data == heap_arr[i])
        {
            return i;
        }
    }

    return -1;
}

/**
 * 在數組實現的堆中,向下調整元素的位置,使之符合大根堆
 * 注:   
 *     在數組試下你的堆中,第 i 個節點的
 *     左孩子的下標是 2*i+1, 
 *     右孩子的下標是 2*i+2,
 *     父節點的下標是 (i-1)/2
 *     
 * @param  start [一般從刪除元素的位置開始]
 * @param  end   [數組的最後一個索引]
 */
static void max_heap_fixup_down(int start, int end)
{
    int curr_node_pos = start;
    int left_child = 2*start+1;
    int curr_node_data = heap_arr[curr_node_pos];



    while(left_child <= end) 
    {

        // left_child 是左孩子, left_child+1是同一個父節點下的右孩子
        if (left_child < end && heap_arr[left_child] < heap_arr[left_child+1])
        {
            // 從被刪除的節點的左右孩子中選取較大的,賦值給父節點
            left_child++;
        }
        if (curr_node_data >= heap_arr[left_child])
        {
            // 選出孩子節點的較大者之後,與當前節點比較
            break;
        }
        else
        {
            heap_arr[curr_node_pos] = heap_arr[left_child];
            curr_node_pos = left_child;
            left_child = 2*left_child+1;
        }
    }

    heap_arr[curr_node_pos] = heap_arr[left_child];
}

/**
 * 刪除對中的數據 data
 * @param data [description]
 */
static int max_heap_delete(int data)
{
    if (heap_size == 0)
    {
        printf("堆已空!\n");
        return -1;
    }
    int index = get_data_index_from_heap(data);
    if (index < 0)
    {
        printf("刪除失敗, 數據 [%d] 不存在!\n", data);
        return -1;
    }

    // 刪除index的元素,使用最後的元素將其替換
    heap_arr[index] = heap_arr[--heap_size];

    // 刪除元素之後,調整堆
    max_heap_fixup_down(index, heap_size-1);
}

/**
 * 在數組實現的堆中,將元素向上調整
 * 注:   
 *     在數組試下你的堆中,第 i 個節點的
 *     左孩子的下標是 2*i+1, 
 *     右孩子的下標是 2*i+2,
 *     父節點的下標是 (i-1)/2
 *     
 * @param  start [從數組的最後一個元素開始,start是最後一個元素的下標]
 */
static void max_heap_fixup_up(int start)
{
    int curr_node_pos = start;
    int parent = (start-1)/2;
    int curr_node_data = heap_arr[curr_node_pos];

    // 從最後一個元素開始比價,知道第0個元素
    while(curr_node_pos > 0) 
    {   
        // 當前節點的數據小於父節點,退出
        if (curr_node_data <= heap_arr[parent])
        {
            break;
        }
        else
        {
            // 交換父節點和當前節點
            heap_arr[curr_node_pos] = heap_arr[parent];
            heap_arr[parent] = curr_node_data;

            curr_node_pos = parent;
            parent = (parent-1)/2;
        }
    }
}

/**
 * 將新數據插入到二叉堆中
 * @param  data [插入數據]
 * @return      [成功返回0, 失敗返回-1]
 */
int max_heap_insert(Type data)
{
    if (heap_size == MAX_NUM)
    {
        printf("堆已經滿了!\n");
        return -1;
    }

    heap_arr[heap_size] = data;
    // 調整堆 
    max_heap_fixup_up(heap_size);
    heap_size++; // 對的數量自增

    return 0;
}

/**
 * 打印二叉堆
 */
void max_heap_print()
{
    for (int i = 0; i < heap_size; ++i)
    {
        printf("%d ", heap_arr[i]);
    }
}

int main(int argc, char const *argv[])
{
    Type tmp[] = {10, 40, 30, 60, 90, 70, 20, 50, 80};
    int len = ARRAY_LEN(tmp);

    printf("---> 添加元素:\n");
    for (int i = 0; i < len; ++i)
    {
        printf("%d ", tmp[i]);
        max_heap_insert(tmp[i]);
    }   

    printf("\n---> 最大堆: ");
    max_heap_print();

    max_heap_insert(85);
    printf("\n---> 插入元素之後 最大堆: ");
    max_heap_print();


    max_heap_delete(90);
    printf("\n---> 刪除元素之後 最大堆: ");
    max_heap_print();
    printf("\n");

    return 0;
}

堆的應用場景

堆排序

分兩個過程:建堆和排序,建堆的過程就是堆插入元素的過程,我們可以對初始數組原地建堆,然後再依次輸出堆頂元素即可達到排序的目的。建堆的時間複雜度爲 O(n),排序過程的時間複雜度爲 O(nlogn),堆排序不是穩定的排序算法,因爲在排序的過程中存在將堆的最後一個元素跟堆頂元素交換的操作,可能改變原始相對順序。

堆常用來實現優先隊列。

在隊列中,操作系統調度程序反覆提取隊列中第一個作業並運行,因爲實際情況中某些時間較短的任務將等待很長時間才能結束,或者某些不短小,但具有重要性的作業,同樣應當具有優先權。堆即爲解決此類問題設計的一種數據結構。
- 合併有序小文件

假如有 100 個小文件,每個小文件都爲 100 MB,每個小文件中存儲的都是有序的字符串,現在要求合併成一個有序的大文件,那麼如何做呢?

直觀的做法是分別取每個小文件的第一行放入數組,再比較大小,依次插入到大文件中,假如最小的行來自於文件 a,那麼插入到大文件中後,從數組中刪除該行,再取文件 a 的下一行插入到數組中,再次比較大小,取出最小的插入到大文件的第二行,依次類推,整個過程很像歸併排序的合併函數。每次插入到大文件中都要循環遍歷整個數組,這顯然是低效的。

而藉助於堆這種優先級隊列就很高效。比如我們可以分別取 100 個文件的第一行建一個小頂堆,假如堆頂元素來自於文件 a,那麼取出堆頂元素插入到大文件中,並從堆頂刪除該元素(就是堆實現中 removeMax 函數), 然後再從文件 a 中取下一行插入到堆頂中,重複以上過程就可以完成合並有序小文件的操作。

刪除堆頂數據和往堆中插入數據的時間複雜度都是 O(logn),n 表示堆中的數據個數,這裏就是 100。

- 高性能定時器

假如有很多定時任務,如何設計一個高性能的定時器來執行這些定時任務呢?假如每過一個很小的單位時間(比如 1 秒),就掃描一遍任務,看是否有任務到達設定的執行時間。如果到達了,就拿出來執行。這顯然是浪費資源的,因爲這些任務的時間間隔可能長達數小時。

藉助於堆這種優先級隊列我們這可以這樣設計:將定時任務按時間先後的順序建一個小頂堆,先取出堆頂任務,查詢其執行時間與當前時間之差,假如爲 T 秒,那麼在 T - 1 秒的時間內,定時器什麼也不需要做,當 T 秒間隔達到時就取出任務執行,對應的從堆頂刪除堆頂元素,然後再取下一個堆頂元素,查詢其執行時間。

這樣,定時器既不用間隔 1 秒就輪詢一次,也不用遍歷整個任務列表,性能也就提高了。

- topK 問題

取 top k 元素的情形可分爲兩類,一類是靜態數據集合,也就是說數據確定後不再增加新的元素,另一類是動態數據集合,會隨時增加元素,但依然求第 k 大元素。

對於靜態數據,我們可以先從靜態數據依次插入小頂堆中,維護一個大小爲 k 的小頂堆,遍歷其餘數據,依次插入到大小爲 k 的小頂堆中,如果元素比 k 小,則不做處理,繼續遍歷下一個數據,如果比 k 大,則刪除堆頂堆,並將該值插入到堆頂中,這樣遍歷結束時,堆頂元素就是第 k 大元素。

遍歷數組需要 O(n) 的時間複雜度,一次堆化操作需要 O(logK) 的時間複雜度,所以最壞情況下,n 個元素都入堆一次,所以時間複雜度就是 O(nlogK)。

對於動態數據,處理方法也是一樣的,相當於實時求 top k,那麼每次求 top k 時重新計算一下即可,時間複雜度仍是 O(nlogK),n 表示當前的數據的大小。我們可以一直都維護一個 K 大小的小頂堆,當有數據被添加到集合中時,我們就拿它與堆頂的元素對比。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,則不做處理。這樣,無論任何時候需要查詢當前的前 K 大數據,我們都可以裏立刻返回給他。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章