一、堆排序概述
堆排序是一种基于二叉堆数据结构的高效排序算法。它具有稳定的时间复杂度为O(nlogn),适用于大规模数据的排序。堆排序具有原地排序的特点,即不需要额外的存储空间,几个指针变量使用O(1)空间,元素交换和堆化操作都是在原数组上进行的。然而,堆排序的稳定性较差,在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。
堆是一种特殊的树形数据结构,分为最大堆和最小堆。最大堆中父节点的值大于或等于任何子节点的值;最小堆中父节点的值小于或等于任何子节点的值。堆可以用数组实现,对于数组中的任意索引 i :父节点索引为 (i-1)/2;左子节点索引为 2*i+1;右子节点索引为 2*i+2。
二、堆的概念与性质
(一)二叉堆的定义
二叉堆是一种特殊的堆,分为最大堆和最小堆。在最大堆中,父节点的值总是大于或等于任何一个子节点的值;在最小堆中,父节点的值总是小于或等于任何一个子节点的值。二叉堆可以通过数组实现,这样避免了指针的使用,大大加快了访问速度。对于一个最大堆,如果有一个关键码的集合,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:
且
( i=0,1,2...)。对于一个最小堆,则满足:
且
( i=0,1,2...)。
(二)堆的存储形式
堆可以用一个一维数组来实现存储。对于数组中的任意索引 i,如果 i 表示父节点,那么左子节点的索引为 2*i+1,右子节点的索引为 2*i+2;如果 i 表示子节点,那么父节点的索引为 (i-1)/2。例如,在一个最小堆中,假设数组为 [2, 4, 5, 7, 8],其中索引为 0 的位置不使用,从索引为 1 的位置开始计数。那么索引为 1 的元素 2 是根节点,它的左子节点为索引为 3 的元素 7,右子节点为索引为 4 的元素 8;索引为 2 的元素 4 的左子节点不存在(因为 2*2+1=5,超出了数组长度),右子节点也不存在;索引为 3 的元素 7 的父节点是索引为 1 的元素 2,它没有子节点;索引为 4 的元素 8 的父节点也是索引为 1 的元素 2,它也没有子节点。这种存储形式使得堆的操作可以直接通过数组索引进行,提高了操作效率。
三、堆排序原理与步骤
(一)构建大顶堆
堆排序的第一步是构建大顶堆。假设我们有一个待排序的数组,首先需要找到最后一个非叶子节点。根据堆的性质,最后一个非叶子节点的位置是数组长度除以 2 减 1。例如,如果数组长度为 10,那么最后一个非叶子节点的位置是 10/2-1=4。
从这个最后一个非叶子节点开始,我们逐个节点地向上进行调整,以构建大顶堆。对于每个节点,我们比较它与其左右子节点的值。如果当前节点小于左子节点的值,就交换当前节点和左子树的值;交换完后要检查左子树是否满足大顶堆的性质,不满足则重新调整子树结构。同样,如果当前节点小于右子节点的值,就交换当前节点和右子树的值;交换完后要检查右子树是否满足大顶堆的性质,不满足则重新调整子树结构。
以数组 [3, 7, 16, 10, 21, 23] 为例,最后一个非叶子节点位置是 6/2-1=2,对应的值是 16。首先比较 16 和它的左子节点 10,由于 16 大于 10,不需要交换。接着比较 16 和它的右子节点 21,由于 16 小于 21,交换这两个值,此时数组变为 [3, 7, 21, 10, 16, 23]。交换后需要检查右子树是否满足大顶堆的性质,这里右子树只有一个节点 23,满足大顶堆性质,无需调整。然后继续向上调整前一个非叶子节点 7,重复上述过程,直到根节点。当所有非叶子节点都调整完毕后,大顶堆构建完成。
(二)交换与调整
完成大顶堆的构建后,我们开始进行排序操作。首先,将堆顶元素与末尾元素交换,此时末尾元素就是当前最大元素。然后,对剩余的元素重新调整为大顶堆。
例如,对于上面构建好的大顶堆 [23, 7, 21, 10, 16, 3],将堆顶元素 23 与末尾元素 3 交换,得到 [3, 7, 21, 10, 16, 23]。此时,除了最后一个元素 23,剩余的元素 [3, 7, 21, 10, 16] 不再是大顶堆,需要重新调整。
重新调整的过程与构建大顶堆类似,从第一个非叶子节点开始,逐个节点地向下进行调整。对于每个节点,比较它与其左右子节点的值,如果当前节点小于较大的子节点的值,就交换当前节点和该子节点的值,并继续向下调整,直到满足大顶堆的性质。
重复上述交换与调整的过程,每次将当前最大元素 “沉” 到数组的末尾,直到整个数组排序完成。例如,在第二次交换与调整后,数组可能变为 [16, 7, 21, 10, 3, 23],然后继续进行下一次操作,直到数组完全有序。
四、C 语言实现示例
(一)代码结构分析
以下是对堆排序函数结构的详细分析:
1、调整堆操作:
- 在多个代码示例中,调整堆的操作通常是通过递归实现的。以构建大顶堆为例,从给定的节点开始,比较该节点与其左右子节点的值。如果当前节点小于较大的子节点,则进行交换,并继续对交换后的子节点进行调整,以确保满足大顶堆的性质。
- 例如,在代码示例中常见的调整堆函数通常具有以下形式:
void Adjust(int* nums, int start, int end)
{
int father = start; // father 代表当前要调整的父节点位置
int child = 2 * father + 1; // child 代表父节点的左子节点位置
while (child <= end)
{
if (child + 1 <= end && nums[child] < nums[child + 1])
// 如果右子节点存在且右子节点值大于左子节点值,则 child 指向右子节点
{
child++;
}
if (nums[child] > nums[father])
// 如果子节点值大于父节点值,则交换父节点和子节点的值
{
int temp = nums[father];
nums[father] = nums[child];
nums[child] = temp;
// 更新 father 和 child,继续调整以 father 为根的子树
father = child;
child = 2 * father + 1;
}
else // 如果子节点值小于等于父节点值,调整结束
{
break;
}
}
}
- 在这个函数中,首先确定当前节点的左右子节点,然后通过比较选择较大的子节点与当前节点进行交换,并不断重复这个过程,直到满足大顶堆的性质。
2、交换元素操作:
- 交换元素的操作通常是通过临时变量实现的。在堆排序过程中,需要将堆顶元素与数组中的其他元素进行交换,以实现将最大元素 “沉” 到数组的末尾。
- 例如:
void swap(int* a, int* b)
{
int temp = *a;
*a = *b;
*b = temp;
}
- 这个函数用于交换两个整数指针所指向的值,在堆排序中用于交换堆顶元素和其他元素。
3、构建大顶堆操作:
- 构建大顶堆通常从最后一个非叶子节点开始,逐个节点地向上进行调整。通过调用调整堆的函数,确保每个节点都满足大顶堆的性质。
- 例如:
void buildBigHeap(int* arr, int len)
{
for (int i = floor(len / 2) - 1; i >= 0; i--)
{
// 调用调整堆的函数
Adjust(arr, i, len - 1);
}
}
- 这个函数通过循环遍历从最后一个非叶子节点开始,调用调整堆的函数,逐步构建大顶堆。
(二)实际运行效果
以下是不同代码示例的运行结果展示:
1、对于一个简单的数组int a[] = {7, 3, 8, 5, 1, 2};,经过堆排序后,得到升序排列的结果为1, 2, 3, 5, 7, 8。
- 首先构建大顶堆,找到最后一个非叶子节点,按照堆排序的步骤进行调整和交换。经过多次迭代,最终实现了数组的有序排列。
- 例如,在代码运行过程中,可以通过打印数组的中间结果来观察堆排序的每一步操作。
2、对于一个较大规模的随机数组,如通过以下方式生成随机数组:
int* create_and_generate_random_array(int size)
{
int* array = (int*)malloc(sizeof(int) * size);
srand(time(NULL));
for (int i = 0; i < size; i++)
{
array[i] = rand() % 1000;
}
return array;
}
- 调用堆排序函数对这个随机数组进行排序,可以看到数组中的元素逐渐按照从小到大的顺序排列。
- 通过打印排序前后的数组,可以直观地验证堆排序算法的正确性和高效性。
总的来说,通过不同的代码示例和实际运行效果,可以看出堆排序算法在 C 语言中的实现能够有效地对数组进行排序,具有较高的效率和稳定性。
五、算法特性总结
(一)时间复杂度
堆排序的时间复杂度为 O(nlogn)。在构建大顶堆的过程中,需要从最后一个非叶子节点开始,逐个节点进行调整,调整的次数与节点的高度有关。对于一个包含 n 个元素的数组,最后一个非叶子节点的位置是 n/2-1,所以构建大顶堆的时间复杂度为 O(n)。在排序过程中,每次交换堆顶元素和末尾元素后,需要对剩余的元素重新调整为大顶堆,调整的次数也是 O(logn)。因此,堆排序的总时间复杂度为 O(nlogn)。
(二)空间复杂度
堆排序是一种原地排序算法,空间复杂度为 O(1)。在排序过程中,只需要几个额外的指针变量来辅助操作,不需要额外的存储空间。元素交换和堆化操作都是在原数组上进行的,不会占用额外的内存空间。
(三)稳定性
堆排序是不稳定的排序算法。在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。例如,对于数组 [3, 3', 2],如果在构建大顶堆的过程中,先交换了 3 和 3',然后再交换堆顶元素和堆底元素,此时 3 和 3' 的相对位置就发生了变化。
(四)适用场景
- 适用于大规模数据的排序。由于堆排序的时间复杂度为 O(nlogn),并且是原地排序算法,不需要额外的存储空间,所以适用于大规模数据的排序。
- 适用于需要快速找到最大或最小元素的场景。可以利用堆的特性,快速找到最大或最小元素,例如在优先队列中。
(五)局限性
- 不稳定的排序算法,对于需要保持元素相对顺序的场景不适用。
- 对于小规模数据的排序,可能不如一些简单的排序算法高效。例如,对于包含少量元素的数组,插入排序可能比堆排序更快。