优先队列及(二叉)堆

  数据结构书籍与算法书(包括算法导论算法设计)一般将优先队列(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

相关文章
相关标签/搜索