文章目录
前言
这段时间重新复习了一遍数据结构,学习完排序算法之后,想实现一个堆排序,但只完成堆排序的算法并不是很合我的心意。因此打算用java实现一个优先队列,也就是堆。以此文章记录。
一、什么是堆/优先队列?
1. 数组角度
数据结构课本的解释是:
存在n个元素的序列{k1,k2,k3,…,kn} ,当且仅当满足以下关系时,
{
k
i
>
=
k
2
i
k
i
>
=
k
2
i
+
1
\begin{cases} k_i >= k_{2i} \\ k_i >= k_{2i+1} \end{cases}
{ki>=k2iki>=k2i+1
就构成了一个大顶堆,而小顶堆则相反。(本文所讲均为大顶堆)
2.完全二叉树角度
从完全二叉树的角度来看,大顶堆要满足每一个非叶子节点都比它的左右孩子(若存在)大。
即有:
{
r
o
o
t
.
v
a
l
>
=
r
o
o
t
.
l
e
f
t
.
v
a
l
r
o
o
t
.
l
e
f
t
!
=
n
u
l
l
r
o
o
t
.
v
a
l
>
=
r
o
o
t
.
r
i
g
h
t
.
v
a
l
r
o
o
t
.
r
i
g
h
t
!
=
n
u
l
l
\begin{cases} root.val >= root.left.val & root.left != null \\ root.val >= root.right.val & root.right != null \end{cases}
{root.val>=root.left.valroot.val>=root.right.valroot.left!=nullroot.right!=null
如下图所示:
3.两种解释的联系
如果对于树这种数据结构比较熟悉的话,特别是完全二叉树的性质,即
编号为i的结点,如果它的左孩子存在的话,编号为2i; 如果它的右孩子存在的话,编号为2i+1
上述的两个不等式就可以表示为
4.堆的性质
- 堆的根节点(也就是数组的第一个元素)始终是所有元素中最大的。
这个很容易根据定义得到——根节点要大于左右子树的结点,而左右子树又分别大于其左右子树,递归下去。由于一棵树除了根节点之外都有父节点,每一个结点都小于其父节点,所有可以知道根节点一定是所有元素中最大的。 - 所有的非叶子结点大于其左右孩子的结点。
这个是堆的定义。
二、堆排序实现
1. 初始化一个大顶堆
借用别的大佬的图示:
2. 不断弹出堆顶元素并调整堆
- 弹出堆顶(将堆顶元素和最后一个元素交换位置)
- 重新调整堆顶元素使其变成一个最大堆
3. 细节
- 当进行交换之后,不是交换完就形成一个堆了,而是需要继续判断下坠的结点是否满足堆的性质。
三、代码实现
1.仅实现堆排序
调整根节点为i的子树,使其变为一个大顶堆
private void adjustHeap(int i) {
int temp = array[i];
//2i+1为左子树
//这里的length是一个全局变量,表示的是堆的逻辑长度(弹出元素长度减一)
for (int k = 2 * i + 1; k < length; k = 2 * k + 1) {
//选出左右子树最大的值
if (k + 1 < length && array[k + 1] > array[k]) {
k = k + 1;
}
//注意是 array[k] 与 temp 比较
if (array[k] > temp) {
array[i] = array[k];
i = k;
} else {
break;
}
}
array[i] = temp;
}
初始化大顶堆
private void sort() {
for (int i = length / 2 - 1; i >= 0; i--) {
adjustHeap(i);
}
}
进行堆排序,这里是直接将元素打印出来(也可以new一个新的数组存储)
(课本的做法是将弹出元素赋给最后一个元素)
public void heapSort(){
//如果堆不空
while(!isEmpty()){
//将堆顶赋值给res
int res = array[0];
//将最后一个数值赋给堆顶,并使得该堆的逻辑长度减一
array[0] = array[--length];
//重新调整结点0所在的树
adjustHeap(0);
System.out.print(res+" ");
}
}
测试
2.进行封装为数据结构
直接看代码即可
import java.util.Arrays;
public class HeapSort {
//声明array数组用于存储堆
private int[] array;
//数组的初始容量
private static final int SIZE = 10;
//数组每次扩大的增量
private static final int INCREMENTAL = 5;
//数组的实际有效长度(存储了多少个数据)
private int length;
/*
无参构造函数
初始化数组
有效长度赋值为0
*/
HeapSort() {
array = new int[SIZE];
length = 0;
}
/*
有参构造函数
可以传入一个数组或者一串int型的变量
会将这些数据赋值给array
并调用私有的初始化方法init将数据调整为最大堆
*/
HeapSort(int... array) {
this.array = new int[array.length];
//这里不要使用引用赋值,不然会修改到原数组
for(int i = 0; i < array.length; i++){
this.array[i] = array[i];
}
length = array.length;
init();
}
/*
初始化堆的函数
调整length/2 到 0 这些非叶子节点
*/
private void init() {
for (int i = length / 2 - 1; i >= 0; i--) {
adjustHeap(i);
}
}
//判空
private boolean isEmpty() {
return length <= 0;
}
//弹出堆顶元素并重新调整堆
public int poll() {
if (!isEmpty()) {
int res = array[0];
array[0] = array[--length];
adjustHeap(0);
return res;
} else {
throw new ArrayIndexOutOfBoundsException();
}
}
//插入新的数据
public void add(int n) {
//如果当前的堆已经满了,重新创建一个新的堆
if (length == array.length) {
array = Arrays.copyOf(array, length + INCREMENTAL);
}
//插入新元素
array[length++] = n;
//这里是重点: 由于是在数组最后面插入,需要调整插入位置到根节点的整棵树
// length/2-1是插入结点的父节点
// 要寻找插入结点的父节点的父节点,使用 i = (i-1) / 2
for (int i = length / 2 - 1; i >= 0; i = (i - 1) / 2) {
adjustHeap(i);
if (i == 0) {
break;
}
}
}
//返回堆顶元素不弹出
public int peek() {
if (!isEmpty()) {
return array[0];
} else {
throw new ArrayIndexOutOfBoundsException();
}
}
//返回堆的有效长度
public int size() {
return length;
}
//调整算法——核心
private void adjustHeap(int i) {
int temp = array[i];
//2i+1为左子树
//之前说左子树为2i是基于索引从1开始,这里用2i+1是因为数组下标从0开始
for (int k = 2 * i + 1; k < length; k = 2 * k + 1) {
//选出左右子树最大的值
if (k + 1 < length && array[k + 1] > array[k]) {
k = k + 1;
}
//注意是 array[k] 与 temp 比较
if (array[k] > temp) {
array[i] = array[k];
i = k;
} else {
break;
}
}
array[i] = temp;
}
// //测试使用
// public void display(){
// System.out.print("当前堆内情况:");
// for(int i = 0; i < length; i++){
// System.out.print(array[i] + " ");
// }
// System.out.println();
// }
}
测试代码
@Test
public void heapSortTest() {
HeapSort hs = new HeapSort();
//从小到大插入15个数
for (int i = 0; i <= 14; i++) {
hs.add(i * i);
}
hs.display();
System.out.print("堆的长度为:");
System.out.println(hs.size());
System.out.print("弹出的堆顶元素为:");
System.out.println(hs.poll());
hs.display();
System.out.print("再弹出一个元素:");
System.out.println(hs.poll());
hs.display();
System.out.print("当前堆的大小:");
System.out.println(hs.size());
System.out.print("堆顶元素为(不弹出):");
System.out.println(hs.peek());
for (int i = 1; i <= 14; i++) {
System.out.print(hs.poll() + " ");
}
}
测试结果
测试代码解释:
首先初始化一个空堆
循环调用add方法(从小到大)插入15(>10)个元素,这里测试了(add方法的自动增长)即使数据再多,堆也能自适应增长
插入结束!
第一行输出:调用display查看堆内情况(堆顶为最大(196),非叶子结点大于左右孩子)
第二行输出:调用了size方法
第三行输出:调用poll方法,不同于peek。poll会弹出,peek只是查看。输出为196
第四行输出:查看弹出之后堆的情况(每次弹出都会调整)
第五、六行输出:同样是poll测试
第七行输出:调用peek方法,只查不出
第八行是一个for循环,循环弹出14个元素(这里的size为13)所以所有元素出堆之后,再次调用poll方法会抛出异常并捕获。
时间复杂度分析
直接分析核心函数adjustHeap,每次循环k=2*k+1,最坏情况就是堆顶为最小,需要调整log2(n)个数,调整只涉及简单赋值,故调整的时间复杂度为O(1)。
调用一次adjustHeap函数的时间复杂度为O(log2(n))
init方法会调用n/2次adjustHeap。建堆的时间复杂度为O(nlog2(n))
排序会调用n次的adjustHeap。复杂度为O(nlog2(n))
应用
- 可以在分支限界的剪枝使用(涉及到算法就不赘述)
- 优先队列的使用,例如在构建最小生成树,使用克鲁斯卡尔算法,每次会找剩余边的最小值,这不就刚好是堆吗,每次弹出一个最小值判断是否联系了两个不连通的分支
- 从大量元素(假设1010)中选出前100个最大/小的,其他排序算法基本都是需要把所有数据排完再挑选出前100个。或者使用快排,枢纽选得合适的话也很快。堆排不需要所有元素有序,只需要执行1次init和100次的poll方法,复杂度为(100+1010 / 2)* log2(1010).