【每日算法】堆排序&优先队列

堆排序(heapsort)的运行时间为O(n logn),是一种原地排序算法,是不稳定的排序算法。

基本介绍

先直观感受一下,下面就是一个堆:

20 17 8 7 16 3

什么??上面不就一个数组吗……?!

没错,(二叉)堆数据结构是一种数组对象。

不过,让我们用另外一种方式来看这个数组:

对于表示堆的数组arr[0…n-1],我们以arr[0]为根,给定某个节点下标i,令其父节点和左右后代节点的下标为:

parent(i) = (i-1)/2;

left(i) = 2*i+1;

right(i) = 2*i+2;

(具体实现时,可用移位来实现乘以2和除以2)

于是,它可以看作一棵完全二叉树:

【每日算法】堆排序&优先队列_第1张图片

可是,这也只是一棵完全二叉树,有啥特别之处呢?

特点就是:除根节点以外的每个节点i,都有arr[ parent(i) ] >= arr[i]。

堆分为最大堆和最小堆,上面就是最大堆,最小堆的特点则是:除根节点以外的每个节点i,都有arr[ parent(i) ] <= arr[i]。

堆排序一般使用最大堆,最大堆中的最大元素位于根节点。

因为具有n个元素的堆是基于一颗完全二叉树的,所以其高度为O(log n)。

保持堆的性质

在清楚什么是最大堆之后,我们来谈一谈如何保持堆的性质,也就是说,如果堆中有节点不满足堆的性质,我们如何进行调整。

首先,我们假定以节点i的左右儿子为根的两棵二叉树都是最大堆,而以节点i为根的二叉树可能不是最大堆,则调整的过程如下:

  1. 从元素arr[i], arr[left(i)], arr[right(i)]中找出最大的元素,将下标存在largest中;
  2. 如果arr[i]是最大的,说明以节点i为根的二叉树是最大堆,无须调整,程序结束;否则,交换arr[i]和arr[largest],于是arr[i], arr[left(i)], arr[right(i)]三者满足了最大堆的性质,但是交换后,下标为largest的节点存放arr[i]的值,以该节点为根的子树又可能违反最大堆的性质,因此需要对该子树递归调用本调整过程。

基于以上思想,我们用代码实现如下:

//arr[0...size-1]
void maxHeapify(int arr[], int i, int size)
{
    int l = left(i);
    int r = right(i);
    int largest = i;
    if (l < size && arr[l] > arr[largest])
        largest = l;
    if (r < size && arr[r] > arr[largest])
        largest = r;
    if (largest != i)
    {
        exchange(arr[i], arr[largest]);
        maxHeapify(arr, largest, size);
    }
}

再强调一遍,本函数的调用前提是:i的左右子树都是最大堆。

建堆

上面保持堆的性质是一个铺垫,它也是堆算法中的核心部分,后面我们将利用它完成建堆和堆排序。

我们先看看如何使用maxHeapify()来将一个数组arr[0…size-1]变成一个最大堆。

对于每一片树叶,我们都可以看作是一个只含一个元素的堆。于是对于叶子节点的父亲节点(左右子树都是最大堆),我们可以调用maxHeapify()来进行调整。调整之后,我们得到更大的堆,对于这些堆的父节点,我们又可以调用maxHeapify()来进行调整。

为保证maxHeapify()的调用前提,我们只需从最下面的非叶子节点开始调整,一直到根节点,整个堆建立完毕。

那么,最下面的非叶子节点的下标是多少?

在这里我只给出结论,有兴趣的读者可以尝试证明一下:

当用数组表示存储了n个元素的堆时,叶子节点的下标为:n/2, n/2+1, … , n-1。 (n/2表示向下取整)

于是我们的调整顺序为n/2-1, … , 0:

buildMaxHeap(int arr[], int size)
{
    for (int i = size/2-1; i >= 0; --i)
        maxHeapify(arr, i, size);
}

