数据结构——堆排序(HeapSort)C语言实现

3,348 阅读6分钟

前言

之前写过堆的实现,但是不够简单,应试考试中最好不要使用,会显得复杂。这次复盘是因为这是考研复试遇到的题目,发现自己对其掌握还是不够熟练,于是结合自己的复习过程对其进行总结和归纳。参考资料:王道数据结构视频+极客时间算法训练营。

一、什么是堆?

首先,堆是一组关键字序列,可以用数组进行存储,且下标从1开始,即Heap[1...n]。这时候我们需要回忆一下完全二叉树——除了最后一层外,其他任何一层的节点数均达到最大值,且最后一层也只是在最右侧缺少节点,我们会使用数组存储完全二叉树,且下标从1开始,那么下标为i的parent结点的左孩子的下标就是2i,右孩子的下标就是2i+1。堆的形态可以看做是具备某种特点的完全二叉树:

  • 若Heap(i) < Heap(2i) 且 Heap(i) < Heap(2i + 1),我们称之为小顶堆。也就是说小顶堆的每一个parent结点关键字比其child结点关键字小。
  • 若Heap(i) > Heap(2i) 且 Heap(i) > Heap(2i + 1),我们称之为大顶堆。大顶堆的每一个parent结点关键字比其child结点关键字大。
  • i的范围是1 <= i <= (n/2向下取整)

下面可以对比其物理存储与逻辑存储的图解

小根堆物理
小根堆物理
小根堆逻辑
小根堆逻辑
大根堆物理
大根堆物理
大根堆逻辑
大根堆逻辑

二、怎样将无序序列转化成堆?

这里以大根堆为例。做法如下: 对所有parent结点(下标n/2向下取整~1)进行调整: (1)出口:若孩子结点的关键字均小于双亲结点的关键字,则该双亲结点的调整完成。 (2)过程:若存在孩子结点的关键字大于双亲结点的关键字,则将最大的孩子结点与双亲结点进行互换(swap),指针移动到孩子位置并继续判断(1)与(2),直到出现(1)的情况,或者到达叶子结点。

过程图如下:
无序序列如图

无序序列物理
无序序列物理

无序序列逻辑 调整过程如图:
(1)对下标最后的双亲结点进行调整,我们发现符合条件,不必调整

1 (2)指针前移,来到前一个双亲结点。 2-1 我们发现孩子结点17大于双亲结点13,所以把它们交换。 2-2 我们发现孩子结点是叶子结点,所以结束调整。
(3)此时指针来到下标为3的结点6.该结点的孩子结点都比6大,但是16更大,所以我们把6与16进行调换。

3 此时,指针来到橘色的孩子结点6,发现是叶子结点,结束调整。
(4)指针前移,来到下标为2的结点9.我们发现9<17<21,所以我们将9与21进行调换。

4-1 指针来到调换后的结点9,我们发现9<12,于是又开始调换。

4-2 调换结束后,指针来到叶子结点,结束调整。
(5)指针前移,来到下标为1的结点14,采用上述方法进行调整即可。

5-1
5-1

5-2 检查完成,此时大顶堆构建完毕。
代码如下:

#include<stdio.h>

void AdjustDown (int A[], int k, int len) {
    A[0] = A[k]; //哨兵保存双亲结点关键字
    int i;
    for (i = 2 * k; i <= len; i *= 2) {
        if (i < len && A[i] < A[i+1]) //右孩子>左孩子,指针移到右孩子
            i++;
        if (A[0] >= A[i]) //哨兵关键字比孩子结点关键字最大值大,出口
            break;
        else {
            A[k] = A[i];// 孩子结点的值赋给双亲结点
            k = i;// 指针指向孩子结点
        }
        A[k] = A[0]; //将哨兵的关键字保存到调整结束的位置
    }
}

void BuildMaxHeap( int A[], int len ) {
    int i;
    for (i = len / 2; i > 0; i--)
        AdjustDown(A, i, len);
}

int main(int argc, char const *argv[]) {
    int A[11] = {0, 14, 9, 6, 13, 21, 10, 16, 17, 2, 12};
    BuildMaxHeap(A, 10);
    int i;
    for (i = 1; i <= 10; i++)
        printf("%4d",A[i]);
    return 0;
}

测试结果:

测试结果
测试结果

三、堆排序的实现原理

我们已经了解了建立堆的过程,我们发现最后一次调整是对顶点进行调整,而堆的顶点恰恰是当前堆的最大值。提醒到这里,相信你一定发现了,进行堆排序时(这里大顶堆就是形成从大到小的有序序列),我们将大顶堆的堆顶元素输出,然后将其与最后一个元素进行互换,并将堆的长度减小1,此时再对当前堆进行调整,重新形成大顶堆。以此类推,直到最后只剩下一个元素,输出后,堆长度为0,排序结束。
过程图如下:

输出序列:21
然后准备与最后一个结点交换 1 此时堆长度-1,即灰色的结点已经被我们在逻辑上剔除。此时的新堆顶关键字是9,我们需要对它进行调整,让新堆变成大顶堆。 2 调整后,我们得到新堆如下所示。我们输出堆顶结点关键字17,然后准备与当前新堆的绿色结点(当前新堆的最后一个元素)进行调换。
输出序列:21 17 3 调整过后,我们又得到了新堆,但依然需要对堆顶结点进行调整。并且删掉刚刚输出的结点(灰色结点) 4 调整后新的大顶堆如图所示

5 以下的过程可以依次类推。
这里代码实现略有出入,因为我们在调整之后才对数组进行输出,所以我们最终会得到一个从小到大排序的有序序列。下面的代码需要结合上述代码中的函数。

void swap (int *a, int *b) {
    int temp;
    temp = *a;
    *a = *b;
    *b = temp;
}
void HeapSort ( int A[], int len) {
    int i;
    BuildMaxHeap(A, len);
    for (i = len; i > 1; i--) {
        swap(&A[i], &A[1]);
        AdjustDown(A, 1, i-1);
    }
}
int main(int argc, char const *argv[]) {
    int A[11] = {0, 14, 9, 6, 13, 21, 10, 16, 17, 2, 12};
    HeapSort(A, 10);
    int i;
    for (i = 1; i <= 10; i++)
        printf("%4d",A[i]);
    return 0;
}

测试结果:

测试结果
测试结果

四、递归写法实现

void heapify(int array[], int length, int i) {
    int left = 2 * i + 1, right = 2 * i + 2;
    int largest = i;

    if (left < length && array[left] > array[largest]) {
        largest = left;
    }
    if (right < length && array[right] > array[largest]) {
        largest = right;
    }

    if (largest != i) {
        int temp = array[i]; array[i] = array[largest]; array[largest] = temp;
        heapify(array, length, largest);
    }
}

void heapSort(int array[], int length) {
    int i;
    if (length == 0) return;

    for (i = length/2 - 1; i >= 0; i--)
        heapify(array, length, i);

    for (i = length - 1; i >= 0; i--) {
        int temp = array[0]; array[0] = array[i]; array[i] = temp;
        heapify(array, i, 0);
    }
}

五、总结

堆排序的时间复杂度是O(nlogn),空间复杂度为O(1),是不稳定的选择排序算法。

本文使用 mdnice 排版