0、引言
在许多问题中,当对数据集进行频繁的插入和删除操作时,往往需要快速确定最大或最小的元素。处理这种问题的方法之一,就是使用一个已排好序的数据集。通过这种方法,最大或最小元素总是处在数据集的头部(这取决于使用升序还是降序排列)。然而,将数据集一遍又一遍地进行排序的代价是非常高的。并且很多情况下,将元素排序并不是操作的目的,最终我们可能在真正要做的工作之外做了很多其他的工作。想要快速地找到最大或者最小元素,只需要让元素存储在可以找到它的位置上就行。堆和优先队列就是一种处理这种问题的有效方法。
堆:它是一种树型组织,使我们能够迅速确定包含最大值的结点。维持一棵树的代价低于维持一个有序数据集的代价。同样,我们可以通过堆快速地找到包含最小值的元素。
优先队列:它是一个从堆自然衍生而来的数据结构。在优先队列中,数据保存在一个堆中,这样我们能够迅速确定下一个最高优先级的结点。所谓元素的"优先级"在不同的问题中意义也不相同。
1、堆的描述
堆是一棵二叉树,通常其子结点存储的值比父结点的值小。所以根结点是树中最大的结点。同样,我们也可以让堆向另一种方向发展,即每个子结点存储的值比父结点的值大。这样根结点就是树中最小的结点。这样的二叉树是局部有序的,任何一个结点与其兄弟结点之间都没有必然的顺序关系,但它与其父子结点有大小关系。子结点比父结点小的堆称为最大值堆,这是因为根结点存储该树所有结点的最大值。反之,子结点比父结点大的堆称为最小值堆。
堆是左平衡的树,所以随着结点的增加,树会逐级从左至右增长。因此对于堆来说,一个比较好的表示左平衡二叉树的方式是,将结点通过水平遍历的方式连续存储到一个数组中。假设有一个零索引数组,这表示数组中处于位置i处的结点,其父结点位于⌊i-1⌋/2,计算中要忽略(i-1)/2的小数部分。其左结点和右结点分别位于2i+1和2i+2位置上。这样的组织结构对于堆来说非常重要,因为通过它我们能迅速地定位堆的最后一个结点:最后一个结点指处于树中最深层最右端的结点。这在实现某些堆操作时非常重要。
2、堆的接口定义
heap_init
——————
void heap_init(Heap *heap, int (*compare)(const void *key1, const void *key2), void (*destroy)(void *data));
返回值:无
描述:初始化堆heap。在对堆进行其他操作之前必须首先调用初始化函数。在堆形成的过程中,函数指针compare会被堆的各种操作调用,用来比较堆中的结点。如果堆为最大值堆,当key1>key2时,函数返回1;当key1=key2时,函数返回0;当key1<key2时,函数返回-1。如果堆为最小值堆,那么函数的返回结果相反。参数destroy是一个函数指针,通过调用其所指向的函数来释放动态分配的内存空间。例如,如果一个堆包含使用malloc动态分配内存的数据,那么当堆销毁时,destroy会调用free来释放内存空间。当一个结构化数据包含若干动态分配内存的数据成员时,destroy应该指向一个用户定义的函数来释放数据成员和结构本身的内存空间。如果堆中的数据不需要释放,那么destroy应该指向NULL。
复杂度:O(1)
heap_destroy
——————
void heap_destroy(Heap *heap);
返回值:无
描述:销毁堆heap。在调用heap_destroy之后不再允许进行其他操作,除非再次调用heap_init。heap_destroy会删除堆中所有的结点,在删除的同时调用heap_init中destroy所指向的销毁函数(前提是此函数指针不为NULL)。
复杂度:O(n),其中n是堆中结点的个数。
heap_insert
——————
int heap_insert(Heap *heap, const void *data);
返回值:如果插入元素成功,返回0;否则,返回-1。
描述:向堆heap中插入一个结点。新结点包含一个指向data的指针,只要结点仍然存在于堆中,此指针就一直有效。与data相关的内存空间将由函数的调用者来管理。
复杂度:O(lg n),其中n是堆中结点的个数。
heap_extract
——————
int heap_extract(Heap *heap, void **data);
返回值:如果结点释放成功,返回0;否则,返回-1。
描述:从堆heap中释放堆顶部的结点。返回时,data指向被释放结点中存储的数据。与data相关的内存空间将由函数的调用者来管理。
复杂度:O(lg n),其中n是堆中结点的个数。
heap_size
——————
int heap_size(const Heap *heap);
返回值:堆中的结点个数。
描述:获取堆heap结点个数的宏。
复杂度:O(1)
3、堆的实现与分析
这里的堆使用二叉树实现,其结点按照树的层次结构存放在一个数组中。结构Heap是堆的数据结构。此结构包含4个成员:size指明堆中结点的个数;compare和destroy是用于封装传入heap_init的函数指针;tree是堆中存储结点的数组。
// 堆的头文件
/* heap.h */
#ifndef HEAP_H
#define HEAP_H
/* Define a structure for heaps. */
typedef struct Heap_
{
int size;
int (*compare)(const void *key1, const void *key2);
void (*destroy)(void *data);
void **tree;
} Heap;
/* Public Interface. */
void heap_init(Heap *heap, int (*compare)(const void *key1, const void *key2), void (*destroy)(void *data));
void heap_destroy(Heap *heap);
int heap_insert(Heap *heap, const void *data);
int heap_extract(Heap *heap, void **data);
#define heap_size(heap) ((heap)->size)
#endif // HEAP_H
// 堆的实现
/* heap.c */
#include <stdlib.h>
#include <string.h>
#include "heap.h"
/* Define private macros used by the heap implementation. */
#define heap_parent(npos) ((int)(((npos) - 1) / 2))
#define heap_left(npos) (((npos) * 2) + 1)
#define heap_right(npos) (((npos) * 2) + 2)
/* heap_init */
void heap_init(Heap *heap, int (*compare)(const void *key1, const void *key2), void (*destroy)(void *data))
{
/* Initialize the heap. */
heap->size = 0;
heap->compare = compare;
heap->destroy = destroy;
heap->tree = NULL;
return;
}
/* heap_destroy */
void heap_destroy(Heap *heap)
{
int i;
/* Remove all the nodes from the heap. */
if(heap->destroy != NULL)
{
for(i = 0; i < heap_size(heap); i++)
{
/* Call a user-defined function to free dynamically allocated data. */
heap->destroy(heap->tree[i]);
}
}
/* Free the storage allocated for the heap. */
free(heap->tree);
/* No operations are allowed now, but clear the structure as precaution. */
memset(heap, 0, sizeof(Heap));
return;
}
/* heap_insert */
int heap_insert(Heap *heap, const void *data)
{
void *temp;
int ipos, ppos;
/* Allocate storage for the node. */
if((temp = (void **)realloc(heap->tree, (heap_size(heap) + 1) * sizeof(void *))) == NULL)
{
return -1;
}
else
{
heap->tree = temp;
}
/* Insert the node after the last node. */
heap->tree[heap_size(heap)] = (void *)data;
/* Heapify the tree by pushing the contents of the new node upward. */
ipos = heap_size(heap);
ppos = heap_parent(ipos);
while(ipos > 0 && heap->compare(heap->tree[ppos], heap->tree[ipos]) < 0)
{
/* swap the contents of the current node and its parent. */
temp = heap->tree[ppos];
heap->tree[ppos] = heap->tree[ipos];
heap->tree[ipos] = temp;
/* Move up one level in the tree to continue heapifying. */
ipos = ppos;
ppos = heap_parent(ipos);
}
/* Adjust the size of the heap to account for the inserted node. */
heap->size++;
return 0;
}
/* heap_extract */
int heap_extract(Heap *heap, void **data)
{
void *save, *temp;
int ipos, lpos, rpos, mpos;
/* Do not allow extraction from an empty heap. */
if(heap_size(heap) == 0)
return -1;
/* Extract the node at the top of the heap. */
*data = heap->tree[0];
/* Adjust the storage used by the heap. */
save = heap->tree[heap_size(heap) - 1];
if(heap_size(heap) - 1 > 0)
{
if((temp = (void **)realloc(heap->tree, (heap_size(heap) - 1) * sizeof(void *))) == NULL)
{
return -1;
}
else
{
heap->tree = temp;
}
/* Adjust the size of the heap to account for the extracted node. */
heap->size--;
}
else
{
/* Manage the heap when extracting the last node. */
free(heap->tree);
heap->tree = NULL;
heap->size = 0;
return 0;
}
/* Copy the last node to the top. */
heap->tree[0] = save;
/* Heapify the tree by pushing the contents of the new top downward. */
ipos = 0;
lpos = heap_left(ipos);
rpos = heap_right(ipos);
while(1)
{
/* Select the child to swap with the current node. */
lpos = heap_left(ipos);
rpos = heap_right(ipos);
if(lpos < heap_size(heap) && heap->compare(heap->tree[lpos], heap->tree[ipos]) > 0)
{
mpos = lpos;
}
else
{
mpos = ipos;
}
if(rpos < heap_size(heap) && heap->compare(heap->tree[rpos], heap->tree[mpos]) > 0)
{
mpos = rpos;
}
/* When mpos is ipos, the heap property has been restored. */
if(mpos == ipos)
{
break;
}
else
{
/* Swap the contents of the current node and the selected child. */
temp = heap->tree[mpos];
heap->tree[mpos] = heap->tree[ipos];
heap->tree[ipos] = temp;
/* Move down one level in the tree to continue heapifying. */
ipos = mpos;
}
}
return 0;
}
heap_init
堆由heap_init初始化,经过初始化的堆才能进行其他操作。堆的初始化过程比较简单,它将size成员设置为0;将compare成员指向compare;将destroy成员指向destroy;然后将tree指针设置为NULL。
heap_init的时间复杂度为O(1),因为初始化堆的几个步骤都能在固定时间内完成。
heap_destroy
堆由heap_destroy进行销毁。该函数只要用于移除堆中的所有结点。当在heap_init中destroy指针不为NULL时,destroy将指向此函数以便在移除每个结点时调用。
heap_destroy的时间复杂度为O(n),其中n为堆中结点的个数。这是由于必须遍历堆中所有的结点来释放堆中的数据。如果destroy为NULL,那么heap_destroy的复杂度为O(1)。
heap_insert
堆由heap_insert向堆中插入结点。函数将结点指向调用者传入的数据。首先,要为新的结点重新分配存储空间,以保证树能容纳此结点。新插入的结点将首先放到数组的末尾。此时将破坏堆的固有特性,所以我们必须调整树的结构,对结点进行重新排列。
在插入结点时,为了重新排列一棵树,只需要考虑新结点插入的那个分支,因为这是形成堆的局部开始分支。从新结点开始,将结点向树的上方层层移动,比较每个子结点和它的父结点。在每一层上,如果父结点与子结点的位置不正确,就交换结点的内容。这个交换过程会不断进行直到某一层不再需要进行交换为止,或者直到结点到达树的顶部。最后,通过堆数据结构中的size成员来更新堆的容量。
heap_insert的时间复杂度为O(lg n),其中n为堆中结点的个数。因为在最坏情况下,需要将新结点的内容从树的底层移动的树的顶部,这是一个lgn级别的遍历过程。而剩下的操作都能在固定时间内完成。
heap_extract
堆由heap_extract操作来释放堆顶部的结点。首先,将data指向将要释放结点的数据。接下来,保存最后一个结点的内容,将树的大小减一,为树重新分配一个稍小的存储空间。在确定以上操作都成功之后,将最后一个结点中的内容拷贝到根结点中。显然,这个过程会破坏堆固有的特性,所以我们必须调整树的结构,对结点进行重新排列。
在取出结点后,为了重新排列一棵树,从根结点开始沿树干层层向下移动,与结点的两个子结点进行比较。在每一层上,如果父结点与子结点的位置不正确,就交换结点的内容,此时需要将父结点与较大的那个子结点进行交换。这个交换过程会不断进行下去直到某一层不再需要进行交换为止,或者直到结点到达一个叶子结点。最后通过递减堆数据结构中的size成员更新堆的容量。
heap_extract的时间复杂度为O(lg n),其中n为堆中结点的个数。因为在最坏的情况下,需要将新结点中的内容从树的顶部一直移动到树的一个叶子结点上,这是一个lgn级别的遍历过程。而剩下的操作都能在固定时间内完成。
heap_size
这个宏计算堆中结点的个数。它通过访问结构Heap的size成员来获得。
其时间复杂度为O(1),因为访问结构中的成员是一个运行时间固定的简单操作。
4、优先队列的描述
优先队列将数据按照优先级顺序排列。一个优先队列由许多有序的元素构成,所以优先级最高的元素可以有效而快速地确定。例如,我们看一组用来做负载均衡的服务器,时时观察它们的使用情况。当连接请求到达时,优先队列可以告知当前哪个服务器是处理此连接请求的最佳服务器。在这种情况下,最空闲的服务器获得的优先级最高,因为它可以最好地处理服务请求。
5、优先队列的接口定义
pqueue_init
——————
void pqueue_init(PQueue *pqueue, int (*compare)(const void *key1, const void *key2), void (*destroy)(void *data));
返回值:无
描述:初始化优先队列pqueue。在对优先队列进行其他操作之前必须首先调用初始化函数。在优先队列的形成过程中,函数指针compare会被优先队列的各种操作调用,用来维持优先队列的堆特性。如果队列中大的值有较高的优先级,那么当key1>key2时,函数返回1;当key1=key2时,函数返回0;当key1<key2时,函数返回-1。如果相反队列中小的值有较高的优先级,那么函数的返回结果相反。参数destroy是一个函数指针,通过调用destroy指向的函数来释放动态分配的内存空间。例如,如果一个优先队列包含使用malloc动态分配内存的数据,那么当销毁优先队列时,destroy会调用free来释放内存空间。当一个结构化数据包含若干个动态分配内存的数据成员时,destroy应该指向一个用户自定义的函数来释放数据成员和结构本身的内存空间。如果优先队列中的数据不需要释放,那么destroy应该指向NULL。
复杂度:O(1)
pqueue_destroy
——————
void pqueue_destroy(PQueue *pqueue);
返回值:无
描述:销毁优先队列pqueue。在调用pqueue_destroy之后不再允许进行其他操作,除非再次调用pqueue_init。pqueue_destroy会从优先队列中移除所有的元素,在删除每个元素的同时调用pqueue_init中的destroy所指向的销毁函数(前提是此函数指针不为NULL)。
复杂度:O(n),其中n是优先队列中结点的个数。
pqueue_insert
——————
int pqueue_insert(PQueue *pqueue, const void *data);
返回值:如果插入元素成功,返回0;否则,返回-1。
描述:向优先队列pqueue中插入一个元素。新元素包含一个指向data的指针,只要结点仍然存在于优先队列中,此指针就一直有效。与data相关的内存空间将由函数的调用者来管理。
复杂度:O(lg n),其中n是优先队列中结点的个数。
pqueue_extract
——————
int pqueue_extract(PQueue *pqueue, void **data);
返回值:如果元素提取成功,返回0;否则,返回-1。
描述:从优先队列pqueue中提取优先队列顶部的元素。返回时,data指向已提取元素中存储的数据。与data相关的内存空间将由函数的调用者来管理。
复杂度:O(lg n),其中n是优先级队列中结点的个数。
pqueue_peek
——————
void *pqueue_peek(const PQueue *pqueue);
返回值:优先队列中优先级最高的元素;如果队列为空,那么返回NULL。
描述:获取优先队列pqueue中优先级最高元素的宏。
复杂度:O(1)
pqueue_size
——————
int pqueue_size(const PQueue *pqueue);
返回值:优先队列中的结点个数。
描述:获取优先队列pqueue结点个数的宏。
复杂度:O(1)
6、优先队列的实现与分析
我们可以通过很多方法来实现一个优先队列。最常用而简单的方法就是维护一个有序数据集。在这个有序数据集中,优先级最高的元素位于数据集的头部。然而,插入或提取元素之后必须重新排列数据集,这是一个复杂度为O(n)的操作(n表示数据集元素的个数)。因此,更好的方法就用一个局部有序的堆来实现优先队列。我们回忆一下堆的定义,位于堆顶部的结点往往优先级最高,而且插入或提取数据之后重新排列堆的复杂度仅为O(lg n)。
我们可以通过这种简单的方式typedef Heap PQueue来实现一个优先队列。这是因为优先队列与堆的操作基本相同,优先队列仅比堆多了一个接口而已。为了实现这些接口,我们只需要将优先队列的相应操作定义成堆的操作即可。优先队列中独有的操作pqueue_peek与pqueue_extract相类似,只是pqueue_peek返回队列中优先级最高的元素,而不删除它。
// 优先队列的头文件
/* pqueue.h */
#ifndef PQUEUE_H
#define PQUEUE_H
#include "heap.h"
/* Implement priority queues as heaps. */
typedef Heap PQueue;
/* Public Interface */
#define pqueue_init heap_init
#define pqueue_destroy heap_destroy
#define pqueue_insert heap_insert
#define pqueue_extract heap_extract
#define pqueue_peek(pqueue) ((pqueue)->tree == NULL ? NULL : (pqueue)->tree[0])
#define pqueue_size heap_size
#endif // PQUEUE_H
7、优先队列的示例:包裹分拣
绝大多数快递服务公司都会提供几种服务供客户选择。通常来说,如果你愿意支付更多的快递费用,那么你的包裹能够保证更快地到达。因为很多大的快递服务公司每天要处理数以百万计的包裹,所以将这些包裹按照优先级排序是很重要的。这在投递的人力物力有限的情况下尤为重要。在这种情况下,具有高优先级的包裹往往优先投递。例如,一架用于投递服务的飞机,如果它在某个繁忙大城市的中心机场每天只跑一个往返,那么第二天就要求投递的包裹在当天就应该装上飞机。
确保包裹能够按照正确的优先级顺序送达到指定的目的地的方法是,将包裹的信息按照正确的优先级顺序存储在一个优先级队列中。包裹分拣过程首先扫描包裹的信息,并将信息录入系统中。对于每个扫描过的包裹,其信息将会按照优先级顺序存储到优先队列中,以便当包裹在系统中传递时,具有最高优先级的包裹将首先投递。
下面的代码中列举了两个函数get_parcel和put_parcel,它们都是用来操作一个包含包裹信息Parcel的优先队列。Parcel在parcel.h中定义,此处没有列举出来,其中主要包括一些包裹信息。一个分拣器调用put_parcel将一个包裹信息加载到系统中。Parcel中传递给put_parcel的一个成员变量代表优先序号。put_parcel将一个包裹插入一个优先队列中,并按照优先级找到它在队列中的位置。当分拣器准备在系统中传递下一个包裹时,它会调用get_parcel。函数get_parcel会取到最高优先级的包裹,这样就能保证包裹按照正确的顺序处理。
优先队列是用来管理包裹的最佳方法,因为某些场合,我们只关心下一个优先级最高的包裹是哪一个。这样可以避免维护包裹完全有序的系统开销。get_parcel和put_parcel的时间复杂度都为O(lg n),因为这两个函数分别只调用了复杂度为O(lg n)的函数pqueue_extract和pqueue_insert。
// 包裹分拣函数的实现
/* parcels.c */
#include <stdlib.h>
#include <string.h>
#include "parcel.h"
#include "parcels.h"
#include "pqueue.h"
/* get_parcel */
int get_parcel(PQueue *parcels, Parcel *parcel)
{
Parcel *data;
if(pqueue_size(parcels) == 0)
{
/* Return that there are no parcels. */
return -1;
}
else
{
if(pqueue_extract(parcels, (void **)&data) != 0)
{
/* Return that a parcel could not be retrieved. */
return -1;
}
else
{
/* Pass back the highest-priority parcel. */
memcpy(parcel, data, sizeof(Parcel));
free(data);
}
}
return 0;
}
/* put_parcel */
int put_parcel(PQueue *parcels, const Parcel *parcel)
{
Parcel *data;
/* Allocate storage for the parcel. */
if((data = (Parcel *)malloc(sizeof(Parcel))) == NULL)
return -1;
/* Insert the parcel into the priority queue. */
memcpy(data, parcel, sizeof(Parcel));
if(pqueue_insert(parcels, data) != 0)
return -1;
return 0;
}