優先隊列及(二叉)堆

  數據結構書籍與算法書(包括算法導論算法設計)一般將優先隊列(Priority Queue)與堆(Heap)放在一塊兒講,算法導論上先講堆這個特殊的數據結構,後講堆的兩個應用,堆排序與優先隊列。算法設計這本書先講優先隊列是個什麼樣的數據結構,有什麼性質,爲何須要優先隊列這種數據結構,而後講實現優先隊列有什麼樣的要求,而這些要求數組(Array)和鏈表(Linked List)都不能知足,因此咱們須要設計一種新的數據結構來知足這些要求,那就是堆。我的更喜歡算法設計書上這種順序。html

  某些特定的算法,只須要數據的一部分信息,而不須要所有的信息,這個時候爲了提高算法的效率,可能須要設計某個特定的數據結構,這個特定的數據結構只保留了該算法須要的那部分信息,而捨棄了其他的信息,捨棄這部分信息換來了效率上的提高,這正是咱們所須要的。舉個例子直觀一點,數組這種數據結構,你能夠知道數組中每一個元素(element)的值,這至關於知道全部信息,而堆這種數據結構,譬如最小堆,你只知道堆頂的元素是多少,而堆中其它的元素你是不知道的,至關於你只知道部分信息。而若是某個算法,你只關心一批數據中的最小值,而不關心具體每一個數據的值,那最小堆就正能知足你的需求。效率方面而言,對於大小爲n的數組,求最小值須要遍歷整個數組,時間爲\(\mathcal{O}(n)\),而最小堆的堆頂元素即堆中數據的最小值,只須要\(\mathcal{O}(1)\)時間。ios

  在【待填坑】穩定匹配問題中,須要維護一個集合S,對集合S的操做包括:插入元素、刪除元素,訪問最高優先級(優先級本身定義)的元素,而優先隊列正是爲此設計的。c++

優先隊列

定義

優先隊列是一種數據結構,其維護一個集合S,每個元素\(v\in S\)都有對應的鍵值\(key(v)\)表示該元素的優先級,小鍵值對應高優先級。優先隊列支持插入元素、刪除元素、訪問最小鍵值元素\(^{[1]}\)算法

  優先隊列的一個典型應用是簡化的計算機進程調度(process scheduling)問題,每個進程有一個優先級。每一個進程的產生不是按照優先級順序,咱們維護一個進程的集合,每次咱們在集合中選取一個最高優先級的進程去運行,同時從集合中刪除該進程,另外咱們還會往這個集合增長新的進程,這些正對應着優先隊列的功能。api

指望複雜度

  那麼咱們指望大小爲n的優先隊列的時間複雜度達到多少呢?
  咱們知道基於比較的排序算法的時間複雜度的下界爲\(\mathcal{O}(n\log n)\),從這個下界出發,咱們能夠得出優先隊列每次插入元素、刪除元素、訪問最小鍵值元素的指望時間複雜度。設想咱們有一個大小爲n的數組,咱們依次將每一個數組元素都加入到優先隊列中,而後再將優先隊列的元素依次都取出來,那麼取出來元素就已經有順序了,咱們實現了對一個數組的排序。以上操做共有n次插入、n次取出、n次刪除操做,那麼可知,優先隊列的這些基本操做的時間複雜度的(大概)下界應該是\(\mathcal{O}(\log n)\)。但實際狀況中,因爲優先隊列的實現方法不同,基本操做的時間複雜度下界也不一樣\(^{[3]}\),可是對於數組排序這個問題而言,採用優先隊列的方法進行排序(實際上就是堆排序)的時間複雜度下界是\(\mathcal{O}(n\log n)\)數組

數組和鏈表的侷限

  對於數組或者鏈表而言,基本操做可否達到\(\mathcal{O}(\log n)\)
  答案是否認的。以進程調度問題舉例,假如咱們按照優先級順序把進程放在不一樣的位置,那麼訪問操做和刪除操做的時間均可以是\(\mathcal{O}(1)\)。但插入操做就不符合要求了,對於數組而言,找到要插入的位置可經過二分查找達到\(\mathcal{O}(\log n)\)的時間,但插入元素的時間是\(\mathcal{O}(n)\),而對於鏈表而言,插入元素的時間是\(\mathcal{O}(1)\),但咱們要找到插入的位置須要\(\mathcal{O}(n)\)的時間。綜上,數組和鏈表都不符合咱們的要求,須要設計新的數據結構——堆。數據結構

定義

  堆有不少種類型,二叉堆、二項堆、斐波那契堆等,在這裏講的是二叉堆。二叉堆能夠看做是平衡二叉樹或近似的徹底二叉樹,平衡二叉樹中任意一個節點的左右子樹的深度之差不超過1,徹底二叉樹的葉節點的深度相同,內部節點的度(degree,孩子節點的數量)相同。
