引入
首先,现在说得的堆不是内存得一片区域,而是一种数据结构。
在说堆之前我们可以先复习一下之前学习的优先级队列,我们之前的优先级队列,使用数组实现的,虽然实现优先级队列可以有很多不同的内部结构。这种实现方式,岁然删除最大项的时间为O(1),但是插入的话还是需要较长的O(N)时间。这是因为每次插入数据,平均要移动数组中一半的数据以插入新的数据项,以保持数组在插入数据之后依然是有序的。
我们现在说的堆是优先级队列的另外一种实现方式。是用树的方式实现优先级队列,这种结构的优先级队列的插入和删除的时间都为O(logN)。虽然整体上说删除的时间是慢了一点,但是,插入的时间还是快了很多的。当速度比较重要且插入操作比较多的时候,可以选择使用堆来实现优先级队列。
堆
堆是有如下结构的二叉树:
1. 它是完全的二叉树。也就是说,除了树的最后一层节点不需要是满的,其他的每一层从左到右必须是满的。
2. 它常常用一个数组实现。
3. 堆中的每一个节点都满足堆的条件。也就是说每一个节点的关键字都大于等于这个节点的子节点的关键字。
完全二叉树与不完全二叉树图示:
堆再存储器中的表示图示:(用数组存储二叉树图示)
!!!堆只是一个概念上的表示。注意树是完全的二叉树,并且所有的节点都满足堆的条件。
堆主要是用于实现优先级队列:优先级队列和实现它的堆之间有着非常紧密的联系。下面我们给出一部分简化代码:
// 堆类
class Heap {
private Node heapArray[]; // 用于存储堆数据的数组
public void insert(Node nd) { } // 插入节点的方法
public Node remove() { } // 删除节点的方法
}
// 优先级队列类
class priorityQueue{
private Heap theHeap; // 内部维护着堆类
public void insert(Node nd) { theHeap.insert(nd); } // 内部调用堆的插入
public Node remove() { return theHeap.remove(); } // 内部调用堆的删除
}
从上面的简化代码我们可以看出:
1. 优先级队列是一个可以用不同的方法实现的ADT
2. 堆是一个更为基础的数据结构
下面说一下对于堆的一些具体的操作:
首先得分析一下堆得结构:堆和二叉树相比是弱序的。何为弱序?我们对比一下:
在二叉树中插入每一条数据都是严格的左边的节点的数据大于右边节点的数据,所以们就可以通过一些查找的算法在很快的O(logN)的时间级内找到我们需要的节点,
但是在堆中,虽然同样使用的是二叉树我们看上面的堆实现的数组就可以看出其并没有这么严格的要求,对于堆而言值要求从根到节点的每一条路径,节点都是按照降序排列的,所以我们在堆中查找一个节点是非常困难的。因为我们在查找的过程中没有足够的信息来决定选择通过节点的两个子节点中的哪一个走向下一层。他也没办法再O(logN)的时间内删除掉一个节点。因为我们找不到那个节点。(如果我们要删除某个节点,获取可以通过再有序数组中查找到对应的节点来删除,但是这样会消耗O(N)的时间来干这件事情)。
所以我们会发现,其实堆这种组织非常接近无序。不过作为堆而言,我们需要它干的只有两件事情:1. 移除最大的节点 2. 快速插入新的节点
如何移除?
移除其实就是删除最大的节点,这个操作本来是非常容易的,因为最大的节点总是的根的位置。
maxNode = heapArray[0];
但是我们把根移除之后,树结构就是不完全的,我们的数组中也是出现的空洞,我们要解决这个问题,本来可以将数组中的元素全部上移一个单位,但是这样肯定是非常慢的。
我们采用更快的方法:步骤大概如下:
1. 移走根
2. 把最后一个节点移动到根的位置
3. 一直向下筛选这个节点,直到它在一个大于它的节点之下,小于它的节点之上为止。
移走了根,数组的大小-1,把最后一个节点也就是最后一层最右边的节点移动到根的位置上,然后我们开始操作这个新的根,因为它不是根,所以我们必须要找到目前为止最适合的根,而且要把该节点新位置的问题处理好。在被筛目标节点的每个暂时停留的目位置上,向下筛选的算法都要检查哪个节点更大。然后目标节点和更大的节点交换位置。如果说要把目标节点和较小的子节点位置交换,那么这个子节点 就会变为大子节点的父节点,这违背了堆的条件。
插入
插入节点和删除节点不同的是插入节点是向上筛选,而不是向上筛选。节点在初始时插入到数组最后第一个空着的单元中,数组容量+1.
heapArray[N] = newNode;
N++;
因为在插入之后其父节点是在树的底端值应该是比较小的,那么新插入的节点的值应该是相对比较大的,这就违反了堆的条件。所以就需要向上筛选,直到它在一个大于它的节点之下,小于它的节点之上。
向上筛选的过程是比较简单的,因为它不用比较两个子节点的关键字大小,直接和其父节点比较即可、换位即可。
不是真的交换
在我们上述的两种操作方式中,不管是向下筛选还是向上筛选,我们都是要去不断的交换位置,但是每交换一次位置就需要进行三次复制。
// 交换a和b
int temp = a;
a = b;
b = temp; // 一共三次
现在计假如我们要进行三次交换,就代表这要进行九次复制,这肯定会影响到代码执行大的速度,但是我们又想到了一个办法:用复制代替交换
加入我们要进行三次交换,加入现在有A、B、C、D四个节点,我们现在要做的就是将顺序变为B、C、D、A,加入我们用交换的方法去做,那就是A和B交换,A和C交换、A和D交换,三次交换9次复制,
但是如果我们用复制的思维去做的话,就是这样的,备份A,B复制到A,C复制到B,D复制到C,A复制到D(备份也算一次复制),这样我们就把之前的9次复制变成了现在的5次复制。
对于很多层数的堆,这样的方式可以节省的复制次数接近3的倍数。
上代码
public class Heap{
private Node[] heapArray; // 堆数组
private int maxSize; // 堆的容量
private int currentSize; // 堆中当前的额元素个数
public Heap(int mx) { // 初始化相关参数
maxSize = mx;
currentSize = 0;
heapArray = new Node[maxSize];
}
// 当前堆中是否为空
public boolean isEmpty() {return currentSize == 0;}
// 插入新元素
public boolean insert(int key) {
if (currentSize == maxSize)
return false; // 数据已满无法插入
Node newNode = new Node(key);
heapArray[currentSize] = newNode; // 当前数组的下一个位置插入数据,也就是最后一层的右边第一个为空的位置
trickleUp(currentSize++); // 向上筛选
return true;
}
// 向上筛选
public void trickleUp(int index) {
int parent = (index - 1) / 2; // 自动向下取整 获取到其父节点的索引
Node bottom = heapArray[index]; // 将要移动的节点先做备份
while (index > 0 && heapArray[parent].getKey() < bottom.getKey()) {
heapArray[index] = heapArray[parent]; // 首先将当前节点的父节点下移
index = parent; // 然后再将父节点作为当前的节点 继续向上查询
parent = (index - 1) / 2; // 继续向上查找父节点
}
heapArray[index] = bottom; // 找到对应的位置 插入之前备份的新节点
}
// 删除最大值
public Node remove() {
Node root = heapArray[0]; // 根节点就是数组的第一个元素
heapArray[0] = heapArray[--currentSize]; // 将最后一层最右边的节点的值赋给 根
trickleDown(0); // 向下筛选
return root; // 返回根节点
}
// 向下筛选
public void trickleDown(int index) {
int largeChild; // 维护一个较大节点的变量
Node top = heapArray[index]; // 备份要移动的节点
while (index < currentSize/2) { // ???
int leftChild = 2*index + 1;
int rightChild = leftChild + 1;
// 如果右节点的下标没有越界 且 左节点的值小于右节点的值
if (rightChild < currentSize && heapArray[leftChild].getKey() < heapArray[rightChild].getKey())
largeChild = rightChild; // 正常情况下 比较大的是右节点
else // 还有特殊情况比如没有右节点的情况
largeChild = leftChild; // 比较大的节点就是左节点
if (top.getKey() >= heapArray[largeChild].getKey()) // 如果顶端的节点(现在根位置上的节点)的key大于等于largeChild的节点的key 向下筛选就完成了
break; // 直接返回
heapArray[index] = heapArray[largeChild]; // 不断的向下复制
index = largeChild;
}
heapArray[index] = top; // 将找到的很合适位置上加入我们备份好的节点
}
// 更改Key值,,
public boolean chhange(int index, int newValue) {
if (index < 0 || index >= currentSize)
return false;
int oldValue = heapArray[index].getKey();
heapArray[index].setKey(newValue);
if (oldValue < newValue) // 它比较大
trickleUpindex); // 向上筛选
else
trickleDown(index); // 它比较小 向下筛选
return true; // 更改Key 完成
}
}
扩展数组
如果我们再插入数据的过程中,插入了过多的数据,超出了堆数组的容量。这时候我们可以新创建一个数组,把数据从旧的数组中复制到新的数组中。执行复制操作的时间是线性的。但是增大数组容量的操作并不会经常的发生,特别是每次拓展数组容量的时候,数组的容量都充分地增大了。
堆操作的效率
对于有足够数据量的堆,向上和向下筛选算法是我们目前所有的操作中最费时间的。再这个过程中所需要的复制的次数和树的该股有关,如果树的高度为4层,需要执行四次复制把“洞”从顶层移到底层。
堆是一种特殊的二叉树,二叉树的层数L等于log2(N+1),其中 N 为节点数。trickleUp() 和 trickleDown() 中的循环执行了 L-1 次,所以 trickleUp()执行时间和log2N成正比,trickleDown()执行时间略长一点,因为它需要执行额外的比较。总之,这里讨论的堆操作的时间复杂度都为O(logN)。