1、什么是堆
1)堆是一个树,或完全二叉树,或近似完全二叉树。
(注:完全二叉树是指:若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边的二叉树。)
2)堆需要满足这样的一个性质:
1.父结点的键值总是大于或等于(小于或等于)任何一个子节点的键值(大根堆/最大堆、小根堆/最小堆)。
(注:当父结点的键值总是大于或等于任何一个子节点的键值时为最大堆。当父结点的键值总是小于或等于任何一个子节点的键值时为最小堆。)
2.每个结点的左子树和右子树都是一个二叉堆。
(注:即每个结点的值都大于<或小于>左子树及右子树中的值。)
3. 堆是一个平衡树。
(注:平衡树是指一棵空树或其的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。)
2、堆的存储
首先我们对堆中的元素进行一个编号。从根结点开始依次编号1,2,3,……
(注:当然可以按照数组编号的方式,编为0,1,2,……我们这里采用略过a[0]来处理。如果采用数组编号的方式,之下的规律会和此处展示的规律有所区别。)
我们观察这些编号之间的关系。
通过观察我们可以发现如下规律:
1、假设父亲结点的编号为 i ,则其左子树的编号为 2*i ,右子树的编号为 2*i + 1。
2、假设子结点的编号为 i ,则其父亲结点的编号为 i/2(整数除法)。
正是因为这样一种好的规律,我们完全可以用数组来构建堆。
3、堆的基本操作-建堆
对一个从1到n的数据,我们先以乱序将其存入数组。
然后我们堆这个数组的前n项进行建堆操作。根据2中堆结点编号所满足的性质,用数组来模拟建立一个堆。堆中最后一个元素的父亲结点编号一定是n/2(整数除法)。
因此,我们从n/2结点开始,到编号为1的结点结束,顺次检查并使其满足1中堆的性质。这样,我们就可以建立一个堆。
我们先假设“检查并使其满足1中堆的性质"这一操作为一个函数:int Heapify(int i) ; 其作用为使编号为 i 的结点之下的树满足1中堆的性质。
下面,我们完成实现上述操作的函数:
void BuildHeap(int n)//建堆函数(按照1,2,3,……编号时,且n,a[N]当为全局变量时)
{
for(int i = n/2;i>=1;i--)
{
Heapify(i);//从下往上维护
}
}
void BuildHeap(int a[], int n) //建堆函数(按照0,1,2,……编号时)
{
for(int i = (n-1)/2; i >= 0; i--)
{
Heapify(a, i);
}
}
4、堆的基本操作-维护堆
3中提到的函数Heapify(int i) , 他想要实现的操作有:
1、使编号为 i 的结点和其子结点比较,判断结点 i 和其子结点是否满足1中的性质。
2、如果满足则结束。如果不满足,则通过交换元素的值来使其满足这样的性质。
3、如果进行了交换,则说明子结点被换为了较大的数(小根堆)或较小的数(大根堆)。则这个子结点以下的部分是否满足条件,我们不得而知。因此,我们还要对这个子结点一下的部分运行对维护函数,以使整个树满足这样的性质。
下面,我们展示维护堆的函数(小根堆):<n、a[N]的传入问题,可以将n,a[N]设置为全局变量,也可以将n,a[N]作为参数传入Heapify()函数中>
void Heapify(int i)//堆维护函数(从结点i以下的堆进行维护)(按照1,2,3,……编号时,且n,a[N]当为全局变量时)
{
int min = i, left = 2 *i,right = 2*i +1,temp;
min = ((left<=n)&&(a[left] < a[min]))?left:min;
min = ((right<=n)&&(a[right] < a[min]))?right:min;//找出结点i和其两分支的最小值的索引值
if(min != i)
{
temp = a[i];
a[i] = a[min];
a[min] = temp;//如果结点不是 三个元素中的最小值则交换结点和最小值的值
Heapify(min); //原为最小值的值现在变大了,所以要对其后的结点堆维护
}
}
void Heapify(int a[],int i,int n)//堆维护函数(从结点i以下的堆进行维护)
// (按照0,1,2,……编号时,由于在BuildHeap()函数中调用时,BuildHeap()函数中已经有n,所以不必再将n传入,当然传入n也可以。如果要应用堆排序,尽量传入)
{
int min = i, left = 2 *i + 1,right = 2*i + 2,temp;
min = ((left<n)&&(a[left] < a[min]))?left:min;
min = ((right<n)&&(a[right] < a[min]))?right:min;//找出结点i和其两分支的最小值的索引值
if(min != i)
{
temp = a[i];
a[i] = a[min];
a[min] = temp;//如果结点不是 三个元素中的最小值则交换结点和最小值的值
Heapify(min); //原为最小值的值现在变大了,所以要对其后的结点堆维护
}
}
注意:
min = ((left<=n)&&(a[left] < a[min]))?left:min;
min = ((right<=n)&&(a[right] < a[min]))?right:min;
中边界条件(left<=n)和(right<=n)一定不要忘记,否则不能限制对数组前n个元素进行建堆。
5、堆的基本操作-插入元素
在已经形成的堆中插入元素,我们通常将新加入的元素放在堆的最末,然后自下而上对堆进行一个维护。
由于从这个新数据的父结点到根结点必然为一个有序的数列,因此只需要让这个数据和这个数据的父结点进行比较,如果不满足则交换,并进一步与父结点的父结点进行比较,一直进行这样的操作,知道出现了满足的情况,那么此时一定已经是一个满足1中所有条件的堆了。
维护函数(小根堆):(注:插入排序的思想)
// 新加入i结点 其父结点为 i / 2 (按照1,2,3,……编号时)
void MinHeapFixup(int a[], int i)
{
int j, temp;
temp = a[i];//记录加入的值
j = i / 2; //父结点
while (j >= 0 && i != 0)
{
if (a[j] <= temp)
break;
a[i] = a[j]; //把较大的子结点往下移动,替换它的子结点
i = j;
j = i / 2;
}
a[i] = temp;//插入元素
}
// 新加入i结点 其父结点为(i - 1) / 2 (按照0,1,2,……编号时)
void MinHeapFixup(int a[], int i)
{
int j, temp;
temp = a[i];//记录加入的值
j = (i - 1) / 2; //父结点
while (j >= 0 && i != 0)
{
if (a[j] <= temp)
break;
a[i] = a[j]; //把较大的子结点往下移动,替换它的子结点
i = j;
j = (i - 1) / 2;
}
a[i] = temp;//插入元素
}
6、堆的基本操作-删除元素
我们使用堆,通常是要提取出堆中的最小元素(小根堆)或堆中的最大元素(大根堆)。
因此,我们删除元素通常只是用于删除堆顶元素。
但是当我们删除了堆顶元素后,怎样将堆重建起来呢?
我们可以将堆的堆尾元素转移到堆顶,然后将堆的大小减小,然后从顶部开始对堆进行一个维护。使其满足1中的性质。
所以,我们使用4中的函数Heapify(1)(按照1,2,3,……编号时)或Heapify(0)(按照0,1,2,……编号时)
7、堆排序
由于堆得堆顶元素是所有元素的最小或最大值,因此,我们可以通过建堆来实现排序。
首先,取下堆顶元素得到最小元素,然后将堆尾元素填入堆顶元素进行堆维护,形成一个新堆,继续截取堆顶元素,以此类推,便可得到升序或降序的有序数列。
由于堆也是用数组模拟的,故堆化数组后,第一次将A[0]与A[n - 1]交换,再对A[0…n-2]重新恢复堆。第二次将A[0]与A[n – 2]交换,再对A[0…n - 3]重新恢复堆,重复这样的操作直到A[0]与A[1]交换。由于每次都是将最小的数据并入到后面的有序区间,故操作完成后整个数组就有序了。(这里说的是编号为0,1,2,……时)
用上述方法的话,如果想得到升序数列,则需要建立大根堆,建立降序数列则需要建立小根堆。
void Heapsort(int a[], int n) (编号为0,1,2,……时)
{
for (int i = n - 1; i >= 1; i--)
{
Swap(a[i], a[0]);
Heapify(a, 0, i);
}
}
由于每次重新恢复堆的时间复杂度为O(logN),共N - 1次重新恢复堆操作,再加上前面建立堆时N / 2次向下调整,每次调整时间复杂度也为O(logN)。二次操作时间相加还是O(N * logN)。故堆排序的时间复杂度为O(N * logN)。