ide


徹底二叉樹

圖1 徹底二叉樹

下圖是一個堆的示意圖,同時也是一個平衡二叉樹,能夠看出,堆之因此叫作近似的徹底二叉樹是由於不是全部內部節點的度都相同。

堆(平衡二叉樹)

圖2 堆(平衡二叉樹)

  堆有一個性質,稱做 heap order, 對於最小堆而言,即樹中任意一個節點的鍵值key要大於等於其父節點的鍵值key,最大堆反之。圖2表示的是最小堆。
  一般採用數組來存儲堆,圖2所示的最小堆能夠存儲以下圖3所示:

堆的數組表示

圖3 堆的數組表示

其中,數組A下標從1到N,N爲堆的大小,A[1]是根節點,A[2]是根節點的左子孩節點,A[3]是根節點的右子孩節點。實際上,對於任何一個節點,若其在數組中的位置是i,則它的左子孩節點位置 \(left\_child(i)=2i\),右子孩節點位置 \(right\_child=2i+1\),它的父節點(假若有)的位置 \(parent(i)=\lfloor i \rfloor\)\(\lfloor i \rfloor\)表示對i向下取整。圖3中的箭頭從父節點分別指向左右子孩節點。

用堆實現優先隊列

基本操做

  咱們回顧優先隊列的基本操做,並看看用數組表示的最小堆怎麼實現這些操做。函數

  • 訪問優先級最高(鍵值key最小)的元素

  由堆的heap order性質能夠知道,A[1]便是鍵值最小的元素,因此只須要返回A[1]的值便可。優化

  • 插入元素

  咱們維護一個變量\(length\)表示堆的大小,每次往堆裏添加元素的時候,將\(length\)加1,而後將元素的值賦給數組A中\(length\)位置。

  • 刪除元素

  優先隊列的許多應用一般只會在訪問優先級最高的元素後刪除該元素。對於數組A而言,只須要把A[1]刪除便可,具體實現時,咱們將A[length]賦值給A[1],而後length減一。

  咱們須要注意一點,插入元素刪除元素會改變數組的值,而改變以後該數組是否還能表示一個堆呢?答案是不必定,由於數組值改變後不必定符合heap order,因此咱們須要作一些操做,來維護堆的heap order性質。

維護堆的性質

  以維護最大堆的heap order性質爲例,插入元素後,A[length]的值有可能大於A[parent(length)]的值,因此須要將A[length]的值調整到合適的位置。須要heap_increase_key來實現插入操做,僞代碼\(^{[2]}\)以下:

heap_increase_key(A, i, key){
    A[i] = key
    while(i > 1 && A[parent(i)] < A[i]) {
        exchange A[i] and A[parent(i)]
        i = parent(i)
    }
}

  簡單來講,就是若A[i]的值大於其父節點的值,則交換兩者,直到A[i]的值小於等於父節點的值或已到達根節點。圖4是heap_increase_key(A, 9, 15)的示意圖:


heap_increase_key(A, 9, 15)

圖4 heap_increase_key(A, 9, 15)

  相似地,刪除元素後,A[length]的值賦值給A[1],而此時A[1]可能小於A[left_child(1)]或A[right_child(1)],因此須要將A[1]的值調整到合適的位置。採用max_heapify函數來實現刪除操做,輸入數組A和下標i,咱們假設A[i]是惟一違反堆性質的值,調用max_heapify(A, i)使得A[i]的值在最大堆中「逐級降低」,從而維護堆的性質。僞代碼 \(^{[2]}\)以下:

max_heapify(A, i){
    l = left_child(i)
    r = right_child(i)
    largest = i
    if(l <= length && A[l] > A[i])
        largest = l
    if(r <= length && A[r] > A[largest])
        largest = r
    if(largest != i){
        exchange A[i] and A[largest]
        max_heapify(A, largest);
    }
}

  簡單來講,就是在i節點及左右子孩節點中,選出鍵值最大的節點largest,若largest不是i,則交換A[i]和A[largest]的值,此時這三個節點是符合堆性質的,但A[largest]可能違反堆性質,因此咱們遞歸調用max_heapify(A, largest)函數。圖5是max_heapify(A, 2)的示意圖。


max_heapify(A, 2)

