在学习堆排序之前,首先需要了解堆的含义:在含有 n 个元素的序列中,如果序列中的元素满足下面其中一种关系时,此序列可以称之为堆
。
- ki ≤ k2i 且 ki ≤ k2i+1(在 n 个记录的范围内,第 i 个关键字的值小于第 2i 个关键字,同时也小于第 2i+1 个关键字)
- ki ≥ k2i 且 ki ≥ k2i+1(在 n 个记录的范围内,第 i 个关键字的值大于第 2i 个关键字,同时也大于第 2i+1 个关键字)
对于堆的定义也可以使用完全二叉树
来解释,因为在完全二叉树中第 i 个结点的左孩子恰好是第 2i 个结点,右孩子恰好是 2i+1 个结点。如果该序列可以被称为堆,则使用该序列构建的完全二叉树中,每个根结点的值都必须不小于(或者不大于)左右孩子结点的值。
以无序表 {49,38,65,97,76,13,27,49} 来讲,其对应的堆用完全二叉树来表示为:
提示:堆用完全二叉树表示时,其表示方法不唯一,但是可以确定的是树的根结点要么是无序表中的最小值,要么是最大值。
堆排序的原理
堆排序的基本思想
是:通过将无序表转化为堆,可以直接找到表中最大值或者最小值,然后将其提取出来,令剩余的记录再重建一个堆,取出次大值或者次小值,如此反复执行就可以得到一个有序序列,此过程为堆排序。
堆排序过程的代码实现需要解决两个问题:
- 如何将得到的无序序列转化为一个堆;
- 在输出堆顶元素之后(完全二叉树的树根结点),如何调整剩余元素构建一个新的堆。
首先先解决第 2 个问题。图 3 所示为一个完全二叉树,若去除堆顶元素,即删除二叉树的树根结点,此时用二叉树中最后一个结点 97 代替,如下图所示:
此时由于结点 97 比左右孩子结点的值都大,破坏了堆的结构,所以需要进行调整:首先以堆顶元素 97 同左右子树比较,同值最小的结点交换位置,即 27 和 97 交换位置:
由于替代之后破坏了根结点右子树的堆结构,所以需要进行和上述一样的调整,即令 97 同 49 进行交换位置:
通过上述的调整,之前被破坏的堆结构又重新建立。从根结点到叶子结点的整个调整的过程,被称为“筛选
”。
解决第一个问题使用的就是不断筛选的过程,如下图所示,无序表 {49,38,65,97,76,13,27,49} 初步建立的完全二叉树,如下图所示:
在对上图做筛选工作时,规律
是从底层结点开始,一直筛选到根结点。对于具有 n 个结点的完全二叉树,筛选工作开始的结点为第 ⌊n/2⌋
个结点(此结点后序都是叶子结点,无需筛选)。
所以,对于有 8 个结点的完全二叉树,筛选工作从第 4 个结点 97 开始,由于 97 > 49 ,所以需要相互交换,交换后如下图所示:
然后再筛选第 3 个结点 65 ,由于 65 比左右孩子结点都大,则选择一个最小的同 65 进行交换,交换后的结果为:
然后筛选第 2 个结点,由于其符合要求,所以不用筛选;最后筛选根结点 49 ,同 13 进行交换,交换后的结果为:
交换后,发现破坏了其右子树堆的结构,所以还需要调整,最终调整后的结果为:
堆排序的实现
在实现中用到了"数组实现的二叉堆的性质"。在第一个元素的索引为 0 的情形中:
- 性质一:索引为 i 的左孩子的索引是 (2*i+1);
- 性质二:索引为 i 的右孩子的索引是 (2*i+2);
- 性质三:索引为 i 的父结点的索引是 floor((i-1)/2);
所以利用最大堆进行排序的整个过程为:
- 初始化堆:将数列a[0…n]构造成最大堆;
- 交换数据:将a[0]和a[n]交换,使a[n]是a[0…n]中的最大值;然后将a[0…n-1]重新调整为最大堆。接着,将a[1]和a[n-1]交换,使a[n-1]是a[0…n-1]中的最大值;然后将a[0…n-2]重新调整为最大值。依次类推,直到整个数列都是有序的。
public class HeapSort {
public static void main(String[] args) {
int[] array = {5, 9, 1, 9, 5, 3, 7, 6, 1};// 待排序数组
sort(array);
print(array);
}
/**
* 从小到大排序
*/
public static void sort(int array[]) {
int n = array.length - 1;
// 从(n/2) --> 0逐次遍历。遍历之后,得到的数组实际上是一个(最大)二叉堆。
for (int i = n / 2; i >= 0; i--) {
heapAdjust(array, i, n);
}
// 从最后一个元素开始对序列进行调整,不断的缩小调整的范围直到第一个元素
for (int i = n; i > 0; i--) {
// 交换array[0]和array[i]。交换后,array[i]是array[0...i]中最大的。
swap(array, 0, i);
// 调整array[0...i-1],使得array[0...i-1]仍然是一个最大堆。
// 即,保证array[0]是array[0...i-1]中的最大值。
heapAdjust(array, 0, i - 1);
}
}
/**
* 最大堆的向下调整算法。
* 注:数组实现的堆中,第N个节点的左孩子的索引值是(2N+1),右孩子的索引是(2N+2)。
* 其中,N为数组下标索引值,如数组中第1个数对应的N为0。
*
* @param array 待排序的数组
* @param s 被下调节点的起始位置(一般为0,表示从第1个开始)
* @param n 截至范围(一般为数组中最后一个元素的索引)
*/
public static void heapAdjust(int array[], int s, int n) {
// 下面循环中每轮循环的父节点值或者理解为下轮循环的父节点值
int temp = array[s];
for (int i = 2 * s + 1; i <= n; i *= 2 + 1) {
// i 是左孩子,i+1 是右孩子
if (i < n && array[i] < array[i + 1]) {
i++; // 左右两孩子中选择较大者,即array[i + 1]
}
if (temp >= array[i]) {
break; // 父节点值最大,调整结束
}
// 将父节点和相应的左右孩子节点中最大的值给父节点
array[s] = array[i];
// 下轮循环的父节点索引
s = i;
}
array[s] = temp; // 最后将temp值赋给array[s]
}
/** 交换数组中两个元素的位置 */
public static void swap(int array[], int low, int high) {
int temp = array[low];
array[low] = array[high];
array[high] = temp;
}
/** 打印数组 */
public static void print(int array[]) {
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
System.out.println();
}
}
堆排序的特点及性能
提示:代码中为了体现构建堆和输出堆顶元素后重建堆的过程,堆在构建过程中,采用的是堆的第二种关系,即父亲结点的值比孩子结点的值大;重建堆的过程也是如此。
堆排序在最坏的情况下,其时间复杂度仍为O(nlogn)。这是相对于快速排序的优点所在。同时堆排序只需要一个用于记录交换(temp)的辅助存储空间,所需的运行空间很小。
堆排序是不稳定的算法
,它不满足稳定算法的定义。它在交换数据的时候,是比较父结点和子节点之间的数据,所以,即便是存在两个数值相等的兄弟节点,它们的相对顺序在排序也可能发生变化。
算法稳定性 – 假设在数列中存在a[i]=a[j],若在排序之前,a[i]在a[j]前面;并且排序之后,a[i]仍然在a[j]前面。则这个排序算法是稳定的!