1

优先队列是一种使用比较广泛的数据结构。不同于一般的队列,优先队列的元素都具有优先级,优先级高的元素会被优先选取。利用这个特点,我们可以根据元素值的大小来设置优先级,值最大/最小的拥有最高的优先级。这样,我们就可以快速地获取队列中最大/最小的元素。这篇文章我将着重比较三种常见的,构造优先队列的数据结构 - 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)中:

  1. index = x节点的左子节点的index = 2 * x + 1

  2. index = x节点的右子节点的index = 2 * x + 2

  3. index = x节点的父节点的index = floor((x - 1) / 2)

在2)中:

  1. index = x节点的左子节点的index = 2 * x

  2. index = x节点的右子节点的index = 2 * x + 1

  3. index = x节点的父节点的index = floor(x / 2)

Min Heap一般支持插入,删除,创建和查找函数。我们这里详细讲解下插入(创建)和删除。

插入

插入可以分为两步:
第一步,在数列的末尾添加需要插入的值。
第二步,比较该节点与其父节点的大小,如果比其父节点大,插入结束;如果比其父节点小,交换这两个节点并重复步骤2直到插入结束或者该节点成为根节点。

我们通过下面这个示意图来看看具体是怎样将14插入到最小堆的的:

最小堆的插入操作

了解了如何插入后,我们分析下插入操作的时间复杂度:

  1. 在最好的情况下,插入节点的值大于其父节点,我们不需要对堆进行调整,插入完成,时间复杂度为O(1)。

  2. 在最坏的情况下,插入的节点值比根节点还小,那么我们需要将该节点一直交换到根节点,因此时间复杂度是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直到删除操作结束。

删除操作的时间复杂度和插入一样:

  1. 在最好的情况下,删除的时间复杂度为O(1) - 比如整个最小堆的节点都有相同的key,我们只需要比较一次。

  2. 在最坏的情况下,我们需要将根节点交换到堆的最下一层,因此时间复杂度是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的节点即可将该堆调整成最小堆。

我们来分析下时间复杂度,我这里直接引用数据结构与算法(五)中的内容:

clipboard.png

根据计算,这么做可以达到O(N)的时间复杂度。

下面是最小堆建造的代码:

for (int i = heap_size / 2; i >=0; i--) 
    min_heap_down_update(i);

min_heap_down_update()是在删除操作中实现的。

Leftist Heap(左倾堆)

Skew Heap(斜堆)


RdouTyping
1k 声望112 粉丝