一、堆的定义
堆分为小根堆和大根堆两种。对于一个小根堆,它是具有如下特性的一棵完全二叉树:
(1)若树根结点存在左孩子,则根结点的值小于等于左孩子结点的值。
(2)若树根结点存在右孩子,则根结点的值也小于等于右孩子结点的值。
(3)以左、右孩子为根的子树又各是一个堆。
大根堆的定义与上述类似,只要把条件“小于等于”改为“大于等于”就可以了。由堆的定义可知,若一棵完全二叉树是堆,则该树中以每个结点为根的子树也都是一个堆。
图7-4(a)和(b)分别为一个小根堆和一个大根堆。根据堆的定义可知,堆顶结点,即满足堆条件的整个完全二叉树的根结点,对于小根堆来说具有最小值,对于大根堆来说具有最大值。图7-4(a)是一个小根堆,堆中的最小值为堆顶结点的值18;图7-4(b)是一个大根堆,堆中的最大值为堆顶结点的值74。若用堆来表示优先级队列,则堆顶结点具有最高的优先级,每次做删除操作只要删除堆顶结点即可。
二、堆的接口类
堆是满足一定条件要求的完全二叉树,它需要自己的插入和删除方法,所以堆的接口类要继承一般二叉树的接口类,同时又要定义自己的插入和删除方法。下面给出堆的接口类的具体定义:
//定义堆的接口
public interface Heap extends BinaryTree{
void insert(Object obj); //向堆中插入一个元素obj
Object delete(); //从堆中删除堆顶元素并返回
}
三、堆的存储结构和顺序存储类
堆同一般二叉树一样既可以采用顺序存储,也可以采用链接存储,但由于堆是一棵完全二叉树,所以适宜采用顺序存储,这样既能够充分利用其存储空间,又便于访问孩子结点和双亲结点。
对堆进行顺序存储时,首先要对堆中的所有结点进行编号,然后再以编号为下标存储到指定数组的对应元素中。为了利用数组的0号元素,让堆中结点的编号从0而不是从1开始,当然编号次序仍然按照从上到下、同一层从左到右进行,若堆中含有n个结点,则编号范围为0~n-1。
让堆中的结点从0开始编号后,编号为0~2/n(向下取整)-1的结点为分支结点,编号为2/n~n-1的结点为叶子结点;当n为奇数则每个分支结点既有左孩子又有右孩子,当n为偶数则编号最大的分支结点只有左孩子没有右孩子;对于每个编号为i的分支结点,其左孩子结点的编号为2i+1,右孩子结点的编号为2i+2;除编号为0的堆顶结点外,对于其余编号为i的结点,其双亲结点的编号为(i-1)/2。
我们知道,当树根结点的编号从1开始时,若一个结点的编号为i,则左、右孩子结点的编号分别为2i和2i+1;当树根结点的编号从0开始时,树中所有结点的编号都减少1,则i变为i-1,2i和2i+1分别变为2i-1和2i,也可以分别表示为2(i-1)+1和2(i-1)+2,即左、右孩子结点的编号是父结点编号的2倍加1和2倍加2,若用j表示i-1,则j的左、右孩子结点的编号为2j+1和2j+2,其中,j>=0且j<=2/n(向下取整)-1,n为堆中的结点数。同理,当树根结点的编号从1开始时,i结点的双亲编号为i/2(向下取整);当树根结点的编号从0开始时,编号为i-1结点的双亲结点的编号为i/2(向下取整)-1,即为i/2(向下取整)-2/2=i/2-2/2(向下取整)=((i-1)-1)/2(向下取整),把i-1用j表示后,可以得到结点编号为j的双亲结点的编号为(j-1)/2(向下取整),其中,j>1且j<n。对于图7-4(a)和(b)所示的堆,对应的顺序存储结构如下:
当一个堆采用顺序存储结构时,需要利用java语言定义出相应的类,并且要实现Heap接口类中定义的所有方法,假定该类用SequenceHeap表示,它包含的数据成员有3个;第一个用来给定存储所使用的数组的初始默认长度,假定用整型常量minSize表示;第二个定义存储堆所使用的一维数组,假定用标识符heapArray表示,元素类型为Object;第三个定义一个整型变量,用来存储堆的长度,即当前堆中所包含的元素数,假定用标识符len表示。下面给出SequenceHeap类的具体定义。(在最下面)
堆有大根堆和小根堆之分,对他们进行插入和删除运算的方法相同,只是相应的判断条件相反而已,这里以小根堆为例,进行讨论,当需要使用大根堆时只要改变算法中的相应条件即可。
1、向堆中插入一个元素
向堆中插入一个元素时,首先将该元素写入到堆尾,即堆中最后一个元素的后面,也就是下标为len的位置上,然后经调整成为一个新堆。由于在原有堆上插入一个新元素后,可能使以该元素的双亲结点为根的子树不为准,从而使整个树不为堆,所以必须进行调整,使之成为一个堆。调整的方法很简单,若新元素小于双亲结点的值,就让它们互换位置;新元素换到双亲位置后,使得以该位置为根的子树成为堆,但新元素可能还小于此位置的双亲结点的值,从而使以上一层的双亲结点为根的子树不为堆,还需要按上述方法继续调整,这样持续传递上去,直到以新位置的双亲结点为根的子树仍为一个堆或者调整到堆顶为止,此时得到的整个完全二叉树又成为了一个堆。
例如,对于图7-4(a)所示的小根堆,若向它插入一个新元素50时,由于它不小于双亲结点的值35,所以以35为根的子树仍为一个堆,从而使整个二叉树仍然是一个堆,此次插入不需要做任何调整。插入新元素50后得到的堆如图7-5(所示)。
对于图7-4(a)所示的堆,若向它插入一个新元素30,由于它小于双亲结点的值35,所以需要将30与35对调位置,对调后因新元素30不小于其双亲元素18,所以调整结束,得到的整个二叉树为一个堆,插入结果如图7-5(b)所示。
对于图7-4(a)所示的堆,若向它插入的一个新元素为15,由于它小于双亲元素35,所以需要将15与35对调位置,对调后因新元素15小于其双亲元素18,所以又需要将15与18对调位置,此时新元素被调整到堆顶位置,所以调整结束,得到的插入后结果如图7-5(c)所示。
向堆中插入一个新元素的算法描述为:
//向堆中插入一个元素obj,使得插入后仍是一个堆
public void insert(Object obj) {
if(len==heapArray.length) //堆满时重新分配大一倍的存储空间并进行复制操作
{
Object []p=new Object[2*len];
for(int i=0;i<len;i++)
{
p[i]=heapArray[i]; //复制原堆内容
}
heapArray=p; //使heapArray指向新数组空间
}
heapArray[len++]=obj; //向堆尾添加新元素
int i=len-1; //给i置初值,用i指向待调整元素的当前位置
while(i!=0) //寻找新元素的最终位置
{
int j=(i-1)/2; //j指向下标为i的元素的双亲元素
if(((Comparable)obj).compareTo(heapArray[j])>0)
{
break; //找到新元素插入位置则退出循环
}
heapArray[i]=heapArray[j]; //双亲元素下移
i=j; //继续向上一层寻找插入位置
}
heapArray[i]=obj; //把新元素插入到最终位置
}
此算法的运行时间主要取决于while循环的执行次数,它等于新元素向双亲位置逐层上移的次数,此次数最多等于整个树的深度减1,所以算法的时间复杂度为O(log2n),其中,n为堆的大小(长度)。
2、从堆中删除元素
从堆中删除元素就是删除堆顶元素并使之返回。堆顶元素被删除后,留下的堆顶位置应由堆尾元素来填补,这样既保持了顺序存储结构的特点又不需要移动其他任何元素。把堆尾元素移动到堆顶位置后,它可能不小于左、右孩子结点,使整个二叉树不为堆,所以需要一个调整过程,使之变为含有n-1个元素的堆(假定删除前为n个元素)。调整过程首先从树根结点开始,若树根结点的值(即原堆尾元素值)大于两个孩子结点中的最小值,就将它与具有最小值的孩子结点互换其值,使得根结点的值小于两个孩子结点的值;原树根结点的值被对调到一个孩子位置后,可能使以该位置为根的子树又不为堆,因而又需要使新元素向孩子一层调整,如此下去,直到以调整后的位置为根的子树成为一个堆或调整到叶子结点为止。
例如,对于图7-4(a)所示的堆,若从中删除定点元素18时,需要把堆尾元素60写入到堆顶位置成为堆顶元素,由于60大于两个孩子中的最小值26,所以应互换60和26的位置,60被移到新位置后,又大于两个孩子中的最小值26,所以应互换60和26的位置,60被移到新位置后,又大于两个孩子中的最小值48,所以接着同48互换位置,此时60已被调整到叶子结点,所以调整完成,得到的完全二叉树又成为一个堆。
若图7-4(a)所示的堆尾元素不是60而是45,则进行删除操作时把45写入到堆顶位置后,因45大于两个孩子中的最小值26,所以需把它对调到左孩子26的位置,此时他小于两个孩子中的最小值48,表明以45所在的新位置为根的子树已经成为一个堆,至此调整结束。
从堆中删除元素的算法描述为:
//从堆中删除堆顶元素并将它返回,删除后仍要保持为一个堆
public Object delete() {
if(len==0)
{
return null; //若为空堆,则返回空值
}
Object temp=heapArray[0]; //将堆顶元素暂存temp中以便返回
len--; //堆的长度减1
if(len==0)
{
return temp; //若删除操作后变为空堆则返回
}
Object x=heapArray[len]; //将待调整的原堆尾元素暂存x中
int i=0; //用i指向待调整元素的位置,初始值为0
int j=1; //用j指向i位置元素的左孩子,初始值为1
while(j<=len-1) //寻找待调整元素的最终位置
{
if(j<len-1&&((Comparable)heapArray[j]).compareTo(heapArray[j+1])>0)
{
j++; //若右孩子存在并且较小,应使j指向右孩子
}
if(((Comparable)x).compareTo(heapArray[j])<=0)
{
break; //若条件成立则找到最终位置,退出循环
}
heapArray[i]=heapArray[j]; //孩子元素上移到双亲位置
i=j;j=2*i+1; //使i和j分别指向下一层结点
}
heapArray[i]=x; //把待调整元素放到最终位置
return temp; //返回原堆顶元素
}
此算法的运行时间主要取决于while循环的执行次数,它等于堆顶新元素向孩子位置逐层下移的次数,此次数最多等于整个树的深度减1,所以堆删除算法的时间复杂度同插入算法相同,均为O(log2n)。
在解决实际问题时,若每次只需要从数据中取出(即删除)具有最小值的元素,则适合采用堆这种数据结构,因为其插入和删除元素的时间复杂度均为O(log2n)。若采用线性表来实现这种功能,其删除最小值元素的时间复杂度将为O(n)。
若通过循环语句从一个堆中依次删除元素并输出,直到堆空为止,则得到的输出序列是按照升序排列的。
3、以二叉树广义表的形式输出一个堆
因为一个堆是一棵完全二叉树,所以可以输出一般二叉树的方法来输出一个堆。输出一般二叉树是一个递归算法,类似于先序遍历,它首先输出树根结点的值,接着递归输出左子树和右子树,当然在输出左子树之前要输出一个左圆括号、在输出右子树之后也要输出一个右圆括号,并且在左、右子树之间要输出一个逗号作为分隔符。另外,因为堆是采用顺序存储结构,所以整个树根元素在对应数组中的下标位置为0,并且当下标值大于等于堆的长度len值时则为空子树。下面给出采用二叉树广义表的形式输出一个堆的递归算法描述,它与已经介绍过的输出一棵采用链接存储结构的二叉树的递归算法相类似。
//输出一个堆的广义表表示的私有递归函数
private void output(int ht)
{
//以树根元素的下标ht为参数调用相应的递归函数进行输出
if(ht<len) //堆为空时结束返回,否则向下执行
{
System.out.print(heapArray[ht]); //输出根结点的值
if(2*ht+1<len) //若存在孩子结点则执行
{
System.out.print('('); //输出左括号
output(2*ht+1);
if(2*ht+2<len) //若存在孩子结点则执行
{
System.out.print(',');
output(2*ht+2);
}
System.out.print(')'); //输出右括号
}
}
}
此递归算法的时间复杂度为O(n),空间复杂度为O(log2n),其中,n为堆的长度。
下面给出SequenceHeap类的具体定义。
public class SequenceHeap implements Heap{
final int minSize=10; //数组初始长度
private Object[] heapArray; //数组声明,元素类型为系统根基类
private int len; //当前堆的实际长度
//无参构造函数的定义
public SequenceHeap()
{
len=0; //堆初始为空,即长度为0
heapArray=new Object[minSize]; //数组初始长度为minSize的值为10
}
public SequenceHeap(int n) { //带初始长度参数的构造函数的定义
if(n<minSize)
{
n=minSize; //当n较小时,取值为minSize的值
}
len=0;
heapArray=new Object[n]; //数组的初始长度为n的值
}
//实现二叉树接口中的方法
public boolean createBTree(String str) {
System.out.println("在堆中不需要使用一般二叉树的建立算法,返回假!");
return false;
}
//实现二叉树接口中的方法
public void traverseBtree(String s) {
System.out.println("在堆中不需要使用一般二叉树的遍历算法!");
}
//实现二叉树接口中的方法
public Object findBTree( Object x) {
System.out.println("在堆中不需要使用一般二叉树的查找算法,返回空!");
return null;
}
//求堆的深度
public int depthBTree() {
//不需要循环或递归,比求一般二叉树深度的算法要简单得多
return (int)(Math.log10(len)/Math.log10(2))+1;
}
//求堆中的结点数
public int countBTree() {
return len;
}
//检查一个堆是否为空
public boolean isEmpty() {
return len==0;
}
//清除一个堆为空
public void clearBTree() {
len=0;
}
//按照广义表格式,输出一个堆
public void printBTree(BTreeNode rt) {
output(0); //以树根元素的下标0为参数调用相应的递归函数进行输出
System.out.println(); //输出一个堆后换行
}
//输出一个堆的广义表表示的私有递归函数
private void output(int ht)
{
//以树根元素的下标ht为参数调用相应的递归函数进行输出
if(ht<len) //堆为空时结束返回,否则向下执行
{
System.out.print(heapArray[ht]); //输出根结点的值
if(2*ht+1<len) //若存在孩子结点则执行
{
System.out.print('('); //输出左括号
output(2*ht+1);
if(2*ht+2<len) //若存在孩子结点则执行
{
System.out.print(',');
output(2*ht+2);
}
System.out.print(')'); //输出右括号
}
}
}
//向堆中插入一个元素obj,使得插入后仍是一个堆
public void insert(Object obj) {
if(len==heapArray.length) //堆满时重新分配大一倍的存储空间并进行复制操作
{
Object []p=new Object[2*len];
for(int i=0;i<len;i++)
{
p[i]=heapArray[i]; //复制原堆内容
}
heapArray=p; //使heapArray指向新数组空间
}
heapArray[len++]=obj; //向堆尾添加新元素
int i=len-1; //给i置初值,用i指向待调整元素的当前位置
while(i!=0) //寻找新元素的最终位置
{
int j=(i-1)/2; //j指向下标为i的元素的双亲元素
if(((Comparable)obj).compareTo(heapArray[j])>0)
{
break; //找到新元素插入位置则退出循环
}
heapArray[i]=heapArray[j]; //双亲元素下移
i=j; //继续向上一层寻找插入位置
}
heapArray[i]=obj; //把新元素插入到最终位置
}
//从堆中删除堆顶元素并将它返回,删除后仍要保持为一个堆
public Object delete() {
if(len==0)
{
return null; //若为空堆,则返回空值
}
Object temp=heapArray[0]; //将堆顶元素暂存temp中以便返回
len--; //堆的长度减1
if(len==0)
{
return temp; //若删除操作后变为空堆则返回
}
Object x=heapArray[len]; //将待调整的原堆尾元素暂存x中
int i=0; //用i指向待调整元素的位置,初始值为0
int j=1; //用j指向i位置元素的左孩子,初始值为1
while(j<=len-1) //寻找待调整元素的最终位置
{
if(j<len-1&&((Comparable)heapArray[j]).compareTo(heapArray[j+1])>0)
{
j++; //若右孩子存在并且较小,应使j指向右孩子
}
if(((Comparable)x).compareTo(heapArray[j])<=0)
{
break; //若条件成立则找到最终位置,退出循环
}
heapArray[i]=heapArray[j]; //孩子元素上移到双亲位置
i=j;j=2*i+1; //使i和j分别指向下一层结点
}
heapArray[i]=x; //把待调整元素放到最终位置
return temp; //返回原堆顶元素
}
}