1 前言
堆排序(英语:Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
关于数据结构堆相关内容我们在堆(二叉堆)-优先队列-数据结构和算法(Java)已经讲解。
堆排序可以分为2个阶段。在堆的构造阶段中,我们可以将原数组重新组织安排进一个堆中;然后在下沉排序阶段,我们从堆中按递减(或递增)顺序取出所有元素并得到排序结果。以从大到小排序为例,我们将使用一个最大堆并重复删除最大元素。为了排序的需要我们不在将堆的具体表示隐藏,并将直接使用swim()和sink()操作。这样我们在排序时就可以将需要排序的数组本身作为堆,因此无需任何额外的空间。
2 堆的构造
由N个给定的元素来构造一个堆如何做呢?
- 从左至右扫描数组,用swim()保证扫描指针左侧的所有元素已经是一棵堆有序的完全树,就像连续向堆中插入元素一样。
- 时间复杂度 N lg N N\lg N NlgN
- 从右至左用sink()函数构造子堆。数组的每个元素都已经是子堆的根节点,sink()对这些子堆适用。如果一个节点的2个子结点都已经是堆了,那么在该结点上调用sink()可以把他们变成一个堆。重复这个过程,直到位置为1的位置调用sink()方法,扫描结束。
- 我们可以跳过大小为1的子堆。
命题R:用下沉操作由N个元素构造堆只需小于2N次比较以及少于N次交换。
3 下沉排序
堆排序的主要工作在第二阶段完成。这里我们将堆顶元素删除,然后堆缩小后数组中空出的位置。
命题S:将N个元素排序,堆排序只需( 2 N lg N + 2 N ) 2N\lg N+2N) 2NlgN+2N)次比较(以及一半的交换)
证明:2N项来自于堆的构造。 2 N lg N 2N\lg N 2NlgN来自于每次下沉操作最大可能需要 2 lg N 2\lg N 2lgN次比较
4 排序实现和测试
堆索引从0开始的实现代码4-1如下:
import java.util.Arrays;
import java.util.Comparator;
/**
* 堆排序
* 默认使用最大堆从小到大排序
* 传入从大到小比较器为使用最小堆从大到小排序
* @author Administrator
* @date 2022-12-06 22:15
*/
public class Heap {
/**
* 比较器
* 默认从小到大
*/
private static Comparator<Comparable> comparator = Comparable::compareTo;
/**
* 堆排序
* @param a 数组
* @param comparator 比较器
*/
public static void sort(Comparable[] a, Comparator<Comparable> comparator) {
Heap.comparator = comparator;
sort(a);
}
/**
* 堆排序,默认最大堆,从小到大排序
* @param a 数组
*/
public static void sort(Comparable[] a) {
int s = a.length - 1;
for (int i = (s - 1) / 2 ; i >= 0; i--) {
sink(a, i, s);
}
while (s > 0) {
exch(a, 0, s--);
sink(a, 0, s);
}
}
/**
* 下沉元素
* @param a 二叉堆
* @param i 下沉元素索引
* @param s 最大索引
*/
private static void sink(Comparable[] a, int i, int s) {
while (2 * ( i+1 ) - 1 <= s) {
int j = 2 * (i + 1) - 1;
if (j < s && compare(a, j, j+1)) j++;
if (!compare(a, i, j)) break;
exch(a, i, j);
i = j;
}
}
/**
* 交换元素
* @param a 数组
* @param i 索引
* @param j 索引
*/
private static void exch(Comparable[] a, int i, int j) {
// 索引-1目的使堆索引对应数组0开始的索引
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 比较数组2个位置元素
* @param a 数组
* @param i 索引
* @param j 索引
* @return
*/
private static boolean compare(Comparable[] a, int i, int j) {
return comparator.compare(a[i], a[j]) < 0;
}
public static void main(String[] args) {
Integer[] a = {-1, 132, 234, 2, 22, 92992, 992, 991};
// 从小到大排序
sort(a);
// 从大到小排序
// sort(a, ((o1, o2) -> o2.compareTo(o1)));
System.out.println(Arrays.toString(a));
}
}
堆索引从1开始的实现(算法第4版)代码4-2如下所示:
public class Heap {
private Heap() { }
public static void sort(Comparable[] pq) {
int n = pq.length;
for (int k = n/2; k >= 1; k--)
sink(pq, k, n);
int k = n;
while (k > 1) {
exch(pq, 1, k--);
sink(pq, 1, k);
}
}
private static void sink(Comparable[] pq, int k, int n) {
while (2*k <= n) {
int j = 2*k;
if (j < n && less(pq, j, j+1)) j++;
if (!less(pq, k, j)) break;
exch(pq, k, j);
k = j;
}
}
private static boolean less(Comparable[] pq, int i, int j) {
return pq[i-1].compareTo(pq[j-1]) < 0;
}
private static void exch(Object[] pq, int i, int j) {
Object swap = pq[i-1];
pq[i-1] = pq[j-1];
pq[j-1] = swap;
}
public static void main(String[] args) {
String[] a = {-1, 132, 234, 2, 22, 92992, 992, 991};
Heap.sort(a);
System.out.println(Arrays.toString(a));
}
}
- 索引问题:对比
- 堆索引从0开始,
- 第i个结点的左右子结点索引分别为 2 ( i + 1 ) − 1 和 2 ( i + 1 ) 2(i+1)-1和2(i+1) 2(i+1)−1和2(i+1)
- 构造开始索引为 ( s − 1 ) / 2 , s (s - 1) / 2,s (s−1)/2,s为数组最大索引,结束索引0
- 第二阶段下沉排序范围 s ∼ 1 s\sim 1 s∼1
- 下沉循环判断条件 2 ∗ ( i + 1 ) − 1 < = s 2 * ( i+1 ) - 1 <= s 2∗(i+1)−1<=s即左子节点小于等于最大索引
- 交换和比较方法索引正常索引
- 堆索引从1开始
- 第i个结点的左右子结点索引 2 i 和 2 i + 1 2i和2i+1 2i和2i+1
- 构造开始索引为 n / 2 , n n / 2,n n/2,n为数组长度,结束索引1
- 第二阶段下沉排序范围 n ∼ 2 n\sim 2 n∼2
- 下沉循环判断条件 2 ∗ k < = n 2 * k <= n 2∗k<=n即左子节点小于等于数组长度
- 需要交换和比较的时候起始堆索引切换到数组索引需要-1
- 堆索引从0开始,
- 使用那种实现都可以,相对来说下面的实现更容易理解和方便计算。
5 分析
4.1 先下沉在上浮
大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底。Floyd在1964年观察发现,我们正好可以通过免去检查元素是否到达正确位置来节省时间。在下沉中总是直接提升较大的子结点直至到达堆底,然后在使元素上浮到正确的位置。这个想法几乎可以将比较的次数减少一半-接近归并排序的比较次数(随机数组)。这种方法需要额外的空间,因此在实际应用中只有当比较操作代价较高时才有用(例如,当我们在将字符串或者其他键值较长类型的元素进行排序时)。
4.2 性能
- 优势:堆排序在排序复杂性的研究中有着重要的地位,因为它是我们所知的唯一能够同时最优地利用空间和时间的方法-在最坏的情况下也能够保证使用 ∼ 2 N lg N \sim2N\lg N ∼2NlgN次比较和恒定的额外空间。
- 劣势:但现代系统的许多应用很少使用它,因为它无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至希尔排序。
- 适用场景:当空间十分紧张时(例如在嵌入式系统或低沉本的移动设备中)它很流行,因为它用几行就能实现较好的性能。
- 前景:用堆实现的优先队列在现代应用程序中越来越重要,因为它能在插入和删除元素操作混合的动态场景中保证对数级别的运行时间。
5 后记
如果小伙伴什么问题或者指教,欢迎交流。
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/algorithm
[1][美]Robert Sedgewich,[美]Kevin Wayne著;谢路云译.算法:第4版[M].北京:人民邮电出版社,2012.10