在本教程中,您将学习堆排序算法的工作原理。此外,您还将找到使用C语言的示例。
堆排序是计算机程序设计中一种流行而有效的排序算法。学习如何编写堆排序算法需要了解两种类型的数据结构-数组和树。
我们把要排序的初始数字集存储在一个数组中,例如 [10, 3, 76, 34, 23, 32],排序后,我们得到一个排序后的数组 [3,10,23,32,34,76]。堆排序的工作原理是将数组的元素可视化为一种称为堆的特殊类型的完整二叉树。
数组索引和树元素之间的关系
一个完整的二叉树有一个有趣的属性,我们可以用它来找到任何节点的子节点和父节点。
如果数组中任何元素的索引为 i,则索引 2i+1 中的元素将成为左子级,索引 2i+2 中的元素将成为右子级。此外,索引 i 处任何元素的父元素由(i-1)/2 的下界给出。
让我们测试一下,
Left child of 1 (index 0)
= element in (2*0+1) index
= element in 1 index
= 12
Right child of 1
= element in (2*0+2) index
= element in 2 index
= 9
Similarly,
Left child of 12 (index 1)
= element in (2*1+1) index
= element in 3 index
= 5
Right child of 12
= element in (2*1+2) index
= element in 4 index
= 6
我们还要确认规则适用于查找任何节点的父节点
Parent of 9 (position 2)
= (2-1)/2
= ½
= 0.5
~ 0 index
= 1
Parent of 12 (position 1)
= (1-1)/2
= 0 index
= 1
理解数组索引到树位置的这种映射对于理解堆数据结构如何工作以及如何使用它实现堆排序至关重要。
什么是堆数据结构?
堆是一种特殊的基于树的数据结构。如果满足以下条件,则称二叉树遵循堆数据结构:
- 它是一个完整的二叉树
- 树中的所有节点都遵循其大于子节点的属性,即,最大元素位于根,所有的子节点都小于根节点,依此类推。这样的堆称为最大堆。相反,如果所有节点都小于其子节点,则称为最小堆。
下面的示例图显示了最大堆和最小堆。
如何“堆”出一棵树
从一个完整的二叉树开始,我们可以通过在堆的所有非叶元素上运行一个名为heapify的函数将其修改为最大堆。
因为heapify使用递归,所以很难理解。所以让我们首先考虑一下如何用三个元素堆一棵树。
heapify(array)
Root = array[0]
Largest = largest( array[0] , array [2*0 + 1]. array[2*0+2])
if(Root != Largest)
Swap(Root, Largest)
上面的示例显示了两种情况:一种情况,根是最大的元素,我们无需执行任何操作;另一种情况中,根中有一个较大的子元素,我们需要交换以维护最大堆属性。
如果您以前使用过递归算法,您可能已经确定这一定是基本情况。
现在让我们考虑另一个场景,其中存在多个级别。
顶部元素不是最大堆,但是所有子树都是最大堆。
为了保持整个树的最大堆属性,我们必须不断向下推2直到它到达正确的位置。
因此,要在两个子树都是最大堆的树中维护最大堆属性,我们需要在根元素上反复运行heapify,直到它大于其子元素或成为叶节点。
我们可以在一个heapify函数中结合这两个条件:
void heapify(int arr[], int n, int i) {
// Find largest among root, left child and right child
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
// Swap and continue heapifying if root is not largest
if (largest != i) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest);
}
}
此函数适用于基本情况和任意大小的树。因此,只要子树是最大堆,我们就可以将根元素移动到正确的位置,以保持任何树的最大堆状态。
建立最大堆
为了从任何树构建一个最大堆,我们可以从下到上对每个子树进行堆化,并在将函数应用于包括根元素在内的所有元素之后得到一个最大堆。
在完整树的情况下,非叶子节点的第一个索引由 n/2 - 1 给出。之后的所有其他节点都是叶节点,因此不需要堆化。
因此,我们可以建立一个最大堆:
// Build heap (rearrange array)
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
如上图所示,我们首先对最小的树进行堆化,然后逐渐向上移动,直到到达根元素。
如果到目前为止你已经了解了一切,恭喜你,你正在掌握堆排序的路上。
堆排序如何工作?
- 由于树满足最大堆属性,因此最大的项存储在根节点上。
- Swap:移除根元素并将其放在数组的末尾(第n个位置),将树的最后一项(堆)放在空位置。
- Remove:将堆大小减小1。
- Heapify:再次Heapify根元素,以便根中有最高的元素。
- 重复此过程,直到对列表中的所有项进行排序。
下面的代码显示了该操作。
// Heap sort
for (int i = n - 1; i >= 0; i--) {
swap(&arr[0], &arr[i]);
// Heapify root element to get highest element at root again
heapify(arr, i, 0);
}
C示例
// Heap Sort in C
#include <stdio.h>
// Function to swap the the position of two elements
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
void heapify(int arr[], int n, int i) {
// Find largest among root, left child and right child
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
// Swap and continue heapifying if root is not largest
if (largest != i) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest);
}
}
// Main function to do heap sort
void heapSort(int arr[], int n) {
// Build max heap
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// Heap sort
for (int i = n - 1; i >= 0; i--) {
swap(&arr[0], &arr[i]);
// Heapify root element to get highest element at root again
heapify(arr, i, 0);
}
}
// Print an array
void printArray(int arr[], int n) {
for (int i = 0; i < n; ++i)
printf("%d ", arr[i]);
printf("\n");
}
// Driver code
int main() {
int arr[] = {1, 12, 9, 5, 6, 10};
int n = sizeof(arr) / sizeof(arr[0]);
heapSort(arr, n);
printf("Sorted array is \n");
printArray(arr, n);
}
堆排序复杂度
堆排序对于所有情况(最佳情况、平均情况和最坏情况)都具有O(nlog n)时间复杂度。
让我们了解原因。包含n个元素的完全二叉树的高度是log n。
正如我们前面所看到的,要完全堆化子树已经是最大堆的元素,我们需要不断地将元素与其左右子元素进行比较,并向下推,直到它到达相应点,在该点它的两个子元素都小于它。
在最坏的情况下,我们需要将元素从根移动到叶节点,进行log(n)倍数的比较和交换。
在构建最大堆阶段,我们对n/2个元素执行此操作,因此构建堆步骤的最坏情况复杂度是 n/2*log n ~ nlog n。
在排序步骤中,我们将根元素与最后一个元素交换,并将根元素堆化。对于每个元素,最多花费log n时间,因为我们可能必须将元素从根交换到叶。所以我们重复n次,堆排序步骤所花时间就是nlog n。
此外,由于构建最大堆(build_max_heap)和堆排序(heap_sort)步骤是相继执行的,因此算法复杂性不会成倍增加,而是保持在nlog n的程度。
堆排序的空间复杂度为O(1)。与快速排序相比,它的最坏情况较好,为O(nlog n)。快速排序的最坏情况为O(
n
2
n^2
n2)。但在其他情况下,快速排序更快。内省排序(Introsort)是堆排序的一种替代方法,它将快速排序和堆排序结合起来,以保留两者的优点:最坏情况下堆排序的速度,快速排序的平均速度。
堆排序的应用
与安全相关的系统和嵌入式系统(如Linux内核)使用堆排序,因为堆排序的运行时间上有O(nlogn)上限,辅助存储的上限是恒定的O(1)。
尽管堆排序即使在最坏情况下也有O(nlogn)的时间复杂度,但它没有更多的应用(对比其他排序算法如快速排序、合并排序)。但是,如果我们要从项目列表中提取最小(或最大)的数据,而无需考虑剩余项目的顺序,则可以使用基础数据结构,堆。例如,优先级队列。
参考文档
[1]Parewa Labs Pvt. Ltd.Heap Sort Algorithm[EB/OL].https://www.programiz.com/dsa/heap-sort,2021-01-01.