什么是堆(二叉堆)?
堆,这里是二叉堆的简称,其实就是一棵二叉树,还是二叉树里比较特殊的完全二叉树。如果不熟悉的二叉树的相关概念的朋友,需要先复习一下,起码要知道二叉树的概念,比如子节点,叶子节点,树的层级这些概念。
完全二叉树:除了最后一层以外的所有层的节点都是被填满的,最后一层的叶子节点如果没有被填满,那么叶子节点的顺序是从左向右排列的。
当然,堆也有区分是 大顶堆 、 小顶堆 :
- 大顶堆:根节点是整棵二叉树中最大的数字。
- 小顶堆:根节点是整棵二叉树中最大的数字。
用堆来实现什么?
有的时候,我们需要利用有限的空间来获取一些Top K这种数据,这种数据可以是最大的,也可以是最小的。
为啥说是有限空间?比如我们有个10G的数据,但是我们只有200M的内存,我们要取出前一万个最大的数字。常规的做法就是排序呗,但是这个内存太小,不足以让你在内存里面排序。
那咋整,每次拿个几万数据来排序,然后再和原来的那个一万个逐个比较?这是一种办法,但是时间复杂度太高。每次排序都要
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN),然后还要算上和原来的排好序的数组的进行比较,交换,搬移的时间,时间太久了。
那么有没有一个比较好的方法来完成这个任务呢?
答案就是利用优先队列,也就是堆这个数据结构,至于如何操作我们等等再讲。
为什么要用堆?
如果不是堆这个数据结构,那么可以用哪些来实现优先队列呢?
- 数组:入队(插入)时候,时间复杂度 O ( 1 ) O(1) O(1);出队(删除)的时候,需要遍历整个数组,删除,并且把后面的元素前移,时间复杂度为 O ( n ) O(n) O(n)。
- 链表:入队时间复杂度为 O ( 1 ) O(1) O(1);出队的时候,需要遍历整个数组,并且删除,但是不用进行数据搬移。
- 二叉查找树:入队的时间复杂度为 O ( l o g ( n ) ) O(log(n)) O(log(n));出队时候时间复杂度为 O ( 1 ) O(1) O(1)。但是有个弊端,就是在出队时候,会出现子树不平衡的现象,容易变成单链表,比如,如果是小顶堆,那么左子树就会一直减少;如果是大顶堆,那么右子树就会一直减少。
- 平衡二叉查找树:入队时间 O ( l o g ( n ) ) O(log(n)) O(log(n)),出队的时间复杂度 O ( 1 ) O(1) O(1)。但是平衡二叉查找树也支持许多不需要的特性,使用起来显得有点浪费,特别是不管哪种平衡二叉查找树的实现都是复杂的实现。
如何实现二叉堆?
(1)实现哪些接口
首先,我们要想清楚,这个二叉堆要什么功能?
一般数据结构个,增加,删除,查询,修改,这几个基本操作是必须的。
同样,身为数据结构,二叉堆也有以上几种操作,不过,不同的是,它 “变异” 了。
二叉堆的实现是接口:
public interface Heap<E extends Comparable> {
/**
* add element.
* @param e element
*/
void add(E e);
/**
* delete max element or min element.
* @return max element or min element.
*/
E delete();
/**
* find max element or min element.
* @return max element or min element.
*/
E find();
/**
* replace max element or min element.
* @param e to replacing-element.
*/
void replace(E e);
}
为什么用Comparable这个排序接口,主要是有compareTo这个接口,我们的这个二叉堆肯定是要去有比较前后大小性质的,所以需要进行排序。
add: 这个不用说,就是增加节点的操作。
注意:以下都是“变异”后的操作了
delete: 这个操作有点特殊,不是删除任意的节点,而是删除二叉堆中最大的或者是最小的点 。至于说到底是最大还是最小,这个取决于你实现的是最大堆还是最小堆。
find: 这个不是按照查找任意节点,而是查找最大的或者最小的点。至于说到底是最大还是最小,这个取决于你实现的是最大堆还是最小堆。
replace: 就是把最大的,或者最小的那个节点替换掉。至于说到底是最大还是最小,这个取决于你实现的是最大堆还是最小堆。
(2)用最大堆作为例子
首先我们需要明白一个概念,虽然二叉堆是个二叉树,但是它如果用树的结构来存储数据是很耗费空间;因为它是个完全的二叉树,所以我们可以用数组来存储数据。
要理解这段话并不容易,首先我们要知道,之所以二叉树树的节点的数据结构,一般都要存一个左节点和右节点,我们可以根据当前节点来获取左节点和右节点,有的二叉树的节点结构有获取父节点的需求,还会增加一个父节点的点。所以,我们可以知道一条信息,就是你如果获取了某个树的节点,那么相应的,你需要获取这个二叉树的左孩子的节点的值,右孩子的节点的值。
因为二叉堆是个满二叉树,所以,我们只要知道某个索引,我们就可以根据索引来算出这个坐标。
先来看看下面这张图:
其实就是把完全二叉树给塞进数组里面。
这里我们要知道计算左孩子,右孩子,父亲节点的索引几个公式。
父亲节点:
(
i
n
d
e
x
−
1
)
/
2
(index - 1) / 2
(index−1)/2
左孩子:
i
n
d
e
x
∗
2
+
1
index * 2 + 1
index∗2+1
右孩子:
i
n
d
e
x
∗
2
+
2
index * 2 + 2
index∗2+2
我们用19这个节点来作为例子:
19的索引 :2
父亲的索引:0
左孩子的索引:5
右孩子的索引:6
可以带入公式,看看是否正确。
知道如何去保存数据之后,我们再来看看堆需要几个基本操作。
增加:
首先就是我们无论如何都要保存堆的一个性质,我们假设我们增加了一个元素,我们要保证增加元素过后,依然保证最大堆的性质不变,就是随便在什么地方,父亲节点大于孩子节点。
那么这个增加元素在最后,我们要怎么修改呢?
把增加的元素和他的父亲节点去比,如果比他父亲大,就交换,如果比父亲小,那么就结束。代码实现就是:
public void add(E e) {
data.add(e);
size++;
shiftUp(size - 1);
}
/**
* 上移操作:在底层,开始往上移动。
* @param index 下移的坐标
*/
private void shiftUp(int index) {
int parentIndex = (index - 1) / 2;
// 与父亲节点比较,如果大于父亲节点,就交换,
while (parentIndex >= 0 && data.get(parentIndex).compareTo(data.get(index)) < 0) {
swap(index, parentIndex);
index = parentIndex;
parentIndex = (index - 1) / 2;
}
}
删除
把最大的节点删除,那么就会空出一个节点,别的节点也要相应的占位上来;其实这样看来,是比较复杂的,但是我们可以吧最后一个节点赋值到第一个节点,然后把最后一个结点删除,
然后去吧第一个节点进行比较,然后归位。
@Override
public E delete() {
if (size <= 0) {
throw new IllegalArgumentException("size under 0.");
}
E ans = data.get(0);
data.set(0, data.get(size - 1));
data.remove(size - 1);
size--;
shiftDown(0);
return ans;
}
/**
* 下移操作
* @param index 下移的坐标
*/
private void shiftDown(int index) {
int maxIndex = index;
while (true) {
int leftChildIndex = 2 * index + 1;
if (leftChildIndex < size && data.get(leftChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = leftChildIndex;
}
int rightChildIndex = 2 * index + 2;
if (rightChildIndex < size && data.get(rightChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = rightChildIndex;
}
if (maxIndex == index) {
break;
}
swap(maxIndex, index);
index = maxIndex;
}
}
其实堆里面,最重要就是两个操作,一个上移,一个下沉,只要搞懂了这个两个操作,堆你就拿下了。
我们来看看完整的代码。
public class MaxHeapMine<E extends Comparable> implements Heap<E> {
private ArrayList<E> data;
private int size;
public MaxHeapMine() {
data = new ArrayList<>();
size = 0;
}
public MaxHeapMine(E[] elements) {
data = new ArrayList<>();
size = 0;
for (E item : elements) {
add(item);
}
}
@Override
public void add(E e) {
data.add(e);
size++;
shiftUp(size - 1);
}
@Override
public E delete() {
if (size <= 0) {
throw new IllegalArgumentException("size under 0.");
}
E ans = data.get(0);
data.set(0, data.get(size - 1));
data.remove(size - 1);
size--;
shiftDown(0);
return ans;
}
@Override
public E find() {
if (size <= 0) {
throw new IllegalArgumentException("size under 0.");
}
return data.get(0);
}
@Override
public void replace(E e) {
data.set(0, e);
shiftDown(0);
}
/**
* 上移操作
* @param index 下移的坐标
*/
private void shiftUp(int index) {
int parentIndex = (index - 1) / 2;
while (parentIndex >= 0 && data.get(parentIndex).compareTo(data.get(index)) < 0) {
swap(index, parentIndex);
index = parentIndex;
parentIndex = (index - 1) / 2;
}
}
/**
* 下移操作
* @param index 下移的坐标
*/
private void shiftDown(int index) {
int maxIndex = index;
while (true) {
int leftChildIndex = 2 * index + 1;
if (leftChildIndex < size && data.get(leftChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = leftChildIndex;
}
int rightChildIndex = 2 * index + 2;
if (rightChildIndex < size && data.get(rightChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = rightChildIndex;
}
if (maxIndex == index) {
break;
}
swap(maxIndex, index);
index = maxIndex;
}
}
private void swap(int a, int b) {
E temp = data.get(a);
data.set(a, data.get(b));
data.set(b, temp);
}
public static void main(String[] args) {
Integer[] test = {1, 2, 3, 4, 0, 9, 8, 7, 6, 5, 20, 19, 18, 17, 16};
MaxHeapMine<Integer> maxHeapMine = new MaxHeapMine<>(test);
for (int i = 0, len = test.length; i < len; ++i) {
System.out.println(maxHeapMine.delete());
System.out.println(maxHeapMine.find());
}
}
}
你可以把代码直接拷贝运行,看看效果如何,最后会打印出一组排好序的数字的。
其实上面的代码可以继续优化,减少交换的次数。不过有些不太好理解。有兴趣的同学可以是试试。