优先队列是一种使用比较广泛的数据结构。不同于一般的队列,优先队列的元素都具有优先级,优先级高的元素会被优先选取。利用这个特点,我们可以根据元素值的大小来设置优先级,值最大/最小的拥有最高的优先级。这样,我们就可以快速地获取队列中最大/最小的元素。这篇文章我将着重比较三种常见的,构造优先队列的数据结构 - Binary Heap(二叉堆), Leftist Heap(左倾堆)和Skew Heap(斜堆)。
这篇文章的完成借鉴了很多网上的资料,其中最主要的是这几篇:
二叉堆(一)之 图文解析 和 C语言的实现
Priority Queues (Heaps)
数据结构与算法(五)
Binary Heap(二叉堆)
我这里直接给出维基百科中关于Binary Heap的解释:
二叉堆是一种特殊的堆,二叉堆是完全二叉树或者是近似完全二叉树。二叉堆满足堆特性:父节点的键值总是保持固定的序关系于任何一个子节点的键值,且每个节点的左子树和右子树都是一个二叉堆。
当父节点的键值总是大于或等于任何一个子节点的键值时为最大堆。当父节点的键值总是小于或等于任何一个子节点的键值时为最小堆。
我这里补充一下完全二叉树的概念:完全二叉树是指除了树的最下层外,所有层的节点都达到最大,并且最下层不满的节点都位于左支。
在构造Binary Heap时,我们一般都使用数组而不是链表(网上也很少有用链表实现Binary Heap的资料),我这里也用数组来构造Binary Heap。Binary Heap分为最大堆和最小堆,在本文我只介绍最小堆,最大堆和最小堆的实现基本一样。
Min Heap(最小堆)
我们先来看个最小堆的例子:
我们可以看到,所有节点的值都小于等于其子节点的值。这里需要注意,构造Binary Heap时,我们可以用两种形式的数列1)使用index = 0的元素;2)不使用index = 0的元素
上面的例子使用的是第二种形式,下面所有关于最小堆的代码是基于第一种形式。这两种形式有一个很小的区别,在1)中:
index = x节点的左子节点的index = 2 * x + 1
index = x节点的右子节点的index = 2 * x + 2
index = x节点的父节点的index = floor((x - 1) / 2)
在2)中:
index = x节点的左子节点的index = 2 * x
index = x节点的右子节点的index = 2 * x + 1
index = x节点的父节点的index = floor(x / 2)
Min Heap一般支持插入,删除,创建和查找函数。我们这里详细讲解下插入(创建)和删除。
插入
插入可以分为两步:
第一步,在数列的末尾添加需要插入的值。
第二步,比较该节点与其父节点的大小,如果比其父节点大,插入结束;如果比其父节点小,交换这两个节点并重复步骤2直到插入结束或者该节点成为根节点。
我们通过下面这个示意图来看看具体是怎样将14插入到最小堆的的:
了解了如何插入后,我们分析下插入操作的时间复杂度:
在最好的情况下,插入节点的值大于其父节点,我们不需要对堆进行调整,插入完成,时间复杂度为O(1)。
在最坏的情况下,插入的节点值比根节点还小,那么我们需要将该节点一直交换到根节点,因此时间复杂度是O(h),其中h是最小堆的高度。根据完全二叉树的性质,有N个节点的完全二叉树的高度为log(N + 1),因此O(h) = O(log(N + 1)) = O(logN)。关于完全二叉树高度的证明请参考这篇博文:二叉查找树(一)之 图文解析 和 C语言的实现
综上,最小堆的插入算法平均时间复杂度是O(logN)。
下面是插入操作的代码:
/****************************************************************************************
* Insert Operation
***************************************************************************************/
void min_heap_up_update(int key) {
int p_node_index, new_node_index;
/* set inserted node's init index */
new_node_index = heap_size;
/* get inserted node's father node's index and key */
p_node_index = (new_node_index - 1 ) / 2;
while (new_node_index > 0) {
if (min_heap[p_node_index]<= key) {
break;
} else {
/* please note we do not swap key between father node and child
* node, we only assign father node's key to its child node's key */
min_heap[new_node_index] = min_heap[p_node_index];
new_node_index = p_node_index;
p_node_index = (p_node_index - 1) / 2;
}
}
/* at his point, we assign key to the inserted node */
min_heap[new_node_index] = key;
}
void min_heap_insert(int key) {
if (heap_size == MAX_SIZE) {
printf("Min Heap is full...\n");
return;
}
min_heap[heap_size] = key;
min_heap_up_update(key);
heap_size++;
}
在代码的实现上,我们并没有不断的交换符合条件的父节点和子节点,我们只是在最后确定了新节点的位置后,我们才将这个节点的key设置为我们需要的key。在最小堆的代码中,我们用 heap_size
这个全局变量表示当前堆的大小,用 min_heap[]
这个全局数组表示最小堆。
删除
这里的删除指的是删除最小值,也就是删除根节点。删除的操作和插入的操作类似,只是插入是通过向上更新最小堆,而删除是通过向下更新最小堆。删除操作可以分为两步:
第一步,用最小堆的最后一个节点去取代根节点。
第二步,用更新后的第一个节点与其较小的子节点比较,如果该节点比其较小的子节点小,删除操作结束;否则交换这两个节点并重复步骤2直到删除操作结束。
删除操作的时间复杂度和插入一样:
在最好的情况下,删除的时间复杂度为O(1) - 比如整个最小堆的节点都有相同的key,我们只需要比较一次。
在最坏的情况下,我们需要将根节点交换到堆的最下一层,因此时间复杂度是O(logN)。
综上,最小堆的删除算法平均时间复杂度是O(logN)。
下面是删除操作的代码:
/****************************************************************************************
* Delete Operation
***************************************************************************************/
void min_heap_down_update(int position) {
int c_node_index, cur_node_index, cur_node_val;
cur_node_index = position;
cur_node_val = min_heap[cur_node_index];
c_node_index = 2 * cur_node_index + 1;
while (c_node_index < heap_size) {
/* if node has two children we choose the one with smaller key */
if ((c_node_index < heap_size - 1) && (min_heap[c_node_index] > min_heap[c_node_index + 1]))
c_node_index = c_node_index + 1;
if (cur_node_val <= min_heap[c_node_index]) {
break;
} else {
min_heap[cur_node_index] = min_heap[c_node_index];
cur_node_index = c_node_index;
c_node_index = 2 * c_node_index + 1;
}
}
min_heap[cur_node_index] = cur_node_val;
}
void min_heap_remove() {
if (heap_size == 0) {
printf("Min Heap is empty...\n");
return;
}
min_heap[0] = min_heap[heap_size - 1];
min_heap_down_update(0);
heap_size--;
}
同插入操作类似,在代码中我们并没有不断的交换父子节点的值,只是在删除结束后,我们才更新节点的值。
构造
我们可以简单的通过不断的插入节点来完成最小堆的构造,根据插入操作的复杂度,要构造一个N个节点的最小堆需要的时间复杂度是O(N*log(N))。有没有更快速的方法来构造最小堆呢?方法是有的,我们来看看如何使用O(N)的时间来构造一个包含N个节点的最小堆。
插入的方法是自下而上的构造最小堆,我们这里的方法是自上而下的构造最小堆。要满足最小堆成立,我们需要保证所有的节点往下都构成最小堆。因此,我们可以将需要添加到最小堆的数按任意顺序放入最小堆的数组(此时不是最小堆),然后通过不断的调整来使其成为最小堆。这么做有一个好处,我们只需要调整前N/2的节点。为什么呢?因为堆中的后N/2的节点是叶节点,它们已经是最小堆了,因此我们只需要调整前N/2的节点即可将该堆调整成最小堆。
我们来分析下时间复杂度,我这里直接引用数据结构与算法(五)中的内容:
根据计算,这么做可以达到O(N)的时间复杂度。
下面是最小堆建造的代码:
for (int i = heap_size / 2; i >=0; i--)
min_heap_down_update(i);
min_heap_down_update()
是在删除操作中实现的。