圖5 max_heapify(A, 2)

  heap_increase_key和max_heapify都是沿着樹的路徑走,最壞狀況下從葉節點走到根節點(max_heapify從根節點走到葉節點),則時間複雜度爲 \(\mathcal{O}(\log n)\)

  相似heap_increase_key和max_heapify,不可貴到heap_decrese_key和min_heapify,從而咱們能夠將優先隊列的插入元素和刪除元素操做完善以下:

  • 插入元素

  咱們維護一個變量\(length\)表示堆的大小,每次往堆裏添加元素的時候,將\(length\)加1,而後將INT_MIN賦給A[length],而後調用1次heap_decrease_key(A, length, key)。

  • 刪除元素

  優先隊列的許多應用一般只會在訪問優先級最高的元素後刪除該元素。對於數組A而言,只須要把A[1]刪除便可,具體實現時,咱們將A[length]賦值給A[1],而後length減一。而後調用1次min_heapify(A, 1)。

優先隊列基本操做的時間複雜度

操做 時間複雜度
插入元素 \(\mathcal{O}(\log n)\)
刪除元素 \(\mathcal{O}(\log n)\)
訪問優先級最高的元素 \(\mathcal{O}(1)\)

  

具體實現

  根據上面的基本操做,給出基於最小堆的優先隊列的僞代碼以下:

  • 訪問最小鍵值元素
heap_minimum(A){
    return A[1]
}
  • 插入操做
min_heap_insert(key){
    length = length + 1
    A[length] = INT_MAX
    heap_decrease_key(length, key)
}
  • 訪問鍵值最大元素後刪除該元素
heap_extract_min(A){
    min = A[1]
    A[1] = A[length]
    length = length - 1
    min_heapify(A, 1)
    return min
}

例子

算法課的練習題:Dynamic Median

  防止連接失效截一張圖放這:


Dynamic Median

圖6 Dynamic Median

算法思路

  分別實現一個最大堆、一個最小堆,最大堆中的全部元素小於等於最小堆中的任何元素。最大堆最小堆的大小相差不超過1,當最小堆的大小比最大堆的大小大1時,中位數爲最小堆的堆頂元素,其他狀況,中位數均爲最大堆的堆頂元素(與題目要求一致)。插入新元素時,若元素值大於中位數則插入到最小堆,反之,插入到最大堆,同時應保持兩個堆的大小相差不超過1。

本身編寫堆實現

  代碼比較長,由於爲了與上文中僞代碼的函數名對應,分開實現最大堆最小堆。

Result: 23596kB, 1084ms.

#include <stdio.h>
#include <math.h>
#include <limits.h>
#include <algorithm>
#include <iostream>

#define parent(i) (int)std::floor(i/2)
#define left(i) i * 2
#define right(i) i * 2 + 1

int A[5005], B[5005];//分別存儲最大堆、最小堆
int max_heap_size, min_heap_size;

void exchange(int* array, int i, int j) {
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}

//最大堆
void heap_increase_key(int i, int key) {
    if (key < A[i])
        printf("error: new key is smaller than current key.");
    A[i] = key;
    while (i > 1 && A[parent(i)] < A[i])
    {
        exchange(A, i, parent(i));
        i = parent(i);
    }
}

void max_heap_insert(int key) {
    max_heap_size++;
    A[max_heap_size] = INT_MIN;
    heap_increase_key(max_heap_size, key);
}

int heap_maximum(void) {
    return A[1];
}

void max_heapify(int i) {
    int l = left(i), r = right(i);
    int largest = i;
    if (l <= max_heap_size && A[l] > A[i])
        largest = l;
    if (r <= max_heap_size && A[r] > A[largest])
        largest = r;
    if (largest != i) {
        exchange(A, i, largest);
        max_heapify(largest);
    }
}

int heap_extract_max(void) {
    int max = A[1];
    A[1] = A[max_heap_size];
    max_heap_size--;
    max_heapify(1);
    return max;
}

//最小堆
void heap_decrease_key(int i, int key) {
    if (key > B[i])
        printf("error: new key is bigger than current key.");
    B[i] = key;
    while (i > 1 && B[parent(i)] > B[i])
    {
        exchange(B, i, parent(i));
        i = parent(i);
    }
}

void min_heap_insert(int key) {
    min_heap_size++;
    B[min_heap_size] = INT_MAX;
    heap_decrease_key(min_heap_size, key);
}

int heap_minimum(void) {
    return B[1];
}

void min_heapify(int i) {
    int l = left(i), r = right(i);
    int smallest = i;
    if (l <= min_heap_size && B[l] < B[i])
        smallest = l;
    if (r <= min_heap_size && B[r] < B[smallest])
        smallest = r;
    if (smallest != i) {
        exchange(B, i, smallest);
        min_heapify(smallest);
    }
}

int heap_extract_min(void) {
    int min = B[1];
    B[1] = B[min_heap_size];
    min_heap_size--;
    min_heapify(1);
    return min;
}

