数据结构---堆
导语
- 堆的逻辑数据结构实际上是一个可以使用数组实现的完全二叉树(堆也一定是平衡二叉树),所以学习堆,完全二叉树不是很了解的,可以看一下树结构---基础中完全二叉树的简单介绍。
- 本篇实现自定义最大堆时,使用之前自定义的动态数组作为物理存储结构
1.堆基础
- 最大堆:某个节点的值总是大于等于其左右孩子的值
- 最小堆:某个节点的值总是小于等于其左右孩子的值
- 完全二叉树:由完全二叉树的性质,可知除了叶子层之外,其他层可以组成一个满二叉树,而叶子则是由从左右向缺失节点的。这一特性,使用层序存储(即层序遍历的顺序)的方式,可以使用一个数组来完成
- 树节点的父子关系:可以转化成数组中的索引之间的关系
堆.png
由上述特性,我们在使用数组存储堆中一个元素后,可以轻松找到这个元素的父亲节点的位置和其左右孩子的位置。
/**
* 返回完全二叉树的数组表示中
* 一个索引所表示的元素的父亲结点的索引
*
* @param index :传入元素索引
* @return :父亲元素索引
*/
private int parent(int index) {
if (index == 0) {
throw new IllegalArgumentException("index-0 doesn't have parent!");
}
return (index - 1) / 2;
}
/**
* 返回完全二叉树的数组表示中
* 一个索引所表示的元素的左孩子结点的索引
*
* @param index:传入元素索引
* @return :其左孩子索引
*/
private int leftChild(int index) {
return index * 2 + 1;
}
/**
* 返回完全二叉树的数组表示中
* 一个索引所表示的元素的右孩子结点的索引
*
* @param index:传入元素索引
* @return :其右孩子索引
*/
private int rightChild(int index) {
return index * 2 + 2;
}
2.使用动态数组实现堆
2.1.使用数组作为物理内存结构
public class MaxHeap<E extends Comparable<E>> {
private Array<E> data;
public MaxHeap(int capacity) { data = new Array<>(capacity); }
public MaxHeap() { data = new Array<>(); }
public MaxHeap(E[] arr) {
data = new Array<>(arr);
//当传入一个数组构造最大堆的时候
//从最大堆的数组中第一个非叶子节点开始,一直到根节点进行下沉操作
//第一个非叶子节点的索引 = 最后一个叶子节点的父节点索引
for (int i = parent(data.getSize() - 1); i >= 0; i--) {
siftDown(i);
}
}
public int getSize() { return data.getSize(); }
public boolean isEmpty() { return data.isEmpty(); }
}
- 根据堆的性质,每个父节点的值比左右孩子值要大,每个元素要满足可以比较,因此泛型 E extends Comparable<E>
- 代码中提供了三个构造函数,前两个不需要解释,第三个构造函数是传入一个数组,构造成一个堆结构的数据,相对比较复杂,单独拿出来讨论
2.2 参数为数组的构造函数
-
在构造过程中主要有两步操作:
1.首先先将所有传进来的数组添加到堆中的数组中
2.之后在第一个非叶子节点的位置开始,一直到根节点进行下沉操作 -
为何从第一个非叶子节点进行下沉操作,而不是从最后一个元素开始,便可使整个数组满足堆性质
2.1.为何从第一个非叶子节点开始?为了满足堆的性质,需要把节点元素与其左右子节点元素中最大的元素发生位置交换,之后再交换其最大子节点与最大子节点的子节点中的最大的元素,一直交换到该节点为整棵树的叶子节点为止,而这个交换的过程实质上是下沉操作。由于所有的叶子节点不再有非空子结点,并不需要与子节点发生比较这个操作,因此只需要从第一个非叶子节点开始就可以,并不需要从最后一个叶子节点开始。
2.2.如何找到第一个非叶子节点
由于完全二叉树是以数组形式层序存储的,因此我们可以轻松的找到第一个叶子节点的索引,而由于是完全二叉树的性质,最后一层非满层都会在左侧,而右侧的叶子节点必然在上一层中,所以第一个非叶子节点一定会是最后一个叶子节点的父节点
堆-第一个非叶子节点.png -
下沉操作
实质上某节点与其最大子节点比较,若值小于最大子节点,则发生交换。这个过程就是下沉过程
下沉操作.png
在上述构造函数执行的实质上是从第一个非叶子节点到根结点进行下沉操作。
/**
* 下沉操作:
* 若该节点的值比其左右孩子结点的元素值要写:与其孩子结点中最大的元素值进行交换,直到满足最大堆性质
*
* @param i :索引 = 0
*/
private void siftDown(int i) {
//在下沉过程中,若其左孩子结点的索引值 <= 元素的最大索引值,才进行交换操作
//否则,说明其左右孩子并不存在
while (leftChild(i) < data.getSize()) {
//说明左孩子一定存在,右孩子不一定
//此时左右孩子默认左孩子最大,且保存其索引值
int maxIndex = leftChild(i);
//其右孩子索引值是否存在
if (maxIndex + 1 < data.getSize()) {
//则比较左右孩子中值最大,并返回最大的索引值
if (data.get(maxIndex).compareTo(data.get(maxIndex + 1)) < 0) {
maxIndex = maxIndex + 1;
}
}
if (data.get(i).compareTo(data.get(maxIndex)) >= 0) {
break;
}
data.swap(i, maxIndex);
i = maxIndex;
}
}
2.3.删除操作(取出堆中最大的元素)
- 删除操作的思路
1.在最大堆中取出最大元素,即取出根元素
2.后继节点选用最后一个叶子节点,用这个值替换根元素的值,并删除最后一个节点
3.再对这个新的根元素进行下沉操作,来满足堆的性质
/**
* 取出堆中最大的元素
*
* @return :索引为0 的数据
*/
public E extractMax() {
//获得最大的元素
E e = findMax();
//以最后一个元素为后继,将最后一个元素赋值给头元素(交换)
data.swap(0, data.getSize() - 1);
//删除最后一个元素
data.removeLast();
//此时最后一个元素继承了第一个元素位置,但是可能比其孩子元素小,因此进行下沉操作
siftDown(0);
return e;
}
2.4.添加操作
- 添加操作思路
1.按照完全二叉树的性质,挨着最后一个叶子节点添加新结点
2.新节点并不满足堆的性质,所以需要对新添节点进行上浮操作
/**
* 向堆中添加元素
*
* @param e :待添加的元素
*/
public void add(E e) {
data.addLast(e);
//上浮新添加元素,根据其索引来比较与其父元素大小来满足最大二叉堆性质
siftUp(data.getSize() - 1);
}
- 上浮操作
下沉操作是让一些小于最大子节点的节点往下移动,而上浮操作正好相反,是让一些大于父节点的节点,与父节点交换位置。两个操作的目的都是为了保证父节点大于左右子节点。
上浮操作.png
/**
* 上浮操作:
* 当往一个二叉树的数组中添加一个元素时,为了满足最大二叉堆的性质
* 即新添加元素比其父元素要小的性质:若新添元素小于其父元素,需要交换二叉堆组成的数组中相应的 新添元素和其父元素的值
*
* @param i :新添元素所在最大二叉堆的数组中的索引
*/
private void siftUp(int i) {
//若i索引不是根且值大于其父索引的值,交换值
while (i > 0 && data.get(i).compareTo(data.get(parent(i))) > 0) {
data.swap(i, parent(i));
i = parent(i);
}
}
2.5.替换操作
- 替换操作思路
1.先查找出堆中最大的元素,即根元素,替换成新的元素
2.对新的元素进行下沉操作
/**
* 查看堆中最大的元素
*
* @return :返回索引为0 的元素
*/
public E findMax() {
if (data.getSize() == 0) {
throw new IllegalArgumentException("Can not findMax when heap is empty!");
}
return data.get(0);
}
/**
* 取出堆中最大的元素,并替换成元素E
*
* @param e :替换的元素
* @return :
*/
public E replace(E e) {
//查询最大的元素并返回
E ret = findMax();
//替换e到最大元素位置
data.set(0, e);
//下沉保持最大堆性质
siftDown(0);
return ret;
}
3.堆的时间复杂度
- 堆的主要操作发生在上浮和下沉两个核心操作上,而这两个操作对于堆的逻辑结构完全二叉树来说,是一个O(logn)的操作。因此堆的增删改操作时间复杂度都是O(logn)
- 直接对一个数组转换构成一个堆,时间复杂度为O(n)
- 堆有一个排序算法,即堆排序,简单说一下,对n个元素进行操作,而每个元素需要进行一次下沉操作,所以堆排序的时间复杂度是O(nlogn)