概念
堆可以理解为是一种特殊的树,只要满足以下两个条件就可以称之为堆:
- 是一个完全二叉树
- 堆中每个节点值都大于等于或者小于等于其子树中每个节点的值
完全二叉树的概念可能有的人有点忘了,这里做下简单描述:
二叉树除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。
每个节点都大于等于子树中每个节点的值的堆,我们叫作“大顶堆”。每个节点值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。
定义很简单,我来考考大家,下面这几个二叉树是不是堆?
1和2是大顶堆,3是小顶堆,4不是堆,因为它不满足完全二叉树的结构。
怎么去实现一个堆?
这里我用数组来实现堆的结构,用数组的话,如下图所示:
通过数组完成了大顶堆的构造,这里面有三个名词需要先解释下,可以辅助我们理解代码。
假设我们的节点存储都是从数组第二个位置开始存储,定义每个节点在数组的位置为i,
其左子节点为:2i, 右子节点为:2i+1。
这个规律通过图不难推导出来,这也是需要重点掌握的,接下来我们就来一步一步用代码实现堆的各种功能。
往堆中插入一个元素
下面的示例我都以大顶堆进行举例讲解
堆化: 往堆中插入一个元素,我们还需要满足堆的两个特性。如果插入后不满足堆的特性,我们就需要进行调整,这个调整的过程我们称之为堆化。
怎么去进行堆化呢,其实就是让新插入的节点和它的父节点进行比较,如果发现不满足子节点小于等于父节点,就互换两个节点值,一直重复这个过程,直到整个堆都满足父子大小关系。
代码实现
public class NewMaxHeap {
//存储数据,从下标1开始存储
private int[] datas;
//堆目前已经存储堆数据个数
private int count;
//堆可以存储堆最大数据个数
private int capacity;
public NewMaxHeap(int capacity) {
datas = new int[capacity + 1];
this.capacity = capacity;
}
public void insert(int data) {
if (count >= capacity) {
//堆已满
return;
}
++count;
datas[count] = data;
int i = count;
/* 不断循环比较父子节点大小,
*
* 直到整个堆父子节点大小满足堆的要求
*/
while (datas[i] > getParent(i)) {
swap(data, i);
i = getParent(i);
}
}
/**
* 两个节点的值互换
*
* @param index
* @param parent
*/
private void swap(int index, int parent) {
int tmp = datas[index];
datas[index] = parent;
datas[parent] = tmp;
}
/**
* 根据节点索引位获取父节点索引位
*
* @param index 节点索引位
* @return
*/
private int getParent(int index) {
if (index <= 0) {
throw new IllegalArgumentException("no parent");
}
return index / 2;
}
}
复制代码
删除堆顶元素
删除堆顶元素其实就是删除堆最大的元素。如何进行删除呢?
我们可以将堆顶元素值和最后一个节点值进行互换,互换完成后,再利用父子节点对比方法,堆不满足父子节点大小关系的进行互换,不断重复这个过程,直到满足堆堆条件。这个过程其实就是从上往下的堆化方法。
代码示例:
/**
* 删除堆顶元素
*/
public void deleteMaxData() {
if (count == 0) {
return;
}
datas[1] = datas[count];
--count;
//自上而下进行堆化
heapify(datas, count, 1);
}
/**
* 自上而下堆化
*
* @param i
*/
private void heapify(int[] datas, int count, int i) {
while (true) {
int maxPos = i;
//比较父节点和左子节点大小
if (2 * i <= count && datas[i] < datas[2 * i]) {
maxPos = 2 * i;
}
//比较父节点和右子节点大小
if (i * 2 + 1 <= count && datas[maxPos] < datas[i * 2 + 1]) {
maxPos = 2 * i + 1;
}
//没有变化说明已经堆化结束,跳出循环
if (maxPos == i) {
break;
}
//节点值互换
swap(datas, maxPos, i);
i = maxPos;
}
}
复制代码
时间复杂度分析
堆化的过程就是顺着节点所在的路径进行比较交换,因此堆化的时间复杂度跟树的高度是成正比的,也就是 O( log n )。
插入数据和删除堆顶数据主要的核心逻辑就是堆化,所以两者时间复杂度都是 O( log n )。
堆排序
堆排序就是利用堆(都以大顶堆举例)进行排序堆方法。首先将待排序的序列构造成一个大顶堆,那么最大值就是堆顶的根节点。然后将这个根节点和堆的末尾元素进行互换,那么此时末尾元素就是最大值,将剩余的n-1个元素重新构造成一个堆,这样就得到了n个元素的次大值,反复执行,就能得到一个有序序列了。
建堆
实现堆排序的第一步就是将一个带排序的序列构造成一个大顶堆。这里我们采用从上往下的堆化方式,代码如下:
/**
* 将一个无序数组构建成堆
*
* @param datas 无序数组
* @param n 元素个数
*/
private void bulidHeap(int[] datas, int n) {
for (int i = n / 2; i >= 1; --i) {
//堆化
heapify(datas, n, i);
}
}
复制代码
假设我们要排序的序列是{0,7,5,19,8,4,1,20,13,16},堆元素从数组下标1开始,因此总共有9个元素,
这个for循环可能大家很疑惑,这个for循环i是从 9/2=4 开始,4->3->2->1的变量变化。为啥不是从1到9或者从9到1,而是从4到1呢?其实通过下面的图我们就可以明白了,它们都是有孩子的节点。
排序
建堆结束后,就要按照大顶堆堆特性来组织完成最终的排序,排序就是不断将堆顶元素和末尾元素进行交换,交换完后对n-1个元素进行重新堆化,接着继续交换,不断重复该过程,最终就完成了排序。
排序代码
/**
* 堆排序
*
* @param datas 源数据
* @param n 数据长度
*/
public void sort(int[] datas, int n) {
//构建堆
bulidHeap(datas, n);
//排序
int k = n;
while (k > 1) {
swap(datas, 1, k);
--k;
heapify(datas, k, 1);
}
}
复制代码
堆排序复杂度分析
堆排序包括建堆和排序两个操作,建堆的时间复杂度是 O(n) ,排序的时间复杂度是 O(n\log n),所以堆排序整体的时间复杂度就是 O(n\log n)。
总结
本篇文章我们讲解了堆的概念,堆是一种完全二叉树,并且堆每个节点的值都大于等于或小于等于子节点的值。
接着我们讲解了堆中重要的两个操作插入数据和删除堆顶元素,两者其实都用到了堆化的操作。插入数据时我们是从下往上进行对话,删除堆顶元素我们是通过把数组的最后一个元素移到堆顶,然后从上往下堆化。
最后我们讲到了堆的应用,就是进行堆排序,堆排序包含建堆和排序两个过程。建堆就是将一个无序序列通过从上往下堆化的过程完成堆的构建。排序就是不断的迭代将堆顶元素放到堆的末尾,并将堆的元素减1,然后再堆化,不断重复这个过程,最终就形成了有序序列。
堆的知识点就大致讲完了,但是刷算法的路还很长,那就继续坚持,关注小蛋我,一起交流,一起进步,算法的干货会源源不断的带给大家。