int quary(void) {
    if (min_heap_size == max_heap_size + 1)
        return heap_minimum();
    else//max_heap_size = min_heap_size + 1或size相等
        return heap_maximum();
}

void insert(int x) {
    if ((!min_heap_size) && (!max_heap_size))//第一個數據
        max_heap_insert(x);
    else {
        int median = quary();
        if (x < median) {
            max_heap_insert(x);
            if (max_heap_size == min_heap_size + 2)//保持最大堆和最小堆的size相差不超過1
                min_heap_insert(heap_extract_max());
        }           
        else {
            min_heap_insert(x);
            if (min_heap_size == max_heap_size + 2)
                max_heap_insert(heap_extract_min());
        }           
    }

}

void del(void) {//del操做後,最大堆最小堆的size相差不超過1的性質不變
    if (min_heap_size == max_heap_size + 1)
        heap_extract_min();
    else
        heap_extract_max();
}
int main() {
    int t, n, x;
    char op;
    scanf("%d", &t);
    while (t--) {
        max_heap_size = 0;
        min_heap_size = 0;
        scanf("%d", &n);
        for (int i = 0; i < n; i++) {
            scanf(" %c", &op);
            if (op == 'I') {
                scanf("%d", &x);
                insert(x);
            }
            else if (op == 'Q')
                printf("%d\n", quary());
            else
                del();
        }
    }
    return 0;
}

庫函數實現

  STL提供了priority_queue,默認是最大堆,經過自定義「ordering criterion」能夠定義最小堆。支持如下幾個操做:

  • empty()
  • size()
  • front()
  • push_back()
  • pop_back()

  這些操做也正是咱們本身實現的優先隊列的基本操做,empty()和size()可經過length得出,front()即訪問最高優先級的元素,push_back()即插入元素,pop_back()即刪除front()的元素。須要提一點,以上只是優先隊列的基本操做,可是有時候咱們須要增長一些特殊的操做。仍是拿進程調度舉例,進程按照編號1, 2, ... , N,不斷產生,而且有對應的優先級,每產生一個進程,咱們將其放入堆中,假如咱們如今有一個需求,咱們想要改變編號爲3的進程的優先級,怎麼實現?上文中提到過,堆捨棄了部分信息,咱們不知道編號爲3的進程如今在數組A的哪個位置。而上文中的heap_increase_key(A, i)只是更改數組A中第i個位置的鍵值,但i表明着位置,不表明進程的編號。因此怎麼辦?咱們能夠經過維護一個大小爲N的數組position,每次插入進程時、刪除進程時,咱們會變更部分進程在堆(即數組A)中的位置,這時咱們用position記錄下來每一個進程所在的位置,這樣咱們經過索引position數組就能改變特定進程的鍵值了。固然,STL的priority_queue是封裝好的,要想實現上述操做,只能是本身實現優先隊列,在Dijkstra算法(樸素實現、優先隊列優化)這篇博客中有程序實例如何利用position數組實現改變特定節點(進程)的鍵值。
  本題並不須要實現上述這種特殊操做。

Result: 23724kB, 1288ms.

#include <stdio.h>
#include <queue>
#include <functional>

std::priority_queue<int> max_heap;
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;

int quary(void) {
    if (min_heap.size() == max_heap.size() + 1)
        return min_heap.top();
    else//max_heap_size = min_heap_size + 1或size相等
        return max_heap.top();
}

void insert(int x) {
    if (min_heap.empty() && max_heap.empty())
        max_heap.push(x);
    else {
        int median = quary();
        if (x < median) {
            max_heap.push(x);
            if (max_heap.size() == min_heap.size() + 2) {
                min_heap.push(max_heap.top());
                max_heap.pop();
            }               
        }
        else {
            min_heap.push(x);
            if (min_heap.size() == max_heap.size() + 2) {
                max_heap.push(min_heap.top());
                min_heap.pop();
            }               
        }
    }
}

void del(void) {
    if (min_heap.size() == max_heap.size() + 1)
        min_heap.pop();
    else
        max_heap.pop();
}
int main() {
    int t, n, x;
    char op;
    scanf("%d", &t);
    while (t--) {
        while (!min_heap.empty())
            min_heap.pop();
        while (!max_heap.empty())
            max_heap.pop();
        scanf("%d", &n);
        for (int i = 0; i < n; i++) {
            scanf(" %c", &op);
            if (op == 'I') {
                scanf("%d", &x);
                insert(x);
            }
            else if (op == 'Q')
                printf("%d\n", quary());
            else
                del();
        }
    }
    return 0;
}

參考:

[1] 算法設計
[2] 算法導論
[3] Algorithm Design lecture slides: binary and binomial heaps

相關文章
相關標籤/搜索