堆排序

千辛万苦,终于铺垫完了,下面进行堆排序就得心应手了!

为进行原地排序,我们引入另一个变量:heap_size,它用来表示堆的大小,而用size来表示数组的大小。

于是数组arr[0…size-1]中,arr[0…heap_size-1]为堆,arr[heap_size, size-1]为排好序的元素。

由最大堆的性质可知道,arr[0]存放着堆中最大的元素,于是可以利用该性质如下排序:

  1. 初始heap_size = size,调用buildMaxHeap(arr, heap_size)建立最大堆;
  2. 令i = size-1,交换arr[0]和arr[i],heap_size–,i–;
  3. 交换后,原来根的子女仍是最大堆,而根元素则可能违背了最大堆的性质,所以调用maxHeapify(arr, 0, heap_size)进行调整;
  4. 重复以上过程,直到堆的大小变为2,此时再重复一次以上过程,整个数组便从小到大排好序了。

代码实现:

void heapSort(int arr[], int size)
{
    if (NULL == arr || size <= 0)
        return ;
    int heap_size = size;
    buildMaxHeap(arr, heap_size);
    for (int i = size-1; i >= 1; --i)
    {
        exchange(arr[0], arr[i]);
        --heap_size;
        maxHeapify(arr, 0, heap_size);
    }
}

优先队列

堆排序是一个很漂亮的算法,但是实际应用中,由于快排隐含的常数因子较小,往往优于堆排序,所以堆排序用的没那么多。

但是堆这个数据结构的用处很大,特别是用来实现优先队列。

这里我们只讨论基于最大堆实现的最大优先队列。

优先队列是一种用来维护由一组元素构成的集合S的数据结构,每个元素都有一个key。最大优先队列支持以下操作:

  1. insert(S, x):将元素x插入到S中;
  2. maximum(S):返回S中具有最大key的元素;
  3. extractMax(S):去掉并返回S中具有最大key的元素;
  4. increaseKey(S, x, k):将元素x的key值增加到k(k不能小于原key值)。

下面看看具体实现(伪代码):

//O(1)
heapMaximum(A)
{
    return A[0];
}

去掉S中具有最大key的元素类似于堆排序:

//O(log n)
heapExtractMax(A)
{
    if (heap_size < 1)
        error "heap underflow";
    max = A[0]; //用于返回
    A[0] = A[heap_size-1]; //将最大值交换到尾部
    --heap_size; //抛弃交换到尾部的最大值
    maxHeapify(A, 0, heap_size); //O(log n)
    return max;
}

将元素x的key值增加到k之后,可能违反堆的性质(这里的违反与之前的并不一样,此处key值的增加并不影响以该元素为根的堆的性质,而是影响了以其祖先节点为根的堆的性质),我们需要将该元素不断地与其父母比较,如果该元素的key较大,则交换key并继续移动,当该元素的key小于其父母时,最大堆性质成立。

//O(log n)
heapIncreaseKey(A, i, key)
{
    if (key < A[i])
        error "new key is smaller than current key";
    A[i] = key;
    while (i > 0 && A[parent(i)] < A[i])
    {
        exchange(A[i], A[parent(i)]);
        i = parent(i);
    }
}

插入一个新的元素,只需要在末端先添加一个key为负无穷的元素,然后调用heapIncreaseKey来增加其key值至x即可:

maxHeapInsert(A, x)
{
    ++heap_size;
    A[heap_size-1] = -∞;
    heapIncreaseKey(A, i, x);
}

以上为优先队列的内容~优先队列的应用这里就不多说了,操作系统中用的很多(比如作业调度)~

参考资料:《算法导论》 第六章 堆排序

每天进步一点点,Come on!

(●’◡’●)

本人水平有限,如文章内容有错漏之处,敬请各位读者指出,谢谢!

你可能感兴趣的:(优先队列,堆排序,排序算法,最大堆,完全二叉树)