堆排序是一种基于二叉堆数据结构的排序算法。它的主要思想是将待排序的序列构建成一个大顶堆(或小顶堆),然后 依次取出堆顶元素,将其与堆中最后一个元素交换,并调整堆,使得剩余元素仍满足堆的性质。重复这个过程,直到堆中只剩下一个待调整元素,即可得到一个有序序列。

上面说,大顶堆依次将堆顶(当前最大)元素与堆中最后一个元素交换。因此,基于大顶堆的堆排序后的结果是一个升序序列,基于小顶堆的堆排序后的结果是一个降序序列。

上面调整堆的堆化过程,是将「剩余的元素」重新构造成一个堆(通过交换的方式放在堆尾的元素不再参与堆化调整)。

堆排序步骤

按文章开头的描述,堆排序的过程可以分为两个主要步骤:构建堆和调整堆。下面以大顶堆的堆排序来进行描述。

  1. 构建堆:
    首先,将待排序的序列构建成一个初始堆。可以 从最后一个非叶子节点开始,依次向前遍历 ,对每个节点进行调整,使得该节点的值大于其子节点的值。这个过程称为堆化,可以使用 下沉操作 来实现。

  2. 调整堆:
    将堆顶元素与堆中最后一个元素交换位置,并将堆的大小减一 。然后对堆顶元素进行 下沉操作,使得剩余元素仍满足堆的性质。重复这个过程,直到堆中只剩下一个元素,即可得到一个有序序列。

堆排序在实际应用中具有较高的效率和稳定性,尤其适用于大规模数据的排序。

堆化的两个主要操作是下沉(弹出堆顶元素时使用)和上浮(往堆中添加新元素时使用)操作,可以参考 数据结构之堆基础与堆结构(数组实现)

堆排序复杂度

堆排序的时间复杂度为 O(nlogn),其中 n 是待排序序列的长度。由于堆排序只需要常数的辅助空间,因此它是一种原地排序算法,空间复杂度为 O(1)。但 堆排序是不稳定的排序算法,即相同元素的顺序可能会发生改变

堆排序实现

从无序序列构建堆

对于给定的长度为 nn 的无序序列进行堆排序的第一个过程是:构建堆——将无序序列构建成一个二叉堆数据结构。

上面说「从最后一个非叶子节点开始,依次向前遍历」,最后一个非叶子节点的数组索引(从 00 开始)为 n/21n/2 - 1

为了完成从无序序列构建堆的这一过程。我们首先给出堆化中的下沉操作。假设,我们现在已经有一个满足堆属性的数组,当我们修改了数组某一个索引位置的值时,就需要重新对这个位置,以及之前的所有位置都进行堆化调整

例如,对于下面的大顶堆:

1
2
3
4
5
    60
/ \
30 40
/ \
25 28

如果把索引 1 位置的值 30 修改为 70,那么索引 1 和 索引 0 都需要进行堆化调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 调整索引 1 位置
60
/ \
70 40
/ \
25 28

// 调整索引 0 位置
70
/ \
60 40
/ \
25 28

如果把索引 1 位置的值 30 修改为 15,那么索引 1 和 索引 0 也都需要进行堆化调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 调整索引 1 位置
60
/ \
28 40
/ \
25 15

// 调整索引 0 位置(满足堆属性,随即退出)
60
/ \
28 40
/ \
25 15

对于给定的长度为 n 的数组(满足堆属性),当修改了索引 i 位置的值时,堆化调整以索引 i 为根节点的子树,使这棵以索引 i 为根节点的树重新满足堆属性(但不再能保证整个数组满足堆属性,若需要整棵树都满足堆属性,还需要按顺序对以索引 i-1 到以索引 0为根的子树进行堆化调整)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 下沉操作,使以索引 i 为根节点的子树满足堆属性
void heapify(int arr[], int n, int i) {
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;
}

// 如果最大值不是根节点,则进行交换,并递归调整子堆
if (largest != i) {
int temp = arr[i];
arr[i] = arr[largest];
arr[largest] = temp;

heapify(arr, n, largest);
}
}

现在开始从最后一个非叶子节点,依次向前遍历进行下沉堆化。每遍历一个节点,都会使得以这个节点为根节点的树满足堆属性,那么当遍历完索引为 0 的位置后,整个数组将满足堆属性,构成一棵二叉堆。

1
2
3
4
// 构建堆(从最后一个非叶子节点开始,依次向前遍历进行下沉堆化)
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}

调整堆

将堆顶元素与堆中最后一个元素交换位置,并将堆的大小减一 。然后对堆顶元素进行 下沉操作,使得剩余元素仍满足堆的性质。重复这个过程,直到堆中只剩下一个元素,即可得到一个有序序列。

1
2
3
4
5
6
7
// 依次取出堆顶元素,并调整这个更小的堆
for (int i = n - 1; i >= 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
heapify(arr, i, 0); // 将堆的大小减小为 i
}

堆排序完整代码

堆排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void heapify(int arr[], int n, int i);

// 堆排序
void heapSort(int arr[], int n) {
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}

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

堆排序测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>

void heapify(int arr[], int n, int i);
void heapSort(int arr[], int n);

void printArray(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}

int main(int argc, char *argv[]) {
int arr[] = {12, 11, 13, 5, 6, 7};
int n = sizeof(arr) / sizeof(arr[0]);

printf(" 原始数组:");
printArray(arr, n);

heapSort(arr, n);

printf(" 排序后数组:");
printArray(arr, n);

return 0;
}

测试结果

1
2
原始数组:12 11 13 5 6 7
排序后数组:5 6 7 11 12 13