堆排序原理与实现详解
我们来详细、系统地解释一下**堆排序**。
堆排序是一种基于**二叉堆**数据结构的比较类排序算法。它以其原址排序(只需要常数级别的额外空间)和O(n log n)的时间复杂度而闻名。
为了理解堆排序,我们需要分步拆解几个核心概念。
1. 什么是“堆”?
这里的“堆”不是指内存管理中的堆,而是一种特殊的**完全二叉树**。它必须满足以下性质之一:
* **大顶堆**:每个节点的值都**大于或等于**其左右子节点的值。
* 推论:堆顶(根节点)是整个堆中的**最大元素**。
* **小顶堆**:每个节点的值都**小于或等于**其左右子节点的值。
* 推论:堆顶(根节点)是整个堆中的**最小元素**。
在堆排序中,我们通常使用**大顶堆**。
**重要特性**:堆通常用**数组**来存储。对于一个给定下标 `i` 的节点:
* 其父节点下标:`(i-1) / 2`
* 其左子节点下标:`2*i + 1`
* 其右子节点下标:`2*i + 2`
例如,下面是一个大顶堆及其数组表示:
```
[90]
/ \
[80] [70]
/ \ /
[40] [30][20]
```
数组:`[90, 80, 70, 40, 30, 20]`
---
2. 堆排序的核心思想
堆排序的巧妙之处在于利用了大顶堆(或小顶堆)的堆顶是最大(或最小)值这一特性。其基本思想可以概括为两个步骤:
1. **建堆**:将一个无序的数组构建成一个大顶堆。
2. **排序**:不断地将堆顶元素(当前最大值)与堆的末尾元素交换,然后缩小堆的范围,并对新的堆顶元素进行“下沉”操作,以重新维持堆的性质。重复此过程,直到堆中只剩下一个元素。
---
### 3. 堆排序的详细步骤
我们以数组 `[4, 10, 3, 5, 1]` 的升序排序为例(使用大顶堆)。
# 步骤一:构建大顶堆
目标是重新排列数组元素,使其满足大顶堆的性质。
1. **从最后一个非叶子节点开始**。最后一个非叶子节点的下标是 `n/2 - 1`(n是数组长度)。这里 `n=5`,所以从下标 `5/2 - 1 = 1` 开始(即元素 `10`)。
2. **进行“下沉”操作**:
* **“下沉”**:对于一个节点,如果它比它的子节点小,就将它与较大的那个子节点交换,并继续向下比较,直到它大于等于它的所有子节点,或者成为叶子节点。
3. **从下到上,从右到左地对所有非叶子节点执行“下沉”**:
* 处理下标1(元素10):它的子节点是下标3(5)和4(1)。10比它们都大,无需下沉。堆状态:`[4, 10, 3, 5, 1]`
* 处理下标0(元素4):它的子节点是下标1(10)和2(3)。4 < 10,所以与10交换。交换后,下标1变成了4。现在4的子节点是下标3(5)和4(1)。4 < 5,所以与5交换。交换后,下标3变成了4,它已经是叶子节点,停止。
* 现在数组变成了:`[10, 5, 3, 4, 1]`。检查一下,这已经是一个大顶堆了。
```
[10]
/ \
[5] [3]
/ \
[4] [1]
```
步骤二:排序
现在,堆顶元素(下标0)是最大值。
1. **第一次交换与下沉**:
* 将堆顶元素(10)与当前堆的最后一个元素(1)交换。交换后,数组为 `[1, 5, 3, 4, 10]`。此时,`10` 已经位于其最终的正确位置。我们将堆的大小减1(现在有效的堆范围是前4个元素 `[1, 5, 3, 4]`)。
* 对新的堆顶元素 `1` 进行**下沉**操作,以重新构建大顶堆。
* 1的子节点是5和3。5更大,所以1和5交换。数组变为 `[5, 1, 3, 4, 10]`。
* 现在1(在下标1)的子节点是4(在下标3)。1 < 4,所以交换。数组变为 `[5, 4, 3, 1, 10]`。现在又形成了一个有效的大顶堆(范围在前4个元素)。
2. **重复此过程**:
* **第二次**:将堆顶(5)与当前堆的最后一个元素(1)交换。数组变为 `[1, 4, 3, 5, 10]`。`5` 和 `10` 都在最终位置。堆大小减1(有效堆是 `[1, 4, 3]`)。
* 对 `1` 进行下沉:1的子节点是4和3,与4交换。数组变为 `[4, 1, 3, 5, 10]`。
* **第三次**:将堆顶(4)与当前堆的最后一个元素(3)交换。数组变为 `[3, 1, 4, 5, 10]`。`4`, `5`, `10` 在最终位置。堆大小减1(有效堆是 `[3, 1]`)。
* 对 `3` 进行下沉:3的子节点是1,无需交换。
* **第四次**:将堆顶(3)与当前堆的最后一个元素(1)交换(也就是自己和自己交换)。数组变为 `[1, 3, 4, 5, 10]`。排序完成。
最终,我们得到了升序排列的数组:`[1, 3, 4, 5, 10]`。
---
4. 算法复杂度分析
* **时间复杂度**:**O(n log n)**
* **建堆过程**:看似是O(n log n),但通过精细分析,其平均时间复杂度可以达到**O(n)**。
* **排序过程**:需要进行n-1次循环,每次循环中主要操作是堆顶的“下沉”,下沉操作的时间复杂度与树高有关,即O(log n)。所以排序过程的时间复杂度是**O(n log n)**。
* 综合起来,堆排序的**最好、最坏、平均**时间复杂度都是 **O(n log n)**。这是一个非常稳定的性能。
* **空间复杂度**:**O(1)**
* 因为堆排序是**原址排序**,所有操作都在原数组上进行,只使用了常数级别的临时变量。
---
5. 优缺点
**优点**:
* 时间复杂度稳定在O(n log n),效率高。
* 空间复杂度低,是原址排序。
**缺点**:
* 算法不稳定(相等的元素在排序后相对位置可能会改变)。
* 在数据量较小的情况下,其常数因子可能使得性能不如快速排序或归并排序。
* 由于内存访问模式比较跳跃(不像快速排序那样局部顺序访问),对CPU缓存不友好。
---
总结
堆排序是一种高效且节省内存的排序算法。它的核心在于:
1. **将待排序序列构造成一个大顶堆**。
2. **不断取出堆顶的最大值,并将其放到序列末尾,然后调整剩余部分使其保持堆结构**。
理解堆排序的关键在于理解**堆的数据结构**和**下沉操作**。
例子构建数字序列 **825479** 的大顶堆。
## 1. 将数组视为完全二叉树
首先,我们将数组 `[8, 2, 5, 4, 7, 9]` 看作一个完全二叉树:
```
8
/ \
2 5
/ \ /
4 7 9
```
数组索引对应关系:
- 索引0: 8
- 索引1: 2
- 索引2: 5
- 索引3: 4
- 索引4: 7
- 索引5: 9
## 2. 构建大顶堆的过程
**大顶堆性质**:每个节点的值都大于或等于其子节点的值。
我们从最后一个非叶子节点开始向前调整(最后一个非叶子节点下标 = n/2 - 1 = 6/2 - 1 = 2)。
### 步骤1:调整索引2(值为5)
节点5的子节点是9(索引5),5 < 9,需要交换:
```
交换前:
5
/
9
交换后:
9
/
5
```
数组变为:`[8, 2, 9, 4, 7, 5]`
树结构:
```
8
/ \
2 9
/ \ /
4 7 5
```
### 步骤2:调整索引1(值为2)
节点2的子节点是4和7,最大值是7,2 < 7,需要交换:
```
交换前:
2
/ \
4 7
交换后:
7
/ \
4 2
```
数组变为:`[8, 7, 9, 4, 2, 5]`
树结构:
```
8
/ \
7 9
/ \ /
4 2 5
```
### 步骤3:调整索引0(值为8)
节点8的子节点是7和9,最大值是9,8 < 9,需要交换:
```
交换前:
8
/ \
7 9
交换后:
9
/ \
7 8
```
数组变为:`[9, 7, 8, 4, 2, 5]`
树结构:
```
9
/ \
7 8
/ \ /
4 2 5
```
### 步骤4:检查交换后的影响
交换后,索引2的值变为8,需要检查是否需要继续调整:
节点8的子节点是5,8 > 5,不需要调整。
## 3. 最终的大顶堆
**最终数组**:`[9, 7, 8, 4, 2, 5]`
**树形结构**:
```
9
/ \
7 8
/ \ /
4 2 5
```
**验证大顶堆性质**:
- 节点9 ≥ 子节点7和8 ✓
- 节点7 ≥ 子节点4和2 ✓
- 节点8 ≥ 子节点5 ✓
- 叶子节点4、2、5没有子节点 ✓
原始数组 `[8, 2, 5, 4, 7, 9]` 经过堆化后,得到的大顶堆为 `[9, 7, 8, 4, 2, 5]`。
这个堆满足大顶堆的所有条件,堆顶元素9是整个堆中的最大值。如果要进行堆排序,接下来就可以开始不断取出堆顶元素进行排序了。